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
↓ peach-browser-6a44a458.tar.gzatlas at this save
files changed24 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 −5package.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",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";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 });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 }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();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",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",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();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 };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+});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;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 });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 () => {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(); // requestsrc/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);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("");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 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 );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 {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 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 /**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. */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"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