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
↓ peach-browser-4e04c362.tar.gzatlas at this save
files changed7 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 −0package.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",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 });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 }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": {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 }));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.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