6a44a458e7e5

enforce signed revocation history and cross-client manifest v5 (v5.2.32)

time
2026-09-10 15:16 UTC · 1 month ago
machine
koh2
agent
camel-stream/auto
session
identity revocation remediation
parent
086afa1eb15a
24 files package.json+1 −1scripts/check-cross-client-sync.mjs+14 −2src/background/device-identity.test.ts+11 −4src/background/device-identity.ts+115 −90src/background/mesh-relay-listener.ts+9 −1src/background/message-policy.test.ts+1 −1src/background/message-policy.ts+2 −0src/background/messages.ts+13 −0src/background/paired-devices.ts+31 −10src/background/pending-adoption-holder.test.ts+39 −0src/background/pending-adoption-holder.ts+44 −0src/background/relay-adoption-bg.test.ts+37 −8src/background/relay-adoption-bg.ts+20 −2src/background/sync-coordinator.test.ts+14 −14src/background/sync-runner.test.ts+2 −2src/background/sync-runner.ts+7 −22src/background/sync-vault-adapter.test.ts+34 −45src/background/sync-vault-adapter.ts+149 −50src/shared/device-manifest.ts+50 −1src/shared/rendezvous.test.ts+5 −9src/shared/rendezvous.ts+16 −32src/shared/sync-types.ts+10 −9src/shared/types.ts+2 −0src/shared/vault-adopt.ts+12 −5
package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.2.31",3+  "version": "5.2.32",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

scripts/check-cross-client-sync.mjs+14 −2
@@ -12,6 +12,7 @@1212 const androidCoordinator = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt"), "utf8");1313 const browserSyncTypes = readFileSync(resolve(browserRoot, "src/shared/sync-types.ts"), "utf8");1414 const androidManifest = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt"), "utf8");15+const androidSyncMessage = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/SyncMessage.kt"), "utf8");1516 const browserPairedDevices = readFileSync(resolve(browserRoot, "src/background/paired-devices.ts"), "utf8");1617 const androidPairedDevices = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt"), "utf8");1718 @@ -21,11 +22,22 @@2122 if (!browserCoordinator.includes("helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION")) throw new Error("Browser does not negotiate device manifests from SYNC_HELLO");2223 if (!androidCoordinator.includes("val exchangeDeviceManifest = vault.negotiatesDeviceManifest(hello)")) throw new Error("Android does not negotiate device manifests from SYNC_HELLO");2324 if (!androidCoordinator.includes("peerVersion == DEVICE_MANIFEST_VERSION")) throw new Error("Android does not require an exact device-manifest version match");24-if (!browserSyncTypes.includes("DEVICE_MANIFEST_VERSION = 0x04") || !androidManifest.includes("DEVICE_MANIFEST_VERSION = 0x04")) throw new Error("Browser and Android device-manifest versions differ");25+26+const manifestVersion = (source, declaration) => {27+  const match = source.match(new RegExp(`${declaration}\\s*=\\s*(0x[0-9a-f]+|\\d+)`));28+  if (!match) throw new Error(`Missing ${declaration} declaration`);29+  return Number(match[1]);30+};31+if (manifestVersion(browserSyncTypes, "DEVICE_MANIFEST_VERSION") !== manifestVersion(androidManifest, "DEVICE_MANIFEST_VERSION")) {32+  throw new Error("Browser and Android device-manifest versions differ");33+}34+if (!browserSyncTypes.includes("issuerSigningPublicKeyHex") || !browserSyncTypes.includes("signatureHex") ||35+    !androidSyncMessage.includes("issuerSigningKeyHex") || !androidSyncMessage.includes("signatureHex")) {36+  throw new Error("A client lacks signed revocation fields");37+}2538 if (!browserPairedDevices.includes("MAX_REVOCATIONS = 500") || !androidPairedDevices.includes("MAX_REVOCATION_RECORDS = 500")) throw new Error("Browser and Android revocation ledger caps differ");2639 if (!browserPairedDevices.includes("enqueueDeviceMutation") || !androidPairedDevices.includes("RegistryMutationGate")) throw new Error("A paired-device registry lacks whole-list mutation serialization");2740 if (!browserPairedDevices.includes("function capRevocations") || !androidPairedDevices.includes("fun capRevocationRecords")) throw new Error("A client lacks largest-issuer-first revocation retention");28-2941 const browserEndpoint = String.raw`3042 import { createInterface } from "node:readline";3143 import { createServer, createConnection } from "node:net";

view file

src/background/device-identity.test.ts+11 −4
@@ -52,17 +52,22 @@5252   });5353 5454   it("fresh_install_writes_id_to_local_only", async () => {55+    const vault = await import("./vault");56+    await vault.createVault("identity-test-password");5557     const { getOrCreateDeviceId } = await import("./device-identity");5658 5759     const id = await getOrCreateDeviceId();5860 5961     expect(typeof id).toBe("string");6062     expect(id.length).toBeGreaterThan(0);61-    expect(local[DEVICE_ID_KEY]).toBe(id);63+    expect(local[DEVICE_ID_KEY]).toBeUndefined();64+    expect(local["peach_local_identity_v2"]).toBeDefined();6265     expect(syncSet).not.toHaveBeenCalled();6366   });6467 6568   it("returns_a_stable_id_across_calls", async () => {69+    const vault = await import("./vault");70+    await vault.createVault("identity-test-password");6671     const { getOrCreateDeviceId } = await import("./device-identity");6772 6873     const first = await getOrCreateDeviceId();@@ -72,13 +77,15 @@7277   });7378 7479   it("replaces_a_copied_local_id_when_adoption_reports_a_collision", async () => {75-    local[DEVICE_ID_KEY] = "copied-browser-id";80+    const vault = await import("./vault");81+    await vault.createVault("identity-test-password");7682     const { getOrCreateDeviceId, rotateDeviceId } = await import("./device-identity");7783 84+    const original = await getOrCreateDeviceId();7885     const fresh = await rotateDeviceId();7986 80-    expect(fresh).not.toBe("copied-browser-id");81-    expect(local[DEVICE_ID_KEY]).toBe(fresh);87+    expect(fresh).not.toBe(original);88+    expect(local["peach_local_identity_v2"]).toBeDefined();8289     expect(await getOrCreateDeviceId()).toBe(fresh);8390     expect(syncSet).not.toHaveBeenCalled();8491   });

view file

src/background/device-identity.ts+115 −90
@@ -1,31 +1,30 @@1-// Local-device identity helpers for the sync architecture.2-//3-// `deviceId` is a stable UUID v4 generated on first call and persisted in4-// `chrome.storage.local`. It is deliberately device-local and must NOT roam:5-// it is the only symmetry-breaker in the sync-identity HKDF (see PCH-01-004),6-// so a shared id across profiles would collide derived sync keys.7-//8-// `deviceName` defaults to a short, friendly browser+OS description and9-// can be overridden by the user from Settings → Sync.10-//11-// The local sync identity itself is derived deterministically from the12-// vault master key (`deriveSyncIdentityKeypair`); the function below is a13-// thin coordinator that pulls the master key out of `vault.ts` and exposes14-// only the public half to callers that don't need to sign.1+// Local-device identity helpers. Cryptographic credentials are generated once2+// per installation and kept in an encrypted local record; vault unlock is the3+// only way to hydrate the operational cache.154 5+import { generateKeyPairFromSeed } from "@stablelib/x25519";166 import {177   computeVaultId,18-  deriveSyncIdentityKeypair,19-  deriveSyncSigningKeypair,208   pubkeyToHex,219 } from "../shared/sync-identity";2210 import type { LocalIdentity } from "../shared/pairing-handshake";2311 import type { DeviceType } from "../shared/sync-types";12+import { decrypt, encrypt } from "./aes";2413 import { getRootKeyHex, isUnlocked } from "./vault";2514 26-const DEVICE_ID_KEY = "peach_device_id";2715 const DEVICE_NAME_KEY = "peach_device_name";16+const IDENTITY_RECORD_KEY = "peach_local_identity_v2";17+const IDENTITY_INFO = "peach-local-identity-v2";2818 19+interface IdentityRecord {20+  v: 2;21+  deviceId: string;22+  x25519PrivateKeyHex: string;23+  ed25519SeedHex: string;24+}25+26+let cached: IdentityRecord | null = null;27+2928 export interface LocalDeviceIdentity {3029   deviceId: string;3130   deviceName: string;@@ -34,70 +33,111 @@3433   signingPublicKeyHex: string;3534 }3635 37-export async function getOrCreateDeviceId(): Promise<string> {38-  // PCH-01-004: deviceId is the ONLY symmetry-breaker in the sync-identity HKDF39-  // (sync-identity.ts:54). It must NOT roam across profiles via chrome.storage.sync,40-  // or two profiles on the same browser account sharing a vault derive identical41-  // static + signing keys and collide in the device graph. Keep it device-local.42-  const local = await chrome.storage.local.get(DEVICE_ID_KEY);43-  const localId = (local as Record<string, unknown>)[DEVICE_ID_KEY];44-  if (typeof localId === "string" && localId.length > 0) return localId;45-46-  const fresh = generateUuidV4();47-  await chrome.storage.local.set({ [DEVICE_ID_KEY]: fresh });48-  return fresh;36+function hex(bytes: Uint8Array): string {37+  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");4938 }39+function bytes(value: string): Uint8Array {40+  if (!/^[0-9a-f]{64}$/i.test(value)) throw new Error("identity_migration_required");41+  const out = new Uint8Array(32);42+  for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);43+  return out;44+}45+async function loadRecord(root: Uint8Array): Promise<IdentityRecord | null> {46+  if (cached) return cached;47+  const row = (await chrome.storage.local.get(IDENTITY_RECORD_KEY))[IDENTITY_RECORD_KEY] as Record<string, unknown> | undefined;48+  if (!row) return null;49+  if (row.v !== 2 || typeof row.nonce !== "string" || typeof row.ciphertext !== "string" || typeof row.tag !== "string") {50+    throw new Error("identity_migration_required");51+  }52+  let parsed: IdentityRecord;53+  try {54+    parsed = JSON.parse(await decrypt(root, row.nonce, row.ciphertext, row.tag, IDENTITY_INFO)) as IdentityRecord;55+  } 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;63+}64+async function ensureRecord(root: Uint8Array): Promise<IdentityRecord> {65+  const existing = await loadRecord(root);66+  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;73+  return record;74+}5075 51-/** Replace a copied profile identity after an authenticated peer detects a collision. */76+export async function getOrCreateDeviceId(): Promise<string> {77+  if (!isUnlocked()) throw new Error("vault_locked");78+  const rootHex = getRootKeyHex(); if (!rootHex) throw new Error("vault_locked");79+  return (await ensureRecord(bytes(rootHex))).deviceId;80+}5281 export async function rotateDeviceId(): Promise<string> {53-  const fresh = generateUuidV4();54-  await chrome.storage.local.set({ [DEVICE_ID_KEY]: fresh });55-  return fresh;82+  if (!isUnlocked()) throw new Error("vault_locked");83+  const rootHex = getRootKeyHex();84+  if (!rootHex) throw new Error("vault_locked");85+  const root = bytes(rootHex);86+  const x = new Uint8Array(32);87+  const ed = new Uint8Array(32);88+  try {89+    crypto.getRandomValues(x);90+    crypto.getRandomValues(ed);91+    const record: IdentityRecord = {92+      v: 2,93+      deviceId: generateUuidV4(),94+      x25519PrivateKeyHex: hex(x),95+      ed25519SeedHex: hex(ed),96+    };97+    const sealed = await encrypt(root, JSON.stringify(record), IDENTITY_INFO);98+    await chrome.storage.local.set({ [IDENTITY_RECORD_KEY]: { v: 2, ...sealed } });99+    cached = record;100+    return record.deviceId;101+  } finally {102+    root.fill(0);103+    x.fill(0);104+    ed.fill(0);105+  }56106 }57-58107 export async function getDeviceName(): Promise<string> {59108   const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);60109   const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];61-  if (typeof stored === "string" && stored.length > 0) return stored;62-  return defaultDeviceName();110+  return typeof stored === "string" && stored.length > 0 ? stored : defaultDeviceName();63111 }64-65112 export async function setDeviceName(name: string): Promise<void> {66113   const trimmed = name.trim();67114   if (!trimmed) throw new Error("device name must not be empty");68115   if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");69116   await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });70117 }71-72-/**73- * Build the public-facing identity record for the local extension. The74- * private key half (used only for ongoing Noise KK sessions) is not75- * exposed by this function — Phase-1 pairing uses ephemeral keys only.76- */77-export async function buildLocalIdentity(78-  masterKey: Uint8Array,79-): Promise<LocalDeviceIdentity> {80-  const deviceId = await getOrCreateDeviceId();81-  const deviceName = await getDeviceName();82-  const kp = deriveSyncIdentityKeypair(masterKey, deviceId);83-  const signing = await deriveSyncSigningKeypair(masterKey, deviceId);84-  return {85-    deviceId,86-    deviceName,87-    deviceType: "extension",88-    longTermPublicKeyHex: pubkeyToHex(kp.publicKey),89-    signingPublicKeyHex: pubkeyToHex(signing.publicKeyRaw),90-  };118+export async function buildLocalIdentity(masterKey: Uint8Array): Promise<LocalDeviceIdentity> {119+  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 ?? "") };91126 }92-93-/**94- * Return the local sync identity keypair (public and private). Reserved95- * for the Phase-2 WebSocket sync coordinator that runs in the background.96- * Never surface the private key over a message handler.97- */98-export function deriveLocalSyncKeypair(masterKey: Uint8Array, deviceId: string) {99-  return deriveSyncIdentityKeypair(masterKey, deviceId);127+async function importSigningKey(seed: Uint8Array): Promise<CryptoKey> {128+  const prefix = Uint8Array.of(0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20);129+  const pkcs8 = new Uint8Array(prefix.length + 32);130+  pkcs8.set(prefix);131+  pkcs8.set(seed, prefix.length);132+  return crypto.subtle.importKey("pkcs8", pkcs8 as Uint8Array<ArrayBuffer>, { name: "Ed25519" }, true, ["sign"]);100133 }134+function base64ToHex(value: string): string {135+  const raw = atob(value.replace(/-/g, "+").replace(/_/g, "/"));136+  return Array.from(raw, (c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");137+}138+export function deriveLocalSyncKeypair(_masterKey: Uint8Array, _deviceId: string) {139+  throw new Error("identity_migration_required");140+}101141 102142 function defaultDeviceName(): string {103143   // Best-effort browser + platform string. Avoids userAgent parsing — the@@ -124,36 +164,21 @@124164   return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;125165 }126166 127-/**128- * The full local pairing identity plus its sync keypair, derived from the129- * stable ROOT key so pairing survives a master-password change. Callers own130- * the returned key material and must zero it.131- *132- * Lived in `code-pairing-bg.ts` until LAN code pairing was retired133- * (docs/sync-lan-retirement.md); it was never LAN-specific.134- */135167 export async function getLocalPairingIdentity(): Promise<{136168   identity: LocalIdentity;137169   privateKey: Uint8Array;138170   publicKey: Uint8Array;171+  signingPrivateKey: CryptoKey;139172 }> {140173   if (!isUnlocked()) throw new Error("vault_locked");141-  const masterKeyHex = getRootKeyHex();142-  if (!masterKeyHex) throw new Error("vault_locked");143-  const masterKey = new Uint8Array(masterKeyHex.length / 2);144-  for (let i = 0; i < masterKey.length; i++) {145-    masterKey[i] = parseInt(masterKeyHex.slice(i * 2, i * 2 + 2), 16);146-  }174+  const rootHex = getRootKeyHex(); if (!rootHex) throw new Error("vault_locked");175+  const root = bytes(rootHex);147176   try {148-    const base = await buildLocalIdentity(masterKey);149-    const vaultId = await computeVaultId(masterKey);150-    const keys = deriveSyncIdentityKeypair(masterKey, base.deviceId);151-    return {152-      identity: { ...base, vaultId },153-      privateKey: keys.secretKey,154-      publicKey: keys.publicKey,155-    };156-  } finally {157-    masterKey.fill(0);158-  }177+    const record = await ensureRecord(root);178+    const privateKey = bytes(record.x25519PrivateKeyHex);179+    const publicKey = generateKeyPairFromSeed(privateKey).publicKey;180+    const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex));181+    const base = await buildLocalIdentity(root);182+    return { identity: { ...base, vaultId: await computeVaultId(root) }, privateKey, publicKey, signingPrivateKey };183+  } finally { root.fill(0); }159184 }

view file

src/background/mesh-relay-listener.ts+9 −1
@@ -32,6 +32,13 @@3232 3333 const RECONCILE_INTERVAL_MS = 60_000;3434 35+interface MeshIdentity {36+  identity: Omit<SyncPeerIdentity, "longTermPrivateKey">;37+  privateKey: Uint8Array;38+  publicKey: Uint8Array;39+  signingPrivateKey?: CryptoKey;40+}41+3542 interface WaiterHandle {3643   abort: AbortController;3744 }@@ -179,7 +186,7 @@179186     if (isDecoyMode()) return;180187     if (!current || !isRouteCapableMeshPeer(current)) return;181188     let session: Awaited<ReturnType<typeof openRelaySyncResponderSession>> | null = null;182-    let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;189+    let local: MeshIdentity | null = null;183190     let succeeded = false;184191     try {185192       local = await getMeshIdentity();@@ -229,6 +236,7 @@229236   identity: Omit<SyncPeerIdentity, "longTermPrivateKey">;230237   privateKey: Uint8Array;231238   publicKey: Uint8Array;239+  signingPrivateKey?: CryptoKey;232240 } | null> {233241   if (isUnlocked()) return getLocalPairingIdentity();234242   const cached = await getLockedSyncIdentity();

view file

src/background/message-policy.test.ts+1 −1
@@ -21,7 +21,7 @@2121       "SETUP_PIN", "DISABLE_PIN", "FIRE_CANARY_PING", "ACTIVATE_LICENSE", "GET_LICENSE", "GET_LICENSE_KEY",2222       "IS_DURESS_ENABLED", "SETUP_DURESS", "DISABLE_DURESS", "VERIFY_MAIN_PASSWORD", "CHANGE_PASSWORD",2323       "GET_SITE_PASSWORD_POLICY", "GET_LOCAL_IDENTITY", "LIST_PAIRED_DEVICES", "RENAME_PAIRED_DEVICE",24-      "UNPAIR_DEVICE", "RENAME_LOCAL_DEVICE", "START_ADOPTION_SOURCE", "START_ADOPTION_RECEIVER",24+      "UNPAIR_DEVICE", "RENAME_LOCAL_DEVICE", "LIST_PENDING_ADOPTION_HOLDERS", "REMOVE_PENDING_ADOPTION_HOLDER", "START_ADOPTION_SOURCE", "START_ADOPTION_RECEIVER",2525       "CANCEL_ADOPTION_SOURCE", "SOURCE_SAS_DECISION", "RECEIVER_SAS_DECISION", "ADOPTION_SOURCE_SAS", "ADOPTION_SOURCE_RESULT",2626       "TRIGGER_SYNC", "GET_SYNC_STATE", "GET_SYNC_SETTINGS", "SET_SYNC_SETTINGS", "GET_PENDING_PASSKEY",2727       "APPROVE_PASSKEY", "DENY_PASSKEY", "GET_PASSKEY_PROVIDER_STATE", "SET_PASSKEY_PROVIDER_ENABLED",

view file

src/background/message-policy.ts+2 −0
@@ -56,6 +56,8 @@5656   RENAME_LOCAL_DEVICE: "extension-page-only",5757   START_ADOPTION_SOURCE: "extension-page-only",5858   START_ADOPTION_RECEIVER: "extension-page-only",59+  LIST_PENDING_ADOPTION_HOLDERS: "extension-page-only",60+  REMOVE_PENDING_ADOPTION_HOLDER: "extension-page-only",5961   CANCEL_ADOPTION_SOURCE: "extension-page-only",6062   SOURCE_SAS_DECISION: "extension-page-only",6163   RECEIVER_SAS_DECISION: "extension-page-only",

view file

src/background/messages.ts+13 −0
@@ -97,6 +97,10 @@9797   startRelayAdoptionSource,9898 } from "./relay-adoption-bg";9999 import {100+  listPendingAdoptionHolders,101+  removePendingAdoptionHolder,102+} from "./pending-adoption-holder";103+import {100104   getSyncSettings,101105   setRelayUrl,102106   setSyncMode,@@ -1727,6 +1731,15 @@17271731         }17281732         return { ok: resolveSourceSasConfirmation(payload.id, payload.ok) };17291733       }1734+      case "LIST_PENDING_ADOPTION_HOLDERS": {1735+        return { ok: true, data: await listPendingAdoptionHolders() };1736+      }1737+1738+      case "REMOVE_PENDING_ADOPTION_HOLDER": {1739+        await removePendingAdoptionHolder();1740+        return { ok: true };1741+      }1742+17301743 17311744       case "CANCEL_ADOPTION_SOURCE": {17321745         cancelRelayAdoptionSource();

view file

src/background/paired-devices.ts+31 −10
@@ -76,8 +76,9 @@7676 async function writePairedDevicesRaw(devices: PairedDevice[]): Promise<void> {7777   await chrome.storage.local.set({7878     [REGISTRY_KEY]: devices.map((device) => {79-      if (!isValidSecret(device.rendezvousSecretHex)) return device;80-      const scrubbed = { ...device };79+      const normalized = withSigningKeyHistory(device);80+      if (!isValidSecret(normalized.rendezvousSecretHex)) return normalized;81+      const scrubbed = { ...normalized };8182       delete scrubbed.rendezvousSecretHex;8283       return scrubbed;8384     }),@@ -193,6 +194,14 @@193194     throw new Error("device_identity_immutable");194195   }195196 }197+function withSigningKeyHistory(device: PairedDevice): PairedDevice {198+  if (!device.signingPublicKeyHex || !device.signingKeyHistory?.length) return device;199+  const current = device.signingPublicKeyHex.toLowerCase();200+  const history = device.signingKeyHistory201+    .filter((key) => Number.isInteger(key.version) && key.version > 0 && /^[0-9a-f]{64}$/i.test(key.publicKeyHex))202+    .map((key) => ({ version: key.version, publicKeyHex: key.publicKeyHex.toLowerCase() }));203+  return { ...device, signingKeyHistory: [...history.filter((key) => key.publicKeyHex !== current), { version: history.find((key) => key.publicKeyHex === current)?.version ?? history.reduce((max, key) => Math.max(max, key.version), 0) + 1, publicKeyHex: current }] };204+}196205 197206 export async function renamePairedDevice(deviceId: string, name: string): Promise<void> {198207   const alias = name.trim();@@ -342,20 +351,32 @@342351 }343352 344353 /**345- * Clear a revocation. The ONLY legitimate caller is a direct, SAS-confirmed346- * re-pair — a physically present user on both devices. Never call this from347- * a path a remote peer can reach.354+ * Clear a revocation after an authenticated, SAS-confirmed direct re-pair.355+ * A changed signing key is accepted only through this seam and the old key356+ * remains in immutable history.348357  */349358 export async function upsertPairedDeviceAndClearRevocation(device: PairedDevice): Promise<void> {350-  if (!device.deviceId) throw new Error("device.deviceId is required");351-  if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");352-  if (!device.deviceName) throw new Error("device.deviceName is required");353-  if (!device.deviceType) throw new Error("device.deviceType is required");359+  if (!device.deviceId || !device.longTermPublicKeyHex || !device.deviceName || !device.deviceType) {360+    throw new Error("invalid paired device");361+  }354362   await enqueueDeviceMutation(async () => {355363     const devices = await listPairedDevicesRaw();356364     const stored = devices.find((candidate) => candidate.deviceId === device.deviceId);357365     const incoming = { ...device, deviceName: capDeviceName(device.deviceName!) };358-    if (stored) assertIdentityBinding(stored, incoming);366+    if (stored) {367+      if (stored.longTermPublicKeyHex && incoming.longTermPublicKeyHex &&368+          !constantTimeEqualHex(stored.longTermPublicKeyHex, incoming.longTermPublicKeyHex)) {369+        throw new Error("device_identity_immutable");370+      }371+      if (stored.signingPublicKeyHex && incoming.signingPublicKeyHex &&372+          !constantTimeEqualHex(stored.signingPublicKeyHex, incoming.signingPublicKeyHex)) {373+        incoming.signingKeyHistory = [374+          ...(stored.signingKeyHistory ?? []),375+          { version: (stored.signingKeyHistory?.reduce((m, k) => Math.max(m, k.version), 0) ?? 0) + 1,376+            publicKeyHex: stored.signingPublicKeyHex.toLowerCase() },377+        ];378+      }379+    }359380     const index = devices.findIndex((candidate) => candidate.deviceId === device.deviceId);360381     if (index >= 0) {361382       const merged = { ...devices[index], ...incoming };

view file

src/background/pending-adoption-holder.test.ts+39 −0
@@ -0,0 +1,39 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+import {3+  listPendingAdoptionHolders,4+  persistPendingAdoptionHolder,5+  readPendingAdoptionHolder,6+  removePendingAdoptionHolder,7+} from "./pending-adoption-holder";8+9+const data: Record<string, unknown> = {};10+vi.stubGlobal("chrome", { storage: { local: {11+  get: vi.fn(async (key: string) => ({ [key]: data[key] })),12+  set: vi.fn(async (value: Record<string, unknown>) => Object.assign(data, value)),13+  remove: vi.fn(async (key: string) => { delete data[key]; }),14+} } });15+16+const request = { type: "ADOPT_REQUEST", device_id: "phone", device_name: "Phone", device_type: "android" } as const;17+18+describe("pending adoption holder", () => {19+  beforeEach(() => { delete data.peach_pending_adoption_holder_v1; });20+21+  it("persists and lists a typed candidate across reads", async () => {22+    await persistPendingAdoptionHolder(request);23+    const holder = await readPendingAdoptionHolder();24+    expect(holder?.request.device_id).toBe("phone");25+    expect((await listPendingAdoptionHolders())).toHaveLength(1);26+  });27+28+  it("removes an explicitly revoked candidate", async () => {29+    await persistPendingAdoptionHolder(request);30+    await removePendingAdoptionHolder();31+    expect(await listPendingAdoptionHolders()).toEqual([]);32+  });33+34+  it("does not silently delete malformed persisted data", async () => {35+    data.peach_pending_adoption_holder_v1 = "not-json";36+    expect(await readPendingAdoptionHolder()).toBeNull();37+    expect(data.peach_pending_adoption_holder_v1).toBe("not-json");38+  });39+});

view file

src/background/pending-adoption-holder.ts+44 −0
@@ -0,0 +1,44 @@1+import type { AdoptRequest } from "../shared/vault-adopt";2+import { storageGet, storageRemove, storageSet } from "../shared/storage";3+4+export const PENDING_ADOPTION_HOLDER_KEY = "peach_pending_adoption_holder_v1";5+6+export type PendingAdoptionHolder = {7+  request: AdoptRequest;8+  createdAt: number;9+};10+11+function isHolder(value: unknown): value is PendingAdoptionHolder {12+  if (!value || typeof value !== "object") return false;13+  const holder = value as Partial<PendingAdoptionHolder>;14+  return typeof holder.createdAt === "number" && Number.isFinite(holder.createdAt)15+    && !!holder.request && typeof holder.request === "object"16+    && typeof (holder.request as Partial<AdoptRequest>).device_id === "string";17+}18+19+export async function persistPendingAdoptionHolder(request: AdoptRequest): Promise<void> {20+  await storageSet(PENDING_ADOPTION_HOLDER_KEY, JSON.stringify({ request, createdAt: Date.now() }));21+}22+23+/** Returns null for absent or malformed data; malformed bytes remain for explicit recovery/removal. */24+export async function readPendingAdoptionHolder(): Promise<PendingAdoptionHolder | null> {25+  const raw = await storageGet(PENDING_ADOPTION_HOLDER_KEY);26+  if (!raw) return null;27+  try {28+    const parsed: unknown = JSON.parse(raw);29+    return isHolder(parsed) ? parsed : null;30+  } catch {31+    return null;32+  }33+}34+35+export async function listPendingAdoptionHolders(): Promise<PendingAdoptionHolder[]> {36+  const holder = await readPendingAdoptionHolder();37+  return holder ? [holder] : [];38+}39+40+export async function removePendingAdoptionHolder(): Promise<void> {41+  await storageRemove(PENDING_ADOPTION_HOLDER_KEY);42+}43+44+export const revokePendingAdoptionHolder = removePendingAdoptionHolder;

view file

src/background/relay-adoption-bg.test.ts+37 −8
@@ -21,9 +21,12 @@2121   rollbackVault: vi.fn(),2222   createSyncAdapter: vi.fn(),2323   __applyIncomingDeviceManifest: vi.fn(),24-  storageSet: vi.fn(),2524   releaseAutoLock: vi.fn(),2625   deferAutoLock: vi.fn(),26+  storage: new Map<string, string>(),27+  storageSet: vi.fn(),28+  storageGet: vi.fn(),29+  storageRemove: vi.fn(),2730   sasDecisions: [] as Array<{ id: string; ok: boolean }>,2831 }));2932 @@ -103,8 +106,20 @@103106 104107 vi.mock("../shared/storage", () => ({105108   storageSet: mocks.storageSet,109+  storageGet: mocks.storageGet,110+  storageRemove: mocks.storageRemove,111+  storageClear: vi.fn(async () => mocks.storage.clear()),106112 }));107-113+const chromeStorage = {114+  local: {115+    get: async (key: string) => ({ [key]: await mocks.storageGet(key) }),116+    set: async (items: Record<string, string>) => {117+      for (const [key, value] of Object.entries(items)) await mocks.storageSet(key, value);118+    },119+    remove: async (key: string) => mocks.storageRemove(key),120+    clear: async () => mocks.storage.clear(),121+  },122+};108123 vi.mock("./paired-devices", () => ({109124   upsertPairedDevice: mocks.upsert,110125   upsertPairedDeviceAndClearRevocation: mocks.upsert,@@ -192,6 +207,17 @@192207   mocks.releaseAutoLock.mockReset();193208   mocks.deferAutoLock.mockReset();194209   mocks.deferAutoLock.mockReturnValue(mocks.releaseAutoLock);210+  mocks.storage.clear();211+  mocks.storageSet.mockReset();212+  mocks.storageSet.mockImplementation(async (key: string, value: string) => {213+    mocks.storage.set(key, value);214+  });215+  mocks.storageGet.mockReset();216+  mocks.storageGet.mockImplementation(async (key: string) => mocks.storage.get(key) ?? null);217+  mocks.storageRemove.mockReset();218+  mocks.storageRemove.mockImplementation(async (key: string) => {219+    mocks.storage.delete(key);220+  });195221   mocks.isProUser.mockReset();196222   mocks.isProUser.mockResolvedValue(true);197223   mocks.getLicenseKey.mockReset();@@ -210,7 +236,7 @@210236   mocks.getRootKeyHex.mockReturnValue("c".repeat(64));211237   mocks.upsert.mockResolvedValue(undefined);212238   mocks.setPairSecret.mockResolvedValue(undefined);213-    const applyIncomingDeviceManifest = vi.fn(async () => undefined);239+  const applyIncomingDeviceManifest = vi.fn(async () => undefined);214240     mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;215241     mocks.createSyncAdapter.mockReturnValue({216242       buildEntryManifest: vi.fn(async () => ({})),@@ -281,6 +307,7 @@281307     Object.defineProperty(globalThis, "chrome", {282308       configurable: true,283309       value: {310+        storage: chromeStorage,284311         runtime: {285312           async sendMessage(message: { type: string; payload: unknown }) {286313             mocks.pushes.push(message);@@ -560,8 +587,8 @@560587     mocks.openSocket.mockResolvedValue(socket);561588 562589     Object.defineProperty(globalThis, "chrome", {563-      configurable: true,564590       value: {591+        storage: chromeStorage,565592         runtime: {566593           sendMessage(message: { type: string; payload: unknown }) {567594             mocks.pushes.push(message);@@ -623,7 +650,9 @@623650     mocks.rollbackVault.mockReset();624651     mocks.rollbackVault.mockResolvedValue(undefined);625652     mocks.storageSet.mockReset();626-    mocks.storageSet.mockResolvedValue(undefined);653+    mocks.storageSet.mockImplementation(async (key: string, value: string) => {654+      mocks.storage.set(key, value);655+    });627656     mocks.upsert.mockReset();628657     mocks.upsert.mockResolvedValue(undefined);629658     mocks.setPairSecret.mockReset();@@ -632,8 +661,8 @@632661     mocks.triggerSync.mockResolvedValue(undefined);633662     mocks.openSocket.mockResolvedValue(socketWithFrames([]));634663     Object.defineProperty(globalThis, "chrome", {635-      configurable: true,636664       value: {665+        storage: chromeStorage,637666         runtime: {638667           async sendMessage(message: { type: string; payload?: unknown }) {639668             mocks.pushes.push({ type: message.type, payload: message.payload });@@ -717,8 +746,8 @@717746 718747     const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");719748     Object.defineProperty(globalThis, "chrome", {720-      configurable: true,721749       value: {750+        storage: chromeStorage,722751         runtime: {723752           async sendMessage(message: { type: string; payload?: unknown }) {724753             mocks.pushes.push({ type: message.type, payload: message.payload });@@ -788,8 +817,8 @@788817 789818     // Replace the chrome.runtime mock for this one test and approve the displayed SAS.790819     Object.defineProperty(globalThis, "chrome", {791-      configurable: true,792820       value: {821+        storage: chromeStorage,793822         runtime: {794823           async sendMessage(message: { type: string; payload?: unknown }) {795824             mocks.pushes.push({ type: message.type, payload: message.payload });

view file

src/background/relay-adoption-bg.ts+20 −2
@@ -40,6 +40,11 @@4040   getLocalPairingIdentity,4141   rotateDeviceId,4242 } from "./device-identity";43+import {44+  hasAdoptionIdentityConflict,45+  markLastSyncedAt,46+  upsertPairedDeviceAndClearRevocation,47+} from "./paired-devices";4348 import { getSyncSettings, relayInForce } from "./sync-settings";4449 import { activateLicense, getLicenseKey, isProUser } from "./license";4550 import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";@@ -47,7 +52,11 @@4752 import { computeVaultId } from "../shared/sync-identity";4853 import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";4954 import { storageRemove, storageSet } from "../shared/storage";50-import { markLastSyncedAt, upsertPairedDeviceAndClearRevocation, hasAdoptionIdentityConflict } from "./paired-devices";55+import {56+  persistPendingAdoptionHolder,57+  readPendingAdoptionHolder,58+  removePendingAdoptionHolder,59+} from "./pending-adoption-holder";5160 import { isValidPairSecret, setPairSecret } from "./pair-secrets";5261 import { createSyncVaultAdapter } from "./sync-vault-adapter";5362 import { triggerSyncIfReachable } from "./sync-trigger";@@ -290,7 +299,16 @@290299         derivePairingSAS(session.handshakeHash),291300         crypto.randomUUID(),292301       ),293-      upsertPairedDevice: storeAdoptedDevice,302+      persistPendingHolder: persistPendingAdoptionHolder,303+      clearPendingHolder: async (adoptRequest) => {304+        const current = await readPendingAdoptionHolder();305+        if (current?.request.device_id === adoptRequest.device_id) {306+          await removePendingAdoptionHolder();307+        }308+      },309+      async upsertPairedDevice(paired) {310+        await storeAdoptedDevice(paired);311+      },294312       hasDeviceIdentityCollision: async (device) =>295313         hasAdoptionIdentityConflict(device.deviceId, device.longTermPublicKeyHex ?? ""),296314       rotateSourceIdentity: async () => {

view file

src/background/sync-coordinator.test.ts+14 −14
@@ -153,7 +153,7 @@153153         deviceId: "p",154154         version: 0,155155         deviceTime: Date.now(),156-        protocolVersion: 2,156+        protocolVersion: 3,157157       });158158       await peer.receive(); // our manifest159159       await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });@@ -193,7 +193,7 @@193193         deviceId: "p",194194         version: 0,195195         deviceTime: Date.now(),196-        protocolVersion: 2,196+        protocolVersion: 3,197197       });198198       await peer.receive(); // our manifest199199       await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });@@ -225,7 +225,7 @@225225         deviceId: "p",226226         version: 0,227227         deviceTime: Date.now(),228-        protocolVersion: 2,228+        protocolVersion: 3,229229       });230230       await peer.receive();231231       const entries = Object.fromEntries(@@ -248,7 +248,7 @@248248         deviceId: "p",249249         version: 0,250250         deviceTime: Date.now(),251-        protocolVersion: 2,251+        protocolVersion: 3,252252       });253253       await tombstonePeer.receive();254254       const tombstones = Object.fromEntries(@@ -278,7 +278,7 @@278278       expect(sentHello.deviceTime).toBeGreaterThan(0);279279     }280280     // Send a mismatched vaultId to abort early so the test doesn't hang.281-    await peer.send({ type: "SYNC_HELLO", vaultId: "different-vault", deviceId: "p", version: 7, deviceTime: Date.now(), protocolVersion: 2 });281+    await peer.send({ type: "SYNC_HELLO", vaultId: "different-vault", deviceId: "p", version: 7, deviceTime: Date.now(), protocolVersion: 3 });282282     await peer.receive(); // SYNC_ERROR283283     await initPromise;284284   });@@ -313,7 +313,7 @@313313     const [dialerSession, waiterSession] = symmetricSessionPair();314314     const dialerManifest = {315315       body: {316-        manifestVersion: 3,316+        manifestVersion: 5,317317         issuerDeviceId: "dialer",318318         issuedAt: 1,319319         devices: [],@@ -323,7 +323,7 @@323323     } satisfies SignedDeviceManifest;324324     const waiterManifest = {325325       body: {326-        manifestVersion: 3,326+        manifestVersion: 5,327327         issuerDeviceId: "waiter",328328         issuedAt: 1,329329         devices: [],@@ -424,7 +424,7 @@424424       deviceId: "p",425425       version: 3, // different version so exchange proceeds426426         deviceTime: Date.now() - 200_000,427-        protocolVersion: 2,427+        protocolVersion: 3,428428       });429429     // Yield so the coordinator's async continuation can run the clock-skew check.430430     await new Promise((r) => setTimeout(r, 0));@@ -507,7 +507,7 @@507507     const peerProtocol = (async () => {508508       const hello = await peer.receive();509509       expect(hello.type).toBe("SYNC_HELLO");510-      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "device-peer", version: 3, deviceTime: Date.now(), protocolVersion: 2 });510+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "device-peer", version: 3, deviceTime: Date.now(), protocolVersion: 3 });511511 512512       const ourManifest = await peer.receive();513513       expect(ourManifest.type).toBe("SYNC_MANIFEST");@@ -566,7 +566,7 @@566566     const peerProtocol = (async () => {567567       const hello = await peer.receive();568568       expect(hello.type).toBe("SYNC_HELLO");569-      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-B", deviceId: "evil", version: 3, deviceTime: Date.now(), protocolVersion: 2 });569+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-B", deviceId: "evil", version: 3, deviceTime: Date.now(), protocolVersion: 3 });570570       // Peer should receive a SYNC_ERROR and we should stop.571571       const err = await peer.receive();572572       expect(err.type).toBe("SYNC_ERROR");@@ -623,7 +623,7 @@623623     const initPromise = runSyncInitiator(session, vault);624624     const peerProtocol = (async () => {625625       await peer.receive(); // hello626-      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 2, deviceManifestVersion: 4 });626+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 3, deviceManifestVersion: 5 });627627       await peer.receive(); // manifest628628       await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });629629       await peer.receive(); // request@@ -660,7 +660,7 @@660660     const initPromise = runSyncInitiator(session, vault);661661     const peerProtocol = (async () => {662662       await peer.receive();663-      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 2, deviceManifestVersion: 4 });663+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 3, deviceManifestVersion: 5 });664664       await peer.receive();665665       await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });666666       await peer.receive();@@ -688,7 +688,7 @@688688     const initPromise = runSyncInitiator(session, vault);689689     void (async () => {690690       await peer.receive(); // hello691-      await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 2 });691+      await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 3 });692692       await peer.receive(); // our manifest693693       await peer.send({ type: "SYNC_DONE" }); // wrong frame694694     })();@@ -711,7 +711,7 @@711711     const initPromise = runSyncInitiator(session, vault);712712     void (async () => {713713       await peer.receive(); // hello714-      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 5, deviceTime: Date.now(), protocolVersion: 2 });714+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 5, deviceTime: Date.now(), protocolVersion: 3 });715715       await peer.receive(); // manifest716716       await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });717717       await peer.receive(); // request

view file

src/background/sync-runner.test.ts+2 −2
@@ -121,7 +121,7 @@121121     deviceId: "peer-device",122122     version: 0,123123     deviceTime: Date.now(),124-    protocolVersion: 2,124+    protocolVersion: 3,125125   });126126   const manifest = await peer.receive();127127   if (manifest.type !== "SYNC_MANIFEST") throw new Error("expected MANIFEST");@@ -355,7 +355,7 @@355355           deviceId: "peer-device",356356           version: 0,357357           deviceTime: Date.now(),358-          protocolVersion: 2,358+          protocolVersion: 3,359359         });360360       }361361     })().catch(() => undefined);

view file

src/background/sync-runner.ts+7 −22
@@ -24,8 +24,7 @@2424   type SyncPeerIdentity,2525 } from "../shared/sync-transport";2626 import { hexToPubkey } from "../shared/sync-identity";27-import { buildLocalIdentity, deriveLocalSyncKeypair } from "./device-identity";28-import { constantTimeEqualHex } from "../shared/constant-time";27+import { getLocalPairingIdentity } from "./device-identity";2928 import { getRootKeyHex, isUnlocked, isDecoyMode } from "./vault";3029 import { getPairSecret } from "./pair-secrets";3130 import type { PairedDevice } from "../shared/sync-types";@@ -96,23 +95,17 @@9695     rendezvousSecretFromHex(secretHex),9796     Date.now() / 1000,9897   );99-  // Sync identity derives from the stable root key, not the password-derived100-  // key. Reconnect uses both halves directly in standard Noise KK.101-  let masterKey: Uint8Array | null = null;10298   let privateKey: Uint8Array | null = null;10399   let ourIdentity: SyncPeerIdentity;104100   let adapter: SyncVaultAdapter;105101   if (isUnlocked()) {106-    const masterKeyHex = getRootKeyHex();107-    if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };108-    masterKey = hexToBytesLocal(masterKeyHex);109-    const publicIdentity = await buildLocalIdentity(masterKey);110-    const localKeys = deriveLocalSyncKeypair(masterKey, publicIdentity.deviceId);111-    privateKey = localKeys.secretKey;112-    if (!constantTimeEqualHex(publicIdentity.longTermPublicKeyHex, bytesToHex(localKeys.publicKey))) {113-      masterKey.fill(0);102+    const localPairing = await getLocalPairingIdentity();103+    const publicIdentity = localPairing.identity;104+    privateKey = localPairing.privateKey;105+    if (publicIdentity.longTermPublicKeyHex !== bytesToHex(localPairing.publicKey)) {114106       privateKey.fill(0);115-      throw new Error("derived reconnect identity mismatch");107+      localPairing.publicKey.fill(0);108+      return { ok: false, error: "IDENTITY_MISMATCH" };116109     }117110     ourIdentity = { ...publicIdentity, longTermPrivateKey: privateKey };118111     adapter = deps.createAdapter({ deviceName: device.deviceName });@@ -142,7 +135,6 @@142135     );143136     activeInitiatorSessions.add(session);144137   } finally {145-    masterKey?.fill(0);146138     privateKey?.fill(0);147139   }148140 @@ -211,13 +203,6 @@211203   throw new Error(`relay_sync_failed:${detail}`);212204 }213205 214-function hexToBytesLocal(hex: string): Uint8Array {215-  const out = new Uint8Array(hex.length / 2);216-  for (let i = 0; i < out.length; i++) {217-    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);218-  }219-  return out;220-}221206 222207 function bytesToHex(bytes: Uint8Array): string {223208   return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");

view file

src/background/sync-vault-adapter.test.ts+34 −45
@@ -536,7 +536,7 @@536536 537537   it("§C: a vouched peer's revocation evicts the device and permanently blocks re-vouching", async () => {538538     const { adapter } = await setupVault();539-    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");539+    const { buildSignedDeviceManifest, signDeviceRevocation } = await import("../shared/device-manifest");540540     const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");541541     const { findPairedDeviceById, isDeviceRevoked, upsertPairedDevice } =542542       await import("./paired-devices");@@ -562,20 +562,22 @@562562       trustSource: "direct",563563     });564564 565+    const issuerSigningPublicKeyHex = pubkeyToHex(issuerKp.publicKeyRaw);566+    const signedRevocation = await signDeviceRevocation(567+      {568+        deviceId: "stolen-laptop",569+        longTermPublicKeyHex: "d".repeat(64),570+        revokedAt: Date.now(),571+        revokedByDeviceId: issuerId,572+      },573+      issuerKp.privateKey,574+      issuerSigningPublicKeyHex,575+    );565576     const revocation = await buildSignedDeviceManifest(566577       issuerId,567578       [],568579       issuerKp.privateKey,569-      {570-        revoked: [571-          {572-            deviceId: "stolen-laptop",573-            longTermPublicKeyHex: "d".repeat(64),574-            revokedAt: Date.now(),575-            revokedByDeviceId: issuerId,576-          },577-        ],578-      },580+      { revoked: [signedRevocation] },579581     );580582     await adapter.applyIncomingDeviceManifest?.(581583       revocation,@@ -887,12 +889,11 @@887889     expect(await findPairedDeviceById("forged-add")).toBeNull();888890   });889891 890-  it("falls back to derived signing key when stored issuer key is stale", async () => {892+  it("rejects a manifest when the stored issuer signing key is stale", async () => {891893     const { vault, adapter } = await setupVault();892894     const { buildSignedDeviceManifest } = await import("../shared/device-manifest");893895     const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");894896     const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");895-    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");896897 897898     const issuerId = "stale-key-android";898899     const rootHex = vault.getRootKeyHex()!;@@ -904,8 +905,6 @@904905       deviceName: "Pixel",905906       deviceType: "android",906907       longTermPublicKeyHex: "c".repeat(64),907-      // Stale: an older build persisted this at first-pair under a now-bad908-      // seed. The fallback must recover from this without trusting the row.909908       signingPublicKeyHex: "ff".repeat(32),910909       pairedAt: 1_700_000_000_000,911910       trustSource: "direct",@@ -927,21 +926,9 @@927926       issuerKp.privateKey,928927     );929928 930-    await adapter.applyIncomingDeviceManifest?.(931-      manifest,932-      new Uint8Array(32).fill(0xcc),933-    );929+    await adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc));934930 935-    expect(await findPairedDeviceById("onward-peer")).toMatchObject({936-      trustSource: "vouched",937-      relayUrl: MESH_RELAY,938-    });939-    expect((await findPairedDeviceById("onward-peer"))?.rendezvousSecretHex).toBeUndefined();940-    const { getPairSecret: getPairSecretStale } = await import("./pair-secrets");941-    await expect(getPairSecretStale("onward-peer", vault.getRootKeyHex())).resolves.toMatch(942-      /^[0-9a-f]{64}$/,943-    );944-    expect(await listPendingVouchedDevices()).toEqual([]);931+    expect(await findPairedDeviceById("onward-peer")).toBeNull();945932   });946933 947934   it("S1: a manifest signed by one paired peer is refused over a session authenticated as another", async () => {@@ -1003,10 +990,9 @@1003990     });1004991     expect(await listPendingVouchedDevices()).toEqual([]);1005992   });1006-1007993   it("S1: a revoked long-term key cannot walk back in under a fresh deviceId", async () => {1008994     const { adapter } = await setupVault();1009-    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");995+    const { buildSignedDeviceManifest, signDeviceRevocation } = await import("../shared/device-manifest");1010996     const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");1011997     const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");1012998     const { listPendingVouchedDevices } = await import("./pending-vouched-devices");@@ -1032,14 +1018,15 @@10321018       pairedAt: 2,10331019       trustSource: "direct",10341020     });1035-1021+    const issuerSigningPublicKeyHex = pubkeyToHex(issuerKp.publicKeyRaw);1022+    const signedRevocation = await signDeviceRevocation({1023+      deviceId: "stolen-laptop",1024+      longTermPublicKeyHex: stolenKey,1025+      revokedAt: Date.now(),1026+      revokedByDeviceId: issuerId,1027+    }, issuerKp.privateKey, issuerSigningPublicKeyHex);10361028     const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1037-      revoked: [{1038-        deviceId: "stolen-laptop",1039-        longTermPublicKeyHex: stolenKey,1040-        revokedAt: Date.now(),1041-        revokedByDeviceId: issuerId,1042-      }],1029+      revoked: [signedRevocation],10431030     });10441031     await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));10451032     expect(await findPairedDeviceById("stolen-laptop")).toBeNull();@@ -1088,7 +1075,7 @@10881075 10891076   it("S1: a revoked key cannot be written onto a device we still trust", async () => {10901077     const { adapter } = await setupVault();1091-    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");1078+    const { buildSignedDeviceManifest, signDeviceRevocation } = await import("../shared/device-manifest");10921079     const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");10931080     const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");10941081 @@ -1114,13 +1101,15 @@11141101       trustSource: "direct",11151102     });11161103 1104+    const issuerSigningPublicKeyHex = pubkeyToHex(issuerKp.publicKeyRaw);1105+    const signedRevocation = await signDeviceRevocation({1106+      deviceId: "stolen-laptop",1107+      longTermPublicKeyHex: revokedKey,1108+      revokedAt: Date.now(),1109+      revokedByDeviceId: issuerId,1110+    }, issuerKp.privateKey, issuerSigningPublicKeyHex);11171111     const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1118-      revoked: [{1119-        deviceId: "stolen-laptop",1120-        longTermPublicKeyHex: revokedKey,1121-        revokedAt: Date.now(),1122-        revokedByDeviceId: issuerId,1123-      }],1112+      revoked: [signedRevocation],11241113     });11251114     await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));11261115 

view file

src/background/sync-vault-adapter.ts+149 −50
@@ -24,18 +24,19 @@2424 2525 import { HKDF } from "@stablelib/hkdf";2626 import { SHA256 } from "@stablelib/sha256";27+import type { DeviceManifestBody, DeviceRevocation } from "../shared/sync-types";2728 import type { SyncVaultAdapter } from "./sync-coordinator";28-import { getDeviceName, getOrCreateDeviceId } from "./device-identity";29+import { getDeviceName, getLocalPairingIdentity, getOrCreateDeviceId } from "./device-identity";2930 import {3031   computeVaultId,31-  deriveSyncIdentityKeypair,32-  deriveSyncSigningKeypair,3332   hexToPubkey,3433   pubkeyToHex,3534 } from "../shared/sync-identity";3635 import {3736   buildSignedDeviceManifest,37+  signDeviceRevocation,3838   verifyDeviceManifest,39+  verifyDeviceRevocation,3940 } from "../shared/device-manifest";4041 import { constantTimeEqualHex } from "../shared/constant-time";4142 import {@@ -179,21 +180,63 @@179180 180181     async buildOutgoingDeviceManifest() {181182       requireRealUnlocked();182-      const masterKey = requireMasterKey();183-      const issuerDeviceId = await getOrCreateDeviceId();184-      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);185-      const revoked = await listDeviceRevocations();186-      const revokedIds = new Set(revoked.map((r) => r.deviceId));187-      const devices = (await listPairedDevices())188-        .filter((d) => !revokedIds.has(d.deviceId))189-        .map(toManifestEntry)190-        .filter((d): d is ManifestDeviceEntry => d !== null);191-      return buildSignedDeviceManifest(192-        issuerDeviceId,193-        devices,194-        signing.privateKey,195-        { revoked },196-      );183+      const local = await getLocalPairingIdentity();184+      try {185+        const issuerDeviceId = local.identity.deviceId;186+        const revoked = await listDeviceRevocations();187+        const revokedIds = new Set(revoked.map((r) => r.deviceId));188+        const devices = (await listPairedDevices())189+          .filter((d) => !revokedIds.has(d.deviceId))190+          .map(toManifestEntry)191+          .filter((d): d is ManifestDeviceEntry => d !== null);192+        const signedRevoked = (193+          await Promise.all(revoked.map(async (record) => {194+            // A terminal record may have been issued by another mesh member.195+            // Preserve its signature unchanged after checking that issuer's196+            // current or immutable historical signing key is trusted locally.197+            if (198+              record.signatureHex &&199+              record.issuerSigningPublicKeyHex &&200+              record.issuerSigningKeyVersion201+            ) {202+              const issuer = await findPairedDeviceById(record.revokedByDeviceId);203+              const trustedKeys = [204+                ...(issuer?.signingKeyHistory ?? []),205+                ...(issuer?.signingPublicKeyHex206+                  ? [{ version: issuer.signingKeyHistory?.length207+                    ? Math.max(...issuer.signingKeyHistory.map((key) => key.version)) + 1208+                    : 1, publicKeyHex: issuer.signingPublicKeyHex }]209+                  : []),210+              ];211+              for (const key of trustedKeys) {212+                if (213+                  key.version === record.issuerSigningKeyVersion &&214+                  key.publicKeyHex.toLowerCase() === record.issuerSigningPublicKeyHex.toLowerCase() &&215+                  await verifyDeviceRevocation(record, hexToPubkey(key.publicKeyHex))216+                ) return record;217+              }218+              return null;219+            }220+            // Only this device may mint a new signature; never re-issue a221+            // record attributed to a different issuer as our own.222+            if (record.revokedByDeviceId !== issuerDeviceId) return null;223+            return signDeviceRevocation(224+              record,225+              local.signingPrivateKey,226+              local.identity.signingPublicKeyHex!,227+            );228+          }))229+        ).filter((record): record is DeviceRevocation => record !== null);230+        return buildSignedDeviceManifest(231+          issuerDeviceId,232+          devices,233+          local.signingPrivateKey,234+          { revoked: signedRevoked },235+        );236+      } finally {237+        local.privateKey.fill(0);238+        local.publicKey.fill(0);239+      }197240     },198241 199242     async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {@@ -245,18 +288,18 @@245288   await refreshLockedSyncSpool();246289 }247290 248-async function buildCachedIdentity(masterKey: Uint8Array, deviceId: string) {291+async function buildCachedIdentity(masterKey: Uint8Array, _deviceId: string) {249292   const vaultId = await computeVaultId(masterKey);250-  const keys = deriveSyncIdentityKeypair(masterKey, deviceId);293+  const pairing = await getLocalPairingIdentity();251294   const deviceName = await getDeviceName();252295   return {253296     vaultId,254-    deviceId,297+    deviceId: pairing.identity.deviceId,255298     deviceName,256299     deviceType: "extension" as const,257-    longTermPublicKeyHex: pubkeyToHex(keys.publicKey),258-    longTermPrivateKeyHex: bytesToHex(keys.secretKey),259-    longTermPrivateKeyBytes: keys.secretKey,300+    longTermPublicKeyHex: pubkeyToHex(pairing.publicKey),301+    longTermPrivateKeyHex: bytesToHex(pairing.privateKey),302+    longTermPrivateKeyBytes: pairing.privateKey,260303   };261304 }262305 @@ -325,38 +368,93 @@325368     return;326369   }327370 328-  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;371+  let body: DeviceManifestBody | undefined;372+  let selectedSigningKey: { version: number; publicKeyHex: string } | undefined;373+  const issuerSigningPublicKeyHex = issuer.signingPublicKeyHex;374+  const historicalKeys = (issuer.signingKeyHistory ?? [])375+    .filter((key) => Number.isInteger(key.version) && key.version > 0 && /^[0-9a-f]{64}$/i.test(key.publicKeyHex))376+    .map((key) => ({ version: key.version, publicKeyHex: key.publicKeyHex.toLowerCase() }));377+  const currentVersion = historicalKeys.find(378+    (key) => key.publicKeyHex === issuerSigningPublicKeyHex.toLowerCase(),379+  )?.version ?? (historicalKeys.reduce((max, key) => Math.max(max, key.version), 0) + 1);380+  const signingKeys = [381+    ...historicalKeys,382+    { version: currentVersion, publicKeyHex: issuerSigningPublicKeyHex.toLowerCase() },383+  ].filter((key, index, keys) =>384+    keys.findIndex((candidate) => candidate.version === key.version && candidate.publicKeyHex === key.publicKeyHex) === index,385+  );329386   try {330-    body = await verifyDeviceManifest(331-      manifest,332-      hexToPubkey(issuer!.signingPublicKeyHex!),333-    );334-  } catch {335-    // Stored key may be stale (older builds persisted the key derived at336-    // first-pair and never refreshed it). The signing keypair is deterministic337-    // from (masterKey, deviceId) on both sides, so retry once with the locally338-    // re-derived pubkey. A genuinely forged signature throws here and is339-    // silently rejected — only a stale-but-correct key survives the retry.340-    const masterForIssuer = requireMasterKey();341-    try {342-      const derived = await deriveSyncSigningKeypair(343-        masterForIssuer,344-        issuer.deviceId,345-      );346-      body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);347-    } catch {348-      return;349-    } finally {350-      masterForIssuer.fill(0);387+    for (const key of signingKeys) {388+      try {389+        body = await verifyDeviceManifest(manifest, hexToPubkey(key.publicKeyHex));390+        selectedSigningKey = key;391+        break;392+      } catch { /* try the next immutable trusted key */ }351393     }394+    if (!body || !selectedSigningKey) return;395+  } catch {396+    return;352397   }353398   if (body.issuerDeviceId !== issuer.deviceId) {354399     return;355400   }401+  const verifiedRevocations = (402+    await Promise.all(403+      body.revoked.map(async (record) => {404+        // The outer manifest is authenticated by the directly connected405+        // forwarder, but a forwarded revocation retains its original issuer.406+        // Resolve that issuer independently from our paired registry and407+        // immutable signing-key history; never bind it to the forwarder's key.408+        const originalIssuer = await findPairedDeviceById(record.revokedByDeviceId);409+        if (!originalIssuer?.signingPublicKeyHex) return null;410+        const historicalKeys = (originalIssuer.signingKeyHistory ?? [])411+          .filter((key) =>412+            Number.isInteger(key.version) &&413+            key.version > 0 &&414+            /^[0-9a-f]{64}$/i.test(key.publicKeyHex)415+          )416+          .map((key) => ({417+            version: key.version,418+            publicKeyHex: key.publicKeyHex.toLowerCase(),419+          }));420+        const currentKey = originalIssuer.signingPublicKeyHex.toLowerCase();421+        const currentVersion = historicalKeys.find(422+          (key) => key.publicKeyHex === currentKey,423+        )?.version ?? (historicalKeys.reduce(424+          (max, key) => Math.max(max, key.version),425+          0,426+        ) + 1);427+        const trustedKeys = [428+          ...historicalKeys,429+          { version: currentVersion, publicKeyHex: currentKey },430+        ].filter((key, index, keys) =>431+          keys.findIndex((candidate) =>432+            candidate.version === key.version &&433+            candidate.publicKeyHex === key.publicKeyHex434+          ) === index435+        );436+        if (437+          typeof record.issuerSigningPublicKeyHex !== "string" ||438+          !Number.isInteger(record.issuerSigningKeyVersion)439+        ) return null;440+        const key = trustedKeys.find(441+          (candidate) =>442+            candidate.version === record.issuerSigningKeyVersion &&443+            candidate.publicKeyHex === record.issuerSigningPublicKeyHex!.toLowerCase(),444+        );445+        if (!key) return null;446+        return (await verifyDeviceRevocation(record, hexToPubkey(key.publicKeyHex)))447+          ? record448+          : null;449+      }),450+    )451+  ).filter((record): record is DeviceRevocation => record !== null);452+  body = { ...body, revoked: verifiedRevocations };356453 357454   const ourDeviceId = await getOrCreateDeviceId();358455   const masterKey = requireMasterKey();359-  const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);456+  const localPairing = await getLocalPairingIdentity();457+  const localKeys = { publicKey: localPairing.publicKey, secretKey: localPairing.privateKey };360458   try {361459     const pendingRegistry: PairedDevice[] = [];362460     const pendingSecrets: { deviceId: string; secret: string }[] = [];@@ -366,7 +464,7 @@366464       if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue;367465       if ((entry.vouchDepth ?? 0) > (issuer.vouchDepth ?? 0) + 1) continue;368466       const existing = await findPairedDeviceById(entry.deviceId);369-      const meshRoute = deriveManifestMeshRoute(masterKey, localKeys.publicKey, entry);467+      const meshRoute = deriveManifestMeshRoute(localKeys.secretKey, localKeys.publicKey, entry);370468       if (!existing) {371469         if (!meshRoute) continue;372470         pendingRegistry.push({@@ -438,11 +536,12 @@438536   } finally {439537     masterKey.fill(0);440538     localKeys.secretKey.fill(0);539+    localKeys.publicKey.fill(0);441540   }442541 }443542 444543 function deriveManifestMeshRoute(445-  masterKey: Uint8Array,544+  localPrivateKey: Uint8Array,446545   localPublicKey: Uint8Array,447546   entry: ManifestDeviceEntry,448547 ): { rendezvousSecretHex: string; relayUrl: string } | null {@@ -450,7 +549,7 @@450549     if (!entry.relayUrl) return null;451550     const relayUrl = normalizeRelayUrl(entry.relayUrl);452551     const secret = deriveMeshRendezvousSecret(453-      masterKey,552+      localPrivateKey,454553       localPublicKey,455554       hexToPubkey(entry.longTermPublicKeyHex),456555     );

view file

src/shared/device-manifest.ts+50 −1
@@ -77,7 +77,52 @@7777   );7878   return { body, signatureHex: bytesToHex(signature) };7979 }80+/** Canonical fields authenticated by an issuer for one revocation record. */81+export function canonicalDeviceRevocation(record: DeviceRevocation): string {82+  return canonicalise({83+    deviceId: record.deviceId,84+    longTermPublicKeyHex: record.longTermPublicKeyHex.toLowerCase(),85+    revokedAt: record.revokedAt,86+    revokedByDeviceId: record.revokedByDeviceId,87+    issuerSigningKeyVersion: record.issuerSigningKeyVersion ?? 1,88+    issuerSigningPublicKeyHex: record.issuerSigningPublicKeyHex?.toLowerCase() ?? "",89+  });90+}8091 92+export async function signDeviceRevocation(93+  record: DeviceRevocation,94+  signingPrivateKey: CryptoKey,95+  issuerSigningPublicKeyHex: string,96+  issuerSigningKeyVersion = 1,97+): Promise<DeviceRevocation> {98+  const signed = {99+    ...record,100+    issuerSigningKeyVersion,101+    issuerSigningPublicKeyHex: issuerSigningPublicKeyHex.toLowerCase(),102+  };103+  const signature = await signEd25519(104+    signingPrivateKey,105+    new TextEncoder().encode(canonicalDeviceRevocation(signed)),106+  );107+  return { ...signed, signatureHex: bytesToHex(signature) };108+}109+110+export async function verifyDeviceRevocation(111+  record: DeviceRevocation,112+  issuerSigningPubkey: Uint8Array,113+): Promise<boolean> {114+  if (!record.signatureHex || !/^[0-9a-f]{128}$/i.test(record.signatureHex)) return false;115+  try {116+    return await verifyEd25519(117+      issuerSigningPubkey,118+      new TextEncoder().encode(canonicalDeviceRevocation(record)),119+      hexToBytes(record.signatureHex),120+    );121+  } catch {122+    return false;123+  }124+}125+81126 /**82127  * Verify a manifest's signature, version, and freshness window. Throws on83128  * any failure; returns the body on success. The caller is responsible for@@ -205,12 +250,16 @@205250 }206251 207252 function normaliseRevocation(r: DeviceRevocation): DeviceRevocation {208-  return {253+  const normalised: DeviceRevocation = {209254     deviceId: r.deviceId,210255     longTermPublicKeyHex: r.longTermPublicKeyHex.toLowerCase(),211256     revokedAt: r.revokedAt,212257     revokedByDeviceId: r.revokedByDeviceId,213258   };259+  if (r.issuerSigningKeyVersion != null) normalised.issuerSigningKeyVersion = r.issuerSigningKeyVersion;260+  if (r.issuerSigningPublicKeyHex != null) normalised.issuerSigningPublicKeyHex = r.issuerSigningPublicKeyHex.toLowerCase();261+  if (r.signatureHex != null) normalised.signatureHex = r.signatureHex.toLowerCase();262+  return normalised;214263 }215264 216265 function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {

view file

src/shared/rendezvous.test.ts+5 −9
@@ -1,4 +1,5 @@11 import { describe, expect, it, vi } from "vitest";2+import { generateKeyPairFromSeed } from "@stablelib/x25519";23 34 import {45   DEFAULT_RELAY_URL,@@ -21,15 +22,10 @@2122 2223 describe("relay rendezvous contract", () => {2324   it("derives the symmetric cross-client mesh capability vector", () => {24-    const root = Uint8Array.from({ length: 32 }, (_, i) => i);25-    const low = Uint8Array.from({ length: 32 }, (_, i) => i + 32);26-    const high = Uint8Array.from({ length: 32 }, (_, i) => i + 64);27-    const forward = deriveMeshRendezvousSecret(root, low, high);28-    const reverse = deriveMeshRendezvousSecret(root, high, low);29-30-    expect(bytesToHex(forward)).toBe(31-      "ad0359be7dc8fbb7df19ed027354b5029133a23a6023f43c2b3500b43addae7b",32-    );25+    const alice = generateKeyPairFromSeed(new Uint8Array(32).fill(1));26+    const bob = generateKeyPairFromSeed(new Uint8Array(32).fill(2));27+    const forward = deriveMeshRendezvousSecret(alice.secretKey, alice.publicKey, bob.publicKey);28+    const reverse = deriveMeshRendezvousSecret(bob.secretKey, bob.publicKey, alice.publicKey);3329     expect(reverse).toEqual(forward);3430   });3531 

view file

src/shared/rendezvous.ts+16 −32
@@ -1,56 +1,40 @@11 import { HKDF } from "@stablelib/hkdf";22 import { SHA256 } from "@stablelib/sha256";3-3+import { sharedKey } from "@stablelib/x25519";44 export const DEFAULT_RELAY_URL = "wss://relay.peachpasswords.com";55 export const RENDEZVOUS_WINDOW_SECONDS = 3600;66 export const PAIRING_RENDEZVOUS_TOKEN_BYTES = 16;77 88 const SECRET_INFO = new TextEncoder().encode("peach-rendezvous-secret-v1");9-const MESH_SECRET_INFO = new TextEncoder().encode("peach-mesh-rendezvous-v1");9+const MESH_SECRET_INFO = new TextEncoder().encode("peach-mesh-rendezvous-v2");1010 const WINDOW_INFO = new TextEncoder().encode("peach-rendezvous-v1");1111 const EMPTY_SALT = new Uint8Array(0);1212 13+1314 /** Derive pair-only reconnect state from the completed first-pair Noise hash. */1415 export function deriveRendezvousSecret(handshakeHash: Uint8Array): Uint8Array {15-  if (handshakeHash.length !== 32) {16-    throw new Error("handshake hash must be exactly 32 bytes");17-  }16+  if (handshakeHash.length !== 32) throw new Error("handshake hash must be exactly 32 bytes");1817   return new HKDF(SHA256, handshakeHash, EMPTY_SALT, SECRET_INFO).expand(32);1918 }2019 21-/**22- * Derive the reconnect capability for a manifest-introduced device pair.23- *24- * Cross-client encoding contract:25- *   low  = lexicographically smaller 32-byte X25519 public key26- *   high = lexicographically larger 32-byte X25519 public key27- *   info = UTF8("peach-mesh-rendezvous-v1") || low || high28- *   secret = HKDF-SHA256(vaultRoot, salt="", info, 32)29- */20+/** Derive the manifest mesh capability from random local X25519 credentials. */3021 export function deriveMeshRendezvousSecret(31-  vaultRoot: Uint8Array,22+  ourPrivateKey: Uint8Array,3223   ourPublicKey: Uint8Array,3324   peerPublicKey: Uint8Array,3425 ): Uint8Array {35-  if (vaultRoot.length !== 32) {36-    throw new Error("vault root must be exactly 32 bytes");26+  if (ourPrivateKey.length !== 32 || ourPublicKey.length !== 32 || peerPublicKey.length !== 32) {27+    throw new Error("mesh keys must be exactly 32 bytes");3728   }38-  if (ourPublicKey.length !== 32 || peerPublicKey.length !== 32) {39-    throw new Error("mesh public keys must be exactly 32 bytes");40-  }41-  const order = compareBytes(ourPublicKey, peerPublicKey);42-  if (order === 0) {43-    throw new Error("mesh peers must have distinct public keys");44-  }45-  const [low, high] =46-    order < 047-      ? [ourPublicKey, peerPublicKey]48-      : [peerPublicKey, ourPublicKey];29+  if (compareBytes(ourPublicKey, peerPublicKey) === 0) throw new Error("mesh peers must have distinct public keys");30+  const shared = sharedKey(ourPrivateKey, peerPublicKey, true);31+  const low = compareBytes(ourPublicKey, peerPublicKey) < 0 ? ourPublicKey : peerPublicKey;32+  const high = low === ourPublicKey ? peerPublicKey : ourPublicKey;4933   const info = new Uint8Array(MESH_SECRET_INFO.length + 64);50-  info.set(MESH_SECRET_INFO, 0);51-  info.set(low, MESH_SECRET_INFO.length);52-  info.set(high, MESH_SECRET_INFO.length + 32);53-  return new HKDF(SHA256, vaultRoot, EMPTY_SALT, info).expand(32);34+  info.set(MESH_SECRET_INFO); info.set(low, MESH_SECRET_INFO.length); info.set(high, MESH_SECRET_INFO.length + 32);35+  const secret = new HKDF(SHA256, shared, EMPTY_SALT, info).expand(32);36+  shared.fill(0); info.fill(0);37+  return secret;5438 }5539 5640 /**

view file

src/shared/sync-types.ts+10 −9
@@ -46,10 +46,11 @@4646   /**4747    * Long-term Ed25519 verification key for the peer's device-manifest4848    * signatures (v2 brief §4.3). Derived deterministically from the same49-   * master key as `longTermPublicKeyHex`, via `deriveSyncSigningKeypair`.49+   * master key as `longTermPublicKeyHex`.5050    */5151   signingPublicKeyHex?: string;52-  /** v2 brief §4.2 — distinguishes direct-paired vs vouched-via-manifest. */52+  /** Immutable Ed25519 signing keys previously trusted for this device. */53+  signingKeyHistory?: Array<{ version: number; publicKeyHex: string }>;5354   trustSource?: TrustSource;5455   /** Number of vouching edges from a directly paired device. */5556   vouchDepth?: number;@@ -116,9 +117,9 @@116117 /** 0x03 adds each peer edge's relay URL so a newly introduced pair can agree117118  *  on a route without depending on the introducing device. Coordinated wire118119  *  change with peach-android. */119-export const SYNC_PROTOCOL_VERSION = 2 as const;120+export const SYNC_PROTOCOL_VERSION = 3 as const;120121 121-export const DEVICE_MANIFEST_VERSION = 0x04 as const;122+export const DEVICE_MANIFEST_VERSION = 0x05 as const;122123 export const MAX_VOUCH_DEPTH = 3;123124 /** Manifests older than this are silently rejected (v2 brief §4.3). */124125 export const DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000;@@ -168,14 +169,14 @@168169  */169170 export interface DeviceRevocation {170171   deviceId: string;171-  /** X25519 sync identity key at the moment of revocation. Load-bearing, not172-   *  descriptive: `deviceId` is whatever an issuing manifest says it is, so the173-   *  key is what re-vouching is actually matched against. `0`×64 means no key174-   *  was ever stored for the device and matches nothing. */175172   longTermPublicKeyHex: string;176173   revokedAt: number;177-  /** deviceId that issued the revocation, for display only. */178174   revokedByDeviceId: string;175+  /** Issuer signing-key identity, retained immutably with the record. */176+  issuerSigningKeyVersion?: number;177+  issuerSigningPublicKeyHex?: string;178+  /** Ed25519 signature over canonical record fields (excluding signature). */179+  signatureHex?: string;179180 }180181 181182 /** Body that gets canonicalised + signed. Wire format JSON-stable. */

view file

src/shared/types.ts+2 −0
@@ -251,6 +251,8 @@251251   // switch; ADOPTION_SOURCE_SAS / ADOPTION_SOURCE_RESULT are bg→popup pushes.252252   | "START_ADOPTION_SOURCE"253253   | "START_ADOPTION_RECEIVER"254+  | "LIST_PENDING_ADOPTION_HOLDERS"255+  | "REMOVE_PENDING_ADOPTION_HOLDER"254256   | "CANCEL_ADOPTION_SOURCE"255257   | "SOURCE_SAS_DECISION"256258   | "RECEIVER_SAS_DECISION"

view file

src/shared/vault-adopt.ts+12 −5
@@ -279,7 +279,12 @@279279   /** Validate and durably store a Pro key offered by the receiver. */280280   applyLicense?(license: string): Promise<void>;281281   onConsent(request: AdoptRequest): Promise<boolean>;282+  /** Persist the receiver pairing after authenticated identity exchange. */282283   upsertPairedDevice(device: PairedDevice): Promise<void>;284+  /** Persist the request identity before any root-bearing grant is sent. */285+  persistPendingHolder?(request: AdoptRequest): Promise<void>;286+  /** Remove the exact pending request after authenticated completion. */287+  clearPendingHolder?(request: AdoptRequest, device: PairedDevice): Promise<void>283288   /**284289    * True when the receiver's identity conflicts with an existing paired285290    * device or a terminal revocation record. When true, the source sends@@ -759,9 +764,9 @@759764 ): Promise<PairedDevice | null> {760765   const approved = await deps.onConsent(request);761766   if (!approved) return null;762-763767   const confirmation = await receiveFrame(channel);764768   if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;769+  await deps.persistPendingHolder?.(request);765770 766771   let sourceLicense = await deps.license?.();767772   if (!sourceLicense && request.license) {@@ -858,13 +863,15 @@858863       await deps.upsertPairedDevice(freshDevice);859864       await sendFrame(channel, { type: "ADOPT_COMPLETE" });860865       if (!isAdoptApplied(await receiveFrame(channel))) return null;866+      await deps.clearPendingHolder?.(request, freshDevice);861867       return freshDevice;862868     }863869 864-    await deps.upsertPairedDevice(device);865-    await sendFrame(channel, { type: "ADOPT_COMPLETE" });866-    if (!isAdoptApplied(await receiveFrame(channel))) return null;867-    return device;870+      await deps.upsertPairedDevice(device);871+      await sendFrame(channel, { type: "ADOPT_COMPLETE" });872+      if (!isAdoptApplied(await receiveFrame(channel))) return null;873+      await deps.clearPendingHolder?.(request, device);874+      return device;868875   }869876 }870877 

view file