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
2 files src/shared/sync-identity.ts+102 −0src/shared/sync-types.ts+63 −0
src/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 {

view file

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 

view file