// Root→device key derivation, retained for **tests only** — campaign task 2.4. // // Production no longer derives device key material from the vault root: device // identities are random per installation (src/background/device-identity.ts) // and route capabilities come from the DH mesh derivation // (deriveMeshRendezvousSecret). No shipping module under src/ may import this // file; fixtures use it purely to build deterministic keypairs. // // The implementations are byte-identical to the ones removed from // src/shared/sync-identity.ts so pinned manifest vectors do not drift. import { HKDF } from "@stablelib/hkdf"; import { SHA256 } from "@stablelib/sha256"; import { generateKeyPairFromSeed } from "@stablelib/x25519"; import type { SyncIdentityKeypair } from "./sync-identity"; const LEGACY_SYNC_IDENTITY_SALT = new TextEncoder().encode("peach-sync-identity-v1"); const LEGACY_SYNC_SIGNING_SALT = new TextEncoder().encode("peach-sync-signing-v1"); // PKCS#8 prefix for a raw 32-byte Ed25519 seed (RFC 8410 §7). // SEQUENCE(46) { INTEGER 0, AlgorithmIdentifier(id-Ed25519), // OCTET STRING(34) { OCTET STRING(32) } } const ED25519_PKCS8_PREFIX = Uint8Array.of( 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, ); export function deriveSyncIdentityKeypair( masterKey: Uint8Array, deviceId: string, ): SyncIdentityKeypair { if (masterKey.length === 0) throw new Error("masterKey must not be empty"); if (!deviceId) throw new Error("deviceId must not be empty"); const info = new TextEncoder().encode(deviceId); const seed = new HKDF(SHA256, masterKey, LEGACY_SYNC_IDENTITY_SALT, info).expand(32); const kp = generateKeyPairFromSeed(seed); return { publicKey: kp.publicKey, secretKey: kp.secretKey }; } export async function deriveSyncSigningKeypair( masterKey: Uint8Array, deviceId: string, ): Promise<{ privateKey: CryptoKey; publicKeyRaw: Uint8Array }> { if (masterKey.length === 0) throw new Error("masterKey must not be empty"); if (!deviceId) throw new Error("deviceId must not be empty"); const info = new TextEncoder().encode(deviceId); const seed = new HKDF(SHA256, masterKey, LEGACY_SYNC_SIGNING_SALT, info).expand(32); const pkcs8 = new Uint8Array(ED25519_PKCS8_PREFIX.length + 32); pkcs8.set(ED25519_PKCS8_PREFIX, 0); pkcs8.set(seed, ED25519_PKCS8_PREFIX.length); const privateKey = await crypto.subtle.importKey( "pkcs8", pkcs8 as Uint8Array, { name: "Ed25519" }, true, ["sign"], ); const jwk = (await crypto.subtle.exportKey("jwk", privateKey)) as JsonWebKey & { x?: string }; if (!jwk.x) throw new Error("Ed25519 JWK missing 'x' component"); return { privateKey, publicKeyRaw: base64UrlToBytes(jwk.x) }; } function base64UrlToBytes(b64url: string): Uint8Array { const pad = (4 - (b64url.length % 4)) % 4; const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(pad); const bin = atob(b64); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; }