43c9a5b81271
align Sync protocol v3 parity, freeze the local identity schema, and gate root-derived device keys out of production (v5.2.33)
- time
- 2026-09-10 17:07 UTC · 1 month ago
- machine
- koh2
- agent
- deepseek/deepseek-v4-flash
- session
- cross-client remediation
- parent
- 6a44a458e7e5
↓ peach-browser-43c9a5b8.tar.gzatlas at this save
files changed16 files
package.json+1 −1scripts/check-cross-client-sync.mjs+17 −1scripts/root-derivation-gate.test.ts+58 −0src/background/device-identity.test.ts+169 −42src/background/device-identity.ts+148 −32src/background/relay-adoption-bg.test.ts+3 −2src/background/sync-coordinator.test.ts+86 −1src/background/sync-coordinator.ts+10 −2src/background/sync-vault-adapter.test.ts+28 −14src/shared/device-manifest.test.ts+1 −1src/shared/device-manifest.ts+2 −1src/shared/legacy-identity-derivation.test-support.ts+72 −0src/shared/sync-identity.test.ts+2 −6src/shared/sync-identity.ts+12 −98src/shared/sync-outcome.ts+1 −1src/shared/sync-types.ts+12 −11package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "5.2.32",3+ "version": "5.2.33",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",scripts/check-cross-client-sync.mjs+17 −1
@@ -31,6 +31,21 @@3131 if (manifestVersion(browserSyncTypes, "DEVICE_MANIFEST_VERSION") !== manifestVersion(androidManifest, "DEVICE_MANIFEST_VERSION")) {3232 throw new Error("Browser and Android device-manifest versions differ");3333 }34+const syncProtocolVersion = (source, label) => {35+ const match = source.match(/SYNC_PROTOCOL_VERSION\s*=\s*(\d+)/);36+ if (!match) throw new Error(`Missing SYNC_PROTOCOL_VERSION declaration in ${label}`);37+ return Number(match[1]);38+};39+const browserProtocolVersion = syncProtocolVersion(browserSyncTypes, "Browser");40+const androidProtocolVersion = syncProtocolVersion(androidCoordinator, "Android");41+if (browserProtocolVersion !== androidProtocolVersion) {42+ throw new Error(`Browser and Android Sync protocol versions differ (${browserProtocolVersion} vs ${androidProtocolVersion})`);43+}44+// Both clients must require exact equality, never a "greater-or-equal" pass.45+if (browserCoordinator.includes("protocolVersion ?? 1) < SYNC_PROTOCOL_VERSION") ||46+ androidCoordinator.includes("< SYNC_PROTOCOL_VERSION")) {47+ throw new Error("A client still accepts a peer protocol version below its own");48+}3449 if (!browserSyncTypes.includes("issuerSigningPublicKeyHex") || !browserSyncTypes.includes("signatureHex") ||3550 !androidSyncMessage.includes("issuerSigningKeyHex") || !androidSyncMessage.includes("signatureHex")) {3651 throw new Error("A client lacks signed revocation fields");@@ -43,6 +58,7 @@4358 import { createServer, createConnection } from "node:net";4459 const root = process.env.PEACH_BROWSER_ROOT;4560 const coordinator = await import(root + "/src/background/sync-coordinator.ts");61+const syncTypes = await import(root + "/src/shared/sync-types.ts");4662 const mode = process.env.PEACH_SYNC_HARNESS_MODE;4763 const portFile = process.env.PEACH_SYNC_HARNESS_PORT_FILE;4864 const transcriptFile = process.env.PEACH_SYNC_HARNESS_TRANSCRIPT_FILE;@@ -96,7 +112,7 @@96112 async getEncryptedEntries(ids) { return Object.fromEntries(ids.filter((value) => value === entryId).map((value) => [value, id + "-frame"])); },97113 async applyEntries() {},98114 async applyTombstones() {},99- async buildOutgoingDeviceManifest() { return { body: { manifestVersion: 4, issuerDeviceId: id, issuedAt: 1700000000000, devices: [], revoked: [] }, signatureHex: "11".repeat(64) }; },115+ async buildOutgoingDeviceManifest() { return { body: { manifestVersion: syncTypes.DEVICE_MANIFEST_VERSION, issuerDeviceId: id, issuedAt: 1700000000000, devices: [], revoked: [] }, signatureHex: "11".repeat(64) }; },100116 async applyIncomingDeviceManifest() {},101117 };102118 const outcome = mode === "initiator"scripts/root-derivation-gate.test.ts+58 −0
@@ -0,0 +1,58 @@1+// Campaign task 2.4 — no shipping source may derive device key material from the2+// vault root.3+//4+// Root→device derivation survives only in the test-only owners5+// (src/shared/legacy-identity-derivation.test-support.ts and Android6+// app/src/test/.../LegacyIdentityDerivation.kt). Device identities are random7+// per installation, so a new production reference is a regression rather than a8+// style choice — and after the campaign dropped vault-root rotation, this gate9+// is a load-bearing compensating control (see Amendment A-1, §15.3).10+11+import { existsSync, readdirSync, readFileSync } from "node:fs";12+import { dirname, extname, resolve } from "node:path";13+import { fileURLToPath } from "node:url";14+import { describe, expect, it } from "vitest";15+16+const browserRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");17+const androidRoot = resolve(browserRoot, "../peach-android");18+19+const LEGACY_DERIVATION =20+ /\b(?:deriveSyncIdentityKeypair|deriveSyncSigningKeypair|deriveLocalSyncKeypair)\b/;21+22+const TEST_SUFFIXES = [".test.ts", ".test.tsx", ".test-support.ts"];23+24+function sourceFiles(root: string, extensions: string[]): string[] {25+ const found: string[] = [];26+ for (const entry of readdirSync(root, { withFileTypes: true })) {27+ const path = resolve(root, entry.name);28+ if (entry.isDirectory()) found.push(...sourceFiles(path, extensions));29+ else if (extensions.includes(extname(entry.name))) found.push(path);30+ }31+ return found;32+}33+34+function offenders(root: string, extensions: string[], isTest: (path: string) => boolean): string[] {35+ return sourceFiles(root, extensions)36+ .filter((path) => !isTest(path))37+ .filter((path) => LEGACY_DERIVATION.test(readFileSync(path, "utf8")))38+ .map((path) => path.replace(`${browserRoot}/`, "").replace(`${androidRoot}/`, ""));39+}40+41+describe("root→device key derivation stays out of shipping source", () => {42+ it("browser production sources are clean", () => {43+ const found = offenders(44+ resolve(browserRoot, "src"),45+ [".ts", ".tsx"],46+ (path) => TEST_SUFFIXES.some((suffix) => path.endsWith(suffix)),47+ );48+ expect(found).toEqual([]);49+ });50+51+ it("android main sources are clean", () => {52+ // The sibling worktree is required by the cross-client gate too; missing it53+ // must fail loudly rather than silently skip a security check.54+ expect(existsSync(resolve(androidRoot, "app/src/main"))).toBe(true);55+ const found = offenders(resolve(androidRoot, "app/src/main"), [".kt"], () => false);56+ expect(found).toEqual([]);57+ });58+});src/background/device-identity.test.ts+169 −42
@@ -1,56 +1,94 @@11 import { beforeEach, describe, expect, it, vi } from "vitest";2+import type { Mock } from "vitest";23 34 // PCH-01-004: deviceId must live in chrome.storage.local (device-local), never45 // in chrome.storage.sync (which roams across profiles and collides identities).6+//7+// Each case re-imports the module after `vi.resetModules()` so the loader's8+// hydration cache starts empty; that module-loading boundary is the reason the9+// imports below are dynamic rather than static.510 611 type StorageShape = Record<string, unknown>;712 const DEVICE_ID_KEY = "peach_device_id";13+const IDENTITY_KEY = "peach_local_identity_v2";14+const IDENTITY_INFO = "peach-local-identity-v2";815 9-describe("device-identity getOrCreateDeviceId (PCH-01-004)", () => {10- let local: StorageShape;11- let sync: StorageShape;12- let localSet: ReturnType<typeof vi.fn>;13- let syncSet: ReturnType<typeof vi.fn>;14- let syncRemove: ReturnType<typeof vi.fn>;16+let local: StorageShape;17+let sync: StorageShape;18+let localSet: Mock;19+let syncSet: Mock;1520 16- function makeArea(store: StorageShape, set: ReturnType<typeof vi.fn>, remove?: ReturnType<typeof vi.fn>) {17- return {18- get: vi.fn(async (key?: string | string[]) => {19- if (!key) return store;20- if (typeof key === "string") return { [key]: store[key] };21- return key.reduce<Record<string, unknown>>((r, k) => {22- r[k] = store[k];23- return r;24- }, {});25- }),26- set,27- remove: remove ?? vi.fn(async () => undefined),28- };21+function installChromeStorageMock(): void {22+ const makeArea = (store: StorageShape, set: Mock) => ({23+ get: vi.fn(async (key?: string | string[]) => {24+ if (!key) return store;25+ if (typeof key === "string") return { [key]: store[key] };26+ return key.reduce<Record<string, unknown>>((r, k) => {27+ r[k] = store[k];28+ return r;29+ }, {});30+ }),31+ set,32+ remove: vi.fn(async () => undefined),33+ });34+ local = {};35+ sync = {};36+ localSet = vi.fn(async (next: Record<string, unknown>) => {37+ Object.assign(local, next);38+ });39+ syncSet = vi.fn(async (next: Record<string, unknown>) => {40+ Object.assign(sync, next);41+ });42+ (globalThis as { chrome?: typeof chrome }).chrome = {43+ storage: {44+ local: makeArea(local, localSet),45+ sync: makeArea(sync, syncSet),46+ },47+ } as unknown as typeof chrome;48+}49+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);2962 }63+}3064 31- beforeEach(() => {32- vi.resetModules();33- local = {};34- sync = {};35- localSet = vi.fn(async (next: Record<string, unknown>) => {36- Object.assign(local, next);37- });38- syncSet = vi.fn(async (next: Record<string, unknown>) => {39- Object.assign(sync, next);40- });41- syncRemove = vi.fn(async (key: string) => {42- delete sync[key];43- });65+/** Decrypt whatever the loader persisted, for schema assertions. */66+async function readStoredIdentity(): Promise<Record<string, unknown>> {67+ const vault = await import("./vault");68+ const rootHex = vault.getRootKeyHex();69+ if (!rootHex) throw new Error("unlocked vault required");70+ const row = local[IDENTITY_KEY] as Record<string, string>;71+ const root = hexToBytes(rootHex);72+ try {73+ const aes = await import("./aes");74+ return JSON.parse(await aes.decrypt(root, row.nonce, row.ciphertext, row.tag, IDENTITY_INFO));75+ } finally {76+ root.fill(0);77+ }78+}4479 45- const chromeMock = {46- storage: {47- local: makeArea(local, localSet),48- sync: makeArea(sync, syncSet, syncRemove),49- },50- } as unknown as typeof chrome;51- (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;52- });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;84+}5385 86+beforeEach(() => {87+ vi.resetModules();88+ installChromeStorageMock();89+});90+91+describe("device-identity getOrCreateDeviceId (PCH-01-004)", () => {5492 it("fresh_install_writes_id_to_local_only", async () => {5593 const vault = await import("./vault");5694 await vault.createVault("identity-test-password");@@ -61,7 +99,7 @@6199 expect(typeof id).toBe("string");62100 expect(id.length).toBeGreaterThan(0);63101 expect(local[DEVICE_ID_KEY]).toBeUndefined();64- expect(local["peach_local_identity_v2"]).toBeDefined();102+ expect(local[IDENTITY_KEY]).toBeDefined();65103 expect(syncSet).not.toHaveBeenCalled();66104 });67105 @@ -85,8 +123,97 @@85123 const fresh = await rotateDeviceId();86124 87125 expect(fresh).not.toBe(original);88- expect(local["peach_local_identity_v2"]).toBeDefined();126+ expect(local[IDENTITY_KEY]).toBeDefined();89127 expect(await getOrCreateDeviceId()).toBe(fresh);90128 expect(syncSet).not.toHaveBeenCalled();129+ });130+});131+132+describe("device-identity frozen schema (campaign task 2.1)", () => {133+ it("persists every frozen field with public values matching the private material", async () => {134+ const vault = await import("./vault");135+ await vault.createVault("identity-test-password");136+ const { getOrCreateDeviceId } = await import("./device-identity");137+138+ const deviceId = await getOrCreateDeviceId();139+ const stored = await readStoredIdentity();140+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);148+ });149+150+ it("completes a legacy random record in place instead of regenerating it", async () => {151+ const vault = await import("./vault");152+ await vault.createVault("identity-test-password");153+ const legacy = {154+ v: 2,155+ deviceId: "legacy-device-id",156+ x25519PrivateKeyHex: "11".repeat(32),157+ ed25519SeedHex: "22".repeat(32),158+ };159+ await writeRawIdentity(legacy, 2);160+ const { getOrCreateDeviceId } = await import("./device-identity");161+162+ 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}$/);172+ });173+174+ it("rejects a frozen record whose stored public key does not match its private key", async () => {175+ const vault = await import("./vault");176+ await vault.createVault("identity-test-password");177+ await writeRawIdentity({178+ v: 3,179+ deviceId: "tampered",180+ x25519PrivateKeyHex: "11".repeat(32),181+ x25519PublicKeyHex: "33".repeat(32),182+ ed25519SeedHex: "22".repeat(32),183+ ed25519PublicKeyHex: "44".repeat(32),184+ createdAt: 1_700_000_000_000,185+ });186+ const { getOrCreateDeviceId } = await import("./device-identity");187+188+ await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required");189+ });190+191+ it("rejects a frozen record that is missing fields rather than defaulting them", async () => {192+ const vault = await import("./vault");193+ await vault.createVault("identity-test-password");194+ await writeRawIdentity({195+ v: 3,196+ deviceId: "partial",197+ x25519PrivateKeyHex: "11".repeat(32),198+ x25519PublicKeyHex: "33".repeat(32),199+ ed25519SeedHex: "22".repeat(32),200+ createdAt: 1_700_000_000_000,201+ });202+ const { getOrCreateDeviceId } = await import("./device-identity");203+204+ await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required");205+ });206+207+ it("rejects a partial legacy record instead of minting a replacement", async () => {208+ const vault = await import("./vault");209+ await vault.createVault("identity-test-password");210+ await writeRawIdentity({211+ v: 2,212+ deviceId: "legacy-device-id",213+ ed25519SeedHex: "22".repeat(32),214+ });215+ const { getOrCreateDeviceId } = await import("./device-identity");216+217+ await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required");91218 });92219 });src/background/device-identity.ts+148 −32
@@ -16,11 +16,20 @@1616 const IDENTITY_RECORD_KEY = "peach_local_identity_v2";1717 const IDENTITY_INFO = "peach-local-identity-v2";1818 19+/** Frozen local-identity schema (campaign task 2.1). */20+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;24+1925 interface IdentityRecord {20- v: 2;26+ v: typeof IDENTITY_RECORD_VERSION;2127 deviceId: string;2228 x25519PrivateKeyHex: string;29+ x25519PublicKeyHex: string;2330 ed25519SeedHex: string;31+ ed25519PublicKeyHex: string;32+ createdAt: number;2433 }2534 2635 let cached: IdentityRecord | null = null;@@ -42,34 +51,143 @@4251 for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);4352 return out;4453 }54+/** Public values derived from the private material, for creation and for the integrity check on load. */55+async function derivePublicHex(privateHex: {56+ x25519PrivateKeyHex: string;57+ ed25519SeedHex: string;58+}): Promise<{ x25519PublicKeyHex: string; ed25519PublicKeyHex: string }> {59+ const kp = generateKeyPairFromSeed(bytes(privateHex.x25519PrivateKeyHex));60+ const signingPrivateKey = await importSigningKey(bytes(privateHex.ed25519SeedHex));61+ const jwk = (await crypto.subtle.exportKey("jwk", signingPrivateKey)) as JsonWebKey;62+ return {63+ x25519PublicKeyHex: pubkeyToHex(kp.publicKey),64+ ed25519PublicKeyHex: base64ToHex(jwk.x ?? ""),65+ };66+}67+68+/**69+ * Accepts only a complete frozen-schema record: every field present, a real70+ * creation time, and stored public values that match the private material.71+ * Recomputation alone would accept a record whose public half was tampered with.72+ */73+async function parseCurrentRecord(value: unknown): Promise<IdentityRecord | null> {74+ if (typeof value !== "object" || value === null) return null;75+ const row = value as Record<string, unknown>;76+ if (row.v !== IDENTITY_RECORD_VERSION) return null;77+ if (typeof row.deviceId !== "string" || row.deviceId.length === 0) return null;78+ if (typeof row.x25519PrivateKeyHex !== "string" || !HEX_64.test(row.x25519PrivateKeyHex)) return null;79+ if (typeof row.x25519PublicKeyHex !== "string" || !HEX_64.test(row.x25519PublicKeyHex)) return null;80+ if (typeof row.ed25519SeedHex !== "string" || !HEX_64.test(row.ed25519SeedHex)) return null;81+ if (typeof row.ed25519PublicKeyHex !== "string" || !HEX_64.test(row.ed25519PublicKeyHex)) return null;82+ if (typeof row.createdAt !== "number" || !Number.isFinite(row.createdAt) || row.createdAt <= 0) return null;83+ const derived = await derivePublicHex({84+ x25519PrivateKeyHex: row.x25519PrivateKeyHex,85+ ed25519SeedHex: row.ed25519SeedHex,86+ });87+ if (derived.x25519PublicKeyHex !== row.x25519PublicKeyHex.toLowerCase()) return null;88+ if (derived.ed25519PublicKeyHex !== row.ed25519PublicKeyHex.toLowerCase()) return null;89+ return {90+ v: IDENTITY_RECORD_VERSION,91+ deviceId: row.deviceId,92+ x25519PrivateKeyHex: row.x25519PrivateKeyHex,93+ x25519PublicKeyHex: row.x25519PublicKeyHex,94+ ed25519SeedHex: row.ed25519SeedHex,95+ ed25519PublicKeyHex: row.ed25519PublicKeyHex,96+ createdAt: row.createdAt,97+ };98+}99+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;111+ return {112+ deviceId: row.deviceId,113+ x25519PrivateKeyHex: row.x25519PrivateKeyHex,114+ ed25519SeedHex: row.ed25519SeedHex,115+ };116+}117+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+ });123+}124+125+/**126+ * A legacy random record is completed in place — same device ID and keys —127+ * rather than regenerated. `createdAt` is the upgrade time, because the original128+ * creation time was never recorded.129+ */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 = {136+ v: IDENTITY_RECORD_VERSION,137+ deviceId: legacy.deviceId,138+ x25519PrivateKeyHex: legacy.x25519PrivateKeyHex,139+ ed25519SeedHex: legacy.ed25519SeedHex,140+ ...derived,141+ createdAt: Date.now(),142+ };143+ await persistRecord(root, record);144+ return record;145+}146+45147 async function loadRecord(root: Uint8Array): Promise<IdentityRecord | null> {46148 if (cached) return cached;47149 const row = (await chrome.storage.local.get(IDENTITY_RECORD_KEY))[IDENTITY_RECORD_KEY] as Record<string, unknown> | undefined;48150 if (!row) return null;49- if (row.v !== 2 || typeof row.nonce !== "string" || typeof row.ciphertext !== "string" || typeof row.tag !== "string") {151+ if (typeof row.v !== "number" || typeof row.nonce !== "string" || typeof row.ciphertext !== "string" || typeof row.tag !== "string") {50152 throw new Error("identity_migration_required");51153 }52- let parsed: IdentityRecord;154+ let parsed: unknown;53155 try {54- parsed = JSON.parse(await decrypt(root, row.nonce, row.ciphertext, row.tag, IDENTITY_INFO)) as IdentityRecord;156+ parsed = JSON.parse(await decrypt(root, row.nonce, row.ciphertext, row.tag, IDENTITY_INFO));55157 } catch { throw new Error("identity_migration_required"); }56- if (57- parsed.v !== 2 ||58- !/^[0-9a-f]{64}$/i.test(parsed.x25519PrivateKeyHex) ||59- !/^[0-9a-f]{64}$/i.test(parsed.ed25519SeedHex)60- ) throw new Error("identity_migration_required");61- cached = parsed;62- return parsed;158+ const current = await parseCurrentRecord(parsed);159+ if (current) {160+ cached = current;161+ return current;162+ }163+ const legacy = parseLegacyRandomRecord(parsed);164+ if (legacy) {165+ const upgraded = await completeLegacyRecord(root, legacy);166+ cached = upgraded;167+ return upgraded;168+ }169+ throw new Error("identity_migration_required");63170 }171+64172 async function ensureRecord(root: Uint8Array): Promise<IdentityRecord> {65173 const existing = await loadRecord(root);66174 if (existing) return existing;67- const x = new Uint8Array(32); const ed = new Uint8Array(32);68- crypto.getRandomValues(x); crypto.getRandomValues(ed);69- const record: IdentityRecord = { v: 2, deviceId: generateUuidV4(), x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed) };70- const sealed = await encrypt(root, JSON.stringify(record), IDENTITY_INFO);71- await chrome.storage.local.set({ [IDENTITY_RECORD_KEY]: { v: 2, ...sealed } });72- x.fill(0); ed.fill(0); cached = record;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(),188+ };189+ await persistRecord(root, record);190+ cached = record;73191 return record;74192 }75193 @@ -88,14 +206,15 @@88206 try {89207 crypto.getRandomValues(x);90208 crypto.getRandomValues(ed);209+ const privateHex = { x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed) };91210 const record: IdentityRecord = {92- v: 2,211+ v: IDENTITY_RECORD_VERSION,93212 deviceId: generateUuidV4(),94- x25519PrivateKeyHex: hex(x),95- ed25519SeedHex: hex(ed),213+ ...privateHex,214+ ...(await derivePublicHex(privateHex)),215+ createdAt: Date.now(),96216 };97- const sealed = await encrypt(root, JSON.stringify(record), IDENTITY_INFO);98- await chrome.storage.local.set({ [IDENTITY_RECORD_KEY]: { v: 2, ...sealed } });217+ await persistRecord(root, record);99218 cached = record;100219 return record.deviceId;101220 } finally {@@ -117,12 +236,13 @@117236 }118237 export async function buildLocalIdentity(masterKey: Uint8Array): Promise<LocalDeviceIdentity> {119238 const record = await ensureRecord(masterKey);120- const x = bytes(record.x25519PrivateKeyHex);121- const kp = generateKeyPairFromSeed(x);122- const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex));123- const jwk = await crypto.subtle.exportKey("jwk", signingPrivateKey) as JsonWebKey;124- return { deviceId: record.deviceId, deviceName: await getDeviceName(), deviceType: "extension",125- longTermPublicKeyHex: pubkeyToHex(kp.publicKey), signingPublicKeyHex: base64ToHex(jwk.x ?? "") };239+ return {240+ deviceId: record.deviceId,241+ deviceName: await getDeviceName(),242+ deviceType: "extension",243+ longTermPublicKeyHex: record.x25519PublicKeyHex,244+ signingPublicKeyHex: record.ed25519PublicKeyHex,245+ };126246 }127247 async function importSigningKey(seed: Uint8Array): Promise<CryptoKey> {128248 const prefix = Uint8Array.of(0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20);@@ -135,10 +255,6 @@135255 const raw = atob(value.replace(/-/g, "+").replace(/_/g, "/"));136256 return Array.from(raw, (c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");137257 }138-export function deriveLocalSyncKeypair(_masterKey: Uint8Array, _deviceId: string) {139- throw new Error("identity_migration_required");140-}141-142258 function defaultDeviceName(): string {143259 // Best-effort browser + platform string. Avoids userAgent parsing — the144260 // user can rename via Settings.src/background/relay-adoption-bg.test.ts+3 −2
@@ -1009,9 +1009,10 @@10091009 const vaultId = "b".repeat(64);10101010 const longTermPubkeyHex = "99".repeat(32);10111011 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");1012- const { deriveSyncSigningKeypair, pubkeyToHex } = await import(1013- "../shared/sync-identity"1012+ const { deriveSyncSigningKeypair } = await import(1013+ "../shared/legacy-identity-derivation.test-support"10141014 );1015+ const { pubkeyToHex } = await import("../shared/sync-identity");10151016 const issuerKp = await deriveSyncSigningKeypair(10161017 new Uint8Array(32).fill(0x55),10171018 "phone-source-id",src/background/sync-coordinator.test.ts+86 −1
@@ -7,12 +7,13 @@77 } from "./sync-coordinator";88 import {99 DEVICE_MANIFEST_VERSION,10+ SYNC_PROTOCOL_VERSION,1011 type SyncMessage,1112 type SignedDeviceManifest,1213 } from "../shared/sync-types";1314 import type { SyncSession } from "../shared/sync-transport";1415 import { buildSignedDeviceManifest } from "../shared/device-manifest";15-import { deriveSyncSigningKeypair } from "../shared/sync-identity";16+import { deriveSyncSigningKeypair } from "../shared/legacy-identity-derivation.test-support";1617 1718 function inMemoryPair(): [SyncSession, FakePeer] {1819 const a: Uint8Array[] = [];@@ -597,6 +598,90 @@597598 598599 const outcome = await initPromise;599600 await peerProtocol;601+ expect(outcome.status).toBe("errored");602+ expect(outcome.error).toBe("PEER_PROTOCOL_OUTDATED");603+ });604+605+ it("rejects a lower explicit protocol version as repair-required", async () => {606+ const [session, peer] = inMemoryPair();607+ const vault = makeVaultAdapter({});608+609+ const initPromise = runSyncInitiator(session, vault);610+ const peerProtocol = (async () => {611+ await peer.receive(); // hello612+ await peer.send({613+ type: "SYNC_HELLO",614+ vaultId: "vault-abc",615+ deviceId: "p",616+ version: 3,617+ deviceTime: Date.now(),618+ protocolVersion: SYNC_PROTOCOL_VERSION - 1,619+ });620+ const err = await peer.receive();621+ expect(err).toEqual({ type: "SYNC_ERROR", error: "PEER_PROTOCOL_OUTDATED" });622+ })();623+624+ const outcome = await initPromise;625+ await peerProtocol;626+ expect(outcome.status).toBe("errored");627+ expect(outcome.error).toBe("PEER_PROTOCOL_OUTDATED");628+ });629+630+ it("rejects a future protocol version rather than failing open", async () => {631+ const [session, peer] = inMemoryPair();632+ const vault = makeVaultAdapter({});633+634+ const initPromise = runSyncInitiator(session, vault);635+ const peerProtocol = (async () => {636+ await peer.receive(); // hello637+ await peer.send({638+ type: "SYNC_HELLO",639+ vaultId: "vault-abc",640+ deviceId: "p",641+ version: 3,642+ deviceTime: Date.now(),643+ protocolVersion: SYNC_PROTOCOL_VERSION + 1,644+ });645+ const err = await peer.receive();646+ expect(err).toEqual({ type: "SYNC_ERROR", error: "PEER_PROTOCOL_OUTDATED" });647+ })();648+649+ const outcome = await initPromise;650+ await peerProtocol;651+ expect(outcome.status).toBe("errored");652+ expect(outcome.error).toBe("PEER_PROTOCOL_OUTDATED");653+ });654+655+ it("responder rejects an incompatible peer before sending any manifest", async () => {656+ const [session, peer] = inMemoryPair();657+ const vault = makeVaultAdapter({});658+659+ const respondPromise = runSyncResponder(session, vault);660+ await peer.send({661+ type: "SYNC_HELLO",662+ vaultId: "vault-abc",663+ deviceId: "p",664+ version: 3,665+ deviceTime: Date.now(),666+ protocolVersion: SYNC_PROTOCOL_VERSION + 1,667+ });668+669+ const reply = await peer.receive();670+ expect(reply).toEqual({ type: "SYNC_ERROR", error: "PEER_PROTOCOL_OUTDATED" });671+ const outcome = await respondPromise;672+ expect(outcome.status).toBe("errored");673+ expect(outcome.error).toBe("PEER_PROTOCOL_OUTDATED");674+ });675+676+ it("initiator surfaces repair-required when the responder rejects first", async () => {677+ const [session, peer] = inMemoryPair();678+ const vault = makeVaultAdapter({});679+680+ const initPromise = runSyncInitiator(session, vault);681+ await peer.receive(); // hello682+ await peer.send({ type: "SYNC_ERROR", error: "PEER_PROTOCOL_OUTDATED" });683+684+ const outcome = await initPromise;600685 expect(outcome.status).toBe("errored");601686 expect(outcome.error).toBe("PEER_PROTOCOL_OUTDATED");602687 });src/background/sync-coordinator.ts+10 −2
@@ -143,10 +143,17 @@143143 });144144 stage = "waiting_for_device_hello";145145 const helloIn = await receiveMessage(session, timer);146+ // The responder rejects an incompatible HELLO before sending its own, so an147+ // initiator can receive that SYNC_ERROR first. Surface it as the same148+ // repair-required outcome instead of a generic "expected SYNC_HELLO".149+ if (helloIn.type === "SYNC_ERROR" && helloIn.error.includes("PEER_PROTOCOL_OUTDATED")) {150+ return errorOutcome(result, "PEER_PROTOCOL_OUTDATED");151+ }146152 if (helloIn.type !== "SYNC_HELLO") {147153 return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);148154 }149- if ((helloIn.protocolVersion ?? 1) < SYNC_PROTOCOL_VERSION) {155+ // Exact equality: the breaking migration has one supported version.156+ if ((helloIn.protocolVersion ?? 0) !== SYNC_PROTOCOL_VERSION) {150157 await sendMessage(session, { type: "SYNC_ERROR", error: "PEER_PROTOCOL_OUTDATED" });151158 return errorOutcome(result, "PEER_PROTOCOL_OUTDATED");152159 }@@ -305,7 +312,8 @@305312 if (helloIn.type !== "SYNC_HELLO") {306313 return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);307314 }308- if ((helloIn.protocolVersion ?? 1) < SYNC_PROTOCOL_VERSION) {315+ // Exact equality: a lower or higher peer version is repair-required.316+ if ((helloIn.protocolVersion ?? 0) !== SYNC_PROTOCOL_VERSION) {309317 await sendMessage(session, { type: "SYNC_ERROR", error: "PEER_PROTOCOL_OUTDATED" });310318 return errorOutcome(result, "PEER_PROTOCOL_OUTDATED");311319 }src/background/sync-vault-adapter.test.ts+28 −14
@@ -49,7 +49,8 @@4949 }5050 5151 async function signingPubkeyHex(deviceId: string): Promise<string> {52- const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");52+ const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");53+ const { pubkeyToHex } = await import("../shared/sync-identity");5354 const kp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x42), deviceId);5455 return pubkeyToHex(kp.publicKeyRaw);5556 }@@ -67,7 +68,8 @@6768 const deviceId = await adapter.getOurDeviceId();6869 const vaultIdBefore = await adapter.computeVaultId();6970 70- const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");71+ const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");72+ const { pubkeyToHex } = await import("../shared/sync-identity");7173 const rootBefore = vault.getRootKeyHex()!;7274 const unhex = (h: string) => Uint8Array.from(h.match(/../g)!.map((b) => parseInt(b, 16)));7375 const signBefore = pubkeyToHex(@@ -347,7 +349,8 @@347349 it("v3 auto-admits same-vault manifest peers with an independent mesh route", async () => {348350 const { vault, adapter } = await setupVault();349351 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");350- const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");352+ const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");353+ const { pubkeyToHex } = await import("../shared/sync-identity");351354 const {352355 findPairedDeviceById,353356 listPairedDevices,@@ -444,7 +447,8 @@444447 it("rejects manifest key replacement for an existing device", async () => {445448 const { adapter } = await setupVault();446449 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");447- const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");450+ const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");451+ const { pubkeyToHex } = await import("../shared/sync-identity");448452 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");449453 const issuerId = "extension-issuer";450454 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);@@ -490,7 +494,8 @@490494 it("rejects manifest replacement even when the device name changes", async () => {491495 const { adapter } = await setupVault();492496 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");493- const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");497+ const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");498+ const { pubkeyToHex } = await import("../shared/sync-identity");494499 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");495500 const issuerId = "hostile-issuer";496501 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), issuerId);@@ -537,7 +542,8 @@537542 it("§C: a vouched peer's revocation evicts the device and permanently blocks re-vouching", async () => {538543 const { adapter } = await setupVault();539544 const { buildSignedDeviceManifest, signDeviceRevocation } = await import("../shared/device-manifest");540- const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");545+ const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");546+ const { pubkeyToHex } = await import("../shared/sync-identity");541547 const { findPairedDeviceById, isDeviceRevoked, upsertPairedDevice } =542548 await import("./paired-devices");543549 @@ -617,7 +623,8 @@617623 it("§C: a peer cannot revoke US, and our outgoing manifest carries our revocations", async () => {618624 const { adapter } = await setupVault();619625 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");620- const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");626+ const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");627+ const { pubkeyToHex } = await import("../shared/sync-identity");621628 const {622629 isDeviceRevoked,623630 revokeDeviceById,@@ -686,7 +693,8 @@686693 it("a legacy row with no trustSource is not upgraded to direct by a manifest apply", async () => {687694 const { adapter } = await setupVault();688695 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");689- const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");696+ const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");697+ const { pubkeyToHex } = await import("../shared/sync-identity");690698 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");691699 const { isRouteCapableMeshPeer } = await import("../shared/mesh-peer-role");692700 @@ -751,7 +759,8 @@751759 // the pending queue, not the registry.752760 const { adapter } = await setupVault();753761 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");754- const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");762+ const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");763+ const { pubkeyToHex } = await import("../shared/sync-identity");755764 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");756765 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");757766 const vault = await import("./vault");@@ -800,7 +809,8 @@800809 it("applyIncomingDeviceManifest accepts vouched issuers but rejects unauthenticated and bad signatures", async () => {801810 const { vault, adapter } = await setupVault();802811 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");803- const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");812+ const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");813+ const { pubkeyToHex } = await import("../shared/sync-identity");804814 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");805815 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");806816 @@ -892,7 +902,8 @@892902 it("rejects a manifest when the stored issuer signing key is stale", async () => {893903 const { vault, adapter } = await setupVault();894904 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");895- const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");905+ const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");906+ const { pubkeyToHex } = await import("../shared/sync-identity");896907 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");897908 898909 const issuerId = "stale-key-android";@@ -934,7 +945,8 @@934945 it("S1: a manifest signed by one paired peer is refused over a session authenticated as another", async () => {935946 const { adapter } = await setupVault();936947 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");937- const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");948+ const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");949+ const { pubkeyToHex } = await import("../shared/sync-identity");938950 const { upsertPairedDevice } = await import("./paired-devices");939951 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");940952 @@ -993,7 +1005,8 @@9931005 it("S1: a revoked long-term key cannot walk back in under a fresh deviceId", async () => {9941006 const { adapter } = await setupVault();9951007 const { buildSignedDeviceManifest, signDeviceRevocation } = await import("../shared/device-manifest");996- const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");1008+ const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");1009+ const { pubkeyToHex } = await import("../shared/sync-identity");9971010 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");9981011 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");9991012 @@ -1076,7 +1089,8 @@10761089 it("S1: a revoked key cannot be written onto a device we still trust", async () => {10771090 const { adapter } = await setupVault();10781091 const { buildSignedDeviceManifest, signDeviceRevocation } = await import("../shared/device-manifest");1079- const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");1092+ const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");1093+ const { pubkeyToHex } = await import("../shared/sync-identity");10801094 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");10811095 10821096 const issuerId = "phone-issuer";src/shared/device-manifest.test.ts+1 −1
@@ -5,7 +5,7 @@55 type DeviceManifestError,66 verifyDeviceManifest,77 } from "./device-manifest";8-import { deriveSyncSigningKeypair } from "./sync-identity";8+import { deriveSyncSigningKeypair } from "./legacy-identity-derivation.test-support";99 import {1010 DEVICE_MANIFEST_FRESHNESS_MS,1111 DEVICE_MANIFEST_VERSION,src/shared/device-manifest.ts+2 −1
@@ -1,7 +1,8 @@11 // Device manifest — v2 brief §4.3.22 //33 // A device manifest is a list of paired-device entries, signed by the4-// issuer's long-term Ed25519 signing key (`deriveSyncSigningKeypair`).4+// issuer's random per-installation Ed25519 signing key (the local device5+// identity in src/background/device-identity.ts).56 // Manifests are exchanged after the entry sync completes during a normal67 // `SyncSession`, encrypted under reconnect Noise KK along with everything else.78 //src/shared/legacy-identity-derivation.test-support.ts+72 −0
@@ -0,0 +1,72 @@1+// Root→device key derivation, retained for **tests only** — campaign task 2.4.2+//3+// Production no longer derives device key material from the vault root: device4+// identities are random per installation (src/background/device-identity.ts)5+// and route capabilities come from the DH mesh derivation6+// (deriveMeshRendezvousSecret). No shipping module under src/ may import this7+// file; fixtures use it purely to build deterministic keypairs.8+//9+// The implementations are byte-identical to the ones removed from10+// src/shared/sync-identity.ts so pinned manifest vectors do not drift.11+12+import { HKDF } from "@stablelib/hkdf";13+import { SHA256 } from "@stablelib/sha256";14+import { generateKeyPairFromSeed } from "@stablelib/x25519";15+import type { SyncIdentityKeypair } from "./sync-identity";16+17+const LEGACY_SYNC_IDENTITY_SALT = new TextEncoder().encode("peach-sync-identity-v1");18+const LEGACY_SYNC_SIGNING_SALT = new TextEncoder().encode("peach-sync-signing-v1");19+20+// PKCS#8 prefix for a raw 32-byte Ed25519 seed (RFC 8410 §7).21+// SEQUENCE(46) { INTEGER 0, AlgorithmIdentifier(id-Ed25519),22+// OCTET STRING(34) { OCTET STRING(32) <seed> } }23+const ED25519_PKCS8_PREFIX = Uint8Array.of(24+ 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,25+ 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,26+);27+28+export function deriveSyncIdentityKeypair(29+ masterKey: Uint8Array,30+ deviceId: string,31+): SyncIdentityKeypair {32+ if (masterKey.length === 0) throw new Error("masterKey must not be empty");33+ if (!deviceId) throw new Error("deviceId must not be empty");34+ const info = new TextEncoder().encode(deviceId);35+ const seed = new HKDF(SHA256, masterKey, LEGACY_SYNC_IDENTITY_SALT, info).expand(32);36+ const kp = generateKeyPairFromSeed(seed);37+ return { publicKey: kp.publicKey, secretKey: kp.secretKey };38+}39+40+export async function deriveSyncSigningKeypair(41+ masterKey: Uint8Array,42+ deviceId: string,43+): Promise<{ privateKey: CryptoKey; publicKeyRaw: Uint8Array }> {44+ if (masterKey.length === 0) throw new Error("masterKey must not be empty");45+ if (!deviceId) throw new Error("deviceId must not be empty");46+ const info = new TextEncoder().encode(deviceId);47+ const seed = new HKDF(SHA256, masterKey, LEGACY_SYNC_SIGNING_SALT, info).expand(32);48+49+ const pkcs8 = new Uint8Array(ED25519_PKCS8_PREFIX.length + 32);50+ pkcs8.set(ED25519_PKCS8_PREFIX, 0);51+ pkcs8.set(seed, ED25519_PKCS8_PREFIX.length);52+53+ const privateKey = await crypto.subtle.importKey(54+ "pkcs8",55+ pkcs8 as Uint8Array<ArrayBuffer>,56+ { name: "Ed25519" },57+ true,58+ ["sign"],59+ );60+ const jwk = (await crypto.subtle.exportKey("jwk", privateKey)) as JsonWebKey & { x?: string };61+ if (!jwk.x) throw new Error("Ed25519 JWK missing 'x' component");62+ return { privateKey, publicKeyRaw: base64UrlToBytes(jwk.x) };63+}64+65+function base64UrlToBytes(b64url: string): Uint8Array {66+ const pad = (4 - (b64url.length % 4)) % 4;67+ const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(pad);68+ const bin = atob(b64);69+ const out = new Uint8Array(bin.length);70+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);71+ return out;72+}src/shared/sync-identity.test.ts+2 −6
@@ -1,11 +1,7 @@11 import { describe, expect, it } from "vitest";22 import { sharedKey } from "@stablelib/x25519";3-import {4- computeVaultId,5- deriveSyncIdentityKeypair,6- hexToPubkey,7- pubkeyToHex,8-} from "./sync-identity";3+import { computeVaultId, hexToPubkey, pubkeyToHex } from "./sync-identity";4+import { deriveSyncIdentityKeypair } from "./legacy-identity-derivation.test-support";95 106 const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i + 1) & 0xff));117 const OTHER_MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 3 + 5) & 0xff));src/shared/sync-identity.ts+12 −98
@@ -1,65 +1,30 @@1-// Sync identity — long-term X25519 keypair tied to the vault, not the2-// device installation.1+// Sync identity — vault-scoped primitives.32 //4-// sync_identity_keypair = X25519-keygen(HKDF(master_key,5-// salt="peach-sync-identity-v1",6-// info=device_id))7-//83 // vault_id = BLAKE3(master_key || "peach-vault-id"), hex (64 chars / 32 bytes)94 //10-// Determinism is the point: losing and reinstalling Peach against the same11-// vault recovers the same sync identity, so paired peers do not need to12-// re-pair after a reinstall. The master_key must be the same byte buffer13-// that the vault layer holds in memory after unlock — not the user-typed14-// password, not the password-derived key.5+// Device key material is deliberately NOT derived here. Each installation6+// generates random X25519/Ed25519 credentials once and keeps them in a local7+// record (src/background/device-identity.ts); the vault root must never be an8+// input to device-private-key derivation. The legacy root→device derivation9+// survives only in the test-only owner10+// `legacy-identity-derivation.test-support.ts` (campaign task 2.4).1511 //16-// device_id is a per-installation UUID v4. Two devices that share the17-// same vault always derive distinct identities; the device_id is the18-// salt that breaks the symmetry.12+// `vault_id` is a compatibility check, never an authenticator: every mesh13+// member can compute it from the root, so it proves nothing on its own.14+//15+// The signing helpers below take explicit public keys and never touch the root.1916 20-import { HKDF } from "@stablelib/hkdf";21-import { SHA256 } from "@stablelib/sha256";22-import { generateKeyPairFromSeed } from "@stablelib/x25519";2317 import { blake3 } from "hash-wasm";2418 25-const SYNC_IDENTITY_SALT = new TextEncoder().encode("peach-sync-identity-v1");26-const SYNC_SIGNING_SALT = new TextEncoder().encode("peach-sync-signing-v1");2719 const VAULT_ID_INFO = new TextEncoder().encode("peach-vault-id");2820 const VAULT_ID_HEX_LENGTH = 64; // full 32-byte BLAKE3 digest → 64 hex chars2921 30-// PKCS#8 prefix for a raw 32-byte Ed25519 seed (RFC 8410 §7).31-// SEQUENCE(46) { INTEGER 0, AlgorithmIdentifier(id-Ed25519),32-// OCTET STRING(34) { OCTET STRING(32) <seed> } }33-const ED25519_PKCS8_PREFIX = Uint8Array.of(34- 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,35- 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,36-);37-3822 export interface SyncIdentityKeypair {3923 publicKey: Uint8Array; // 32 bytes4024 secretKey: Uint8Array; // 32 bytes — sensitive; treat as key material4125 }4226 4327 /**44- * Derive the deterministic sync identity keypair for a vault on a given45- * installation. Always returns the same keypair for the same46- * (masterKey, deviceId) pair across runs and reinstalls.47- */48-export function deriveSyncIdentityKeypair(49- masterKey: Uint8Array,50- deviceId: string,51-): SyncIdentityKeypair {52- if (masterKey.length === 0) throw new Error("masterKey must not be empty");53- if (!deviceId) throw new Error("deviceId must not be empty");54- const info = new TextEncoder().encode(deviceId);55- // HKDF-SHA256(salt="peach-sync-identity-v1", ikm=master_key, info=device_id)56- // → 32 bytes used directly as the X25519 secret key seed.57- const seed = new HKDF(SHA256, masterKey, SYNC_IDENTITY_SALT, info).expand(32);58- const kp = generateKeyPairFromSeed(seed);59- return { publicKey: kp.publicKey, secretKey: kp.secretKey };60-}61-62-/**6328 * Stable per-vault identifier carried inside encrypted pairing and sync6429 * frames. Receiver rejects the connection if its6530 * own vault_id differs — prevents a paired-but-different-vault peer@@ -81,48 +46,6 @@8146 }8247 8348 /**84- * Derive the deterministic Ed25519 signing keypair used to sign device85- * manifests (v2 brief §4.3). Distinct from the X25519 sync identity so86- * each key class stays single-purpose; both derive from the same master87- * key under a different HKDF salt.88- *89- * Returns Web Crypto `CryptoKey` handles. The private key is imported as90- * EXTRACTABLE (intentionally — see the inline note at the importKey call: the91- * seed is already in memory, and a JWK export is the portable way to recover the92- * matching public key on engines that don't expose it from a pkcs8 handle). The93- * 32-byte raw public key is also returned for inclusion in pairing payloads.94- */95-export async function deriveSyncSigningKeypair(96- masterKey: Uint8Array,97- deviceId: string,98-): Promise<{ privateKey: CryptoKey; publicKeyRaw: Uint8Array }> {99- if (masterKey.length === 0) throw new Error("masterKey must not be empty");100- if (!deviceId) throw new Error("deviceId must not be empty");101- const info = new TextEncoder().encode(deviceId);102- const seed = new HKDF(SHA256, masterKey, SYNC_SIGNING_SALT, info).expand(32);103-104- const pkcs8 = new Uint8Array(ED25519_PKCS8_PREFIX.length + 32);105- pkcs8.set(ED25519_PKCS8_PREFIX, 0);106- pkcs8.set(seed, ED25519_PKCS8_PREFIX.length);107-108- // The seed is already in memory so making the private key extractable109- // costs nothing in terms of attack surface, and the JWK export is the110- // portable way to recover the matching public key in Web Crypto engines111- // that don't expose `publicKey` directly from a `pkcs8`-imported handle.112- const privateKey = await crypto.subtle.importKey(113- "pkcs8",114- pkcs8 as Uint8Array<ArrayBuffer>,115- { name: "Ed25519" },116- true,117- ["sign"],118- );119- const jwk = (await crypto.subtle.exportKey("jwk", privateKey)) as JsonWebKey & { x?: string };120- if (!jwk.x) throw new Error("Ed25519 JWK missing 'x' component");121- const publicKeyRaw = base64UrlToBytes(jwk.x);122- return { privateKey, publicKeyRaw };123-}124-125-/**12649 * Verify an Ed25519 signature against a raw 32-byte public key. Returns12750 * true if the signature is valid. Used to validate incoming device12851 * manifests (v2 brief §4.3).@@ -153,7 +76,7 @@15376 }15477 }15578 156-/** Sign with a private Ed25519 CryptoKey from `deriveSyncSigningKeypair`. */79+/** Sign with a local Ed25519 private CryptoKey imported from the device identity record. */15780 export async function signEd25519(15881 privateKey: CryptoKey,15982 message: Uint8Array,@@ -164,15 +87,6 @@16487 message as Uint8Array<ArrayBuffer>,16588 );16689 return new Uint8Array(sig);167-}168-169-function base64UrlToBytes(b64url: string): Uint8Array {170- const pad = (4 - (b64url.length % 4)) % 4;171- const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(pad);172- const bin = atob(b64);173- const out = new Uint8Array(bin.length);174- for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);175- return out;17690 }17791 17892 export function pubkeyToHex(pk: Uint8Array): string {src/shared/sync-outcome.ts+1 −1
@@ -143,7 +143,7 @@143143 relay_unreachable: "Couldn't reach the relay",144144 handshake_failed: "Reached the relay, but the device didn't answer",145145 vault_id_mismatch: "That device holds a different vault",146- peer_protocol_outdated: "Your other device needs to update before it can sync",146+ peer_protocol_outdated: "Update Peach on both devices, then re-pair them",147147 sync_timeout: "The device stopped responding mid-sync",148148 connection_lost: "The connection dropped mid-sync — will retry",149149 run_failed: "Sync failed",src/shared/sync-types.ts+12 −11
@@ -31,10 +31,11 @@3131 * v3 paired-device record. Stored in `chrome.storage.local` — cryptographic3232 * keys must not leave the device via a third-party cloud service.3333 *34- * `longTermPublicKeyHex` is the counterpart's vault-derived X25519 sync35- * identity static key — see `deriveSyncIdentityKeypair`. Every WebSocket36- * reconnect runs Noise KK with both local static keys, followed by a37- * PAIR_IDENTITY exchange bound to the authenticated keys.34+ * `longTermPublicKeyHex` is the counterpart's random per-installation X2551935+ * sync identity static key. It is generated once locally and is never derived36+ * from the vault root. Every WebSocket reconnect runs Noise KK with both local37+ * static keys, followed by a PAIR_IDENTITY exchange bound to the authenticated38+ * keys.3839 */3940 export interface PairedDevice {4041 deviceId: string;@@ -159,13 +160,13 @@159160 * timestamp in a manifest is peer-supplied. Re-admitting a device requires a160161 * direct, SAS-confirmed re-pair with a physically present user.161162 *162- * HONEST LIMIT: every mesh member holds the vault root key, and signing keys163- * are derived from it (`deriveSyncSigningKeypair(rootKey, deviceId)`), so these164- * signatures prove *vault membership*, not device identity. A revoked device165- * still holds the root key and, running modified software, can ignore its own166- * revocation. This mechanism stops honest peers from talking to a revoked167- * device; it is not a cryptographic ejection. See HOLE 4 — a genuinely168- * compromised device needs the master password and recovery words rotated.163+ * HONEST LIMIT: device signing keys are random per installation and are not164+ * derived from the vault root, so a manifest signature proves possession of165+ * that device's own private key rather than vault membership. Removal is still166+ * enforcement by honest peers, not cryptographic ejection: a revoked device167+ * keeps its own keypair, and a peer that has not yet applied the revocation can168+ * still be convinced. Ejecting a genuinely compromised device requires rotating169+ * the master password and recovery words.169170 */170171 export interface DeviceRevocation {171172 deviceId: string;