// Sync identity — vault-scoped primitives. // // vault_id = BLAKE3(master_key || "peach-vault-id"), hex (64 chars / 32 bytes) // // Device key material is deliberately NOT derived here. Each installation // generates random X25519/Ed25519 credentials once and keeps them in a local // record (src/background/device-identity.ts); the vault root must never be an // input to device-private-key derivation. The legacy root→device derivation // survives only in the test-only owner // `legacy-identity-derivation.test-support.ts` (campaign task 2.4). // // `vault_id` is a compatibility check, never an authenticator: every mesh // member can compute it from the root, so it proves nothing on its own. // // The signing helpers below take explicit public keys and never touch the root. import { blake3 } from "hash-wasm"; const VAULT_ID_INFO = new TextEncoder().encode("peach-vault-id"); const VAULT_ID_HEX_LENGTH = 64; // full 32-byte BLAKE3 digest → 64 hex chars export interface SyncIdentityKeypair { publicKey: Uint8Array; // 32 bytes secretKey: Uint8Array; // 32 bytes — sensitive; treat as key material } /** * 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); } /** * 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 local Ed25519 private CryptoKey imported from the device identity record. */ export async function signEd25519( privateKey: CryptoKey, message: Uint8Array, ): Promise { const sig = await crypto.subtle.sign( "Ed25519", privateKey, message as Uint8Array, ); return new Uint8Array(sig); } 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; }