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-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; 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 && (