// Sync identity — long-term X25519 keypair tied to the vault, not the // device installation. // // sync_identity_keypair = X25519-keygen(HKDF(master_key, // salt="peach-sync-identity-v1", // info=device_id)) // // vault_id = BLAKE3(master_key || "peach-vault-id"), hex (64 chars / 32 bytes) // // Determinism is the point: losing and reinstalling Peach against the same // vault recovers the same sync identity, so paired peers do not need to // re-pair after a reinstall. The master_key must be the same byte buffer // that the vault layer holds in memory after unlock — not the user-typed // password, not the password-derived key. // // device_id is a per-installation UUID v4. Two devices that share the // same vault always derive distinct identities; the device_id is the // salt that breaks the symmetry. import { HKDF } from "@stablelib/hkdf"; import { SHA256 } from "@stablelib/sha256"; import { generateKeyPairFromSeed } from "@stablelib/x25519"; import { blake3 } from "hash-wasm"; const SYNC_IDENTITY_SALT = new TextEncoder().encode("peach-sync-identity-v1"); const SYNC_SIGNING_SALT = new TextEncoder().encode("peach-sync-signing-v1"); const VAULT_ID_INFO = new TextEncoder().encode("peach-vault-id"); const VAULT_ID_HEX_LENGTH = 64; // full 32-byte BLAKE3 digest → 64 hex chars // 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 interface SyncIdentityKeypair { publicKey: Uint8Array; // 32 bytes secretKey: Uint8Array; // 32 bytes — sensitive; treat as key material } /** * Derive the deterministic sync identity keypair for a vault on a given * installation. Always returns the same keypair for the same * (masterKey, deviceId) pair across runs and reinstalls. */ 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); // HKDF-SHA256(salt="peach-sync-identity-v1", ikm=master_key, info=device_id) // → 32 bytes used directly as the X25519 secret key seed. const seed = new HKDF(SHA256, masterKey, SYNC_IDENTITY_SALT, info).expand(32); const kp = generateKeyPairFromSeed(seed); return { publicKey: kp.publicKey, secretKey: kp.secretKey }; } /** * Stable per-vault identifier carried inside encrypted pairing and sync * frames. Receiver rejects the connection if its * own vault_id differs — prevents a paired-but-different-vault peer * from corrupting the registry. * * PRIVACY NOTE (PCH-02-006): vault_id is stable across sessions but is no * longer broadcast by discovery; it travels only after Noise is established. * It remains a one-way identifier — the master key is not recoverable from * the BLAKE3 output. "Rotate sync identity" re-keys the root key and this * identifier. */ export async function computeVaultId(masterKey: Uint8Array): Promise { if (masterKey.length === 0) throw new Error("masterKey must not be empty"); const buf = new Uint8Array(masterKey.length + VAULT_ID_INFO.length); buf.set(masterKey, 0); buf.set(VAULT_ID_INFO, masterKey.length); const fullHex = await blake3(buf); return fullHex.slice(0, VAULT_ID_HEX_LENGTH); } /** * Derive the deterministic Ed25519 signing keypair used to sign device * manifests (v2 brief §4.3). Distinct from the X25519 sync identity so * each key class stays single-purpose; both derive from the same master * key under a different HKDF salt. * * Returns Web Crypto `CryptoKey` handles. The private key is imported as * EXTRACTABLE (intentionally — see the inline note at the importKey call: the * seed is already in memory, and a JWK export is the portable way to recover the * matching public key on engines that don't expose it from a pkcs8 handle). The * 32-byte raw public key is also returned for inclusion in pairing payloads. */ 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, 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); // The seed is already in memory so making the private key extractable // costs nothing in terms of attack surface, and the JWK export is the // portable way to recover the matching public key in Web Crypto engines // that don't expose `publicKey` directly from a `pkcs8`-imported handle. 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"); const publicKeyRaw = base64UrlToBytes(jwk.x); return { privateKey, publicKeyRaw }; } /** * Verify an Ed25519 signature against a raw 32-byte public key. Returns * true if the signature is valid. Used to validate incoming device * manifests (v2 brief §4.3). */ export async function verifyEd25519( publicKeyRaw: Uint8Array, message: Uint8Array, signature: Uint8Array, ): Promise { if (publicKeyRaw.length !== 32) return false; if (signature.length !== 64) return false; try { const key = await crypto.subtle.importKey( "raw", publicKeyRaw as Uint8Array, { name: "Ed25519" }, false, ["verify"], ); return await crypto.subtle.verify( "Ed25519", key, signature as Uint8Array, message as Uint8Array, ); } catch { return false; } } /** Sign with a private Ed25519 CryptoKey from `deriveSyncSigningKeypair`. */ export async function signEd25519( privateKey: CryptoKey, message: Uint8Array, ): Promise { const sig = await crypto.subtle.sign( "Ed25519", privateKey, message as Uint8Array, ); return new Uint8Array(sig); } 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; } export function pubkeyToHex(pk: Uint8Array): string { let s = ""; for (let i = 0; i < pk.length; i++) { s += pk[i].toString(16).padStart(2, "0"); } return s; } export function hexToPubkey(hex: string): Uint8Array { if (hex.length !== 64) throw new Error("public key must be exactly 32 bytes"); const out = new Uint8Array(32); for (let i = 0; i < out.length; i++) { const byte = parseInt(hex.slice(i * 2, i * 2 + 2), 16); if (Number.isNaN(byte)) throw new Error(`invalid hex at offset ${i * 2}`); out[i] = byte; } return out; }