123456789101112import { HKDF } from "@stablelib/hkdf";13import { SHA256 } from "@stablelib/sha256";14import { generateKeyPairFromSeed } from "@stablelib/x25519";15import type { SyncIdentityKeypair } from "./sync-identity";1617const LEGACY_SYNC_IDENTITY_SALT = new TextEncoder().encode("peach-sync-identity-v1");18const LEGACY_SYNC_SIGNING_SALT = new TextEncoder().encode("peach-sync-signing-v1");1920212223const ED25519_PKCS8_PREFIX = Uint8Array.of(24 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,25 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,26);2728export function deriveSyncIdentityKeypair(29 masterKey: Uint8Array,30 deviceId: string,31): SyncIdentityKeypair {32 if (masterKey.length === 0) throw new Error("masterKey must not be empty");33 if (!deviceId) throw new Error("deviceId must not be empty");34 const info = new TextEncoder().encode(deviceId);35 const seed = new HKDF(SHA256, masterKey, LEGACY_SYNC_IDENTITY_SALT, info).expand(32);36 const kp = generateKeyPairFromSeed(seed);37 return { publicKey: kp.publicKey, secretKey: kp.secretKey };38}3940export async function deriveSyncSigningKeypair(41 masterKey: Uint8Array,42 deviceId: string,43): Promise<{ privateKey: CryptoKey; publicKeyRaw: Uint8Array }> {44 if (masterKey.length === 0) throw new Error("masterKey must not be empty");45 if (!deviceId) throw new Error("deviceId must not be empty");46 const info = new TextEncoder().encode(deviceId);47 const seed = new HKDF(SHA256, masterKey, LEGACY_SYNC_SIGNING_SALT, info).expand(32);4849 const pkcs8 = new Uint8Array(ED25519_PKCS8_PREFIX.length + 32);50 pkcs8.set(ED25519_PKCS8_PREFIX, 0);51 pkcs8.set(seed, ED25519_PKCS8_PREFIX.length);5253 const privateKey = await crypto.subtle.importKey(54 "pkcs8",55 pkcs8 as Uint8Array<ArrayBuffer>,56 { name: "Ed25519" },57 true,58 ["sign"],59 );60 const jwk = (await crypto.subtle.exportKey("jwk", privateKey)) as JsonWebKey & { x?: string };61 if (!jwk.x) throw new Error("Ed25519 JWK missing 'x' component");62 return { privateKey, publicKeyRaw: base64UrlToBytes(jwk.x) };63}6465function base64UrlToBytes(b64url: string): Uint8Array {66 const pad = (4 - (b64url.length % 4)) % 4;67 const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(pad);68 const bin = atob(b64);69 const out = new Uint8Array(bin.length);70 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);71 return out;72}