44fc6080ab15

feat: Enable chirp mesh device manifests (v3.6.90)

- Carry manifest signing public keys through chirp pairing - Build signed device manifests from production sync adapter - Apply verified extension-issued manifests as vouched devices - Preserve direct trust and sync ports for propagated peers Verification: npm run typecheck; npm test -- --run; npm run build

time
2026-05-21 19:38 UTC · 5 months ago
machine
koh2
parent
14d9d4bcab05
13 files manifest.firefox.json+1 −1manifest.json+1 −1package-lock.json+2 −2package.json+1 −1src/background/chirp-identity.ts+8 −1src/background/chirp-sync-coordinator.ts+3 −5src/background/chirp-sync-vault-adapter.test.ts+188 −0src/background/chirp-sync-vault-adapter.ts+113 −6src/background/messages.ts+7 −0src/shared/chirp-handshake.test.ts+40 −1src/shared/chirp-handshake.ts+16 −3src/shared/device-manifest.ts+25 −1src/shared/sync-types.ts+5 −0
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach",5-  "version": "3.6.89",5+  "version": "3.6.90",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "3.6.89",4+  "version": "3.6.90",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package-lock.json+2 −2
@@ -1,12 +1,12 @@11 {22   "name": "peach",3-  "version": "3.6.69",3+  "version": "3.6.90",44   "lockfileVersion": 3,55   "requires": true,66   "packages": {77     "": {88       "name": "peach",9-      "version": "3.6.69",9+      "version": "3.6.90",1010       "dependencies": {1111         "@bokuweb/zstd-wasm": "^0.0.27",1212         "@stablelib/blake2s": "^2.0.1",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "3.6.89",3+  "version": "3.6.90",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/chirp-identity.ts+8 −1
@@ -11,7 +11,11 @@1111 // thin coordinator that pulls the master key out of `vault.ts` and exposes1212 // only the public half to callers that don't need to sign.1313 14-import { deriveSyncIdentityKeypair, pubkeyToHex } from "../shared/sync-identity";14+import {15+  deriveSyncIdentityKeypair,16+  deriveSyncSigningKeypair,17+  pubkeyToHex,18+} from "../shared/sync-identity";1519 import type { DeviceType } from "../shared/sync-types";1620 1721 const DEVICE_ID_KEY = "peach_chirp_device_id";@@ -22,6 +26,7 @@2226   deviceName: string;2327   deviceType: DeviceType;2428   longTermPublicKeyHex: string;29+  signingPublicKeyHex: string;2530 }2631 2732 export async function getOrCreateDeviceId(): Promise<string> {@@ -58,11 +63,13 @@5863   const deviceId = await getOrCreateDeviceId();5964   const deviceName = await getDeviceName();6065   const kp = deriveSyncIdentityKeypair(masterKey, deviceId);66+  const signing = await deriveSyncSigningKeypair(masterKey, deviceId);6167   return {6268     deviceId,6369     deviceName,6470     deviceType: extensionDeviceType(),6571     longTermPublicKeyHex: pubkeyToHex(kp.publicKey),72+    signingPublicKeyHex: pubkeyToHex(signing.publicKeyRaw),6673   };6774 }6875 

view file

src/background/chirp-sync-coordinator.ts+3 −5
@@ -165,11 +165,9 @@165165 166166     // v2 brief §5.6 / §4.3 — device manifest exchange. Optional: both ends167167     // must expose the adapter methods. Disabling on either side cleanly168-    // bypasses the step. The exchange is symmetric (each side sends one169-    // and applies the other's) even though in the current device graph170-    // only the extension actually has anything to vouch for; mobiles still171-    // produce a signed manifest, just with an empty `devices[]`, so the172-    // protocol stays uniform.168+    // bypasses the step. The exchange is symmetric on the wire; the169+    // production adapter still treats the extension as the trust anchor and170+    // only applies extension-issued manifests.173171     if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {174172       const ours = await vault.buildOutgoingDeviceManifest();175173       if (ours) {

view file

src/background/chirp-sync-vault-adapter.test.ts+188 −0
@@ -47,6 +47,12 @@4747     return { vault, adapter };4848   }4949 50+  async function signingPubkeyHex(deviceId: string): Promise<string> {51+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");52+    const kp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x42), deviceId);53+    return pubkeyToHex(kp.publicKeyRaw);54+  }55+5056   it("computeVaultId returns a stable 16-char hex string for the same masterKey", async () => {5157     const { adapter } = await setupVault();5258     const id1 = await adapter.computeVaultId();@@ -176,6 +182,187 @@176182     expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();177183   });178184 185+  it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => {186+    const { adapter } = await setupVault();187+    const { upsertPairedDevice } = await import("./paired-devices");188+    await upsertPairedDevice({189+      deviceId: "android-1",190+      deviceName: "Pixel",191+      deviceType: "android",192+      longTermPublicKeyHex: "a".repeat(64),193+      signingPublicKeyHex: await signingPubkeyHex("android-1"),194+      wsPort: 49152,195+      pairedAt: 1_700_000_000_000,196+      trustSource: "direct",197+    });198+    await upsertPairedDevice({199+      deviceId: "legacy-android",200+      deviceName: "Legacy Pixel",201+      deviceType: "android",202+      longTermPublicKeyHex: "b".repeat(64),203+      pairedAt: 1_700_000_000_100,204+      trustSource: "direct",205+    });206+207+    const manifest = await adapter.buildOutgoingDeviceManifest?.();208+    expect(manifest).toBeDefined();209+    expect(manifest?.signatureHex).toMatch(/^[0-9a-f]{128}$/);210+    expect(manifest?.body.devices).toHaveLength(1);211+    expect(manifest?.body.devices[0]).toMatchObject({212+      deviceId: "android-1",213+      deviceName: "Pixel",214+      deviceType: "android",215+      longTermPublicKeyHex: "a".repeat(64),216+      wsPort: 49152,217+    });218+  });219+220+  it("applyIncomingDeviceManifest accepts extension-vouched devices and preserves existing direct trust", async () => {221+    const { adapter } = await setupVault();222+    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");223+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");224+    const {225+      findPairedDeviceById,226+      listPairedDevices,227+      upsertPairedDevice,228+    } = await import("./paired-devices");229+230+    const issuerId = "extension-issuer";231+    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);232+    await upsertPairedDevice({233+      deviceId: issuerId,234+      deviceName: "Firefox",235+      deviceType: "extension",236+      longTermPublicKeyHex: "c".repeat(64),237+      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),238+      pairedAt: 1_700_000_000_000,239+      trustSource: "direct",240+    });241+    await upsertPairedDevice({242+      deviceId: "android-existing",243+      deviceName: "Existing Pixel",244+      deviceType: "android",245+      longTermPublicKeyHex: "d".repeat(64),246+      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),247+      pairedAt: 1_700_000_000_010,248+      trustSource: "direct",249+    });250+251+    const manifest = await buildSignedDeviceManifest(252+      issuerId,253+      [254+        {255+          deviceId: "android-new",256+          deviceName: "New Pixel",257+          deviceType: "android",258+          longTermPublicKeyHex: "e".repeat(64),259+          signingPublicKeyHex: await signingPubkeyHex("android-new"),260+          wsPort: 51234,261+          pairedAt: 1_700_000_000_020,262+        },263+        {264+          deviceId: "android-existing",265+          deviceName: "Existing Pixel renamed",266+          deviceType: "android",267+          longTermPublicKeyHex: "f".repeat(64),268+          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),269+          wsPort: 52345,270+          pairedAt: 1_700_000_000_030,271+        },272+      ],273+      issuerKp.privateKey,274+    );275+276+    await adapter.applyIncomingDeviceManifest?.(manifest);277+278+    const added = await findPairedDeviceById("android-new");279+    expect(added).toMatchObject({280+      deviceName: "New Pixel",281+      deviceType: "android",282+      longTermPublicKeyHex: "e".repeat(64),283+      trustSource: "vouched",284+      wsPort: 51234,285+    });286+    const existing = await findPairedDeviceById("android-existing");287+    expect(existing).toMatchObject({288+      deviceName: "Existing Pixel renamed",289+      longTermPublicKeyHex: "f".repeat(64),290+      trustSource: "direct",291+      wsPort: 52345,292+    });293+    expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([294+      "android-existing",295+      "android-new",296+      "extension-issuer",297+    ]);298+  });299+300+  it("applyIncomingDeviceManifest ignores non-extension issuers and bad signatures", async () => {301+    const { adapter } = await setupVault();302+    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");303+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");304+    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");305+306+    const mobileIssuerId = "android-issuer";307+    const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);308+    await upsertPairedDevice({309+      deviceId: mobileIssuerId,310+      deviceName: "Issuer Pixel",311+      deviceType: "android",312+      longTermPublicKeyHex: "1".repeat(64),313+      signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),314+      pairedAt: 1_700_000_000_000,315+      trustSource: "direct",316+    });317+    const mobileManifest = await buildSignedDeviceManifest(318+      mobileIssuerId,319+      [{320+        deviceId: "should-not-add",321+        deviceName: "Other Pixel",322+        deviceType: "android",323+        longTermPublicKeyHex: "2".repeat(64),324+        signingPublicKeyHex: await signingPubkeyHex("should-not-add"),325+        pairedAt: 1_700_000_000_010,326+      }],327+      mobileIssuerKp.privateKey,328+    );329+    await adapter.applyIncomingDeviceManifest?.(mobileManifest);330+    expect(await findPairedDeviceById("should-not-add")).toBeNull();331+332+    const extensionIssuerId = "extension-bad-sig";333+    const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);334+    await upsertPairedDevice({335+      deviceId: extensionIssuerId,336+      deviceName: "Chrome",337+      deviceType: "extension",338+      longTermPublicKeyHex: "3".repeat(64),339+      signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),340+      pairedAt: 1_700_000_000_020,341+      trustSource: "direct",342+    });343+    const good = await buildSignedDeviceManifest(344+      extensionIssuerId,345+      [{346+        deviceId: "forged-add",347+        deviceName: "Forged Pixel",348+        deviceType: "android",349+        longTermPublicKeyHex: "4".repeat(64),350+        signingPublicKeyHex: await signingPubkeyHex("forged-add"),351+        pairedAt: 1_700_000_000_030,352+      }],353+      extensionKp.privateKey,354+    );355+    const forged = {356+      ...good,357+      signatureHex:358+        good.signatureHex.slice(0, 2) +359+        ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +360+        good.signatureHex.slice(4),361+    };362+    await adapter.applyIncomingDeviceManifest?.(forged);363+    expect(await findPairedDeviceById("forged-add")).toBeNull();364+  });365+179366   it("methods throw VaultLockedError when the vault is locked", async () => {180367     const { vault, adapter } = await setupVault();181368     vault.lock();@@ -185,5 +372,6 @@185372     await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });186373     await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });187374     await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });375+    await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });188376   });189377 });

view file

src/background/chirp-sync-vault-adapter.ts+113 −6
@@ -13,18 +13,24 @@1313 // transport on top of this — the per-entry envelope is belt-and-braces so a1414 // transport-layer leak doesn't expose plaintext entries.1515 //16-// Device-manifest exchange (v2 brief §4.3) is intentionally NOT plumbed here.17-// The coordinator's optional `buildOutgoingDeviceManifest` /18-// `applyIncomingDeviceManifest` hooks are left undefined so the coordinator19-// skips that protocol step. Adding manifest support is a separate concern20-// that requires verified-issuer registry plumbing.16+// Device-manifest exchange (v2 brief §4.3) is plumbed here. The extension is17+// the trust anchor: manifests are signed with its vault-derived Ed25519 key,18+// and receivers only apply manifests whose issuer is a paired extension.2119 2220 import { HKDF } from "@stablelib/hkdf";2321 import { SHA256 } from "@stablelib/sha256";2422 import type { ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";2523 import { getOrCreateDeviceId } from "./chirp-identity";26-import { computeVaultId } from "../shared/sync-identity";2724 import {25+  computeVaultId,26+  deriveSyncSigningKeypair,27+  hexToPubkey,28+} from "../shared/sync-identity";29+import {30+  buildSignedDeviceManifest,31+  verifyDeviceManifest,32+} from "../shared/device-manifest";33+import {2834   applyMergedRemoteEntries,2935   getDeletedEntries,3036   getDerivedKeyHex,@@ -32,7 +38,17 @@3238   getEntryById,3339   isUnlocked,3440 } from "./vault";41+import {42+  findPairedDeviceById,43+  listPairedDevices,44+  upsertPairedDevice,45+} from "./paired-devices";3546 import type { VaultEntry } from "../shared/types";47+import type {48+  ManifestDeviceEntry,49+  PairedDevice,50+  SignedDeviceManifest,51+} from "../shared/sync-types";3652 3753 const ENTRY_KEY_SALT = new TextEncoder().encode("peach-sync-entry-v1");3854 const ENTRY_AAD = "peach-sync-entry-v1";@@ -118,7 +134,98 @@118134         await applyMergedRemoteEntries([], cleaned);119135       }120136     },137+138+    async buildOutgoingDeviceManifest() {139+      requireUnlocked();140+      const masterKey = requireMasterKey();141+      const issuerDeviceId = await getOrCreateDeviceId();142+      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);143+      const devices = (await listPairedDevices())144+        .map(toManifestEntry)145+        .filter((d): d is ManifestDeviceEntry => d !== null);146+      return buildSignedDeviceManifest(147+        issuerDeviceId,148+        devices,149+        signing.privateKey,150+      );151+    },152+153+    async applyIncomingDeviceManifest(manifest) {154+      requireUnlocked();155+      await applyVerifiedExtensionManifest(manifest);156+    },121157   };158+}159+160+function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {161+  if (162+    !device.deviceId ||163+    !device.deviceName ||164+    !device.deviceType ||165+    !device.longTermPublicKeyHex ||166+    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||167+    !device.signingPublicKeyHex ||168+    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)169+  ) {170+    return null;171+  }172+  return {173+    deviceId: device.deviceId,174+    deviceName: device.deviceName,175+    deviceType: device.deviceType,176+    longTermPublicKeyHex: device.longTermPublicKeyHex,177+    signingPublicKeyHex: device.signingPublicKeyHex,178+    wsPort: device.wsPort,179+    pairedAt: device.pairedAt,180+  };181+}182+183+async function applyVerifiedExtensionManifest(184+  manifest: SignedDeviceManifest,185+): Promise<void> {186+  const issuerId = manifest?.body?.issuerDeviceId;187+  if (typeof issuerId !== "string" || !issuerId) return;188+189+  const issuer = await findPairedDeviceById(issuerId);190+  if (191+    !issuer ||192+    issuer.deviceType !== "extension" ||193+    !issuer.signingPublicKeyHex ||194+    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)195+  ) {196+    return;197+  }198+199+  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;200+  try {201+    body = await verifyDeviceManifest(202+      manifest,203+      hexToPubkey(issuer.signingPublicKeyHex),204+    );205+  } catch {206+    return;207+  }208+  if (body.issuerDeviceId !== issuer.deviceId) return;209+210+  const ourDeviceId = await getOrCreateDeviceId();211+  for (const entry of body.devices) {212+    if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {213+      continue;214+    }215+    const existing = await findPairedDeviceById(entry.deviceId);216+    await upsertPairedDevice({217+      ...(existing ?? {}),218+      deviceId: entry.deviceId,219+      deviceName: entry.deviceName,220+      deviceType: entry.deviceType,221+      longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),222+      signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),223+      pairedAt: existing?.pairedAt ?? entry.pairedAt,224+      trustSource: existing?.trustSource ?? (existing ? "direct" : "vouched"),225+      wsPort: existing?.wsPort ?? entry.wsPort,226+      lastSyncedAt: existing?.lastSyncedAt,227+    });228+  }122229 }123230 124231 function requireUnlocked(): void {

view file

src/background/messages.ts+7 −0
@@ -1041,6 +1041,13 @@10411041           typeof candidate.deviceType !== "string" ||10421042           typeof candidate.longTermPublicKeyHex !== "string" ||10431043           !/^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) ||1044+          (1045+            candidate.signingPublicKeyHex !== undefined &&1046+            (1047+              typeof candidate.signingPublicKeyHex !== "string" ||1048+              !/^[0-9a-f]{64}$/.test(candidate.signingPublicKeyHex)1049+            )1050+          ) ||10441051           typeof candidate.pairedAt !== "number"10451052         ) {10461053           return { ok: false, error: "invalid_paired_device" };

view file

src/shared/chirp-handshake.test.ts+40 −1
@@ -12,7 +12,13 @@1212 1313 function identityFor(deviceId: string, deviceName: string, type: LocalIdentity["deviceType"]): LocalIdentity {1414   const kp = deriveSyncIdentityKeypair(MASTER, deviceId);15-  return { deviceId, deviceName, deviceType: type, longTermPublicKeyHex: pubkeyToHex(kp.publicKey) };15+  return {16+    deviceId,17+    deviceName,18+    deviceType: type,19+    longTermPublicKeyHex: pubkeyToHex(kp.publicKey),20+    signingPublicKeyHex: "b".repeat(64),21+  };1622 }1723 1824 describe("chirp pairing handshake (Noise NK + identity exchange)", () => {@@ -37,11 +43,15 @@3743     expect(initLearned.deviceName).toBe("Firefox on MacBook");3844     expect(initLearned.deviceType).toBe("extension");3945     expect(initLearned.longTermPublicKeyHex).toBe(respIdent.longTermPublicKeyHex);46+    expect(initLearned.signingPublicKeyHex).toBe(respIdent.signingPublicKeyHex);47+    expect(initLearned.trustSource).toBe("direct");4048 4149     expect(respLearned.deviceId).toBe("android-001");4250     expect(respLearned.deviceName).toBe("June's Pixel 9");4351     expect(respLearned.deviceType).toBe("android");4452     expect(respLearned.longTermPublicKeyHex).toBe(initIdent.longTermPublicKeyHex);53+    expect(respLearned.signingPublicKeyHex).toBe(initIdent.signingPublicKeyHex);54+    expect(respLearned.trustSource).toBe("direct");4555   });4656 4757   it("rejects a malformed identity frame", async () => {@@ -91,6 +101,35 @@91101     })();92102 93103     await expect(respPromise).rejects.toThrow(/32-byte hex/);104+    await evilInitiator.catch(() => undefined);105+  });106+107+  it("rejects an identity with a malformed signing_public_key_hex", async () => {108+    const respEphemeral = generateKeyPair();109+    const respIdent = identityFor("ext-004", "Firefox", "extension");110+    const [t1, t2] = memoryTransportPair();111+112+    const respPromise = runPairingResponder(113+      respEphemeral.secretKey,114+      respEphemeral.publicKey,115+      respIdent,116+      t2,117+    );118+    const evilInitiator = (async () => {119+      const { noiseNKInitiator } = await import("./noise-js");120+      const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);121+      const frame = JSON.stringify({122+        type: "PAIR_IDENTITY",123+        device_id: "evil",124+        device_name: "evil",125+        device_type: "android",126+        long_term_public_key_hex: "a".repeat(64),127+        signing_public_key_hex: "deadbeef",128+      });129+      await t1.send(session.encrypt(new TextEncoder().encode(frame)));130+    })();131+132+    await expect(respPromise).rejects.toThrow(/signing_public_key_hex/);94133     await evilInitiator.catch(() => undefined);95134   });96135 });

view file

src/shared/chirp-handshake.ts+16 −3
@@ -12,9 +12,10 @@1212 //      so in the audio band, in real time, in the same room.1313 //1414 //   2. Over the resulting Noise transport, each side sends ONE JSON frame15-//      with its `device_id`, `device_name`, `device_type`, and16-//      `long_term_public_key_hex`. Both sides receive the counterpart's17-//      frame and persist it in the paired device registry.15+//      with its `device_id`, `device_name`, `device_type`,16+//      `long_term_public_key_hex`, and device-manifest signing key. Both17+//      sides receive the counterpart's frame and persist it in the paired18+//      device registry.1819 //1920 //   3. The Noise NK session and the chirp ephemerals are discarded —2021 //      future WebSocket sessions use Noise XX with the long-term static@@ -37,6 +38,7 @@3738   deviceName: string;3839   deviceType: DeviceType;3940   longTermPublicKeyHex: string;41+  signingPublicKeyHex?: string;4042   /** Server-only field — Android/iOS/desktop sync hosts announce their port. */4143   wsPort?: number;4244 }@@ -47,6 +49,7 @@4749   device_name: string;4850   device_type: DeviceType;4951   long_term_public_key_hex: string;52+  signing_public_key_hex?: string;5053   ws_port?: number;5154 }5255 @@ -65,6 +68,12 @@6568   if (!/^[0-9a-f]{64}$/.test(parsed.long_term_public_key_hex)) {6669     throw new Error("long_term_public_key_hex is not 32-byte hex");6770   }71+  if (72+    parsed.signing_public_key_hex !== undefined &&73+    !/^[0-9a-f]{64}$/.test(parsed.signing_public_key_hex)74+  ) {75+    throw new Error("signing_public_key_hex is not 32-byte hex");76+  }6877   return parsed as PairingFrame;6978 }7079 @@ -93,6 +102,7 @@93102     device_name: ourIdentity.deviceName,94103     device_type: ourIdentity.deviceType,95104     long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,105+    signing_public_key_hex: ourIdentity.signingPublicKeyHex,96106     ws_port: ourIdentity.wsPort,97107   });98108   await transport.send(session.encrypt(ourFrame));@@ -134,6 +144,7 @@134144     device_name: ourIdentity.deviceName,135145     device_type: ourIdentity.deviceType,136146     long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,147+    signing_public_key_hex: ourIdentity.signingPublicKeyHex,137148     ws_port: ourIdentity.wsPort,138149   });139150   await transport.send(session.encrypt(ourFrame));@@ -147,6 +158,8 @@147158     deviceName: frame.device_name,148159     deviceType: frame.device_type,149160     longTermPublicKeyHex: frame.long_term_public_key_hex,161+    signingPublicKeyHex: frame.signing_public_key_hex,162+    trustSource: "direct",150163     wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined,151164     pairedAt: Date.now(),152165   };

view file

src/shared/device-manifest.ts+25 −1
@@ -129,18 +129,40 @@129129     if (typeof d.deviceId !== "string" || !d.deviceId) {130130       throw new DeviceManifestError("malformed", "device.deviceId missing");131131     }132+    if (typeof d.deviceName !== "string" || !d.deviceName) {133+      throw new DeviceManifestError("malformed", "device.deviceName missing");134+    }135+    if (136+      d.deviceType !== "extension" &&137+      d.deviceType !== "android" &&138+      d.deviceType !== "ios" &&139+      d.deviceType !== "desktop"140+    ) {141+      throw new DeviceManifestError("malformed", "device.deviceType invalid");142+    }132143     if (typeof d.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) {133144       throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");134145     }135146     if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) {136147       throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");137148     }149+    if (150+      d.wsPort !== undefined &&151+      (152+        typeof d.wsPort !== "number" ||153+        !Number.isInteger(d.wsPort) ||154+        d.wsPort < 1 ||155+        d.wsPort > 65535156+      )157+    ) {158+      throw new DeviceManifestError("malformed", "device.wsPort invalid");159+    }138160   }139161   return manifest.body;140162 }141163 142164 function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {143-  return {165+  const normalised: ManifestDeviceEntry = {144166     deviceId: d.deviceId,145167     deviceName: d.deviceName,146168     deviceType: d.deviceType,@@ -148,6 +170,8 @@148170     signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),149171     pairedAt: d.pairedAt,150172   };173+  if (d.wsPort !== undefined) normalised.wsPort = d.wsPort;174+  return normalised;151175 }152176 153177 /**

view file

src/shared/sync-types.ts+5 −0
@@ -178,6 +178,11 @@178178   deviceType: DeviceType;179179   longTermPublicKeyHex: string;180180   signingPublicKeyHex: string;181+  /**182+   * Optional sync WebSocket port for native/mobile hosts. Preserving this in183+   * vouched entries lets mobile peers connect without another audio pairing.184+   */185+  wsPort?: number;181186   pairedAt: number;182187 }183188 

view file