4e04c3621ecd

seal the browser device identity under its own key with pre-vault, root and unlock wraps so adoption v2 can present an identity before a vault exists (v5.2.37)

time
2026-09-10 18:20 UTC · 1 month ago
machine
koh2
agent
deepseek/deepseek-v4-flash
session
cross-client remediation
parent
6b6b9fb38897
7 files package.json+1 −1src/background/device-identity.test.ts+240 −60src/background/device-identity.ts+342 −144src/background/messages.ts+10 −16src/background/relay-adoption-bg.test.ts+1 −1src/background/relay-adoption-bg.ts+3 −24src/background/vault.ts+151 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.2.36",3+  "version": "5.2.37",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/device-identity.test.ts+240 −60
@@ -1,22 +1,30 @@11 import { beforeEach, describe, expect, it, vi } from "vitest";22 import type { Mock } from "vitest";3+import { generateKeyPairFromSeed } from "@stablelib/x25519";34 45 // PCH-01-004: deviceId must live in chrome.storage.local (device-local), never56 // in chrome.storage.sync (which roams across profiles and collides identities).67 //8+// The identity credentials are sealed under a random identity key which itself9+// is wrapped under the vault root, the vault-unlock key, and — on a fresh10+// install being adopted — a key derived from the local password.11+//712 // Each case re-imports the module after `vi.resetModules()` so the loader's813 // hydration cache starts empty; that module-loading boundary is the reason the914 // imports below are dynamic rather than static.1015 1116 type StorageShape = Record<string, unknown>;17+const IDENTITY_KEY = "peach_local_identity_v4";18+const LEGACY_IDENTITY_KEY = "peach_local_identity_v2";19+const LEGACY_INFO = "peach-local-identity-v2";1220 const DEVICE_ID_KEY = "peach_device_id";13-const IDENTITY_KEY = "peach_local_identity_v2";14-const IDENTITY_INFO = "peach-local-identity-v2";1521 1622 let local: StorageShape;1723 let sync: StorageShape;24+let session: StorageShape;1825 let localSet: Mock;1926 let syncSet: Mock;27+let sessionSet: Mock;2028 2129 function installChromeStorageMock(): void {2230   const makeArea = (store: StorageShape, set: Mock) => ({@@ -29,60 +37,83 @@2937       }, {});3038     }),3139     set,32-    remove: vi.fn(async () => undefined),40+    remove: vi.fn(async (key: string | string[]) => {41+      for (const k of Array.isArray(key) ? key : [key]) delete store[k];42+    }),3343   });3444   local = {};3545   sync = {};46+  session = {};3647   localSet = vi.fn(async (next: Record<string, unknown>) => {3748     Object.assign(local, next);3849   });3950   syncSet = vi.fn(async (next: Record<string, unknown>) => {4051     Object.assign(sync, next);4152   });53+  sessionSet = vi.fn(async (next: Record<string, unknown>) => {54+    Object.assign(session, next);55+  });4256   (globalThis as { chrome?: typeof chrome }).chrome = {4357     storage: {4458       local: makeArea(local, localSet),4559       sync: makeArea(sync, syncSet),60+      session: makeArea(session, sessionSet),4661     },4762   } as unknown as typeof chrome;4863 }4964 50-/** Encrypt an arbitrary payload into the identity slot, bypassing the loader. */51-async function writeRawIdentity(payload: unknown, envelopeVersion = 3): Promise<void> {52-  const vault = await import("./vault");53-  const rootHex = vault.getRootKeyHex();54-  if (!rootHex) throw new Error("unlocked vault required");55-  const root = hexToBytes(rootHex);56-  try {57-    const aes = await import("./aes");58-    const sealed = await aes.encrypt(root, JSON.stringify(payload), IDENTITY_INFO);59-    local[IDENTITY_KEY] = { v: envelopeVersion, ...sealed };60-  } finally {61-    root.fill(0);62-  }65+function hexToBytes(hex: string): Uint8Array {66+  const out = new Uint8Array(hex.length / 2);67+  for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);68+  return out;6369 }6470 65-/** Decrypt whatever the loader persisted, for schema assertions. */66-async function readStoredIdentity(): Promise<Record<string, unknown>> {71+/** Seal an arbitrary payload into the pre-envelope identity slot, bypassing the loader. */72+async function writeLegacyIdentity(payload: unknown): Promise<void> {6773   const vault = await import("./vault");74+  const aes = await import("./aes");6875   const rootHex = vault.getRootKeyHex();6976   if (!rootHex) throw new Error("unlocked vault required");70-  const row = local[IDENTITY_KEY] as Record<string, string>;7177   const root = hexToBytes(rootHex);7278   try {73-    const aes = await import("./aes");74-    return JSON.parse(await aes.decrypt(root, row.nonce, row.ciphertext, row.tag, IDENTITY_INFO));79+    local[LEGACY_IDENTITY_KEY] = {80+      v: 3,81+      ...(await aes.encrypt(root, JSON.stringify(payload), LEGACY_INFO)),82+    };7583   } finally {7684     root.fill(0);7785   }7886 }7987 80-function hexToBytes(hex: string): Uint8Array {81-  const out = new Uint8Array(hex.length / 2);82-  for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);83-  return out;88+interface StoredEnvelope {89+  v: number;90+  blob: { nonce: string; ciphertext: string; tag: string };91+  ik: { root?: unknown; local?: unknown; pending?: unknown; pendingSalt?: string };8492 }8593 94+function storedEnvelope(): StoredEnvelope {95+  return local[IDENTITY_KEY] as StoredEnvelope;96+}97+98+/** The private/public pair the loader currently holds for this device. */99+async function currentKeys(): Promise<{100+  deviceId: string;101+  longTermPublicKeyHex: string | undefined;102+  signingPublicKeyHex: string | undefined;103+  derivedPublicHex: string;104+}> {105+  const { getLocalPairingIdentity } = await import("./device-identity");106+  const { identity, privateKey } = await getLocalPairingIdentity();107+  return {108+    deviceId: identity.deviceId,109+    longTermPublicKeyHex: identity.longTermPublicKeyHex,110+    signingPublicKeyHex: identity.signingPublicKeyHex,111+    derivedPublicHex: Array.from(generateKeyPairFromSeed(privateKey).publicKey, (b) =>112+      b.toString(16).padStart(2, "0"),113+    ).join(""),114+  };115+}116+86117 beforeEach(() => {87118   vi.resetModules();88119   installChromeStorageMock();@@ -95,7 +126,6 @@95126     const { getOrCreateDeviceId } = await import("./device-identity");96127 97128     const id = await getOrCreateDeviceId();98-99129     expect(typeof id).toBe("string");100130     expect(id.length).toBeGreaterThan(0);101131     expect(local[DEVICE_ID_KEY]).toBeUndefined();@@ -110,7 +140,6 @@110140 111141     const first = await getOrCreateDeviceId();112142     const second = await getOrCreateDeviceId();113-114143     expect(second).toBe(first);115144   });116145 @@ -121,7 +150,6 @@121150 122151     const original = await getOrCreateDeviceId();123152     const fresh = await rotateDeviceId();124-125153     expect(fresh).not.toBe(original);126154     expect(local[IDENTITY_KEY]).toBeDefined();127155     expect(await getOrCreateDeviceId()).toBe(fresh);@@ -136,84 +164,236 @@136164     const { getOrCreateDeviceId } = await import("./device-identity");137165 138166     const deviceId = await getOrCreateDeviceId();139-    const stored = await readStoredIdentity();167+    const keys = await currentKeys();168+    expect(keys.deviceId).toBe(deviceId);169+    // The stored public half must be the one the private half derives, not an170+    // independent value that merely looks well-formed.171+    expect(keys.longTermPublicKeyHex).toBe(keys.derivedPublicHex);172+    expect(keys.longTermPublicKeyHex).toMatch(/^[0-9a-f]{64}$/);173+    expect(keys.signingPublicKeyHex).toMatch(/^[0-9a-f]{64}$/);174+  });140175 141-    expect(stored.v).toBe(3);142-    expect(stored.deviceId).toBe(deviceId);143-    expect(stored.x25519PrivateKeyHex).toMatch(/^[0-9a-f]{64}$/);144-    expect(stored.x25519PublicKeyHex).toMatch(/^[0-9a-f]{64}$/);145-    expect(stored.ed25519SeedHex).toMatch(/^[0-9a-f]{64}$/);146-    expect(stored.ed25519PublicKeyHex).toMatch(/^[0-9a-f]{64}$/);147-    expect(stored.createdAt as number).toBeGreaterThan(0);176+  it("seals the private material instead of storing it in the clear", async () => {177+    const vault = await import("./vault");178+    await vault.createVault("identity-test-password");179+    const { getOrCreateDeviceId, getLocalPairingIdentity } = await import("./device-identity");180+    await getOrCreateDeviceId();181+182+    const { privateKey } = await getLocalPairingIdentity();183+    const privateHex = Array.from(privateKey, (b) => b.toString(16).padStart(2, "0")).join("");184+    const envelope = storedEnvelope();185+    expect(envelope.v).toBe(4);186+    expect(envelope.ik.root).toBeDefined();187+    expect(envelope.ik.local).toBeDefined();188+    expect(envelope.ik.pending).toBeUndefined();189+    expect(JSON.stringify(envelope)).not.toContain(privateHex);148190   });149191 150192   it("completes a legacy random record in place instead of regenerating it", async () => {151193     const vault = await import("./vault");152194     await vault.createVault("identity-test-password");153-    const legacy = {195+    await writeLegacyIdentity({154196       v: 2,155197       deviceId: "legacy-device-id",156198       x25519PrivateKeyHex: "11".repeat(32),157199       ed25519SeedHex: "22".repeat(32),158-    };159-    await writeRawIdentity(legacy, 2);160-    const { getOrCreateDeviceId } = await import("./device-identity");200+    });161201 202+    const { getOrCreateDeviceId } = await import("./device-identity");162203     expect(await getOrCreateDeviceId()).toBe("legacy-device-id");163-164-    const stored = await readStoredIdentity();165-    expect(stored.v).toBe(3);166-    expect(stored.deviceId).toBe("legacy-device-id");167-    // Same keys: the upgrade must never mint a new identity underneath pairings.168-    expect(stored.x25519PrivateKeyHex).toBe("11".repeat(32));169-    expect(stored.ed25519SeedHex).toBe("22".repeat(32));170-    expect(stored.x25519PublicKeyHex).toMatch(/^[0-9a-f]{64}$/);171-    expect(stored.ed25519PublicKeyHex).toMatch(/^[0-9a-f]{64}$/);204+    // Same identity material, now in the sealed envelope; the root-readable205+    // legacy copy is gone.206+    expect(storedEnvelope().v).toBe(4);207+    expect(local[LEGACY_IDENTITY_KEY]).toBeUndefined();208+    const keys = await currentKeys();209+    expect(keys.deviceId).toBe("legacy-device-id");210+    expect(keys.longTermPublicKeyHex).toBe(211+      Array.from(generateKeyPairFromSeed(hexToBytes("11".repeat(32))).publicKey, (b) =>212+        b.toString(16).padStart(2, "0"),213+      ).join(""),214+    );172215   });173216 174217   it("rejects a frozen record whose stored public key does not match its private key", async () => {175218     const vault = await import("./vault");176219     await vault.createVault("identity-test-password");177-    await writeRawIdentity({220+    await writeLegacyIdentity({178221       v: 3,179222       deviceId: "tampered",180223       x25519PrivateKeyHex: "11".repeat(32),181224       x25519PublicKeyHex: "33".repeat(32),182225       ed25519SeedHex: "22".repeat(32),183226       ed25519PublicKeyHex: "44".repeat(32),184-      createdAt: 1_700_000_000_000,227+      createdAt: Date.now(),185228     });186-    const { getOrCreateDeviceId } = await import("./device-identity");187229 230+    const { getOrCreateDeviceId } = await import("./device-identity");188231     await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required");232+    // A rejected record must not be replaced by a freshly minted identity.233+    expect(local[IDENTITY_KEY]).toBeUndefined();189234   });190235 191236   it("rejects a frozen record that is missing fields rather than defaulting them", async () => {192237     const vault = await import("./vault");193238     await vault.createVault("identity-test-password");194-    await writeRawIdentity({239+    await writeLegacyIdentity({195240       v: 3,196-      deviceId: "partial",241+      deviceId: "missing-fields",197242       x25519PrivateKeyHex: "11".repeat(32),198-      x25519PublicKeyHex: "33".repeat(32),199243       ed25519SeedHex: "22".repeat(32),200-      createdAt: 1_700_000_000_000,201244     });202-    const { getOrCreateDeviceId } = await import("./device-identity");203245 246+    const { getOrCreateDeviceId } = await import("./device-identity");204247     await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required");248+    expect(local[IDENTITY_KEY]).toBeUndefined();205249   });206250 207251   it("rejects a partial legacy record instead of minting a replacement", async () => {208252     const vault = await import("./vault");209253     await vault.createVault("identity-test-password");210-    await writeRawIdentity({254+    await writeLegacyIdentity({211255       v: 2,212-      deviceId: "legacy-device-id",213-      ed25519SeedHex: "22".repeat(32),256+      deviceId: "partial",257+      x25519PrivateKeyHex: "11".repeat(32),214258     });259+215260     const { getOrCreateDeviceId } = await import("./device-identity");261+    await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required");262+    expect(local[IDENTITY_KEY]).toBeUndefined();263+  });264+});216265 266+describe("device-identity pre-vault wallet (adoption v2 prerequisite)", () => {267+  it("mints and reads a durable identity with no vault present", async () => {268+    const { preparePreVaultIdentity, readPreVaultIdentity } = await import("./device-identity");269+    expect((await import("./vault")).isUnlocked()).toBe(false);270+271+    await preparePreVaultIdentity("local-password");272+    const first = await readPreVaultIdentity("local-password");273+    expect(first.deviceId.length).toBeGreaterThan(0);274+    expect(first.longTermPublicKeyHex).toMatch(/^[0-9a-f]{64}$/);275+276+    // Only the password-derived wrap exists before the vault does.277+    const envelope = storedEnvelope();278+    expect(envelope.ik.pending).toBeDefined();279+    expect(envelope.ik.root).toBeUndefined();280+    expect(envelope.ik.local).toBeUndefined();281+  });282+283+  it("survives a browser restart that drops the session cache", async () => {284+    const { preparePreVaultIdentity, readPreVaultIdentity } = await import("./device-identity");285+    await preparePreVaultIdentity("local-password");286+    const before = await readPreVaultIdentity("local-password");287+288+    // A browser restart clears chrome.storage.session and the module state.289+    for (const key of Object.keys(session)) delete session[key];290+    vi.resetModules();291+    const restarted = await import("./device-identity");292+    const after = await restarted.readPreVaultIdentity("local-password");293+294+    expect(after.deviceId).toBe(before.deviceId);295+    expect(after.longTermPublicKeyHex).toBe(before.longTermPublicKeyHex);296+    expect(after.signingPublicKeyHex).toBe(before.signingPublicKeyHex);297+  });298+299+  it("refuses the wrong password rather than minting a replacement", async () => {300+    const { preparePreVaultIdentity, readPreVaultIdentity } = await import("./device-identity");301+    await preparePreVaultIdentity("local-password");302+    // Drop the unlocked-session copy so the password-derived wrap is the only303+    // way in; otherwise the cache answers without consulting it.304+    for (const key of Object.keys(session)) delete session[key];305+306+    await expect(readPreVaultIdentity("not-the-password")).rejects.toThrow(307+      "identity_migration_required",308+    );309+    expect(storedEnvelope().ik.pending).toBeDefined();310+  });311+312+  it("promotes the pre-vault identity to the vault wraps once the vault exists", async () => {313+    const { preparePreVaultIdentity, readPreVaultIdentity, getOrCreateDeviceId } = await import(314+      "./device-identity"315+    );316+    await preparePreVaultIdentity("local-password");317+    const before = await readPreVaultIdentity("local-password");318+319+    const vault = await import("./vault");320+    await vault.createVault("local-password");321+    await readPreVaultIdentity("local-password");322+323+    const envelope = storedEnvelope();324+    expect(envelope.ik.pending).toBeUndefined();325+    expect(envelope.ik.root).toBeDefined();326+    expect(envelope.ik.local).toBeDefined();327+328+    // Same identity, now readable from the vault alone.329+    const after = await getOrCreateDeviceId();330+    expect(after).toBe(before.deviceId);331+    const keys = await currentKeys();332+    expect(keys.longTermPublicKeyHex).toBe(before.longTermPublicKeyHex);333+    expect(keys.signingPublicKeyHex).toBe(before.signingPublicKeyHex);334+  });335+336+  it("refuses to regenerate the identity when no wrapping key is available", async () => {337+    const vault = await import("./vault");338+    await vault.createVault("identity-test-password");339+    const { getOrCreateDeviceId } = await import("./device-identity");340+    const real = await getOrCreateDeviceId();341+342+    // Losing every wrap (no root, no vault-unlock key, no password) must fail343+    // closed rather than mint a new device identity.344+    delete storedEnvelope().ik.root;345+    delete storedEnvelope().ik.local;346+    for (const key of Object.keys(session)) delete session[key];217347     await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required");348+    expect(local[IDENTITY_KEY]).toBeDefined();349+    expect(real.length).toBeGreaterThan(0);350+  });351+});352+353+describe("device-identity lifecycle (key rotation and duress)", () => {354+  it("survives a password change followed by a sync-identity rotation", async () => {355+    const vault = await import("./vault");356+    await vault.createVault("first-password");357+    const { getOrCreateDeviceId, getLocalPairingIdentity } = await import("./device-identity");358+    const before = await getOrCreateDeviceId();359+    const beforeKeys = await getLocalPairingIdentity();360+361+    expect(await vault.changePassword("first-password", "second-password")).toBe(true);362+    expect((await vault.rotateSyncIdentity()).ok).toBe(true);363+364+    // Each swap kills the wrap encrypted under the outgoing key. Force a real365+    // envelope open so a missing re-wrap cannot hide behind the session cache.366+    for (const key of Object.keys(session)) delete session[key];367+    expect(await getOrCreateDeviceId()).toBe(before);368+    const afterKeys = await getLocalPairingIdentity();369+    expect(afterKeys.identity.longTermPublicKeyHex).toBe(beforeKeys.identity.longTermPublicKeyHex);370+    expect(afterKeys.identity.signingPublicKeyHex).toBe(beforeKeys.identity.signingPublicKeyHex);371+  });372+373+  it("does not serve the cached identity inside a duress session", async () => {374+    const vault = await import("./vault");375+    await vault.createVault("real-password");376+    const { getOrCreateDeviceId, buildLocalIdentity } = await import("./device-identity");377+    const real = await getOrCreateDeviceId();378+    await vault.setupDuress("duress-password");379+380+    // Switching an unlocked real session into duress mode must not leave the381+    // real identity reachable through the session cache.382+    expect(await vault.unlock("duress-password")).toBe(true);383+    expect(vault.isDecoyMode()).toBe(true);384+    await expect(buildLocalIdentity()).rejects.toThrow("identity_migration_required");385+    expect(real.length).toBeGreaterThan(0);386+  });387+388+  it("refuses to mint a replacement when the record is missing but a vault exists", async () => {389+    const vault = await import("./vault");390+    await vault.createVault("identity-test-password");391+    const { getOrCreateDeviceId } = await import("./device-identity");392+    await getOrCreateDeviceId();393+394+    delete local[IDENTITY_KEY];395+    for (const key of Object.keys(session)) delete session[key];396+    await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required");397+    expect(local[IDENTITY_KEY]).toBeUndefined();218398   });219399 });

view file

src/background/device-identity.ts+342 −144
@@ -1,26 +1,49 @@1-// Local-device identity helpers. Cryptographic credentials are generated once2-// per installation and kept in an encrypted local record; vault unlock is the3-// only way to hydrate the operational cache.1+// Local-device identity helpers.2+//3+// Credentials are generated once per installation and are never derived from4+// the vault root. They are sealed under a random identity key (IK), and IK is5+// wrapped under every key that can legitimately recover it:6+//7+//   root    the vault root key. Survives a password change and a recovery-word8+//           re-key; the password-derived key survives neither.9+//   local   the vault-unlock key, available from any normal unlock.10+//   pending a key derived from the local password on a fresh install that is11+//           being adopted. Such an install has no vault yet, so adoption v212+//           could not present a real identity unless one exists before the13+//           vault does. The pending wrap is dropped as soon as the vault does.14+//15+// Keeping the root wrap is what stops a password change or a recovery-word16+// re-key from orphaning the identity; regenerating an identity underneath an17+// existing installation is never acceptable.418 519 import { generateKeyPairFromSeed } from "@stablelib/x25519";6-import {7-  computeVaultId,8-  pubkeyToHex,9-} from "../shared/sync-identity";20+import { computeVaultId, pubkeyToHex } from "../shared/sync-identity";1021 import type { LocalIdentity } from "../shared/pairing-handshake";1122 import type { DeviceType } from "../shared/sync-types";1223 import { decrypt, encrypt } from "./aes";13-import { getRootKeyHex, isUnlocked } from "./vault";24+import {25+  getRootKeyHex,26+  isDecoyMode,27+  isUnlocked,28+  openIdentityKey,29+  sealIdentityKeyPreVault,30+  sealIdentityKeyUnderVault,31+  setIdentityHooks,32+  type IdentityKeyEnvelope,33+  type SealedBox,34+} from "./vault";1435 1536 const DEVICE_NAME_KEY = "peach_device_name";16-const IDENTITY_RECORD_KEY = "peach_local_identity_v2";17-const IDENTITY_INFO = "peach-local-identity-v2";37+const IDENTITY_RECORD_KEY = "peach_local_identity_v4";38+const IDENTITY_MARKER_KEY = "peach_local_identity_initialized";39+const LEGACY_IDENTITY_KEY = "peach_local_identity_v2";40+const LEGACY_INFO = "peach-local-identity-v2";41+const BLOB_INFO = "peach-local-identity-blob-v1";42+const SESSION_CACHE_KEY = "peach_local_identity_session";43+const HEX_64 = /^[0-9a-f]{64}$/i;1844 1945 /** Frozen local-identity schema (campaign task 2.1). */2046 const IDENTITY_RECORD_VERSION = 3;21-/** First random-identity format — private seeds only. Completed in place. */22-const LEGACY_RANDOM_RECORD_VERSION = 2;23-const HEX_64 = /^[0-9a-f]{64}$/i;2447 2548 interface IdentityRecord {2649   v: typeof IDENTITY_RECORD_VERSION;@@ -32,7 +55,11 @@3255   createdAt: number;3356 }3457 35-let cached: IdentityRecord | null = null;58+interface IdentityEnvelope {59+  v: 4;60+  blob: SealedBox;61+  ik: IdentityKeyEnvelope;62+}3663 3764 export interface LocalDeviceIdentity {3865   deviceId: string;@@ -42,26 +69,62 @@4269   signingPublicKeyHex: string;4370 }4471 45-function hex(bytes: Uint8Array): string {46-  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");72+function hex(value: Uint8Array): string {73+  return Array.from(value, (b) => b.toString(16).padStart(2, "0")).join("");4774 }75+4876 function bytes(value: string): Uint8Array {49-  if (!/^[0-9a-f]{64}$/i.test(value)) throw new Error("identity_migration_required");77+  if (!HEX_64.test(value)) throw new Error("identity_migration_required");5078   const out = new Uint8Array(32);5179   for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);5280   return out;5381 }82+83+function base64ToHex(value: string): string {84+  const raw = atob(value.replace(/-/g, "+").replace(/_/g, "/"));85+  return Array.from(raw, (c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");86+}87+88+async function importSigningKey(seed: Uint8Array): Promise<CryptoKey> {89+  const prefix = Uint8Array.of(0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20);90+  const pkcs8 = new Uint8Array(prefix.length + 32);91+  pkcs8.set(prefix);92+  pkcs8.set(seed, prefix.length);93+  try {94+    return await crypto.subtle.importKey("pkcs8", pkcs8 as Uint8Array<ArrayBuffer>, { name: "Ed25519" }, true, ["sign"]);95+  } finally {96+    pkcs8.fill(0);97+  }98+}99+54100 /** Public values derived from the private material, for creation and for the integrity check on load. */55101 async function derivePublicHex(privateHex: {56102   x25519PrivateKeyHex: string;57103   ed25519SeedHex: string;58104 }): Promise<{ x25519PublicKeyHex: string; ed25519PublicKeyHex: string }> {59105   const kp = generateKeyPairFromSeed(bytes(privateHex.x25519PrivateKeyHex));106+  const x25519PublicKeyHex = pubkeyToHex(kp.publicKey);107+  kp.secretKey.fill(0);60108   const signingPrivateKey = await importSigningKey(bytes(privateHex.ed25519SeedHex));61109   const jwk = (await crypto.subtle.exportKey("jwk", signingPrivateKey)) as JsonWebKey;110+  return { x25519PublicKeyHex, ed25519PublicKeyHex: base64ToHex(jwk.x ?? "") };111+}112+113+function parseLegacyRandomRecord(value: unknown): {114+  deviceId: string;115+  x25519PrivateKeyHex: string;116+  ed25519SeedHex: string;117+} | null {118+  if (typeof value !== "object" || value === null) return null;119+  const row = value as Record<string, unknown>;120+  if (row.v !== 2) return null;121+  if (typeof row.deviceId !== "string" || row.deviceId.length === 0) return null;122+  if (typeof row.x25519PrivateKeyHex !== "string" || !HEX_64.test(row.x25519PrivateKeyHex)) return null;123+  if (typeof row.ed25519SeedHex !== "string" || !HEX_64.test(row.ed25519SeedHex)) return null;62124   return {63-    x25519PublicKeyHex: pubkeyToHex(kp.publicKey),64-    ed25519PublicKeyHex: base64ToHex(jwk.x ?? ""),125+    deviceId: row.deviceId,126+    x25519PrivateKeyHex: row.x25519PrivateKeyHex,127+    ed25519SeedHex: row.ed25519SeedHex,65128   };66129 }67130 @@ -97,145 +160,288 @@97160   };98161 }99162 100-function parseLegacyRandomRecord(value: unknown): {101-  deviceId: string;102-  x25519PrivateKeyHex: string;103-  ed25519SeedHex: string;104-} | null {105-  if (typeof value !== "object" || value === null) return null;106-  const row = value as Record<string, unknown>;107-  if (row.v !== LEGACY_RANDOM_RECORD_VERSION) return null;108-  if (typeof row.deviceId !== "string" || row.deviceId.length === 0) return null;109-  if (typeof row.x25519PrivateKeyHex !== "string" || !HEX_64.test(row.x25519PrivateKeyHex)) return null;110-  if (typeof row.ed25519SeedHex !== "string" || !HEX_64.test(row.ed25519SeedHex)) return null;163+async function makeRecord(): Promise<IdentityRecord> {164+  const x = crypto.getRandomValues(new Uint8Array(32));165+  const ed = crypto.getRandomValues(new Uint8Array(32));166+  const privateHex = { x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed) };167+  x.fill(0);168+  ed.fill(0);111169   return {112-    deviceId: row.deviceId,113-    x25519PrivateKeyHex: row.x25519PrivateKeyHex,114-    ed25519SeedHex: row.ed25519SeedHex,170+    v: IDENTITY_RECORD_VERSION,171+    deviceId: generateUuidV4(),172+    ...privateHex,173+    ...(await derivePublicHex(privateHex)),174+    createdAt: Date.now(),115175   };116176 }117177 118-async function persistRecord(root: Uint8Array, record: IdentityRecord): Promise<void> {119-  const sealed = await encrypt(root, JSON.stringify(record), IDENTITY_INFO);120-  await chrome.storage.local.set({121-    [IDENTITY_RECORD_KEY]: { v: IDENTITY_RECORD_VERSION, ...sealed },122-  });178+async function persist(179+  record: IdentityRecord,180+  ik: Uint8Array,181+  envelope: IdentityKeyEnvelope,182+): Promise<void> {183+  const blob = await encrypt(ik, JSON.stringify(record), BLOB_INFO);184+  const stored: IdentityEnvelope = { v: 4, blob, ik: envelope };185+  // The marker is what distinguishes "this install has never minted an186+  // identity" from "this install's identity record was lost": a missing record187+  // is only mintable in the first case. Written in the same transaction.188+  await chrome.storage.local.set({ [IDENTITY_RECORD_KEY]: stored, [IDENTITY_MARKER_KEY]: true });123189 }124190 191+// The operational copy of the decrypted record. chrome.storage.session survives192+// an MV3 service-worker restart but is cleared when the browser closes. It is193+// only ever served inside a real unlocked session: serving it while locked or in194+// duress mode would hand out the real device identity.195+async function cacheGet(): Promise<IdentityRecord | null> {196+  if (!isUnlocked() || isDecoyMode()) return null;197+  const session = chrome.storage.session;198+  if (!session) return null;199+  const stored = await session.get(SESSION_CACHE_KEY);200+  return (stored[SESSION_CACHE_KEY] as IdentityRecord | undefined) ?? null;201+}202+203+async function cacheSet(record: IdentityRecord): Promise<void> {204+  await chrome.storage.session?.set({ [SESSION_CACHE_KEY]: record });205+}206+207+async function clearCache(): Promise<void> {208+  await chrome.storage.session?.remove(SESSION_CACHE_KEY);209+}210+211+setIdentityHooks({212+  onSessionEnded: clearCache,213+  onKeysRotated: rewrapIdentity,214+  onVaultCreated: promotePreVaultIdentity,215+});216+125217 /**126- * A legacy random record is completed in place — same device ID and keys —218+ * A legacy random record is completed in place -- same device ID and keys --127219  * rather than regenerated. `createdAt` is the upgrade time, because the original128220  * creation time was never recorded.129221  */130-async function completeLegacyRecord(131-  root: Uint8Array,132-  legacy: { deviceId: string; x25519PrivateKeyHex: string; ed25519SeedHex: string },133-): Promise<IdentityRecord> {134-  const derived = await derivePublicHex(legacy);135-  const record: IdentityRecord = {222+async function completeLegacyRecord(legacy: {223+  deviceId: string;224+  x25519PrivateKeyHex: string;225+  ed25519SeedHex: string;226+}): Promise<IdentityRecord> {227+  return {136228     v: IDENTITY_RECORD_VERSION,137229     deviceId: legacy.deviceId,138230     x25519PrivateKeyHex: legacy.x25519PrivateKeyHex,139231     ed25519SeedHex: legacy.ed25519SeedHex,140-    ...derived,232+    ...(await derivePublicHex(legacy)),141233     createdAt: Date.now(),142234   };143-  await persistRecord(root, record);235+}236+237+/**238+ * Re-seal an identity written before the envelope existed. The old record was239+ * sealed under the vault root, so it is a second root-readable copy of the same240+ * private keys; the re-sealed envelope is durable by the time it is removed.241+ */242+async function migrateLegacyIdentity(): Promise<IdentityRecord | null> {243+  const stored = (await chrome.storage.local.get(LEGACY_IDENTITY_KEY))[LEGACY_IDENTITY_KEY] as244+    | Record<string, string>245+    | undefined;246+  if (!stored) return null;247+  const rootHex = getRootKeyHex();248+  if (!rootHex) throw new Error("identity_migration_required");249+  const root = bytes(rootHex);250+  try {251+    let parsed: unknown;252+    try {253+      parsed = JSON.parse(await decrypt(root, stored.nonce, stored.ciphertext, stored.tag, LEGACY_INFO));254+    } catch {255+      throw new Error("identity_migration_required");256+    }257+    const legacy = parseLegacyRandomRecord(parsed);258+    const record = (await parseCurrentRecord(parsed)) ?? (legacy ? await completeLegacyRecord(legacy) : null);259+    if (!record) throw new Error("identity_migration_required");260+    const ik = crypto.getRandomValues(new Uint8Array(32));261+    try {262+      await persist(record, ik, await sealIdentityKeyUnderVault(ik));263+    } finally {264+      ik.fill(0);265+    }266+    await chrome.storage.local.remove(LEGACY_IDENTITY_KEY);267+    await cacheSet(record);268+    return record;269+  } finally {270+    root.fill(0);271+  }272+}273+274+/** Decrypt and validate the record out of an envelope once its identity key is open. */275+async function readRecordFromEnvelope(stored: IdentityEnvelope, ik: Uint8Array): Promise<IdentityRecord> {276+  let parsed: unknown;277+  try {278+    parsed = JSON.parse(await decrypt(ik, stored.blob.nonce, stored.blob.ciphertext, stored.blob.tag, BLOB_INFO));279+  } catch {280+    throw new Error("identity_migration_required");281+  }282+  const record = await parseCurrentRecord(parsed);283+  if (!record) throw new Error("identity_migration_required");144284   return record;145285 }146286 147-async function loadRecord(root: Uint8Array): Promise<IdentityRecord | null> {287+async function readEnvelope(): Promise<IdentityEnvelope | null> {288+  const stored = (await chrome.storage.local.get(IDENTITY_RECORD_KEY))[IDENTITY_RECORD_KEY] as289+    | IdentityEnvelope290+    | undefined;291+  if (!stored) return null;292+  if (stored.v !== 4 || !stored.blob || !stored.ik) throw new Error("identity_migration_required");293+  return stored;294+}295+296+async function loadRecord(password?: string): Promise<IdentityRecord | null> {297+  const stored = await readEnvelope();298+  if (!stored) return migrateLegacyIdentity();299+300+  const cached = await cacheGet();148301   if (cached) return cached;149-  const row = (await chrome.storage.local.get(IDENTITY_RECORD_KEY))[IDENTITY_RECORD_KEY] as Record<string, unknown> | undefined;150-  if (!row) return null;151-  if (typeof row.v !== "number" || typeof row.nonce !== "string" || typeof row.ciphertext !== "string" || typeof row.tag !== "string") {152-    throw new Error("identity_migration_required");302+303+  const ik = await openIdentityKey(stored.ik, password);304+  if (!ik) throw new Error("identity_migration_required");305+  try {306+    const record = await readRecordFromEnvelope(stored, ik);307+    await cacheSet(record);308+    return record;309+  } finally {310+    ik.fill(0);153311   }154-  let parsed: unknown;312+}313+314+/**315+ * Re-wrap the stored envelope under the vault keys now in force. Every vault316+ * key swap kills the wrap that was encrypted under the old key, so without this317+ * a password change followed by a sync-identity rotation (in either order) would318+ * leave no wrap able to open the identity — permanent, unrecoverable loss. Only319+ * the live vault keys are used; the pre-vault password is never a way in here.320+ */321+async function rewrapIdentity(): Promise<void> {322+  const stored = await readEnvelope();323+  if (!stored || !isUnlocked() || getRootKeyHex() === null) return;324+  const ik = await openIdentityKey(stored.ik);325+  if (!ik) return;155326   try {156-    parsed = JSON.parse(await decrypt(root, row.nonce, row.ciphertext, row.tag, IDENTITY_INFO));157-  } catch { throw new Error("identity_migration_required"); }158-  const current = await parseCurrentRecord(parsed);159-  if (current) {160-    cached = current;161-    return current;327+    const record = await readRecordFromEnvelope(stored, ik);328+    await persist(record, ik, await sealIdentityKeyUnderVault(ik));329+    await cacheSet(record);330+  } finally {331+    ik.fill(0);162332   }163-  const legacy = parseLegacyRandomRecord(parsed);164-  if (legacy) {165-    const upgraded = await completeLegacyRecord(root, legacy);166-    cached = upgraded;167-    return upgraded;333+}334+335+/**336+ * Replace the pre-vault wrap with the vault wraps now that a vault exists. The337+ * pending wrap is the only authority for this: it is sealed under the password338+ * the user chose for this install, so a password that never created this339+ * identity cannot re-seal it into a vault it does not belong to.340+ */341+async function promotePreVaultIdentity(password: string): Promise<void> {342+  const stored = await readEnvelope();343+  if (!stored?.ik.pending || !stored.ik.pendingSalt) return;344+  const ik = await openIdentityKey(345+    { pending: stored.ik.pending, pendingSalt: stored.ik.pendingSalt },346+    password,347+  );348+  if (!ik) return;349+  try {350+    const record = await readRecordFromEnvelope(stored, ik);351+    await persist(record, ik, await sealIdentityKeyUnderVault(ik));352+    await cacheSet(record);353+  } finally {354+    ik.fill(0);168355   }169-  throw new Error("identity_migration_required");170356 }171357 172-async function ensureRecord(root: Uint8Array): Promise<IdentityRecord> {173-  const existing = await loadRecord(root);358+async function ensureRecord(): Promise<IdentityRecord> {359+  const existing = await loadRecord();174360   if (existing) return existing;175-  const x = new Uint8Array(32);176-  const ed = new Uint8Array(32);177-  crypto.getRandomValues(x);178-  crypto.getRandomValues(ed);179-  const privateHex = { x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed) };180-  x.fill(0);181-  ed.fill(0);182-  const record: IdentityRecord = {183-    v: IDENTITY_RECORD_VERSION,184-    deviceId: generateUuidV4(),185-    ...privateHex,186-    ...(await derivePublicHex(privateHex)),187-    createdAt: Date.now(),361+  const cached = await cacheGet();362+  if (cached) return cached;363+  // An identity was minted on this install and its record is now gone. Refuse364+  // rather than hand every peer an unknown device whose new keys no longer365+  // match the revocation ledger.366+  if ((await chrome.storage.local.get(IDENTITY_MARKER_KEY))[IDENTITY_MARKER_KEY]) {367+    throw new Error("identity_migration_required");368+  }369+  const record = await makeRecord();370+  const ik = crypto.getRandomValues(new Uint8Array(32));371+  try {372+    await persist(record, ik, await sealIdentityKeyUnderVault(ik));373+    await cacheSet(record);374+    return record;375+  } finally {376+    ik.fill(0);377+  }378+}379+380+/**381+ * Create the identity for an install that has no vault yet. Adoption v2 sends382+ * this identity in its FIRST frame, long before a root key exists, so the wrap383+ * key comes from the local password the user is choosing. A no-op once any384+ * identity record exists.385+ */386+export async function preparePreVaultIdentity(password: string): Promise<void> {387+  const stored = await chrome.storage.local.get(IDENTITY_RECORD_KEY);388+  if (stored[IDENTITY_RECORD_KEY]) return;389+  const record = await makeRecord();390+  const ik = crypto.getRandomValues(new Uint8Array(32));391+  try {392+    await persist(record, ik, await sealIdentityKeyPreVault(password, ik));393+    await cacheSet(record);394+  } finally {395+    ik.fill(0);396+  }397+}398+399+/** Read the pre-vault identity back for the adoption handshake. */400+export async function readPreVaultIdentity(password: string): Promise<LocalDeviceIdentity> {401+  const record = await loadRecord(password);402+  if (!record) throw new Error("identity_unavailable");403+  return {404+    deviceId: record.deviceId,405+    deviceName: await getDeviceName(),406+    deviceType: "extension",407+    longTermPublicKeyHex: record.x25519PublicKeyHex,408+    signingPublicKeyHex: record.ed25519PublicKeyHex,188409   };189-  await persistRecord(root, record);190-  cached = record;191-  return record;192410 }193411 194412 export async function getOrCreateDeviceId(): Promise<string> {195-  if (!isUnlocked()) throw new Error("vault_locked");196-  const rootHex = getRootKeyHex(); if (!rootHex) throw new Error("vault_locked");197-  return (await ensureRecord(bytes(rootHex))).deviceId;413+  if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked");414+  return (await ensureRecord()).deviceId;198415 }416+199417 export async function rotateDeviceId(): Promise<string> {200-  if (!isUnlocked()) throw new Error("vault_locked");201-  const rootHex = getRootKeyHex();202-  if (!rootHex) throw new Error("vault_locked");203-  const root = bytes(rootHex);204-  const x = new Uint8Array(32);205-  const ed = new Uint8Array(32);418+  if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked");419+  const record = await makeRecord();420+  const ik = crypto.getRandomValues(new Uint8Array(32));206421   try {207-    crypto.getRandomValues(x);208-    crypto.getRandomValues(ed);209-    const privateHex = { x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed) };210-    const record: IdentityRecord = {211-      v: IDENTITY_RECORD_VERSION,212-      deviceId: generateUuidV4(),213-      ...privateHex,214-      ...(await derivePublicHex(privateHex)),215-      createdAt: Date.now(),216-    };217-    await persistRecord(root, record);218-    cached = record;422+    await persist(record, ik, await sealIdentityKeyUnderVault(ik));423+    await cacheSet(record);219424     return record.deviceId;220425   } finally {221-    root.fill(0);222-    x.fill(0);223-    ed.fill(0);426+    ik.fill(0);224427   }225428 }429+226430 export async function getDeviceName(): Promise<string> {227431   const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);228432   const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];229433   return typeof stored === "string" && stored.length > 0 ? stored : defaultDeviceName();230434 }435+231436 export async function setDeviceName(name: string): Promise<void> {232437   const trimmed = name.trim();233438   if (!trimmed) throw new Error("device name must not be empty");234439   if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");235440   await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });236441 }237-export async function buildLocalIdentity(masterKey: Uint8Array): Promise<LocalDeviceIdentity> {238-  const record = await ensureRecord(masterKey);442+443+export async function buildLocalIdentity(): Promise<LocalDeviceIdentity> {444+  const record = await ensureRecord();239445   return {240446     deviceId: record.deviceId,241447     deviceName: await getDeviceName(),@@ -244,17 +450,29 @@244450     signingPublicKeyHex: record.ed25519PublicKeyHex,245451   };246452 }247-async function importSigningKey(seed: Uint8Array): Promise<CryptoKey> {248-  const prefix = Uint8Array.of(0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20);249-  const pkcs8 = new Uint8Array(prefix.length + 32);250-  pkcs8.set(prefix);251-  pkcs8.set(seed, prefix.length);252-  return crypto.subtle.importKey("pkcs8", pkcs8 as Uint8Array<ArrayBuffer>, { name: "Ed25519" }, true, ["sign"]);453+454+export async function getLocalPairingIdentity(): Promise<{455+  identity: LocalIdentity;456+  privateKey: Uint8Array;457+  publicKey: Uint8Array;458+  signingPrivateKey: CryptoKey;459+}> {460+  if (!isUnlocked()) throw new Error("vault_locked");461+  const rootHex = getRootKeyHex();462+  if (!rootHex) throw new Error("vault_locked");463+  const root = bytes(rootHex);464+  try {465+    const record = await ensureRecord();466+    const privateKey = bytes(record.x25519PrivateKeyHex);467+    const publicKey = generateKeyPairFromSeed(privateKey).publicKey;468+    const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex));469+    const base = await buildLocalIdentity();470+    return { identity: { ...base, vaultId: await computeVaultId(root) }, privateKey, publicKey, signingPrivateKey };471+  } finally {472+    root.fill(0);473+  }253474 }254-function base64ToHex(value: string): string {255-  const raw = atob(value.replace(/-/g, "+").replace(/_/g, "/"));256-  return Array.from(raw, (c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");257-}475+258476 function defaultDeviceName(): string {259477   // Best-effort browser + platform string. Avoids userAgent parsing — the260478   // user can rename via Settings.@@ -272,29 +490,9 @@272490   if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {273491     return crypto.randomUUID();274492   }275-  const bytes = new Uint8Array(16);276-  crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);277-  bytes[6] = (bytes[6] & 0x0f) | 0x40;278-  bytes[8] = (bytes[8] & 0x3f) | 0x80;279-  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");280-  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;281-}282-283-export async function getLocalPairingIdentity(): Promise<{284-  identity: LocalIdentity;285-  privateKey: Uint8Array;286-  publicKey: Uint8Array;287-  signingPrivateKey: CryptoKey;288-}> {289-  if (!isUnlocked()) throw new Error("vault_locked");290-  const rootHex = getRootKeyHex(); if (!rootHex) throw new Error("vault_locked");291-  const root = bytes(rootHex);292-  try {293-    const record = await ensureRecord(root);294-    const privateKey = bytes(record.x25519PrivateKeyHex);295-    const publicKey = generateKeyPairFromSeed(privateKey).publicKey;296-    const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex));297-    const base = await buildLocalIdentity(root);298-    return { identity: { ...base, vaultId: await computeVaultId(root) }, privateKey, publicKey, signingPrivateKey };299-  } finally { root.fill(0); }493+  const b = crypto.getRandomValues(new Uint8Array(16));494+  b[6] = (b[6] & 0x0f) | 0x40;495+  b[8] = (b[8] & 0x3f) | 0x80;496+  const h = hex(b);497+  return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;300498 }

view file

src/background/messages.ts+10 −16
@@ -72,6 +72,7 @@7272   buildLocalIdentity,7373   getOrCreateDeviceId,7474   setDeviceName as setDeviceNameV3,75+  type LocalDeviceIdentity,7576 } from "./device-identity";7677 import {7778   isDeviceRevoked,@@ -1599,23 +1600,16 @@15991600         // Not Pro-gated: a free device may attempt to pair so it can RECEIVE a16001601         // Pro license from an already-Pro peer (at least one device must be Pro).16011602         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1602-        // Identity derives from the stable root key, not the password-derived1603-        // key, so pairing survives a master-password change (SEV-2-B).1604-        const masterKeyHex = vault.getRootKeyHex();1605-        if (!masterKeyHex) return { ok: false, error: "vault_locked" };1606-        const masterKey = unhexBytes(masterKeyHex);1607-        try {1608-          const identity = await buildLocalIdentity(masterKey);1609-          // If this device is Pro, advertise the signed license so the peer can1610-          // activate it after SAS confirmation — pairing carries Pro across.1611-          if (await isProUser()) {1612-            const license = await getLicenseKey();1613-            if (license) (identity as { license?: string }).license = license;1614-          }1615-          return { ok: true, data: identity };1616-        } finally {1617-          masterKey.fill(0);1603+        // The identity is sealed under its own key rather than derived from the1604+        // root, so pairing survives a master-password change (SEV-2-B).1605+        const identity: LocalDeviceIdentity & { license?: string } = await buildLocalIdentity();1606+        // If this device is Pro, advertise the signed license so the peer can1607+        // activate it after SAS confirmation — pairing carries Pro across.1608+        if (await isProUser()) {1609+          const license = await getLicenseKey();1610+          if (license) identity.license = license;16181611         }1612+        return { ok: true, data: identity };16191613       }16201614 16211615       case "LIST_PAIRED_DEVICES": {

view file

src/background/relay-adoption-bg.test.ts+1 −1
@@ -861,7 +861,7 @@861861     // call their modules directly from the service worker.862862     expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");863863     expect(mocks.isProUser).toHaveBeenCalled();864-    expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));864+    expect(mocks.localIdentity).toHaveBeenCalled();865865     expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({866866       deviceId: "phone-source-id",867867     }));

view file

src/background/relay-adoption-bg.ts+3 −24
@@ -313,14 +313,7 @@313313         hasAdoptionIdentityConflict(device.deviceId, device.longTermPublicKeyHex ?? ""),314314       rotateSourceIdentity: async () => {315315         await rotateDeviceId();316-        const masterKeyHex = getRootKeyHex();317-        if (!masterKeyHex) throw new Error("identity_unavailable");318-        const masterKey = hexToBytes(masterKeyHex);319-        try {320-          return await buildLocalIdentity(masterKey);321-        } finally {322-          masterKey.fill(0);323-        }316+        return await buildLocalIdentity();324317       },325318     });326319     if (!device) {@@ -470,25 +463,11 @@470463       },471464       async deriveLocalIdentity() {472465         // Direct call for the same reason as applyLicense above.473-        const masterKeyHex = getRootKeyHex();474-        if (!masterKeyHex) throw new Error("identity_unavailable");475-        const masterKey = hexToBytes(masterKeyHex);476-        try {477-          return await buildLocalIdentity(masterKey);478-        } finally {479-          masterKey.fill(0);480-        }466+        return await buildLocalIdentity();481467       },482468       async rotateLocalIdentity() {483469         await rotateDeviceId();484-        const masterKeyHex = getRootKeyHex();485-        if (!masterKeyHex) throw new Error("identity_unavailable");486-        const masterKey = hexToBytes(masterKeyHex);487-        try {488-          return await buildLocalIdentity(masterKey);489-        } finally {490-          masterKey.fill(0);491-        }470+        return await buildLocalIdentity();492471       },493472       async upsertPairedDevice(paired) {494473         // Direct call for the same reason as applyLicense above.

view file

src/background/vault.ts+151 −0
@@ -406,7 +406,41 @@406406 let lockedSyncRefreshHook: (() => Promise<void>) | null = null;407407 let lockedSyncUnlockHook: (() => Promise<void>) | null = null;408408 let decoySyncTeardownHook: (() => void) | null = null;409+export type IdentityHooks = {410+  /** Drop the cached identity: the session is no longer a real unlocked one. */411+  onSessionEnded: () => Promise<void>;412+  /** Re-wrap the identity under the keys now in force, after a vault key swap. */413+  onKeysRotated: () => Promise<void>;414+  /** Promote a pre-vault identity once a vault exists, given the password that created it. */415+  onVaultCreated: (password: string) => Promise<void>;416+};409417 418+let identityHooks: IdentityHooks | null = null;419+420+export function setIdentityHooks(hooks: IdentityHooks): void {421+  identityHooks = hooks;422+}423+424+async function notifyIdentitySessionEnded(): Promise<void> {425+  await identityHooks?.onSessionEnded().catch((error) => {426+    console.warn("[Peach] local identity cache clear failed:", error);427+  });428+}429+430+async function notifyIdentityKeysRotated(): Promise<void> {431+  await identityHooks?.onKeysRotated().catch((error) => {432+    console.warn("[Peach] local identity re-wrap failed:", error);433+  });434+}435+436+export type SealedBox = { nonce: string; ciphertext: string; tag: string };437+export type IdentityKeyEnvelope = {438+  root?: SealedBox;439+  local?: SealedBox;440+  pending?: SealedBox;441+  pendingSalt?: string;442+};443+410444 export function setLockedSyncHooks(hooks: {411445   refresh: () => Promise<void>;412446   afterUnlock: () => Promise<void>;@@ -735,6 +769,10 @@735769   // a user who just reset their password.736770   await saveSessionKey(newKey, "real", epoch);737771   throwIfStale();772+  // Same reason as changePassword: the unlock key just changed, so the773+  // identity's local wrap has to be re-encrypted under it.774+  await notifyIdentityKeysRotated();775+  throwIfStale();738776   // Refresh pw_root_* under the new key so deriveRootKeyFromDerivedKey works739777   // on the next unlock (H2/COR-006). If rootKey is missing — recovery flow740778   // should have populated it — fall back silently rather than blowing up the@@ -800,6 +838,83 @@800838   }801839 }802840 841+export async function sealIdentityKeyUnderVault(ik: Uint8Array): Promise<IdentityKeyEnvelope> {842+  if (vaultMode !== "real" || !rootKey || !derivedKey) throw new Error("vault_locked");843+  // The root wrap survives password change and recovery-words re-key; the844+  // local wrap is available during normal unlock. Pending is only pre-vault.845+  const [root, local] = await Promise.all([846+    encrypt(rootKey, hex(ik), "peach-local-identity-ik-root-v1"),847+    encrypt(derivedKey, hex(ik), "peach-local-identity-ik-local-v1"),848+  ]);849+  return { root, local };850+}851+852+export async function sealIdentityKeyPreVault(853+  password: string,854+  ik: Uint8Array,855+): Promise<IdentityKeyEnvelope> {856+  const salt = generateSalt();857+  const key = await deriveKey(password, salt);858+  try {859+    const pending = await encrypt(key, hex(ik), "peach-local-identity-ik-pending-v1");860+    return { pending, pendingSalt: hex(salt) };861+  } finally {862+    zeroKey(key);863+  }864+}865+866+/**867+ * Open the identity key with any wrapping key that is currently available:868+ * the live vault-unlock key, then the vault root key, then the pre-vault869+ * password. Each attempt is awaited only after the previous one fails, so a870+ * successful early unwrap never leaves a later decrypt rejection unobserved,871+ * and the pre-vault Argon2id is not paid when a vault key already opens the872+ * envelope.873+ */874+export async function openIdentityKey(875+  env: IdentityKeyEnvelope,876+  password?: string,877+): Promise<Uint8Array | null> {878+  if (env.local && derivedKey) {879+    const ik = await openSealedKey(derivedKey, env.local, "peach-local-identity-ik-local-v1");880+    if (ik) return ik;881+  }882+  if (env.root && rootKey) {883+    const ik = await openSealedKey(rootKey, env.root, "peach-local-identity-ik-root-v1");884+    if (ik) return ik;885+  }886+  if (!env.pending || !env.pendingSalt || password === undefined) return null;887+  let key: Uint8Array;888+  try {889+    const salt = unhex(env.pendingSalt);890+    key = await deriveKey(password, salt);891+    salt.fill(0);892+  } catch {893+    return null;894+  }895+  try {896+    return await openSealedKey(key, env.pending, "peach-local-identity-ik-pending-v1");897+  } finally {898+    zeroKey(key);899+  }900+}901+902+/** Decrypt a sealed 32-byte key; null for a wrong key, a bad shape, or corrupt bytes. */903+async function openSealedKey(904+  key: Uint8Array,905+  box: SealedBox,906+  info: string,907+): Promise<Uint8Array | null> {908+  try {909+    const value = unhex(await decrypt(key, box.nonce, box.ciphertext, box.tag, info));910+    if (value.length === 32) return value;911+    value.fill(0);912+    return null;913+  } catch {914+    return null;915+  }916+}917+803918 // AES-GCM-wrap rootKey under derivedKey for the pw_root_* fields. Returns the804919 // hex-encoded ciphertext/nonce/tag triple ready to embed in StoredVault.805920 async function wrapRootKeyForDerivedKey(@@ -1265,6 +1380,12 @@12651380   await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));12661381   await stampLocalVaultMutation();12671382 1383+  // An identity created while this install had no vault was sealed under the1384+  // password alone; the vault keys exist now, so re-seal it under them.1385+  await identityHooks?.onVaultCreated(password).catch((error) => {1386+    console.warn("[Peach] local identity promotion failed:", error);1387+  });1388+12681389   touchAutoLock();12691390 }12701391 @@ -1338,6 +1459,12 @@13381459     if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);13391460     touchAutoLock();13401461 1462+    // The receiver's identity was minted before this vault existed (adoption v21463+    // needs it in its first frame), so re-seal it under the vault keys now.1464+    await identityHooks?.onVaultCreated(password).catch((error) => {1465+      console.warn("[Peach] local identity promotion failed:", error);1466+    });1467+13411468     return { vaultId };13421469   } finally {13431470     rootKeyBytes.fill(0);@@ -1536,6 +1663,13 @@15361663     derivedKey = keyB;15371664     vaultMode = "decoy";15381665     decoySyncTeardownHook?.();1666+    // A duress session must not retain the real vault's root key. It gates the1667+    // recovery words, the sync identity, and the root wrap of the local device1668+    // identity, so leaving it live would expose all three in duress mode.1669+    if (rootKey) {1670+      rootKey.fill(0);1671+      rootKey = null;1672+    }15391673     entries = payloadB.entries;15401674     tombstones = payloadB.tombstones;15411675     duressConfigured = false;@@ -1545,6 +1679,8 @@15451679     if (keyA) zeroKey(keyA);15461680     // PB-26-213: a duress session must never serve the real pair-secret map.15471681     await dropSessionCache();1682+    // Nor the real device identity: a duress session is not a real one.1683+    await notifyIdentitySessionEnded();15481684     if (stale()) {15491685       await lock();15501686       return false;@@ -1702,6 +1838,8 @@17021838   } else {17031839     // PB-26-213: a duress restore must never serve the real pair-secret map.17041840     await dropSessionCache();1841+    // Nor the real device identity.1842+    await notifyIdentitySessionEnded();17051843     if (stale()) { zeroKey(key); return false; }17061844   }17071845   derivedKey = key;@@ -1749,6 +1887,9 @@17491887   duressConfigured = false;17501888   clearAutoLock();17511889   await clearSessionKey();1890+  // The decrypted identity is session state as well, so a locked vault must not1891+  // leave one behind; clear it after the in-memory state is gone.1892+  await notifyIdentitySessionEnded();17521893   await clearRecoveryConfirmPending();17531894 }17541895 @@ -3342,6 +3483,10 @@33423483   if (rootKey) zeroKey(rootKey);33433484   rootKey = newRootKey;33443485 3486+  // 4b. The identity's root wrap was encrypted under the dead root. Without3487+  //     this the identity survives only until the unlock key also changes.3488+  await notifyIdentityKeysRotated();3489+33453490   // 5. Old peers' identities derive from the dead root key — drop them all,33463491   // with their rendezvous secrets (PB-26-213: rotation clears the map, so33473492   // there is nothing to rewrap under the new key).@@ -3433,6 +3578,12 @@34333578     return false;34343579   }34353580   await saveSessionKey(newKey, "real", epoch);3581+  if (stale()) return false;3582+3583+  // The identity envelope holds one wrap per recovering key. Swapping the3584+  // unlock key kills the local wrap, so re-wrap now or the next key swap3585+  // orphans the identity -- and until then the old password still opens it.3586+  await notifyIdentityKeysRotated();34363587   if (stale()) return false;34373588 34383589   // Regenerate noise for slot B when duress is not in use. Runs after the

view file