5460fcb73735
add TrustSource, SignedDeviceManifest, signingPublicKeyHex to sync-types; Ed25519 deriveSyncSigningKeypair + sign/verify helpers in sync-identity
- time
- 2026-05-18 02:44 UTC · 5 months ago
- machine
- koh2
- parent
- e0a8687171e0
↓ peach-browser-5460fcb7.tar.gzatlas at this save
files changed2 files
src/shared/sync-identity.ts+102 −0src/shared/sync-types.ts+63 −0src/shared/sync-identity.ts+102 −0
@@ -23,9 +23,18 @@2323 import { blake3 } from "hash-wasm";2424 2525 const SYNC_IDENTITY_SALT = new TextEncoder().encode("peach-sync-identity-v1");26+const SYNC_SIGNING_SALT = new TextEncoder().encode("peach-sync-signing-v1");2627 const VAULT_ID_INFO = new TextEncoder().encode("peach-vault-id");2728 const VAULT_ID_HEX_LENGTH = 16; // 8 bytes → 16 hex chars2829 30+// PKCS#8 prefix for a raw 32-byte Ed25519 seed (RFC 8410 §7).31+// SEQUENCE(46) { INTEGER 0, AlgorithmIdentifier(id-Ed25519),32+// OCTET STRING(34) { OCTET STRING(32) <seed> } }33+const ED25519_PKCS8_PREFIX = Uint8Array.of(34+ 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,35+ 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,36+);37+2938 export interface SyncIdentityKeypair {3039 publicKey: Uint8Array; // 32 bytes3140 secretKey: Uint8Array; // 32 bytes — sensitive; treat as key material@@ -63,6 +72,99 @@6372 buf.set(VAULT_ID_INFO, masterKey.length);6473 const fullHex = await blake3(buf);6574 return fullHex.slice(0, VAULT_ID_HEX_LENGTH);75+}76+77+/**78+ * Derive the deterministic Ed25519 signing keypair used to sign device79+ * manifests (v2 brief §4.3). Distinct from the X25519 sync identity so80+ * each key class stays single-purpose; both derive from the same master81+ * key under a different HKDF salt.82+ *83+ * Returns Web Crypto `CryptoKey` handles; private key is non-extractable84+ * so it can be passed around freely without risking leakage. The 32-byte85+ * raw public key is also returned for inclusion in pairing payloads.86+ */87+export async function deriveSyncSigningKeypair(88+ masterKey: Uint8Array,89+ deviceId: string,90+): Promise<{ privateKey: CryptoKey; publicKeyRaw: Uint8Array }> {91+ if (masterKey.length === 0) throw new Error("masterKey must not be empty");92+ if (!deviceId) throw new Error("deviceId must not be empty");93+ const info = new TextEncoder().encode(deviceId);94+ const seed = new HKDF(SHA256, masterKey, SYNC_SIGNING_SALT, info).expand(32);95+96+ const pkcs8 = new Uint8Array(ED25519_PKCS8_PREFIX.length + 32);97+ pkcs8.set(ED25519_PKCS8_PREFIX, 0);98+ pkcs8.set(seed, ED25519_PKCS8_PREFIX.length);99+100+ // The seed is already in memory so making the private key extractable101+ // costs nothing in terms of attack surface, and the JWK export is the102+ // portable way to recover the matching public key in Web Crypto engines103+ // that don't expose `publicKey` directly from a `pkcs8`-imported handle.104+ const privateKey = await crypto.subtle.importKey(105+ "pkcs8",106+ pkcs8 as Uint8Array<ArrayBuffer>,107+ { name: "Ed25519" },108+ true,109+ ["sign"],110+ );111+ const jwk = (await crypto.subtle.exportKey("jwk", privateKey)) as JsonWebKey & { x?: string };112+ if (!jwk.x) throw new Error("Ed25519 JWK missing 'x' component");113+ const publicKeyRaw = base64UrlToBytes(jwk.x);114+ return { privateKey, publicKeyRaw };115+}116+117+/**118+ * Verify an Ed25519 signature against a raw 32-byte public key. Returns119+ * true if the signature is valid. Used to validate incoming device120+ * manifests (v2 brief §4.3).121+ */122+export async function verifyEd25519(123+ publicKeyRaw: Uint8Array,124+ message: Uint8Array,125+ signature: Uint8Array,126+): Promise<boolean> {127+ if (publicKeyRaw.length !== 32) return false;128+ if (signature.length !== 64) return false;129+ try {130+ const key = await crypto.subtle.importKey(131+ "raw",132+ publicKeyRaw as Uint8Array<ArrayBuffer>,133+ { name: "Ed25519" },134+ false,135+ ["verify"],136+ );137+ return await crypto.subtle.verify(138+ "Ed25519",139+ key,140+ signature as Uint8Array<ArrayBuffer>,141+ message as Uint8Array<ArrayBuffer>,142+ );143+ } catch {144+ return false;145+ }146+}147+148+/** Sign with a private Ed25519 CryptoKey from `deriveSyncSigningKeypair`. */149+export async function signEd25519(150+ privateKey: CryptoKey,151+ message: Uint8Array,152+): Promise<Uint8Array> {153+ const sig = await crypto.subtle.sign(154+ "Ed25519",155+ privateKey,156+ message as Uint8Array<ArrayBuffer>,157+ );158+ return new Uint8Array(sig);159+}160+161+function base64UrlToBytes(b64url: string): Uint8Array {162+ const pad = (4 - (b64url.length % 4)) % 4;163+ const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(pad);164+ const bin = atob(b64);165+ const out = new Uint8Array(bin.length);166+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);167+ return out;66168 }67169 68170 export function pubkeyToHex(pk: Uint8Array): string {src/shared/sync-types.ts+63 −0
@@ -80,12 +80,35 @@8080 * `lastSyncAt`, `lastKnownIp` are kept for migration only; new code reads8181 * `deviceName`, `longTermPublicKeyHex`, `lastSyncedAt`.8282 */83+/**84+ * Trust source of a paired-device entry — per v2 brief §4.2.85+ *86+ * "direct" — established via an audio pairing session with this device.87+ * The extension only ever has direct entries; mobiles can88+ * also direct-pair with other mobiles (allowed but unusual).89+ * "vouched" — received via a signed device-manifest from the extension90+ * during a normal sync. Functionally equivalent to direct91+ * for sync purposes; surfaced with an "Added automatically"92+ * badge in Settings.93+ */94+export type TrustSource = "direct" | "vouched";95+8396 export interface PairedDevice {8497 deviceId: string;8598 deviceName?: string;8699 deviceType?: DeviceType;87100 longTermPublicKeyHex?: string;88101 /**102+ * Long-term Ed25519 verification key for the peer's device-manifest103+ * signatures (v2 brief §4.3). Derived deterministically from the same104+ * master key as `longTermPublicKeyHex`, via `deriveSyncSigningKeypair`.105+ * Optional during the migration — older paired records lack it, so106+ * manifests issued by such peers can't be verified and will be rejected.107+ */108+ signingPublicKeyHex?: string;109+ /** v2 brief §4.2 — distinguishes direct-paired vs vouched-via-manifest. */110+ trustSource?: TrustSource;111+ /**89112 * The peer's WebSocket sync port. Android picks a random high port90113 * (49152–65535) at first launch and announces it inside the encrypted91114 * pairing channel; the extension caches it here. iOS / Tauri sync hosts@@ -151,8 +174,48 @@151174 | { type: "SYNC_REQUEST"; entryIds: string[] }152175 | { type: "SYNC_ENTRIES"; entries: Record<string, string> }153176 | { type: "SYNC_TOMBSTONES"; tombstones: Record<string, number> }177+ | { type: "SYNC_DEVICE_MANIFEST"; manifest: SignedDeviceManifest }154178 | { type: "SYNC_DONE" }155179 | { type: "SYNC_ERROR"; error: string };180+181+// ---------------------------------------------------------------------------182+// v3 device manifest — propagates trust between mobile devices (v2 brief §4.3)183+// ---------------------------------------------------------------------------184+185+export const DEVICE_MANIFEST_VERSION = 0x01 as const;186+/** Manifests older than this are silently rejected (v2 brief §4.3). */187+export const DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000;188+189+/**190+ * One device entry inside a signed manifest. `longTermPublicKeyHex` is the191+ * X25519 sync identity key the receiver will use for Noise XX, exactly as192+ * if the entry had been audio-paired locally. `signingPublicKeyHex` is the193+ * peer's Ed25519 manifest-verification key — needed so a vouched mobile194+ * can in turn verify manifests issued by the same vault from a different195+ * device.196+ */197+export interface ManifestDeviceEntry {198+ deviceId: string;199+ deviceName: string;200+ deviceType: DeviceType;201+ longTermPublicKeyHex: string;202+ signingPublicKeyHex: string;203+ pairedAt: number;204+}205+206+/** Body that gets canonicalised + signed. Wire format JSON-stable. */207+export interface DeviceManifestBody {208+ manifestVersion: number;209+ issuerDeviceId: string;210+ issuedAt: number;211+ devices: ManifestDeviceEntry[];212+}213+214+export interface SignedDeviceManifest {215+ body: DeviceManifestBody;216+ /** Hex-encoded 64-byte Ed25519 signature over canonicalised body. */217+ signatureHex: string;218+}156219 157220 export type SyncStatusV3 = "up_to_date" | "syncing" | "unreachable" | "never";158221