5cb96ec333eb
add deterministic sync identity derivation via HKDF + X25519 and BLAKE3 vault_id
- time
- 2026-05-18 02:10 UTC · 5 months ago
- machine
- koh2
- parent
- 998d37410c79
↓ peach-browser-5cb96ec3.tar.gzatlas at this save
files changed2 files
src/shared/sync-identity.test.ts+90 −0src/shared/sync-identity.ts+85 −0src/shared/sync-identity.test.ts+90 −0
@@ -0,0 +1,90 @@1+import { describe, expect, it } from "vitest";2+import { sharedKey } from "@stablelib/x25519";3+import {4+ computeVaultId,5+ deriveSyncIdentityKeypair,6+ hexToPubkey,7+ pubkeyToHex,8+} from "./sync-identity";9+10+const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i + 1) & 0xff));11+const OTHER_MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 3 + 5) & 0xff));12+13+describe("deriveSyncIdentityKeypair", () => {14+ it("is deterministic across calls", () => {15+ const a = deriveSyncIdentityKeypair(MASTER, "abc-123");16+ const b = deriveSyncIdentityKeypair(MASTER, "abc-123");17+ expect(a.publicKey).toEqual(b.publicKey);18+ expect(a.secretKey).toEqual(b.secretKey);19+ });20+21+ it("differs across device_id (same master_key)", () => {22+ const a = deriveSyncIdentityKeypair(MASTER, "device-A");23+ const b = deriveSyncIdentityKeypair(MASTER, "device-B");24+ expect(a.publicKey).not.toEqual(b.publicKey);25+ });26+27+ it("differs across master_key (same device_id)", () => {28+ const a = deriveSyncIdentityKeypair(MASTER, "device-A");29+ const b = deriveSyncIdentityKeypair(OTHER_MASTER, "device-A");30+ expect(a.publicKey).not.toEqual(b.publicKey);31+ });32+33+ it("produces 32-byte keys", () => {34+ const kp = deriveSyncIdentityKeypair(MASTER, "x");35+ expect(kp.publicKey.length).toBe(32);36+ expect(kp.secretKey.length).toBe(32);37+ });38+39+ it("yields a working X25519 keypair (DH commutes)", () => {40+ const alice = deriveSyncIdentityKeypair(MASTER, "alice");41+ const bob = deriveSyncIdentityKeypair(MASTER, "bob");42+ const ab = sharedKey(alice.secretKey, bob.publicKey);43+ const ba = sharedKey(bob.secretKey, alice.publicKey);44+ expect(ab).toEqual(ba);45+ });46+47+ it("rejects empty master key", () => {48+ expect(() => deriveSyncIdentityKeypair(new Uint8Array(), "x")).toThrow();49+ });50+51+ it("rejects empty device id", () => {52+ expect(() => deriveSyncIdentityKeypair(MASTER, "")).toThrow();53+ });54+});55+56+describe("computeVaultId", () => {57+ it("is deterministic for the same master key", async () => {58+ const a = await computeVaultId(MASTER);59+ const b = await computeVaultId(MASTER);60+ expect(a).toBe(b);61+ });62+63+ it("differs across master keys", async () => {64+ const a = await computeVaultId(MASTER);65+ const b = await computeVaultId(OTHER_MASTER);66+ expect(a).not.toBe(b);67+ });68+69+ it("returns 16 hex chars (first 8 bytes of BLAKE3)", async () => {70+ const id = await computeVaultId(MASTER);71+ expect(id).toMatch(/^[0-9a-f]{16}$/);72+ });73+});74+75+describe("pubkeyToHex / hexToPubkey", () => {76+ it("round-trips", () => {77+ const pk = Uint8Array.from([0x00, 0xff, 0xa5, 0x10, 0x42]);78+ const hex = pubkeyToHex(pk);79+ expect(hex).toBe("00ffa51042");80+ expect(hexToPubkey(hex)).toEqual(pk);81+ });82+83+ it("rejects odd-length hex", () => {84+ expect(() => hexToPubkey("abc")).toThrow();85+ });86+87+ it("rejects invalid hex chars", () => {88+ expect(() => hexToPubkey("zz")).toThrow();89+ });90+});src/shared/sync-identity.ts+85 −0
@@ -0,0 +1,85 @@1+// Sync identity — long-term X25519 keypair tied to the vault, not the2+// device installation.3+//4+// sync_identity_keypair = X25519-keygen(HKDF(master_key,5+// salt="peach-sync-identity-v1",6+// info=device_id))7+//8+// vault_id = first 8 bytes of BLAKE3(master_key || "peach-vault-id"), hex9+//10+// Determinism is the point: losing and reinstalling Peach against the same11+// vault recovers the same sync identity, so paired peers do not need to12+// re-pair after a reinstall. The master_key must be the same byte buffer13+// that the vault layer holds in memory after unlock — not the user-typed14+// password, not the password-derived key.15+//16+// device_id is a per-installation UUID v4. Two devices that share the17+// same vault always derive distinct identities; the device_id is the18+// salt that breaks the symmetry.19+20+import { HKDF } from "@stablelib/hkdf";21+import { SHA256 } from "@stablelib/sha256";22+import { generateKeyPairFromSeed } from "@stablelib/x25519";23+import { blake3 } from "hash-wasm";24+25+const SYNC_IDENTITY_SALT = new TextEncoder().encode("peach-sync-identity-v1");26+const VAULT_ID_INFO = new TextEncoder().encode("peach-vault-id");27+const VAULT_ID_HEX_LENGTH = 16; // 8 bytes → 16 hex chars28+29+export interface SyncIdentityKeypair {30+ publicKey: Uint8Array; // 32 bytes31+ secretKey: Uint8Array; // 32 bytes — sensitive; treat as key material32+}33+34+/**35+ * Derive the deterministic sync identity keypair for a vault on a given36+ * installation. Always returns the same keypair for the same37+ * (masterKey, deviceId) pair across runs and reinstalls.38+ */39+export function deriveSyncIdentityKeypair(40+ masterKey: Uint8Array,41+ deviceId: string,42+): SyncIdentityKeypair {43+ if (masterKey.length === 0) throw new Error("masterKey must not be empty");44+ if (!deviceId) throw new Error("deviceId must not be empty");45+ const info = new TextEncoder().encode(deviceId);46+ // HKDF-SHA256(salt="peach-sync-identity-v1", ikm=master_key, info=device_id)47+ // → 32 bytes used directly as the X25519 secret key seed.48+ const seed = new HKDF(SHA256, masterKey, SYNC_IDENTITY_SALT, info).expand(32);49+ const kp = generateKeyPairFromSeed(seed);50+ return { publicKey: kp.publicKey, secretKey: kp.secretKey };51+}52+53+/**54+ * Stable per-vault identifier carried in the mDNS TXT record and the55+ * first WebSocket handshake. Receiver rejects the connection if its56+ * own vault_id differs — prevents a paired-but-different-vault peer57+ * from corrupting the registry.58+ */59+export async function computeVaultId(masterKey: Uint8Array): Promise<string> {60+ if (masterKey.length === 0) throw new Error("masterKey must not be empty");61+ const buf = new Uint8Array(masterKey.length + VAULT_ID_INFO.length);62+ buf.set(masterKey, 0);63+ buf.set(VAULT_ID_INFO, masterKey.length);64+ const fullHex = await blake3(buf);65+ return fullHex.slice(0, VAULT_ID_HEX_LENGTH);66+}67+68+export function pubkeyToHex(pk: Uint8Array): string {69+ let s = "";70+ for (let i = 0; i < pk.length; i++) {71+ s += pk[i].toString(16).padStart(2, "0");72+ }73+ return s;74+}75+76+export function hexToPubkey(hex: string): Uint8Array {77+ if (hex.length % 2 !== 0) throw new Error("hex length must be even");78+ const out = new Uint8Array(hex.length / 2);79+ for (let i = 0; i < out.length; i++) {80+ const byte = parseInt(hex.slice(i * 2, i * 2 + 2), 16);81+ if (Number.isNaN(byte)) throw new Error(`invalid hex at offset ${i * 2}`);82+ out[i] = byte;83+ }84+ return out;85+}