From 00dfa05f896acbb3c6f3bd721a66f69191e80364 Mon Sep 17 00:00:00 2001 From: brainstorm-os Date: Tue, 4 Aug 2026 02:00:16 +0200 Subject: [PATCH 1/2] fix(pairing): adopt the joined identity, and refuse to re-point a used vault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-492 and F-493 are one defect with two faces: pairing installs the source's sovereign identity into the target's keystore and nothing else follows. The running session keeps its pre-pairing identity (`VaultSession.identity` is readonly, set once), so the device subscribes the wrong `inbox:`, sends under the wrong `sender`, and fails the self-identity branch in `authorizesWrapInstall` — it pairs, reports success, and syncs nothing. Then `vault.json` still names the old identity, so the NEXT open throws on the mismatch guard and the vault will not open at all. Three changes, in the order the security argument requires. 1. REFUSE when the vault is not pristine (`pairing/vault-pristine.ts`). Adopting an identity is an authority transfer: `authorizesWrapInstall` admits any frame whose sender equals this vault's own sovereign key, bypassing the Owner check, so re-pointing a vault that already holds the user's work would hand the other device unconditional DEK-ROTATION authority over content it was never a member of — and rotation is worse than read, because the victim then emits under a key the attacker holds. Classification is by provenance, not type: `SYSTEM_ENTITY_TYPES` says of itself that it is presentation-only and must never change semantics, so it is the wrong input. Bootstrap principals are a closed set; an unrecognised one counts as user content, so a future seeder that forgets to register makes pairing refuse rather than silently permit. 2. ADOPT in `vault.json` alongside the keystore write. The two must name the same identity or the re-open throws. Safe only because (1) ran first: the identity check is the vault's tamper-evidence for key substitution, and it is now rewritten only when the check has proven there is nothing behind it to protect. 3. RE-OPEN after the pair completes, so the session is rebuilt around the adopted identity in one atomic step. A hot swap would leave the old key in some components and the new one in others inside an authorization path. Deliberately after `paired()`: a re-open failure costs a restart, never an un-paired device holding a half-adopted key. Plus the consent surface: the confirm step now names the identity being adopted by fingerprint. The SAS proves the channel is not relayed; it says nothing about WHOSE identity is on the far end, which is the thing actually being consented to. Not closed: `revokedAt` is still outside the signed payload (LAN-2b(d)). Co-Authored-By: Claude Opus 5 (1M context) --- packages/shell/src/main/index.ts | 15 ++++ .../shell/src/main/ipc/pairing-handlers.ts | 53 +++++++++++- .../src/main/pairing/pairing-service.test.ts | 85 +++++++++++++++++++ .../shell/src/main/pairing/pairing-service.ts | 55 ++++++++++++ .../src/main/pairing/vault-pristine.test.ts | 69 +++++++++++++++ .../shell/src/main/pairing/vault-pristine.ts | 65 ++++++++++++++ .../storage/entities-repo/entities-repo.ts | 14 +++ packages/shell/src/main/vault/vault.ts | 34 ++++++++ packages/shell/src/preload/index.ts | 5 ++ packages/shell/src/renderer/i18n/en.json | 2 + .../renderer/settings/devices-join-flow.tsx | 23 +++++ 11 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 packages/shell/src/main/pairing/vault-pristine.test.ts create mode 100644 packages/shell/src/main/pairing/vault-pristine.ts diff --git a/packages/shell/src/main/index.ts b/packages/shell/src/main/index.ts index 10f78792..4e800aa1 100644 --- a/packages/shell/src/main/index.ts +++ b/packages/shell/src/main/index.ts @@ -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 diff --git a/packages/shell/src/main/ipc/pairing-handlers.ts b/packages/shell/src/main/ipc/pairing-handlers.ts index 7ee6d5e6..c28577fa 100644 --- a/packages/shell/src/main/ipc/pairing-handlers.ts +++ b/packages/shell/src/main/ipc/pairing-handlers.ts @@ -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. @@ -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, @@ -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; }; type ActiveServiceHolder = { @@ -133,9 +152,17 @@ function buildSession( * listener can start or stop between one pairing and the next. */ relayUrl: () => string | null, notify: () => void, + reopenActiveVault: (() => Promise) | 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 | null = null; + const entitiesRepo = (): Promise => { + repo ??= session.dataStores.open("entities").then((db) => new EntitiesRepository(db)); + return repo; + }; return { vaultId: session.vaultId, getUserIdentity: () => ({ @@ -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); @@ -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 }; diff --git a/packages/shell/src/main/pairing/pairing-service.test.ts b/packages/shell/src/main/pairing/pairing-service.test.ts index 06787b53..3683af92 100644 --- a/packages/shell/src/main/pairing/pairing-service.test.ts +++ b/packages/shell/src/main/pairing/pairing-service.test.ts @@ -20,6 +20,7 @@ function makeFakeSession(overrides: Partial = {}): Pairin const deviceX = generateDeviceX25519(); const records: ReturnType = []; let storedIdentitySecret: Uint8Array | null = null; + let reopened = 0; const base: PairingServiceSession = { vaultId: "vlt_pair_test", getUserIdentity: () => ({ publicKey: userPublic, secretKey: userSecret }), @@ -29,6 +30,12 @@ function makeFakeSession(overrides: Partial = {}): 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; @@ -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); + }); }); diff --git a/packages/shell/src/main/pairing/pairing-service.ts b/packages/shell/src/main/pairing/pairing-service.ts index f709b110..02cce0c0 100644 --- a/packages/shell/src/main/pairing/pairing-service.ts +++ b/packages/shell/src/main/pairing/pairing-service.ts @@ -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"; @@ -46,6 +47,7 @@ import { startQrHandshakeOnSource, } from "./pairing-handshake"; import { PairingMode } from "./pairing-payload"; +import { assessVaultPristine } from "./vault-pristine"; export type PairingServiceSession = { vaultId: string; @@ -54,6 +56,15 @@ export type PairingServiceSession = { getDeviceX25519(): { publicKey: Uint8Array }; getRelayUrl(): string | null; saveIdentitySecret(secret: Uint8Array): Promise; + /** 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; + /** 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; devicesAdd(record: SignedAddDeviceRecord): SignedAddDeviceRecord; devicesList(): SignedAddDeviceRecord[]; devicesRevoke(deviceEd25519Pub: string, now?: number): boolean; @@ -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) { @@ -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, @@ -369,6 +401,7 @@ export class PairingService { channelId: join.channelId, expiresAt, mode: PairingMode.Qr, + identityFingerprint: fingerprintPublicKey(join.userEd25519Pub), }; } @@ -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:` 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 }; } diff --git a/packages/shell/src/main/pairing/vault-pristine.test.ts b/packages/shell/src/main/pairing/vault-pristine.test.ts new file mode 100644 index 00000000..0d65980e --- /dev/null +++ b/packages/shell/src/main/pairing/vault-pristine.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { BOOTSTRAP_PRINCIPALS, assessVaultPristine } from "./vault-pristine"; + +describe("assessVaultPristine", () => { + it("an empty vault is pristine", () => { + expect(assessVaultPristine([])).toEqual({ pristine: true, userAuthored: 0 }); + }); + + it("a fresh install — root folder, welcome seed, stock agents — is pristine", () => { + const fresh = [ + { createdBy: "brainstorm.shell" }, + { createdBy: "io.brainstorm.welcome" }, + { createdBy: "io.brainstorm.welcome" }, + { createdBy: "io.brainstorm.welcome/template" }, + { createdBy: "shell" }, + { createdBy: "shell" }, + ]; + expect(assessVaultPristine(fresh)).toEqual({ pristine: true, userAuthored: 0 }); + }); + + it("one note the user wrote makes it non-pristine", () => { + const rows = [ + { createdBy: "brainstorm.shell" }, + { createdBy: "io.brainstorm.welcome" }, + // a sovereign identity key — what `entities.create` stamps + { createdBy: "Se7lyssNZ0D+UDiRLKxlza4GlrSMNKed861JJCAyIYQ=" }, + ]; + expect(assessVaultPristine(rows)).toEqual({ pristine: false, userAuthored: 1 }); + }); + + it("counts every user row, for the refusal message", () => { + const rows = [ + { createdBy: "io.brainstorm.welcome" }, + { createdBy: "userkey-a" }, + { createdBy: "userkey-a" }, + { createdBy: "io.brainstorm.chat" }, + ]; + expect(assessVaultPristine(rows).userAuthored).toBe(3); + }); + + it("fails closed on an unknown principal — a new bootstrap writer must register", () => { + // The failure mode this guards: someone adds a bootstrap pass, forgets to + // list it here, and pairing starts refusing. That is visible and + // recoverable; the inverse (silently permitting an authority transfer) + // is neither. + expect(assessVaultPristine([{ createdBy: "io.brainstorm.some-future-seeder" }])).toEqual({ + pristine: false, + userAuthored: 1, + }); + }); + + it("fails closed on a malformed row", () => { + const rows = [{ createdBy: undefined }, { createdBy: null }, {}] as unknown as { + createdBy: string; + }[]; + expect(assessVaultPristine(rows)).toEqual({ pristine: false, userAuthored: 3 }); + }); + + it("the bootstrap set is exactly the four principals this repo owns", () => { + // A guard on the constant itself: widening it widens who may take over a + // populated vault, so it should never change without being noticed. + expect([...BOOTSTRAP_PRINCIPALS].sort()).toEqual([ + "brainstorm.shell", + "io.brainstorm.welcome", + "io.brainstorm.welcome/template", + "shell", + ]); + }); +}); diff --git a/packages/shell/src/main/pairing/vault-pristine.ts b/packages/shell/src/main/pairing/vault-pristine.ts new file mode 100644 index 00000000..7e12e738 --- /dev/null +++ b/packages/shell/src/main/pairing/vault-pristine.ts @@ -0,0 +1,65 @@ +/** + * F-493 — is this vault safe to re-point at another identity? + * + * Pairing hands the joining device the SOURCE's sovereign identity. Every + * authorization decision downstream keys off that identity, so adopting one is + * an authority transfer, not a settings change. The sharp edge is + * `authorizesWrapInstall`, whose FIRST rule is: + * + * if (keyBytesEqual(input.senderKey, input.selfPub)) return true; + * + * — a frame from this vault's own sovereign key may install or ROTATE a DEK on + * any entity, bypassing the Owner check entirely. That rule is correct and + * exists for exactly the paired-device case. But it means that if a vault which + * already holds the user's own work is re-pointed at someone else's identity, + * that someone else silently gains unconditional rotation authority over + * content they were never a member of — and rotation is worse than read, + * because the victim then emits under a key the attacker holds. + * + * So joining is refused when the vault holds anything the user authored. The + * supported shape is the one the product already assumes (see the comment on + * `saveIdentitySecret` in `ipc/pairing-handlers.ts`): you join a vault, you do + * not merge two. + * + * **Provenance, not type.** The classification is by WHO created the row, not + * what kind it is. `SYSTEM_ENTITY_TYPES` exists but its own contract says it is + * presentation-only and must "never change query or filtering semantics", so it + * is the wrong input for a refusal. Bootstrap principals are a closed set this + * repo controls; anything else is the user. + * + * Fail-closed: an unrecognised principal counts as user content, so a new + * bootstrap writer that forgets to register here makes pairing refuse (visible, + * recoverable) rather than silently permitting an authority transfer. + */ + +/** Rows written by first-launch bootstrap rather than by the person. Each is a + * constant this repo owns — keep in sync with its definition site. */ +export const BOOTSTRAP_PRINCIPALS: ReadonlySet = new Set([ + "brainstorm.shell", // SHELL_ACTOR — the vault root Folder (vault/session.ts) + "io.brainstorm.welcome", // WELCOME_SEED_CREATED_BY (welcome/welcome-content.ts) + "io.brainstorm.welcome/template", // TEMPLATE_CREATED_BY (welcome/seed-template.ts) + "shell", // SHELL_PRINCIPAL — seeded agent records (agents/agent-record.ts) +]); + +/** The subset of an entity row this decision reads. */ +export type PristineCandidate = { createdBy: string }; + +export type PristineVerdict = { + /** True when every row came from a bootstrap principal. */ + pristine: boolean; + /** How many rows look user-authored — for the refusal message. */ + userAuthored: number; +}; + +/** + * Classify a vault's rows. Empty is pristine; so is a fresh install carrying + * only its root Folder, welcome seed and stock agents. + */ +export function assessVaultPristine(rows: readonly PristineCandidate[]): PristineVerdict { + let userAuthored = 0; + for (const row of rows) { + const principal = typeof row?.createdBy === "string" ? row.createdBy : ""; + if (!BOOTSTRAP_PRINCIPALS.has(principal)) userAuthored += 1; + } + return { pristine: userAuthored === 0, userAuthored }; +} diff --git a/packages/shell/src/main/storage/entities-repo/entities-repo.ts b/packages/shell/src/main/storage/entities-repo/entities-repo.ts index fd89945b..b4a50faf 100644 --- a/packages/shell/src/main/storage/entities-repo/entities-repo.ts +++ b/packages/shell/src/main/storage/entities-repo/entities-repo.ts @@ -311,6 +311,20 @@ export class EntitiesRepository { ).all() as Array<{ id: string; type: string }>; } + /** + * F-493 — the creating principal of every live row, and nothing else. + * + * Pairing refuses to re-point a vault that already holds the user's own work + * (`pairing/vault-pristine.ts`), and that decision is made on provenance. The + * projection is deliberately narrow: the check never needs a title, a body or + * a property, so it is not given one. + */ + listCreatedByPrincipals(): Array<{ createdBy: string }> { + return this.stmt( + "SELECT created_by AS createdBy FROM entities WHERE deleted_at IS NULL", + ).all() as Array<{ createdBy: string }>; + } + /** Count of live (non-deleted) entity rows. Stage 10.14 uses `0` as the * "empty vault" signal that makes cold restore-from-zero offerable. */ count(): number { diff --git a/packages/shell/src/main/vault/vault.ts b/packages/shell/src/main/vault/vault.ts index 494b3618..fb26ac40 100644 --- a/packages/shell/src/main/vault/vault.ts +++ b/packages/shell/src/main/vault/vault.ts @@ -9,6 +9,7 @@ import { reconcileAtRestMode, } from "@brainstorm-os/sqlite/at-rest-mode"; import { ulid } from "ulid"; +import { fingerprintPublicKey, publicKeyToBase64 } from "../credentials/identity"; import type { KeystoreBackendName, PickKeystoreOptions } from "../credentials/keystore"; import { type DataStoreKind, archiveCorruptDb } from "../storage/data-stores"; import { assertVaultFormatNotPreFreeze, assertVaultFormatSupported } from "../util/schema-version"; @@ -274,6 +275,39 @@ export async function openVault(path: string, options: OpenVaultOptions = {}): P return entry; } +/** + * F-493 — record an ADOPTED sovereign identity in `vault.json`. + * + * `identityPublicKey` is written once at vault creation and every later writer + * only preserves it, because `VaultSession.open` compares it against the + * keystore and refuses a mismatch — that check is the vault's tamper-evidence + * for key substitution. Pairing installs a different identity into the + * keystore, so without this the next open of a just-joined device throws and + * the vault cannot be opened at all. + * + * **Only ever called for a vault the pristine check passed** (see + * `pairing/vault-pristine.ts`). That ordering is the whole safety argument: on + * a vault holding the user's own work, re-pointing the identity would hand the + * other device unconditional authority over it via `authorizesWrapInstall`'s + * self-identity branch — so that case is refused before any of this runs, and + * the tamper signal is only ever rewritten when there is nothing behind it to + * protect. + */ +export async function adoptVaultIdentity( + vaultPath: string, + identityPublicKey: Uint8Array, +): Promise { + const vaultJsonPath = join(vaultPath, "vault.json"); + const raw = await readFile(vaultJsonPath, "utf8"); + const parsed = JSON.parse(raw) as VaultJson; + const next: VaultJson = { + ...parsed, + identityPublicKey: publicKeyToBase64(identityPublicKey), + identityFingerprint: fingerprintPublicKey(identityPublicKey), + }; + await writeFile(vaultJsonPath, `${JSON.stringify(next, null, 2)}\n`, "utf8"); +} + export async function activateVault( id: string, options: OpenVaultOptions = {}, diff --git a/packages/shell/src/preload/index.ts b/packages/shell/src/preload/index.ts index 872254ef..3e584552 100644 --- a/packages/shell/src/preload/index.ts +++ b/packages/shell/src/preload/index.ts @@ -908,6 +908,11 @@ export type PairingScanPayloadResult = { channelId: string; expiresAt: number; mode: PairingMode; + /** The sovereign identity this device will ADOPT, as `ed25519:<16-hex>`. + * The confirm step names it because joining is an authority transfer: the + * SAS proves the channel is not relayed, but not whose identity is on the + * other end of it. Optional so an older main process still type-checks. */ + identityFingerprint?: string; }; export type PairingConfirmSasResult = { diff --git a/packages/shell/src/renderer/i18n/en.json b/packages/shell/src/renderer/i18n/en.json index 48a87a08..7aa6ebf0 100644 --- a/packages/shell/src/renderer/i18n/en.json +++ b/packages/shell/src/renderer/i18n/en.json @@ -1247,6 +1247,8 @@ "shell.settings.devices.join.pasteSubmit": "Continue", "shell.settings.devices.join.pasteInvalid": "Payload doesn't look like a valid pairing code.", "shell.settings.devices.join.confirmInstruction": "These codes should match — confirm if they do.", + "shell.settings.devices.join.adoptingLabel": "You are joining this identity", + "shell.settings.devices.join.adoptingNote": "This device will take on the identity below and share its vault. Anything already on this device stays with it.", "shell.settings.devices.join.match": "Match", "shell.settings.devices.join.dontMatch": "Don't match", "shell.settings.devices.join.joining": "Joining vault…", diff --git a/packages/shell/src/renderer/settings/devices-join-flow.tsx b/packages/shell/src/renderer/settings/devices-join-flow.tsx index e8ee703e..cb507cc7 100644 --- a/packages/shell/src/renderer/settings/devices-join-flow.tsx +++ b/packages/shell/src/renderer/settings/devices-join-flow.tsx @@ -55,6 +55,10 @@ export type DevicesJoinFlowProps = { type Session = { requestId: string; sas: string; + /** The sovereign identity this device will adopt, as `ed25519:<16-hex>`. + * Shown at the confirm step because joining transfers authority, and the + * SAS alone only proves the channel — not WHOSE identity is on the far end. */ + identityFingerprint?: string; expiresAt: number; }; @@ -109,6 +113,12 @@ export function DevicesJoinFlow({ onClose, onJoined, embedded = false }: Devices setSession({ requestId: result.requestId, sas: result.sas, + // `exactOptionalPropertyTypes` — omit the key rather than set it to + // undefined, so an older main process that does not send one is + // "absent", not "present and empty". + ...(result.identityFingerprint === undefined + ? {} + : { identityFingerprint: result.identityFingerprint }), expiresAt: result.expiresAt, }); setState(DevicesJoinState.ConfirmSas); @@ -218,6 +228,19 @@ export function DevicesJoinFlow({ onClose, onJoined, embedded = false }: Devices {formatSas(session.sas)} + {session.identityFingerprint && ( +
+

+ {t("shell.settings.devices.join.adoptingLabel")} +

+ + {session.identityFingerprint} + +

+ {t("shell.settings.devices.join.adoptingNote")} +

+
+ )} )} {state === DevicesJoinState.Joining && ( From d6e376d467889b75902da59a29f6f57ac1afb087 Mon Sep 17 00:00:00 2001 From: brainstorm-os Date: Tue, 4 Aug 2026 02:08:38 +0200 Subject: [PATCH 2/2] style: format the F-492 adoption test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged unformatted in #489 — that PR ran typecheck and tests but not lint, so `main` has been failing `biome check` since. Formatting only. Co-Authored-By: Claude Opus 5 (1M context) --- .../main/pairing/pairing-identity-adoption.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/shell/src/main/pairing/pairing-identity-adoption.test.ts b/packages/shell/src/main/pairing/pairing-identity-adoption.test.ts index 114ee6de..222658af 100644 --- a/packages/shell/src/main/pairing/pairing-identity-adoption.test.ts +++ b/packages/shell/src/main/pairing/pairing-identity-adoption.test.ts @@ -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:` while the source fans @@ -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;