1234567891011121314151617181920import { HKDF } from "@stablelib/hkdf";21import { SHA256 } from "@stablelib/sha256";22import { generateKeyPairFromSeed } from "@stablelib/x25519";23import { blake3 } from "hash-wasm";2425const SYNC_IDENTITY_SALT = new TextEncoder().encode("peach-sync-identity-v1");26const SYNC_SIGNING_SALT = new TextEncoder().encode("peach-sync-signing-v1");27const VAULT_ID_INFO = new TextEncoder().encode("peach-vault-id");28const VAULT_ID_HEX_LENGTH = 64; 2930313233const ED25519_PKCS8_PREFIX = Uint8Array.of(34 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,35 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,36);3738export interface SyncIdentityKeypair {39 publicKey: Uint8Array; 40 secretKey: Uint8Array; 41}42434445464748export function deriveSyncIdentityKeypair(49 masterKey: Uint8Array,50 deviceId: string,51): SyncIdentityKeypair {52 if (masterKey.length === 0) throw new Error("masterKey must not be empty");53 if (!deviceId) throw new Error("deviceId must not be empty");54 const info = new TextEncoder().encode(deviceId);55 56 57 const seed = new HKDF(SHA256, masterKey, SYNC_IDENTITY_SALT, info).expand(32);58 const kp = generateKeyPairFromSeed(seed);59 return { publicKey: kp.publicKey, secretKey: kp.secretKey };60}6162636465666768export async function computeVaultId(masterKey: Uint8Array): Promise<string> {69 if (masterKey.length === 0) throw new Error("masterKey must not be empty");70 const buf = new Uint8Array(masterKey.length + VAULT_ID_INFO.length);71 buf.set(masterKey, 0);72 buf.set(VAULT_ID_INFO, masterKey.length);73 const fullHex = await blake3(buf);74 return fullHex.slice(0, VAULT_ID_HEX_LENGTH);75}7677787980818283848586878889export async function deriveSyncSigningKeypair(90 masterKey: Uint8Array,91 deviceId: string,92): Promise<{ privateKey: CryptoKey; publicKeyRaw: Uint8Array }> {93 if (masterKey.length === 0) throw new Error("masterKey must not be empty");94 if (!deviceId) throw new Error("deviceId must not be empty");95 const info = new TextEncoder().encode(deviceId);96 const seed = new HKDF(SHA256, masterKey, SYNC_SIGNING_SALT, info).expand(32);9798 const pkcs8 = new Uint8Array(ED25519_PKCS8_PREFIX.length + 32);99 pkcs8.set(ED25519_PKCS8_PREFIX, 0);100 pkcs8.set(seed, ED25519_PKCS8_PREFIX.length);101102 103 104 105 106 const privateKey = await crypto.subtle.importKey(107 "pkcs8",108 pkcs8 as Uint8Array<ArrayBuffer>,109 { name: "Ed25519" },110 true,111 ["sign"],112 );113 const jwk = (await crypto.subtle.exportKey("jwk", privateKey)) as JsonWebKey & { x?: string };114 if (!jwk.x) throw new Error("Ed25519 JWK missing 'x' component");115 const publicKeyRaw = base64UrlToBytes(jwk.x);116 return { privateKey, publicKeyRaw };117}118119120121122123124export async function verifyEd25519(125 publicKeyRaw: Uint8Array,126 message: Uint8Array,127 signature: Uint8Array,128): Promise<boolean> {129 if (publicKeyRaw.length !== 32) return false;130 if (signature.length !== 64) return false;131 try {132 const key = await crypto.subtle.importKey(133 "raw",134 publicKeyRaw as Uint8Array<ArrayBuffer>,135 { name: "Ed25519" },136 false,137 ["verify"],138 );139 return await crypto.subtle.verify(140 "Ed25519",141 key,142 signature as Uint8Array<ArrayBuffer>,143 message as Uint8Array<ArrayBuffer>,144 );145 } catch {146 return false;147 }148}149150151export async function signEd25519(152 privateKey: CryptoKey,153 message: Uint8Array,154): Promise<Uint8Array> {155 const sig = await crypto.subtle.sign(156 "Ed25519",157 privateKey,158 message as Uint8Array<ArrayBuffer>,159 );160 return new Uint8Array(sig);161}162163function base64UrlToBytes(b64url: string): Uint8Array {164 const pad = (4 - (b64url.length % 4)) % 4;165 const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(pad);166 const bin = atob(b64);167 const out = new Uint8Array(bin.length);168 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);169 return out;170}171172export function pubkeyToHex(pk: Uint8Array): string {173 let s = "";174 for (let i = 0; i < pk.length; i++) {175 s += pk[i].toString(16).padStart(2, "0");176 }177 return s;178}179180export function hexToPubkey(hex: string): Uint8Array {181 if (hex.length % 2 !== 0) throw new Error("hex length must be even");182 const out = new Uint8Array(hex.length / 2);183 for (let i = 0; i < out.length; i++) {184 const byte = parseInt(hex.slice(i * 2, i * 2 + 2), 16);185 if (Number.isNaN(byte)) throw new Error(`invalid hex at offset ${i * 2}`);186 out[i] = byte;187 }188 return out;189}