// 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 = first 8 bytes of BLAKE3(master_key || "peach-vault-id"), hex // // 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 VAULT_ID_INFO = new TextEncoder().encode("peach-vault-id"); const VAULT_ID_HEX_LENGTH = 16; // 8 bytes → 16 hex chars 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 in the mDNS TXT record and the * first WebSocket handshake. Receiver rejects the connection if its * own vault_id differs — prevents a paired-but-different-vault peer * from corrupting the registry. */ 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); } 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 % 2 !== 0) throw new Error("hex length must be even"); const out = new Uint8Array(hex.length / 2); 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; }