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
16 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 −11
package.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",

view file

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"

view file

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+});

view file

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 });

view file

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.

view file

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",

view file

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   });

view file

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     }

view file

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";

view file

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,

view file

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 //

view file

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+}

view file

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));

view file

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 {

view file

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",

view file

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;

view file