Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/shell/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2490,6 +2490,21 @@ void app.whenReady().then(async () => {
}
registerPairingHandlers({
getDashboard: () => dashboardWindow,
// F-492 — the joining device adopts the source's sovereign identity, but
// `VaultSession.identity` is readonly and set once, so the running session
// is still its pre-pairing self: subscribed to the wrong `inbox:`, sending
// under the wrong `sender`, and failing the self-identity branch in
// `authorizesWrapInstall`. Re-activating rebuilds the session — and with it
// the live-sync engine, the sharing engine and the relay wiring — around
// the adopted identity, in one step, reusing the path a vault switch
// already takes. A hot swap would leave a window with the old key in some
// components and the new one in others, inside an authorization path.
reopenActiveVault: async () => {
const session = getActiveVaultSession();
if (!session) return;
const { activateVault } = await import("./vault/vault");
await activateVault(session.vaultId);
},
// P2P-1 — a device joined (or was revoked), so re-read the roster the LAN
// handshake authenticates against. It is otherwise only read on a vault
// change, and pairing happens while the vault is already open, so the
Expand Down
53 changes: 52 additions & 1 deletion packages/shell/src/main/ipc/pairing-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { join } from "node:path";
import type { BrowserWindow } from "electron";
import { ipcMain } from "electron";
import { XCHACHA_NONCE_BYTES, bytesToBase64, isSealedSecret } from "../credentials/crypto";
import { publicKeyFromSecret } from "../credentials/identity";
import { base64UrlToBytes, bytesToBase64Url, pairingChannelId } from "../pairing/pairing-channel";
// Used implicitly through the live transport seam — keeping the import
// site explicit makes the relay-blind audit + reviewer scanning easier.
Expand All @@ -41,12 +42,14 @@ import {
type PairingServiceSession,
type PairingServiceTransport,
} from "../pairing/pairing-service";
import { EntitiesRepository } from "../storage/entities-repo";
import { getActiveRelay } from "../sync/active-relay";
import {
type VaultSession,
getActiveVaultSession,
onActiveVaultSessionChanged,
} from "../vault/session";
import { adoptVaultIdentity } from "../vault/vault";
import {
type VaultPropertiesStore,
VaultPropertiesStore as VaultPropertiesStoreClass,
Expand Down Expand Up @@ -100,6 +103,22 @@ export type PairingHandlersOptions = {
* non-LAN address is filtered downstream.
*/
onPairedPeerUrl?: (url: string) => void;
/**
* F-492 — re-open the active vault so the running session adopts the identity
* pairing just installed into the keystore.
*
* `VaultSession.identity` is `readonly`, set once in the constructor, and
* `saveIdentitySecret` only writes the keystore for "the next
* `VaultSession.open`". Without a re-open the joining device runs its whole
* session as its pre-pairing self — subscribed to the wrong `inbox:`, sending
* under the wrong `sender`, and failing the self-identity branch in
* `authorizesWrapInstall` — so it pairs successfully and then syncs nothing.
*
* Supplied by the shell wiring, which owns vault open/close. Absent ⇒ the
* adoption waits for the next launch (the pre-F-492 behaviour), which is why
* the service treats a failure as a warning rather than un-pairing.
*/
reopenActiveVault?: () => Promise<void>;
};

type ActiveServiceHolder = {
Expand Down Expand Up @@ -133,9 +152,17 @@ function buildSession(
* listener can start or stop between one pairing and the next. */
relayUrl: () => string | null,
notify: () => void,
reopenActiveVault: (() => Promise<void>) | undefined,
): PairingServiceSession {
const devicesStore = props.devices();
const identityProvider = session.exposeIdentityForPairing();
// Opened lazily and once: the pristine check runs at most once per join, and
// a pairing attempt should not pay for an entities-db open it never uses.
let repo: Promise<EntitiesRepository> | null = null;
const entitiesRepo = (): Promise<EntitiesRepository> => {
repo ??= session.dataStores.open("entities").then((db) => new EntitiesRepository(db));
return repo;
};
return {
vaultId: session.vaultId,
getUserIdentity: () => ({
Expand Down Expand Up @@ -163,6 +190,29 @@ function buildSession(
// across, so the keys match by construction once the user
// re-opens with the freshly-installed identity).
await session.backend.setSecret(session.vaultId, "identity", secret);
// F-493 — the keystore and `vault.json` must name the SAME identity or
// the next open throws on the mismatch guard and the vault cannot be
// opened at all. Safe here and only here: `scanPayload` refused before
// this point unless the vault is pristine, so re-pointing the identity
// orphans nothing and hands nobody authority over existing work.
await adoptVaultIdentity(session.vaultPath, publicKeyFromSecret(secret));
},
// F-493 — provenance for the pristine check. Only `createdBy` leaves the
// repo; the decision never sees titles or bodies.
listEntityPrincipals: async () => {
try {
return (await entitiesRepo()).listCreatedByPrincipals();
} catch (error) {
// Fail closed: a vault we cannot read is treated as populated rather
// than assumed safe to re-point. `assessVaultPristine` counts an
// unknown principal as user content, so this one row refuses.
console.warn(`[pairing] could not read vault provenance: ${(error as Error).message}`);
return [{ createdBy: "unreadable" }];
}
},
reopenForAdoptedIdentity: async () => {
if (!reopenActiveVault) return;
await reopenActiveVault();
},
devicesAdd: (record) => {
const stored = devicesStore.add(record);
Expand Down Expand Up @@ -474,7 +524,8 @@ export function registerPairingHandlers(options: PairingHandlersOptions): () =>
// Relay first, LAN listener as the fallback — see `getLanListenerUrl`.
const relayUrl = (): string | null => vaultRelayUrl ?? options.getLanListenerUrl?.() ?? null;
const service = new PairingService({
getSession: async () => buildSession(session, props, relayUrl, notify),
getSession: async () =>
buildSession(session, props, relayUrl, notify, options.reopenActiveVault),
transport: buildTransport(),
});
active = { session, props, service };
Expand Down
12 changes: 10 additions & 2 deletions packages/shell/src/main/pairing/pairing-identity-adoption.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ describe("F-492 — pairing's identity adoption on the joining device", () => {
expect(before).not.toBe(source.identity.publicKeyBase64);

// Exactly what `pairing-handlers.ts` binds `saveIdentitySecret` to.
await target.backend.setSecret(target.vaultId, "identity", source.exposeIdentityForPairing().secretKey);
await target.backend.setSecret(
target.vaultId,
"identity",
source.exposeIdentityForPairing().secretKey,
);

// The running session is unchanged — this is why the joining device
// keeps subscribing `inbox:<its OLD identity>` while the source fans
Expand All @@ -69,7 +73,11 @@ describe("F-492 — pairing's identity adoption on the joining device", () => {
it("makes the NEXT vault open fail — vault.json still names the old identity", async () => {
if (!source || !target) throw new Error("expected both sessions");
const targetOriginalPub = target.identity.publicKeyBase64;
await target.backend.setSecret(target.vaultId, "identity", source.exposeIdentityForPairing().secretKey);
await target.backend.setSecret(
target.vaultId,
"identity",
source.exposeIdentityForPairing().secretKey,
);
await target.dispose();
target = undefined;

Expand Down
85 changes: 85 additions & 0 deletions packages/shell/src/main/pairing/pairing-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function makeFakeSession(overrides: Partial<PairingServiceSession> = {}): Pairin
const deviceX = generateDeviceX25519();
const records: ReturnType<PairingServiceSession["devicesList"]> = [];
let storedIdentitySecret: Uint8Array | null = null;
let reopened = 0;
const base: PairingServiceSession = {
vaultId: "vlt_pair_test",
getUserIdentity: () => ({ publicKey: userPublic, secretKey: userSecret }),
Expand All @@ -29,6 +30,12 @@ function makeFakeSession(overrides: Partial<PairingServiceSession> = {}): Pairin
saveIdentitySecret: async (secret) => {
storedIdentitySecret = new Uint8Array(secret);
},
// F-493 — a pristine vault by default, so the existing cases still join.
// The refusal path has its own cases below.
listEntityPrincipals: async () => [{ createdBy: "brainstorm.shell" }],
reopenForAdoptedIdentity: async () => {
reopened += 1;
},
devicesAdd: (record) => {
records.push(record);
return record;
Expand Down Expand Up @@ -205,4 +212,82 @@ describe("makePairingServiceHandler — broker handler", () => {
handler(makeEnvelope("cancelPairing", [{ requestId: "does-not-exist" }])),
).rejects.toMatchObject({ name: "Invalid" });
});

it("F-493 — refuses to join when the vault already holds the user's own work", async () => {
const sourceSession = makeFakeSession();
const sourceSvc = new PairingService({ getSession: async () => sourceSession });
const started = await sourceSvc.startAddDevice({ mode: PairingMode.Qr });
const sourceIdentity = sourceSession.getUserIdentity();
const { decodePairingPayload } = await import("./pairing-payload");
const payload = decodePairingPayload(started.payload);
const sealedIdentity = sealQrIdentityForB(sourceIdentity.secretKey, payload.pairingSecret);

// A vault with one note the person wrote. Joining would re-point it at the
// source's identity, and `authorizesWrapInstall` treats a frame from this
// vault's own sovereign key as authorised to ROTATE a DEK on any entity —
// so the source would gain unconditional authority over that note.
const targetSession = makeFakeSession({
listEntityPrincipals: async () => [
{ createdBy: "brainstorm.shell" },
{ createdBy: "io.brainstorm.welcome" },
{ createdBy: "Se7lyssNZ0D+UDiRLKxlza4GlrSMNKed861JJCAyIYQ=" },
],
});
const targetSvc = new PairingService({ getSession: async () => targetSession });

await expect(
targetSvc.scanPayload({ payload: started.payload, sealedIdentity }),
).rejects.toMatchObject({ name: "Invalid" });

// And it refused BEFORE writing anything: the identity secret is the
// thing that would have bricked the vault, so a refusal that still
// stored it would be no refusal at all.
const stored = (targetSession as unknown as { _stored: () => Uint8Array | null })._stored();
expect(stored).toBeNull();
});

it("F-492 — re-opens the vault after joining so the session adopts the identity", async () => {
const sourceSession = makeFakeSession();
const sourceSvc = new PairingService({ getSession: async () => sourceSession });
const started = await sourceSvc.startAddDevice({ mode: PairingMode.Qr });
const sourceIdentity = sourceSession.getUserIdentity();
const { decodePairingPayload } = await import("./pairing-payload");
const payload = decodePairingPayload(started.payload);
const sealedIdentity = sealQrIdentityForB(sourceIdentity.secretKey, payload.pairingSecret);

let reopens = 0;
const targetSession = makeFakeSession({
reopenForAdoptedIdentity: async () => {
reopens += 1;
},
});
const targetSvc = new PairingService({ getSession: async () => targetSession });
const scanned = await targetSvc.scanPayload({ payload: started.payload, sealedIdentity });
expect(reopens).toBe(0); // not yet — the pair is not complete at scan time
await targetSvc.confirmSas({ requestId: scanned.requestId });
expect(reopens).toBe(1);
});

it("F-492 — a re-open failure leaves the device PAIRED, not half-joined", async () => {
const sourceSession = makeFakeSession();
const sourceSvc = new PairingService({ getSession: async () => sourceSession });
const started = await sourceSvc.startAddDevice({ mode: PairingMode.Qr });
const sourceIdentity = sourceSession.getUserIdentity();
const { decodePairingPayload } = await import("./pairing-payload");
const payload = decodePairingPayload(started.payload);
const sealedIdentity = sealQrIdentityForB(sourceIdentity.secretKey, payload.pairingSecret);

const targetSession = makeFakeSession({
reopenForAdoptedIdentity: async () => {
throw new Error("vault busy");
},
});
const targetSvc = new PairingService({ getSession: async () => targetSession });
const scanned = await targetSvc.scanPayload({ payload: started.payload, sealedIdentity });
// The pair itself is durable before the re-open is attempted, so a failure
// costs a restart — never an un-paired device holding a half-adopted key.
const confirmed = await targetSvc.confirmSas({ requestId: scanned.requestId });
expect(confirmed.addedRecord.sig.length).toBeGreaterThan(0);
expect(targetSession.devicesList().length).toBe(2);
});
});
55 changes: 55 additions & 0 deletions packages/shell/src/main/pairing/pairing-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import type { ServiceHandler } from "../../ipc/broker";
import type { Envelope } from "../../ipc/envelope";
import { type SealedSecret, isSealedSecret } from "../credentials/crypto";
import { fingerprintPublicKey } from "../credentials/identity";
import { PairingChannelGuard, exportSecretSealed } from "../credentials/identity-export";
import { type SignedAddDeviceRecord, signAddDeviceRecord } from "./devices-store";
import { base64UrlToBytes } from "./pairing-channel";
Expand All @@ -46,6 +47,7 @@ import {
startQrHandshakeOnSource,
} from "./pairing-handshake";
import { PairingMode } from "./pairing-payload";
import { assessVaultPristine } from "./vault-pristine";

export type PairingServiceSession = {
vaultId: string;
Expand All @@ -54,6 +56,15 @@ export type PairingServiceSession = {
getDeviceX25519(): { publicKey: Uint8Array };
getRelayUrl(): string | null;
saveIdentitySecret(secret: Uint8Array): Promise<void>;
/** F-493 — every entity row's creating principal, for the pristine check.
* Only `createdBy` is read; see `vault-pristine.ts` for why joining a vault
* that already holds the user's own work is refused. */
listEntityPrincipals(): Promise<readonly { createdBy: string }[]>;
/** F-492 — re-open the vault so the running session adopts the identity that
* was just installed. `VaultSession.identity` is readonly and set once, so
* without this the device keeps its pre-pairing identity for the rest of the
* session: wrong inbox, wrong wire sender, wrong self-identity authz branch. */
reopenForAdoptedIdentity(): Promise<void>;
devicesAdd(record: SignedAddDeviceRecord): SignedAddDeviceRecord;
devicesList(): SignedAddDeviceRecord[];
devicesRevoke(deviceEd25519Pub: string, now?: number): boolean;
Expand Down Expand Up @@ -341,6 +352,11 @@ export class PairingService {
channelId: string;
expiresAt: number;
mode: PairingMode;
/** The sovereign identity this device is about to ADOPT, as a
* `ed25519:<16-hex>` fingerprint. Joining is an authority transfer, not a
* settings change, so the confirm step names what is being adopted rather
* than only proving the channel with the SAS. */
identityFingerprint: string;
}> {
const session = await this.requireSession();
if (typeof args.payload !== "string" || args.payload.length === 0) {
Expand All @@ -349,6 +365,22 @@ export class PairingService {
if (!isSealedSecret(args.sealedIdentity)) {
invalid("sealedIdentity must be a SealedSecret");
}
// F-493 — BEFORE the handshake consumes its one-shot guard and before a
// single byte of the source's identity is written. Joining installs the
// source's sovereign key, and `authorizesWrapInstall` treats a frame from
// this vault's own sovereign key as authorised to rotate a DEK on ANY
// entity — so re-pointing a vault that already holds the user's work hands
// the other identity unconditional authority over content it was never a
// member of. Refuse instead; you join a vault, you do not merge two.
const pristine = assessVaultPristine(await session.listEntityPrincipals());
if (!pristine.pristine) {
const err = new Error(
`This device already has its own vault with ${pristine.userAuthored} item(s) in it. Joining would hand the other device authority over them. Open the vault you want to join on this device first, or join from a device with no work of its own.`,
);
err.name = "Invalid";
throw err;
}

const join = joinQrHandshakeOnTarget({
encodedPayload: args.payload,
sealedIdentity: args.sealedIdentity,
Expand All @@ -369,6 +401,7 @@ export class PairingService {
channelId: join.channelId,
expiresAt,
mode: PairingMode.Qr,
identityFingerprint: fingerprintPublicKey(join.userEd25519Pub),
};
}

Expand Down Expand Up @@ -438,6 +471,28 @@ export class PairingService {
console.warn("[pairing] could not record the source device:", error);
}
pending.machine.paired();

// F-492 — the identity was written to the keystore back in `scanPayload`,
// but `VaultSession.identity` is readonly and set once in the constructor,
// so this session is still running as its PRE-pairing self. Everything
// downstream keys off that: the `inbox:<identity>` the live-sync engine
// subscribed at session start, the wire `sender`, and the self-identity
// branch in `authorizesWrapInstall`. Re-open so the whole session is
// rebuilt around the adopted identity in one atomic step — a hot swap
// would leave a window where some components hold the old key and some
// the new, which in an authorization path is where the bugs live.
//
// Deliberately AFTER `paired()`: the pair itself is complete and durable
// at this point, so a re-open that fails leaves a paired device that needs
// a restart, never an un-paired one.
try {
await session.reopenForAdoptedIdentity();
} catch (error) {
console.warn(
`[pairing] identity adopted but the vault could not be re-opened; a restart will pick it up: ${(error as Error).message}`,
);
}

return { requestId: args.requestId, addedRecord: stored };
}

Expand Down
Loading
Loading