44fc6080ab15
feat: Enable chirp mesh device manifests (v3.6.90)
- time
- 2026-05-21 19:38 UTC · 5 months ago
- machine
- koh2
- parent
- 14d9d4bcab05
↓ peach-browser-44fc6080.tar.gzatlas at this save
files changed13 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 −0manifest.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": {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",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",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": {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 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) {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 });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 {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" };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 });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 };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 /**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