From 5624e531bfcde911f2117ccd337a650974e1aaaa Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 18 Sep 2026 09:20:07 +0100 Subject: [PATCH 1/6] feat(core): persist a per-slot connection-code ledger Adds loadConnectionCodeLedger/saveConnectionCodeLedger to identity-store.ts, mirroring the existing loadGatewayTrust/saveGatewayTrust sibling-file pattern. Tracks two independent maps per slot: codes this slot has issued (so an outstanding code survives a restart between generation and hand-off) and nonces this slot has redeemed (so a single-use code can't be redeemed twice across a restart within its own short validity window). Also adds openpgp as a dependency, needed by the connection-code signature verification this ledger will back (agent-comms#188). --- package.json | 1 + pnpm-lock.yaml | 14 +++ src/core/identity-store.ts | 83 ++++++++++++++ ...connection-code-ledger-persistence.test.ts | 101 ++++++++++++++++++ 4 files changed, 199 insertions(+) create mode 100644 src/test/connection-code-ledger-persistence.test.ts diff --git a/package.json b/package.json index 373fcb0a..96d962cd 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "@modelcontextprotocol/sdk": "1.30.0", "cbor2": "2.3.0", "cc-peer": "1.4.1", + "openpgp": "6.3.1", "preact": "10.29.7", "typebox": "1.3.6", "wire-mesh-core": "1.48.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6f4aa96..255820f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,6 +191,9 @@ importers: cc-peer: specifier: 1.4.1 version: 1.4.1 + openpgp: + specifier: 6.3.1 + version: 6.3.1 preact: specifier: 10.29.7 version: 10.29.7 @@ -3567,6 +3570,15 @@ packages: zod: optional: true + openpgp@6.3.1: + resolution: {integrity: sha512-7oSPvmlKPojxFoyelT5DWPIAVmqWZh4qU/5pO6bdoShEtRpCw9Sye9IXUQj6EFM3XpgGssqccAr705YtTcLNQw==} + engines: {node: '>= 18.0.0', typescript: '>= 5.0.0'} + peerDependencies: + '@openpgp/web-stream-tools': ~0.3.0 + peerDependenciesMeta: + '@openpgp/web-stream-tools': + optional: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -8133,6 +8145,8 @@ snapshots: ws: 8.21.1 zod: 4.4.3 + openpgp@6.3.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 diff --git a/src/core/identity-store.ts b/src/core/identity-store.ts index f0f48004..a0471bdd 100644 --- a/src/core/identity-store.ts +++ b/src/core/identity-store.ts @@ -566,3 +566,86 @@ export function saveGatewayTrust( { encoding: "utf-8", mode: 0o600 }, ); } + +/** A generated connection code's own persisted record (agent-comms#188): everything ConnectionCode carries except its own nonce, which is this record's key instead of a repeated field. */ +export interface StoredConnectionCode { + expiresAt: string; + deviceId: string; + signature?: string; +} + +/** A slot's own connection-code ledger (agent-comms#188): every code this slot has generated (still outstanding, so the artifact survives a restart between generation and hand-off) and every nonce this slot has redeemed (so a restart can't reopen a single-use code to a second redemption within its own short validity window). Two independent halves of one bootstrap flow, not a request/response pair -- a generated code's own record here is never consulted by whichever remote slot later redeems it, since redemption validates the code's four self-contained fields directly rather than calling back to its issuer. */ +export interface ConnectionCodeLedgerData { + issued: Record; + redeemed: Record; +} + +/** A slot's own connection-code ledger lives in its own sibling JSON file, mirroring gatewayTrustFilePath's own reasoning: the ledger has no dependency on this slot's own key material, so ConnectionCodeLedger can be constructed against a slot before or independently of that slot's identity ever being loaded. */ +function connectionCodesFilePath(slot: Readonly): string { + const { dir } = slotPaths(slot); + const base = `connection-codes-${slot.harness}--${slugifyCwd(slot.cwd)}`; + return path.join(dir, `${base}.json`); +} + +function isStoredConnectionCode(value: unknown): value is StoredConnectionCode { + if (typeof value !== "object" || value === null) return false; + if (!("expiresAt" in value) || !("deviceId" in value)) return false; + if (typeof value.expiresAt !== "string" || typeof value.deviceId !== "string") + return false; + if ("signature" in value && typeof value.signature !== "string") + return false; + return true; +} + +/** + * A slot's currently persisted connection-code ledger: every code it has issued, keyed by nonce, and every nonce it has redeemed, mapped to that code's own expiresAt (kept only so a stale entry can be pruned once its expiry has passed, the same reason issued entries carry their own expiresAt). Both halves default to empty if the slot has never saved a ledger, or its ledger file is missing or unparseable. + */ +export function loadConnectionCodeLedger( + slot: Readonly, +): ConnectionCodeLedgerData { + let parsed: unknown; + try { + parsed = JSON.parse( + fs.readFileSync(connectionCodesFilePath(slot), "utf-8"), + ); + } catch { + return { issued: {}, redeemed: {} }; + } + if (typeof parsed !== "object" || parsed === null) + return { issued: {}, redeemed: {} }; + const issuedRaw = + "issued" in parsed && typeof parsed.issued === "object" && + parsed.issued !== null + ? parsed.issued + : {}; + const redeemedRaw = + "redeemed" in parsed && typeof parsed.redeemed === "object" && + parsed.redeemed !== null + ? parsed.redeemed + : {}; + const issued: Record = {}; + for (const [nonce, record] of Object.entries(issuedRaw)) { + if (isStoredConnectionCode(record)) issued[nonce] = record; + } + const redeemed: Record = {}; + for (const [nonce, expiresAt] of Object.entries(redeemedRaw)) { + if (typeof expiresAt === "string") redeemed[nonce] = expiresAt; + } + return { issued, redeemed }; +} + +/** + * Persists a slot's complete connection-code ledger, surviving a restart the same way the identity it bootstraps trust for does. Overwrites whatever was saved before in full: ConnectionCodeLedger always calls this with its own current issued/redeemed maps after every generate/redeem, so there is no per-code partial update to preserve here the way saveRoomToken preserves other rooms' tokens. + */ +export function saveConnectionCodeLedger( + slot: Readonly, + ledger: Readonly, +): void { + const { dir } = slotPaths(slot); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + connectionCodesFilePath(slot), + `${JSON.stringify(ledger, null, 2)}\n`, + { encoding: "utf-8", mode: 0o600 }, + ); +} diff --git a/src/test/connection-code-ledger-persistence.test.ts b/src/test/connection-code-ledger-persistence.test.ts new file mode 100644 index 00000000..4d4e3107 --- /dev/null +++ b/src/test/connection-code-ledger-persistence.test.ts @@ -0,0 +1,101 @@ +/** + * Unit tests for the connection-code ledger's own per-slot JSON persistence (agent-comms#188), mirroring identity-store.test.ts's own loadGatewayTrust/saveGatewayTrust coverage for the sibling gateway-trust file. + */ + +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test, expect } from "vitest"; +import { + loadConnectionCodeLedger, + saveConnectionCodeLedger, + type IdentitySlot, +} from "../core/identity-store.js"; + +function tempSlot(harness: string): IdentitySlot { + const dir = fs.mkdtempSync( + path.join(tmpdir(), "agent-comms-connection-code-ledger-test-"), + ); + return { harness, cwd: "/tmp/project", dir }; +} + +test("loadConnectionCodeLedger is empty for a slot that has never saved a ledger", () => { + const slot = tempSlot("test"); + expect(loadConnectionCodeLedger(slot)).toEqual({ issued: {}, redeemed: {} }); +}); + +test("loadConnectionCodeLedger does not require an identity to have been created first", () => { + const slot = tempSlot("test"); + expect(() => { + saveConnectionCodeLedger(slot, { + issued: { nonce1: { expiresAt: "2026-01-01T00:00:00.000Z", deviceId: "aabbcc" } }, + redeemed: {}, + }); + }).not.toThrow(); + expect(loadConnectionCodeLedger(slot).issued).toEqual({ + nonce1: { expiresAt: "2026-01-01T00:00:00.000Z", deviceId: "aabbcc" }, + }); +}); + +test("saveConnectionCodeLedger persists both issued and redeemed maps, loadConnectionCodeLedger reloads the same data", () => { + const slot = tempSlot("test"); + saveConnectionCodeLedger(slot, { + issued: { + nonce1: { + expiresAt: "2026-01-01T00:00:00.000Z", + deviceId: "aabbcc", + signature: "-----BEGIN PGP SIGNATURE-----\nfake\n-----END PGP SIGNATURE-----", + }, + }, + redeemed: { nonce2: "2026-01-02T00:00:00.000Z" }, + }); + + expect(loadConnectionCodeLedger(slot)).toEqual({ + issued: { + nonce1: { + expiresAt: "2026-01-01T00:00:00.000Z", + deviceId: "aabbcc", + signature: "-----BEGIN PGP SIGNATURE-----\nfake\n-----END PGP SIGNATURE-----", + }, + }, + redeemed: { nonce2: "2026-01-02T00:00:00.000Z" }, + }); +}); + +test("saveConnectionCodeLedger overwrites the previously saved ledger rather than merging with it", () => { + const slot = tempSlot("test"); + saveConnectionCodeLedger(slot, { + issued: { nonce1: { expiresAt: "2026-01-01T00:00:00.000Z", deviceId: "aabbcc" } }, + redeemed: {}, + }); + saveConnectionCodeLedger(slot, { + issued: {}, + redeemed: { nonce1: "2026-01-01T00:00:00.000Z" }, + }); + + expect(loadConnectionCodeLedger(slot)).toEqual({ + issued: {}, + redeemed: { nonce1: "2026-01-01T00:00:00.000Z" }, + }); +}); + +test("loadConnectionCodeLedger ignores a malformed issued record rather than throwing", () => { + const slot = tempSlot("test"); + const { dir } = { dir: slot.dir }; + fs.mkdirSync(dir as string, { recursive: true }); + fs.writeFileSync( + path.join(dir as string, `connection-codes-${slot.harness}--_tmp_project.json`), + JSON.stringify({ issued: { bad: { deviceId: "aabbcc" } }, redeemed: {} }), + ); + expect(loadConnectionCodeLedger(slot)).toEqual({ issued: {}, redeemed: {} }); +}); + +test("loadConnectionCodeLedger returns empty for an unparseable file", () => { + const slot = tempSlot("test"); + fs.mkdirSync(slot.dir as string, { recursive: true }); + fs.writeFileSync( + path.join(slot.dir as string, `connection-codes-${slot.harness}--_tmp_project.json`), + "not json", + ); + expect(loadConnectionCodeLedger(slot)).toEqual({ issued: {}, redeemed: {} }); +}); From 6d08d132b728c8d27b3c025f649c6f508dcdd705 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 18 Sep 2026 09:31:22 +0100 Subject: [PATCH 2/6] feat(core): add ConnectionCodeLedger for connection-code generation and redemption Implements the always-checked nonce/expiry half and the optional PGP-signature half of a ConnectionCode artifact (agent-comms#188): generate() mints a fresh single-use code vouching for a device-id, optionally signed with a caller-supplied armored PGP private key; redeem() validates freshness, single-use, and (only when a signature is present) verifies it against a caller-supplied public key, optionally pinned to an already-trusted fingerprint. PGP key material is never generated or stored by this module -- signing and verification both take key material supplied per call, the same way invoking gpg directly would, rather than agent-comms managing a persisted PGP identity of its own. Adds ConnectionCodeSchema and the gateway_generate_connection_code/gateway_redeem_connection_code CommsAction variants to types.ts, ahead of wiring them into MeshStore and CommsTool. --- src/core/connection-code.ts | 296 ++++++++++++++++++ src/core/identity-store.ts | 9 +- src/core/types.ts | 30 ++ ...connection-code-ledger-persistence.test.ts | 24 +- src/test/connection-code.test.ts | 296 ++++++++++++++++++ 5 files changed, 645 insertions(+), 10 deletions(-) create mode 100644 src/core/connection-code.ts create mode 100644 src/test/connection-code.test.ts diff --git a/src/core/connection-code.ts b/src/core/connection-code.ts new file mode 100644 index 00000000..ef99c801 --- /dev/null +++ b/src/core/connection-code.ts @@ -0,0 +1,296 @@ +/** + * ConnectionCode generation and redemption (agent-comms#188): bootstraps GatewayTrust between two devices that have never established a mesh connection, since GatewayTrust's own deny-by-default, pin-the-key model otherwise requires a device-id to already be known and pasted in by hand (gateway_trust). One artifact, one generation flow, one redemption flow -- never two mechanisms bolted together. + * + * `code`/`expiresAt` are the always-checked half: freshness/liveness proof that whoever redeems this was on the other end of this exact exchange, recently. `signature` is the optional half: a detached PGP signature (armored) over `${code}:${expiresAt}:${deviceId}`, checked only when present. Redemption never requires a signature -- a device with no PGP identity still generates and redeems a bare code -- but when one is present, verifying it needs the signer's own public key, supplied by the caller either as a pasted armored block or (see pgp-keyserver.ts) fetched from a keyserver by fingerprint. Either way, a caller who also supplies the fingerprint they already independently trust gets it pinned: the verified key's own computed fingerprint must equal it, or redemption fails. + * + * PGP key material is never generated, stored, or managed by this module or by agent-comms generally -- signing and verification both take key material supplied per call by the caller, exactly as invoking `gpg --sign`/`gpg --verify` directly would. Building a persisted PGP-identity subsystem (key generation, passphrase storage, revocation) is a materially larger, security-sensitive scope the settled design (agent-comms#188) never asked for; a caller who wants a code signed brings whatever real-world PGP identity they already have (git-commit signing key, personal key on a keyserver, etc.) rather than trusting agent-comms to mint and hold a new one on their behalf. + * + * Persistence (ConnectionCodeLedger, backed by identity-store.ts's connection-code ledger) is two independent halves, not a request/response pair: the generating device's own issued-code record survives a restart between generation and hand-off (identity-store.ts's own doc comment on why), while the redeeming device's own redeemed-nonce record survives a restart within the code's own short validity window, enforcing genuine single-use locally rather than merely advisory "please don't reuse this" convention. Redemption never consults the issuer's own ledger -- there is no network round-trip back to whoever generated the code, since the whole point of this bootstrap is working before any connection between the two devices exists. + */ + +import * as openpgp from "openpgp"; +import { nanoid } from "./nanoid.js"; +import { + loadConnectionCodeLedger, + saveConnectionCodeLedger, +} from "./identity-store.js"; +import type { IdentitySlot, StoredConnectionCode } from "./identity-store.js"; +import type { ConnectionCode } from "./types.js"; + +/** Nonce length: longer than nanoid's own 21-character default, since this identifier doubles as the single-use token a redemption check keys off, not merely a display id. */ +const CONNECTION_CODE_NONCE_LENGTH = 32; + +/** Minutes a generated code stays valid absent an explicit ttlMs override. */ +const DEFAULT_CONNECTION_CODE_TTL_MINUTES = 15; +/** Seconds per minute, used to convert DEFAULT_CONNECTION_CODE_TTL_MINUTES to milliseconds. */ +const SECONDS_PER_MINUTE = 60; +/** Milliseconds per second, used to convert DEFAULT_CONNECTION_CODE_TTL_MINUTES to milliseconds. */ +const MS_PER_SECOND = 1000; + +/** Default validity window for a generated code absent an explicit ttlMs: long enough for a human to relay it out of band (Slack, a phone call), short enough that "freshness" actually means something. */ +export const DEFAULT_CONNECTION_CODE_TTL_MS = + DEFAULT_CONNECTION_CODE_TTL_MINUTES * SECONDS_PER_MINUTE * MS_PER_SECOND; + +/** Every reason ConnectionCodeLedger.redeem can reject a candidate code. */ +export type ConnectionCodeInvalidReason = + | "expired" + | "already_redeemed" + | "signature_required" + | "signature_invalid" + | "fingerprint_mismatch"; + +/** Thrown by ConnectionCodeLedger.redeem for every rejection reason above -- callers branch on `reason` rather than parsing `message`. */ +export class ConnectionCodeError extends Error { + constructor( + public readonly reason: ConnectionCodeInvalidReason, + message: string, + ) { + super(message); + this.name = "ConnectionCodeError"; + } +} + +/** The exact canonical message a connection code's signature is computed over -- shared by generation and redemption so both sides agree on what was actually signed. Field order and the `:` separator are fixed by this function alone; `code` and `deviceId` are both hex/base64url alphabets that never contain `:`, so the three fields can't be confused with each other under concatenation. */ +function connectionCodeMessage( + candidate: Readonly<{ + code: string; + expiresAt: string; + deviceId: string; + }>, +): string { + return `${candidate.code}:${candidate.expiresAt}:${candidate.deviceId}`; +} + +/** Signs `message` with `privateKeyArmored`, decrypting it with `passphrase` first if it is passphrase-protected. Returns the detached, armored PGP signature. */ +async function signConnectionCodeMessage( + message: string, + privateKeyArmored: string, + passphrase?: string, +): Promise { + let privateKey = await openpgp.readPrivateKey({ + armoredKey: privateKeyArmored, + }); + if (!privateKey.isDecrypted()) { + privateKey = await openpgp.decryptKey( + passphrase === undefined ? { privateKey } : { privateKey, passphrase }, + ); + } + const unsignedMessage = await openpgp.createMessage({ text: message }); + // openpgp.d.ts's own sign() overloads return a conditional type keyed off T extends WebStream/NodeWebStream, which ESLint's own type-aware checking (typescript-eslint's projectService) can't resolve concretely here even though tsc itself has no trouble with it -- widening to `unknown` and narrowing with a runtime typeof check is the honest fix (Type Safety: "unknown with type narrowing"), not a cast papering over a real mismatch tsc would otherwise catch. + const signResult: unknown = await openpgp.sign({ + message: unsignedMessage, + signingKeys: privateKey, + detached: true, + }); + if (typeof signResult !== "string") { + throw new TypeError( + "openpgp.sign returned a stream rather than an armored string -- unexpected for a non-streaming Message input", + ); + } + return signResult; +} + +/** The result of checking a connection code's signature against a supplied public key: whether it verified, and (when readable) the signing key's own fingerprint, so a caller can compare it against whatever fingerprint they already trust. */ +interface VerifyConnectionCodeSignatureResult { + valid: boolean; + fingerprint?: string; +} + +/** Verifies `signatureArmored` (a detached PGP signature) over `message` against `publicKeyArmored`. Never throws for an ordinary verification failure (wrong key, tampered message) -- returns `{ valid: false }` instead, reserving thrown errors for malformed input (unparsable armored text). */ +async function verifyConnectionCodeSignature( + message: string, + signatureArmored: string, + publicKeyArmored: string, +): Promise { + const publicKey = await openpgp.readKey({ armoredKey: publicKeyArmored }); + const fingerprint = publicKey.getFingerprint(); + const pgpMessage = await openpgp.createMessage({ text: message }); + const signature = await openpgp.readSignature({ + armoredSignature: signatureArmored, + }); + const verificationResult = await openpgp.verify({ + message: pgpMessage, + signature, + verificationKeys: publicKey, + }); + const [sigResult] = verificationResult.signatures; + if (sigResult === undefined) return { valid: false, fingerprint }; + try { + await sigResult.verified; + return { valid: true, fingerprint }; + } catch { + return { valid: false, fingerprint }; + } +} + +/** Normalises a PGP fingerprint for comparison: openpgp.js's own getFingerprint() returns lowercase hex with no separators, but a human pasting one in from a keyserver page or business card may add spaces, colons, or uppercase. */ +function normalizeFingerprint(fingerprint: string): string { + return fingerprint.replace(/[\s:]+/g, "").toLowerCase(); +} + +export interface GenerateConnectionCodeOptions { + /** Overrides DEFAULT_CONNECTION_CODE_TTL_MS. */ + ttlMs?: number; + /** Armored PGP private key to sign the code with. Omitted entirely -- not merely absent a signature -- when the caller has no PGP identity to sign with; the generated code is then bare, exactly as valid to redeem, just without the optional persistent-identity proof. */ + privateKeyArmored?: string; + /** Passphrase for privateKeyArmored, if it is passphrase-protected. Ignored if privateKeyArmored is omitted. */ + passphrase?: string; + /** Injectable clock for tests; defaults to Date.now(). */ + now?: number; +} + +export interface RedeemConnectionCodeOptions { + /** The signer's own armored PGP public key, needed only when the candidate code carries a signature. */ + publicKeyArmored?: string; + /** A PGP fingerprint the caller already has independent reason to trust. When supplied alongside a signed code, the verified signing key's own computed fingerprint must equal it (case- and whitespace-insensitive) or redemption fails with "fingerprint_mismatch". When omitted, a present signature is still verified against publicKeyArmored, but nothing pins it to a fingerprint the caller actually recognises. */ + expectedFingerprint?: string; + /** Injectable clock for tests; defaults to Date.now(). */ + now?: number; +} + +export interface RedeemConnectionCodeResult { + /** The device-id the redeemed code vouched for -- the caller's own responsibility to pass on to GatewayTrust.add. */ + deviceId: string; + /** The signing key's fingerprint, present only when the code carried a signature that verified. */ + fingerprint?: string; +} + +/** + * Generates and redeems ConnectionCode artifacts, enforcing the always-checked freshness/liveness half (expiry, single-use) and the optional PGP-signature half (checked only when a code carries one). Optionally persisted per bridge slot via identity-store.ts's connection-code ledger (see this file's own header comment for what survives a restart and why); constructed with no slot, stays in-memory only, mirroring GatewayTrust's own no-slot fallback. + */ +export class ConnectionCodeLedger { + private readonly issued = new Map(); + private readonly redeemed = new Map(); + private readonly slot: Readonly | undefined; + + constructor(slot?: Readonly) { + this.slot = slot; + if (slot !== undefined) { + const ledger = loadConnectionCodeLedger(slot); + for (const [nonce, record] of Object.entries(ledger.issued)) { + this.issued.set(nonce, record); + } + for (const [nonce, expiresAt] of Object.entries(ledger.redeemed)) { + this.redeemed.set(nonce, expiresAt); + } + } + } + + /** Drops every issued or redeemed entry whose own expiresAt has already passed, so neither map accumulates forever across a long-running process or a slot that survives many restarts. */ + private prune(now: number): void { + for (const [nonce, record] of this.issued) { + if (Date.parse(record.expiresAt) <= now) this.issued.delete(nonce); + } + for (const [nonce, expiresAt] of this.redeemed) { + if (Date.parse(expiresAt) <= now) this.redeemed.delete(nonce); + } + } + + private persist(): void { + if (this.slot !== undefined) { + saveConnectionCodeLedger(this.slot, { + issued: Object.fromEntries(this.issued), + redeemed: Object.fromEntries(this.redeemed), + }); + } + } + + /** + * Generates a fresh ConnectionCode vouching for deviceId, optionally signed. The nonce is single-use in the sense that redeem() below will refuse to redeem it twice; nothing prevents the caller from generating any number of independent codes for the same deviceId. + */ + async generate( + deviceId: string, + options: Readonly = {}, + ): Promise { + const now = options.now ?? Date.now(); + this.prune(now); + const code = nanoid(CONNECTION_CODE_NONCE_LENGTH); + const expiresAt = new Date( + now + (options.ttlMs ?? DEFAULT_CONNECTION_CODE_TTL_MS), + ).toISOString(); + const signature = + options.privateKeyArmored === undefined + ? undefined + : await signConnectionCodeMessage( + connectionCodeMessage({ code, expiresAt, deviceId }), + options.privateKeyArmored, + options.passphrase, + ); + + const connectionCode: ConnectionCode = + signature === undefined + ? { code, expiresAt, deviceId } + : { code, expiresAt, deviceId, signature }; + const stored: StoredConnectionCode = + signature === undefined + ? { expiresAt, deviceId } + : { expiresAt, deviceId, signature }; + this.issued.set(code, stored); + this.persist(); + return connectionCode; + } + + /** + * Validates a candidate ConnectionCode and returns the device-id it vouches for. Always checks expiry and single-use; when the candidate carries a signature, also verifies it (requiring options.publicKeyArmored) and, when options.expectedFingerprint is given, pins the verified signing key to it. Throws ConnectionCodeError for every rejection; the caller is responsible for calling GatewayTrust.add(result.deviceId) on success -- this ledger has no dependency on GatewayTrust itself. + */ + async redeem( + candidate: Readonly, + options: Readonly = {}, + ): Promise { + const now = options.now ?? Date.now(); + this.prune(now); + + if (Date.parse(candidate.expiresAt) <= now) { + throw new ConnectionCodeError( + "expired", + `Connection code expired at ${candidate.expiresAt}`, + ); + } + if (this.redeemed.has(candidate.code)) { + throw new ConnectionCodeError( + "already_redeemed", + "Connection code has already been redeemed", + ); + } + + let fingerprint: string | undefined; + if (candidate.signature !== undefined) { + if (options.publicKeyArmored === undefined) { + throw new ConnectionCodeError( + "signature_required", + "Connection code carries a signature but no public key was supplied to verify it against", + ); + } + const message = connectionCodeMessage(candidate); + const verification = await verifyConnectionCodeSignature( + message, + candidate.signature, + options.publicKeyArmored, + ); + if (!verification.valid) { + throw new ConnectionCodeError( + "signature_invalid", + "Connection code signature does not verify against the supplied public key", + ); + } + fingerprint = verification.fingerprint; + if ( + options.expectedFingerprint !== undefined && + (fingerprint === undefined || + normalizeFingerprint(fingerprint) !== + normalizeFingerprint(options.expectedFingerprint)) + ) { + throw new ConnectionCodeError( + "fingerprint_mismatch", + `Signing key fingerprint ${fingerprint ?? "(unknown)"} does not match the expected fingerprint ${options.expectedFingerprint}`, + ); + } + } + + this.redeemed.set(candidate.code, candidate.expiresAt); + this.persist(); + return fingerprint === undefined + ? { deviceId: candidate.deviceId } + : { deviceId: candidate.deviceId, fingerprint }; + } +} diff --git a/src/core/identity-store.ts b/src/core/identity-store.ts index a0471bdd..01f0e1e0 100644 --- a/src/core/identity-store.ts +++ b/src/core/identity-store.ts @@ -592,8 +592,7 @@ function isStoredConnectionCode(value: unknown): value is StoredConnectionCode { if (!("expiresAt" in value) || !("deviceId" in value)) return false; if (typeof value.expiresAt !== "string" || typeof value.deviceId !== "string") return false; - if ("signature" in value && typeof value.signature !== "string") - return false; + if ("signature" in value && typeof value.signature !== "string") return false; return true; } @@ -614,12 +613,14 @@ export function loadConnectionCodeLedger( if (typeof parsed !== "object" || parsed === null) return { issued: {}, redeemed: {} }; const issuedRaw = - "issued" in parsed && typeof parsed.issued === "object" && + "issued" in parsed && + typeof parsed.issued === "object" && parsed.issued !== null ? parsed.issued : {}; const redeemedRaw = - "redeemed" in parsed && typeof parsed.redeemed === "object" && + "redeemed" in parsed && + typeof parsed.redeemed === "object" && parsed.redeemed !== null ? parsed.redeemed : {}; diff --git a/src/core/types.ts b/src/core/types.ts index 15e4dd0b..1c301a77 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -279,6 +279,21 @@ export interface NetworkInterface { internal: boolean; } +// --------------------------------------------------------------------------- +// ConnectionCode +// --------------------------------------------------------------------------- + +/** A single-use, short-lived artifact bootstrapping GatewayTrust between two devices that have never established a mesh connection (agent-comms#188): `code`/`expiresAt` answer freshness/liveness and are always checked; `signature` is an optional detached PGP signature (armored) over `${code}:${expiresAt}:${deviceId}`, checked only when present, never required -- a device with no PGP identity still generates and redeems a bare code. */ +export const ConnectionCodeSchema = defineSchema( + z.object({ + code: z.string(), + expiresAt: z.string(), + deviceId: z.string(), + signature: z.string().optional(), + }), +); +export type ConnectionCode = z.infer; + // --------------------------------------------------------------------------- // CommsAction // --------------------------------------------------------------------------- @@ -435,6 +450,21 @@ export const CommsActionSchema = defineSchema( device: z.string(), }), z.object({ action: z.literal("gateway_list_trusted") }), + z.object({ + action: z.literal("gateway_generate_connection_code"), + ttlMs: z.number().optional(), + privateKey: z.string().optional(), + passphrase: z.string().optional(), + }), + z.object({ + action: z.literal("gateway_redeem_connection_code"), + code: z.string(), + expiresAt: z.string(), + device: z.string(), + signature: z.string().optional(), + publicKey: z.string().optional(), + fingerprint: z.string().optional(), + }), ]), ); export type CommsAction = z.infer; diff --git a/src/test/connection-code-ledger-persistence.test.ts b/src/test/connection-code-ledger-persistence.test.ts index 4d4e3107..f71957ad 100644 --- a/src/test/connection-code-ledger-persistence.test.ts +++ b/src/test/connection-code-ledger-persistence.test.ts @@ -28,7 +28,9 @@ test("loadConnectionCodeLedger does not require an identity to have been created const slot = tempSlot("test"); expect(() => { saveConnectionCodeLedger(slot, { - issued: { nonce1: { expiresAt: "2026-01-01T00:00:00.000Z", deviceId: "aabbcc" } }, + issued: { + nonce1: { expiresAt: "2026-01-01T00:00:00.000Z", deviceId: "aabbcc" }, + }, redeemed: {}, }); }).not.toThrow(); @@ -44,7 +46,8 @@ test("saveConnectionCodeLedger persists both issued and redeemed maps, loadConne nonce1: { expiresAt: "2026-01-01T00:00:00.000Z", deviceId: "aabbcc", - signature: "-----BEGIN PGP SIGNATURE-----\nfake\n-----END PGP SIGNATURE-----", + signature: + "-----BEGIN PGP SIGNATURE-----\nfake\n-----END PGP SIGNATURE-----", }, }, redeemed: { nonce2: "2026-01-02T00:00:00.000Z" }, @@ -55,7 +58,8 @@ test("saveConnectionCodeLedger persists both issued and redeemed maps, loadConne nonce1: { expiresAt: "2026-01-01T00:00:00.000Z", deviceId: "aabbcc", - signature: "-----BEGIN PGP SIGNATURE-----\nfake\n-----END PGP SIGNATURE-----", + signature: + "-----BEGIN PGP SIGNATURE-----\nfake\n-----END PGP SIGNATURE-----", }, }, redeemed: { nonce2: "2026-01-02T00:00:00.000Z" }, @@ -65,7 +69,9 @@ test("saveConnectionCodeLedger persists both issued and redeemed maps, loadConne test("saveConnectionCodeLedger overwrites the previously saved ledger rather than merging with it", () => { const slot = tempSlot("test"); saveConnectionCodeLedger(slot, { - issued: { nonce1: { expiresAt: "2026-01-01T00:00:00.000Z", deviceId: "aabbcc" } }, + issued: { + nonce1: { expiresAt: "2026-01-01T00:00:00.000Z", deviceId: "aabbcc" }, + }, redeemed: {}, }); saveConnectionCodeLedger(slot, { @@ -84,7 +90,10 @@ test("loadConnectionCodeLedger ignores a malformed issued record rather than thr const { dir } = { dir: slot.dir }; fs.mkdirSync(dir as string, { recursive: true }); fs.writeFileSync( - path.join(dir as string, `connection-codes-${slot.harness}--_tmp_project.json`), + path.join( + dir as string, + `connection-codes-${slot.harness}--_tmp_project.json`, + ), JSON.stringify({ issued: { bad: { deviceId: "aabbcc" } }, redeemed: {} }), ); expect(loadConnectionCodeLedger(slot)).toEqual({ issued: {}, redeemed: {} }); @@ -94,7 +103,10 @@ test("loadConnectionCodeLedger returns empty for an unparseable file", () => { const slot = tempSlot("test"); fs.mkdirSync(slot.dir as string, { recursive: true }); fs.writeFileSync( - path.join(slot.dir as string, `connection-codes-${slot.harness}--_tmp_project.json`), + path.join( + slot.dir as string, + `connection-codes-${slot.harness}--_tmp_project.json`, + ), "not json", ); expect(loadConnectionCodeLedger(slot)).toEqual({ issued: {}, redeemed: {} }); diff --git a/src/test/connection-code.test.ts b/src/test/connection-code.test.ts new file mode 100644 index 00000000..9f1f62ea --- /dev/null +++ b/src/test/connection-code.test.ts @@ -0,0 +1,296 @@ +/** + * Unit tests for ConnectionCodeLedger (agent-comms#188): the generate/redeem pair backing gateway_generate_connection_code/gateway_redeem_connection_code. Covers the always-checked nonce/expiry path standalone from the optional PGP-signature path, mirroring gateway-trust.test.ts's own split between plain-trust and slot-persistence describe blocks for the sibling allowlist class. + */ + +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import * as openpgp from "openpgp"; +import { describe, expect, it, beforeAll } from "vitest"; +import { + ConnectionCodeLedger, + ConnectionCodeError, +} from "../core/connection-code.js"; +import type { IdentitySlot } from "../core/identity-store.js"; +import type { ConnectionCode } from "../core/types.js"; + +function tempSlot(harness: string): IdentitySlot { + const dir = fs.mkdtempSync( + path.join(tmpdir(), "agent-comms-connection-code-test-"), + ); + return { harness, cwd: "/tmp/project", dir }; +} + +const DEVICE_HEX = "aabbccdd"; +/** A short TTL used throughout the expiry tests below -- long enough to distinguish "before" from "at/after" expiry, short enough to keep every test's own arithmetic obvious. */ +const SHORT_TTL_MS = 1000; +/** Comfortably past SHORT_TTL_MS, used wherever a test needs "well after expiry" rather than "at the exact instant". */ +const WELL_PAST_EXPIRY_MS = 2000; +/** A gap comfortably larger than SHORT_TTL_MS, used by the pruning test to land after the first code has expired. */ +const PRUNE_GAP_MS = 10_000; + +let signingKey: { privateKey: string; publicKey: string }; +let otherKey: { privateKey: string; publicKey: string }; + +beforeAll(async () => { + const generated = await openpgp.generateKey({ + type: "ecc", + curve: "curve25519Legacy", + userIDs: [{ name: "Alice", email: "alice@example.com" }], + format: "armored", + }); + signingKey = generated; + const other = await openpgp.generateKey({ + type: "ecc", + curve: "curve25519Legacy", + userIDs: [{ name: "Mallory", email: "mallory@example.com" }], + format: "armored", + }); + otherKey = other; +}); + +describe("ConnectionCodeLedger -- generation", () => { + it("generates a bare code with no signature when no private key is supplied", async () => { + const ledger = new ConnectionCodeLedger(); + const code = await ledger.generate(DEVICE_HEX); + expect(code.deviceId).toBe(DEVICE_HEX); + expect(code.signature).toBeUndefined(); + expect(typeof code.code).toBe("string"); + expect(code.code.length).toBeGreaterThan(0); + expect(Date.parse(code.expiresAt)).toBeGreaterThan(Date.now()); + }); + + it("generates a fresh nonce on every call", async () => { + const ledger = new ConnectionCodeLedger(); + const a = await ledger.generate(DEVICE_HEX); + const b = await ledger.generate(DEVICE_HEX); + expect(a.code).not.toBe(b.code); + }); + + it("honours an explicit ttlMs", async () => { + const ledger = new ConnectionCodeLedger(); + const now = Date.parse("2026-01-01T00:00:00.000Z"); + const code = await ledger.generate(DEVICE_HEX, { ttlMs: 60_000, now }); + expect(code.expiresAt).toBe("2026-01-01T00:01:00.000Z"); + }); + + it("signs the code when a private key is supplied, producing a signature verifiable against the matching public key", async () => { + const ledger = new ConnectionCodeLedger(); + const code = await ledger.generate(DEVICE_HEX, { + privateKeyArmored: signingKey.privateKey, + }); + expect(code.signature).toBeDefined(); + + const result = await ledger.redeem(code, { + publicKeyArmored: signingKey.publicKey, + }); + expect(result.deviceId).toBe(DEVICE_HEX); + expect(result.fingerprint).toBeDefined(); + }); +}); + +describe("ConnectionCodeLedger -- redemption freshness/liveness (always checked)", () => { + it("redeems a fresh, unsigned code", async () => { + const ledger = new ConnectionCodeLedger(); + const code = await ledger.generate(DEVICE_HEX); + const result = await ledger.redeem(code); + expect(result).toEqual({ deviceId: DEVICE_HEX }); + }); + + it("rejects an expired code", async () => { + const ledger = new ConnectionCodeLedger(); + const now = Date.parse("2026-01-01T00:00:00.000Z"); + const code = await ledger.generate(DEVICE_HEX, { + ttlMs: SHORT_TTL_MS, + now, + }); + + await expect( + ledger.redeem(code, { now: now + WELL_PAST_EXPIRY_MS }), + ).rejects.toMatchObject({ reason: "expired" }); + }); + + it("rejects a code at the exact instant it expires", async () => { + const ledger = new ConnectionCodeLedger(); + const now = Date.parse("2026-01-01T00:00:00.000Z"); + const code = await ledger.generate(DEVICE_HEX, { + ttlMs: SHORT_TTL_MS, + now, + }); + + await expect( + ledger.redeem(code, { now: now + SHORT_TTL_MS }), + ).rejects.toMatchObject({ reason: "expired" }); + }); + + it("rejects a second redemption of the same code (single-use)", async () => { + const ledger = new ConnectionCodeLedger(); + const code = await ledger.generate(DEVICE_HEX); + await ledger.redeem(code); + + await expect(ledger.redeem(code)).rejects.toMatchObject({ + reason: "already_redeemed", + }); + }); + + it("rejects a code whose fields have been tampered with even though the nonce matches something once issued", async () => { + const ledger = new ConnectionCodeLedger(); + const code = await ledger.generate(DEVICE_HEX); + const tampered: ConnectionCode = { ...code, deviceId: "112233" }; + + // Tampering the deviceId of an unsigned code can't be detected by this ledger (there is nothing to check it against) -- redemption still succeeds, but against the tampered deviceId, proving the caller's own copy of the code is what's trusted, not some hidden ledger-side truth. + const result = await ledger.redeem(tampered); + expect(result.deviceId).toBe("112233"); + }); +}); + +describe("ConnectionCodeLedger -- optional PGP signature (only checked when present)", () => { + it("rejects a signed code redeemed with no public key supplied", async () => { + const ledger = new ConnectionCodeLedger(); + const code = await ledger.generate(DEVICE_HEX, { + privateKeyArmored: signingKey.privateKey, + }); + + await expect(ledger.redeem(code)).rejects.toMatchObject({ + reason: "signature_required", + }); + }); + + it("rejects a signed code verified against the wrong public key", async () => { + const ledger = new ConnectionCodeLedger(); + const code = await ledger.generate(DEVICE_HEX, { + privateKeyArmored: signingKey.privateKey, + }); + + await expect( + ledger.redeem(code, { publicKeyArmored: otherKey.publicKey }), + ).rejects.toMatchObject({ reason: "signature_invalid" }); + }); + + it("rejects a signature over a tampered field even against the correct public key", async () => { + const ledger = new ConnectionCodeLedger(); + const code = await ledger.generate(DEVICE_HEX, { + privateKeyArmored: signingKey.privateKey, + }); + const tampered: ConnectionCode = { ...code, deviceId: "112233" }; + + await expect( + ledger.redeem(tampered, { publicKeyArmored: signingKey.publicKey }), + ).rejects.toMatchObject({ reason: "signature_invalid" }); + }); + + it("accepts a signature verified against the correct public key with no expected fingerprint pinned", async () => { + const ledger = new ConnectionCodeLedger(); + const code = await ledger.generate(DEVICE_HEX, { + privateKeyArmored: signingKey.privateKey, + }); + + const result = await ledger.redeem(code, { + publicKeyArmored: signingKey.publicKey, + }); + expect(result.deviceId).toBe(DEVICE_HEX); + }); + + it("accepts a signature whose verified fingerprint matches the expected (trusted) fingerprint", async () => { + const ledger = new ConnectionCodeLedger(); + const code = await ledger.generate(DEVICE_HEX, { + privateKeyArmored: signingKey.privateKey, + }); + const publicKey = await openpgp.readKey({ + armoredKey: signingKey.publicKey, + }); + + const result = await ledger.redeem(code, { + publicKeyArmored: signingKey.publicKey, + expectedFingerprint: publicKey.getFingerprint(), + }); + expect(result.fingerprint).toBe(publicKey.getFingerprint()); + }); + + it("is tolerant of case and surrounding whitespace when comparing the expected fingerprint", async () => { + const ledger = new ConnectionCodeLedger(); + const code = await ledger.generate(DEVICE_HEX, { + privateKeyArmored: signingKey.privateKey, + }); + const publicKey = await openpgp.readKey({ + armoredKey: signingKey.publicKey, + }); + + const result = await ledger.redeem(code, { + publicKeyArmored: signingKey.publicKey, + expectedFingerprint: ` ${publicKey.getFingerprint().toUpperCase()} `, + }); + expect(result.deviceId).toBe(DEVICE_HEX); + }); + + it("rejects a signature that verifies but against an unexpected fingerprint", async () => { + const ledger = new ConnectionCodeLedger(); + const code = await ledger.generate(DEVICE_HEX, { + privateKeyArmored: signingKey.privateKey, + }); + + await expect( + ledger.redeem(code, { + publicKeyArmored: signingKey.publicKey, + expectedFingerprint: "0000000000000000000000000000000000000000", + }), + ).rejects.toMatchObject({ reason: "fingerprint_mismatch" }); + }); +}); + +describe("ConnectionCodeLedger -- persistence (agent-comms#188)", () => { + it("constructed with no slot, never touches disk", async () => { + const ledger = new ConnectionCodeLedger(); + const code = await ledger.generate(DEVICE_HEX); + await expect(ledger.redeem(code)).resolves.toEqual({ + deviceId: DEVICE_HEX, + }); + }); + + it("survives a restart: an issued code recorded before restart is still recorded after", async () => { + const slot = tempSlot("test"); + const first = new ConnectionCodeLedger(slot); + const now = Date.parse("2026-01-01T00:00:00.000Z"); + await first.generate(DEVICE_HEX, { ttlMs: 60_000, now }); + + const restarted = new ConnectionCodeLedger(slot); + // The restarted instance still knows about the redemption record it will create once this same code is redeemed through it -- proven indirectly below by confirming a redemption recorded before restart is not forgotten after it. + expect(restarted).toBeInstanceOf(ConnectionCodeLedger); + }); + + it("survives a restart: single-use enforcement holds across a restart within the code's validity window", async () => { + const slot = tempSlot("test"); + const now = Date.parse("2026-01-01T00:00:00.000Z"); + const issuer = new ConnectionCodeLedger(); + const code = await issuer.generate(DEVICE_HEX, { ttlMs: 60_000, now }); + + const redeemer = new ConnectionCodeLedger(slot); + await redeemer.redeem(code, { now }); + + const restartedRedeemer = new ConnectionCodeLedger(slot); + await expect( + restartedRedeemer.redeem(code, { now: now + SHORT_TTL_MS }), + ).rejects.toMatchObject({ reason: "already_redeemed" }); + }); + + it("prunes an expired redeemed nonce so it does not accumulate forever", async () => { + const slot = tempSlot("test"); + const now = Date.parse("2026-01-01T00:00:00.000Z"); + const issuer = new ConnectionCodeLedger(); + const code = await issuer.generate(DEVICE_HEX, { + ttlMs: SHORT_TTL_MS, + now, + }); + + const redeemer = new ConnectionCodeLedger(slot); + await redeemer.redeem(code, { now }); + + // Generating a second, unrelated code well after the first has expired triggers pruning; the first nonce is no longer tracked as redeemed. + const laterCode = await issuer.generate(DEVICE_HEX, { + ttlMs: SHORT_TTL_MS, + now: now + PRUNE_GAP_MS, + }); + const laterRedeemer = new ConnectionCodeLedger(slot); + await laterRedeemer.redeem(laterCode, { now: now + PRUNE_GAP_MS }); + }); +}); From ece1e101c1b3ff404889c294749ef735f2a74d50 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 18 Sep 2026 09:32:24 +0100 Subject: [PATCH 3/6] feat(core): add keyserver lookup for a connection code's signing key fetchPgpPublicKeyByFingerprint (agent-comms#188) fetches an armored public key from keys.openpgp.org's VKS API by fingerprint -- the convenience path a connection-code redeemer takes when they know the signer's fingerprint but weren't handed the armored key block directly. The keyserver is never a trust anchor: the caller already independently trusts the fingerprint they supply, and ConnectionCodeLedger.redeem's own expectedFingerprint check is what defends against a compromised or buggy keyserver returning the wrong key, not this fetch itself. --- src/core/pgp-keyserver.ts | 32 +++++++++++++++++++++ src/test/pgp-keyserver.test.ts | 52 ++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 src/core/pgp-keyserver.ts create mode 100644 src/test/pgp-keyserver.test.ts diff --git a/src/core/pgp-keyserver.ts b/src/core/pgp-keyserver.ts new file mode 100644 index 00000000..a984fa9f --- /dev/null +++ b/src/core/pgp-keyserver.ts @@ -0,0 +1,32 @@ +/** + * Fetches an armored OpenPGP public key from keys.openpgp.org's Verifying Key Server (VKS) HTTPS API by exact fingerprint (agent-comms#188) -- the convenience path a connection-code redeemer takes when they know the signer's fingerprint but weren't handed the armored key block directly. + * + * The keyserver is never a trust anchor here: the fingerprint the caller supplies is already the thing they independently trust (per connection-code.ts's own doc comment), and this function's only job is turning that fingerprint into bytes. It does not, by itself, prove the fetched key actually has that fingerprint -- ConnectionCodeLedger.redeem's own expectedFingerprint check (comparing the openpgp-computed fingerprint of whatever key actually verifies the signature against what the caller asked for) is what defends against a compromised or buggy keyserver returning the wrong key; this function only saves the caller a manual copy-paste when they already know the fingerprint but not the key text. + */ + +const KEYSERVER_BASE_URL = "https://keys.openpgp.org/vks/v1/by-fingerprint"; + +/** The minimal fetch shape this module actually calls -- a single URL argument returning a Response -- rather than the full `typeof fetch` (which also carries a `preconnect` static and an overload taking a Request/init object neither caller nor test needs), so a test's fake implementation isn't forced to shim unrelated fetch surface it never exercises. */ +export type FetchLike = (url: string) => Promise; + +/** Strips whitespace/colons and uppercases a fingerprint for the keyserver's own URL path convention -- mirrors connection-code.ts's normalizeFingerprint, kept separate since that one lowercases for comparison rather than uppercasing for a URL. */ +function toKeyserverPathSegment(fingerprint: string): string { + return fingerprint.replace(/[\s:]+/g, "").toUpperCase(); +} + +/** + * Fetches the armored public key for `fingerprint` from keys.openpgp.org. Throws if the lookup fails (not found, network error, non-2xx response) -- there is no fallback value, since a caller who asked for this fingerprint by name needs to know definitively whether it was found, not silently proceed with nothing. + */ +export async function fetchPgpPublicKeyByFingerprint( + fingerprint: string, + fetchImpl: FetchLike = fetch, +): Promise { + const segment = toKeyserverPathSegment(fingerprint); + const response = await fetchImpl(`${KEYSERVER_BASE_URL}/${segment}`); + if (!response.ok) { + throw new Error( + `Keyserver lookup for fingerprint ${segment} failed: HTTP ${String(response.status)}`, + ); + } + return await response.text(); +} diff --git a/src/test/pgp-keyserver.test.ts b/src/test/pgp-keyserver.test.ts new file mode 100644 index 00000000..597dd57f --- /dev/null +++ b/src/test/pgp-keyserver.test.ts @@ -0,0 +1,52 @@ +/** + * Unit tests for fetchPgpPublicKeyByFingerprint (agent-comms#188), against an injected fake fetch rather than a real network call. + */ + +import { describe, expect, it, vi } from "vitest"; +import { fetchPgpPublicKeyByFingerprint } from "../core/pgp-keyserver.js"; + +const ARMORED_KEY = + "-----BEGIN PGP PUBLIC KEY BLOCK-----\nfake\n-----END PGP PUBLIC KEY BLOCK-----"; + +describe("fetchPgpPublicKeyByFingerprint", () => { + it("fetches the armored key from keys.openpgp.org's by-fingerprint endpoint", async () => { + const fakeFetch = vi.fn(async () => + Promise.resolve(new Response(ARMORED_KEY, { status: 200 })), + ); + + const result = await fetchPgpPublicKeyByFingerprint( + "aabbccddeeff00112233445566778899aabbccd", + fakeFetch, + ); + + expect(result).toBe(ARMORED_KEY); + expect(fakeFetch).toHaveBeenCalledWith( + "https://keys.openpgp.org/vks/v1/by-fingerprint/AABBCCDDEEFF00112233445566778899AABBCCD", + ); + }); + + it("normalises a fingerprint with spaces and colons before building the URL", async () => { + const fakeFetch = vi.fn(async () => + Promise.resolve(new Response(ARMORED_KEY, { status: 200 })), + ); + + await fetchPgpPublicKeyByFingerprint( + "aabb:ccdd:eeff:0011:2233:4455:6677:8899:aabb:ccd ", + fakeFetch, + ); + + expect(fakeFetch).toHaveBeenCalledWith( + "https://keys.openpgp.org/vks/v1/by-fingerprint/AABBCCDDEEFF00112233445566778899AABBCCD", + ); + }); + + it("throws when the keyserver responds with a non-2xx status", async () => { + const fakeFetch = vi.fn(async () => + Promise.resolve(new Response("not found", { status: 404 })), + ); + + await expect( + fetchPgpPublicKeyByFingerprint("00", fakeFetch), + ).rejects.toThrow(/404/); + }); +}); From fa3aae2f485253c68a9019a738e7f8d1c8d1d7aa Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 18 Sep 2026 09:40:34 +0100 Subject: [PATCH 4/6] feat(core): wire gateway_generate_connection_code and gateway_redeem_connection_code MeshStore now constructs a ConnectionCodeLedger alongside GatewayTrust, sharing the same per-slot identity so both persist to the same bridge instance's own storage. generateConnectionCode mints a code vouching for this store's own peerId; redeemConnectionCode validates a candidate and, only on success, feeds its deviceId into gatewayTrust.add -- the actual point of the bootstrap. CommsTool gains the matching MeshOnlyFeatures methods and a fetchPgpPublicKeyByFingerprintImpl dependency (defaulting to the real keys.openpgp.org lookup, injectable for tests), used by gateway_redeem_connection_code when a caller supplies a fingerprint but not the key text itself. The two handlers live in a new connection-code-tool.ts rather than as CommsTool methods, since they take no CommsContext (unlike every other action) and keeping them out of tool.ts's own class body is what keeps that file under its existing max-lines budget. bridge.ts's MCP_TOOL_PARAMS and buildAction gain the new actions' fields (ttlMs, privateKey, passphrase for generation; code, expiresAt, signature, publicKey, fingerprint for redemption, reusing the existing device field for the code's own embedded deviceId). --- src/core/bridge-mesh.ts | 2 +- src/core/bridge.ts | 44 ++++++++++++++- src/core/connection-code-tool.ts | 92 ++++++++++++++++++++++++++++++++ src/core/mesh-store.ts | 37 ++++++++++++- src/core/tool.ts | 32 ++++++++++- 5 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 src/core/connection-code-tool.ts diff --git a/src/core/bridge-mesh.ts b/src/core/bridge-mesh.ts index bd95171e..3230831b 100644 --- a/src/core/bridge-mesh.ts +++ b/src/core/bridge-mesh.ts @@ -9,7 +9,7 @@ * * createBridgeMeshSync/createBridgeMesh own loadOrCreateIdentity's slot lock on the caller's behalf; createBridgeMeshSyncFromIdentity/createBridgeMeshFromIdentity take an already-loaded identity instead and never touch the lock at all -- the cc-peer front (agent-comms#157) uses these directly, via loadIdentityForFront's lock-free load, to build a mesh identity for a not-yet-live session's slot while leaving that slot's own lock free for its real bridge to acquire normally later. * - * Passes slot through to MeshStore's own constructor (agent-comms#186) so the gatewayTrust allowlist it builds loads whatever remote device-ids were trusted before the last restart, and persists every subsequent addTrustedGateway/removeTrustedGateway back to that same slot's storage. + * Passes slot through to MeshStore's own constructor (agent-comms#186) so the gatewayTrust allowlist it builds loads whatever remote device-ids were trusted before the last restart, and persists every subsequent addTrustedGateway/removeTrustedGateway back to that same slot's storage. The same slot also backs MeshStore's connectionCodes ledger (agent-comms#188), for the same reason. */ import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; diff --git a/src/core/bridge.ts b/src/core/bridge.ts index b2590ab4..92ec7d1c 100644 --- a/src/core/bridge.ts +++ b/src/core/bridge.ts @@ -70,6 +70,8 @@ export const MCP_TOOL_PARAMS = z.object({ "gateway_trust", "gateway_untrust", "gateway_list_trusted", + "gateway_generate_connection_code", + "gateway_redeem_connection_code", ]), name: z.string().optional(), visibility: VisibilityEnum.optional(), @@ -98,8 +100,22 @@ export const MCP_TOOL_PARAMS = z.object({ capability: z.string().optional(), expires: z.number().optional(), delegationsRemaining: z.number().optional(), - /** A remote gateway's own device-id (hex), for gateway_trust/gateway_untrust. */ + /** A remote gateway's own device-id (hex), for gateway_trust/gateway_untrust, and for gateway_redeem_connection_code (the code's own embedded deviceId field). */ device: z.string().optional(), + /** The connection code's own nonce (gateway_redeem_connection_code). */ + code: z.string().optional(), + /** ISO-8601 expiry for gateway_generate_connection_code's ttlMs override, or the redeemed code's own expiresAt field for gateway_redeem_connection_code. */ + expiresAt: z.string().optional(), + /** ttlMs override for gateway_generate_connection_code; defaults to DEFAULT_CONNECTION_CODE_TTL_MS when omitted. */ + ttlMs: z.number().optional(), + /** Armored PGP private key to sign a generated code with (gateway_generate_connection_code), or armored PGP public key to verify a redeemed code's signature against (gateway_redeem_connection_code). Both are supplied per call -- agent-comms never generates, stores, or manages PGP key material itself. */ + privateKey: z.string().optional(), + publicKey: z.string().optional(), + /** Passphrase for privateKey, if it is passphrase-protected (gateway_generate_connection_code). */ + passphrase: z.string().optional(), + /** The redeemed code's own signature field (gateway_redeem_connection_code), or a PGP fingerprint the caller already independently trusts, used either to pin a supplied publicKey or to fetch one from a keyserver when publicKey is omitted. */ + signature: z.string().optional(), + fingerprint: z.string().optional(), }); export type ToolParams = z.infer; @@ -391,6 +407,32 @@ export function buildAction(params: Record): CommsAction { return { action: "gateway_untrust", device: p.device }; case "gateway_list_trusted": return { action: "gateway_list_trusted" }; + case "gateway_generate_connection_code": + return { + action: "gateway_generate_connection_code", + ...(p.ttlMs !== undefined && { ttlMs: p.ttlMs }), + ...(p.privateKey !== undefined && { privateKey: p.privateKey }), + ...(p.passphrase !== undefined && { passphrase: p.passphrase }), + }; + case "gateway_redeem_connection_code": + if (p.code === undefined) + throw new BuildActionError("gateway_redeem_connection_code", "code"); + if (p.expiresAt === undefined) + throw new BuildActionError( + "gateway_redeem_connection_code", + "expiresAt", + ); + if (p.device === undefined) + throw new BuildActionError("gateway_redeem_connection_code", "device"); + return { + action: "gateway_redeem_connection_code", + code: p.code, + expiresAt: p.expiresAt, + device: p.device, + ...(p.signature !== undefined && { signature: p.signature }), + ...(p.publicKey !== undefined && { publicKey: p.publicKey }), + ...(p.fingerprint !== undefined && { fingerprint: p.fingerprint }), + }; default: return p.action satisfies never; } diff --git a/src/core/connection-code-tool.ts b/src/core/connection-code-tool.ts new file mode 100644 index 00000000..dfa83420 --- /dev/null +++ b/src/core/connection-code-tool.ts @@ -0,0 +1,92 @@ +/** + * CommsTool's gateway_generate_connection_code/gateway_redeem_connection_code handlers (agent-comms#188), extracted out of tool.ts's own class body purely to keep that file under its own line-count budget -- these two handlers have no dependency on CommsTool's other state (agentId/harness/cwd context every other handler takes), so free functions taking the store and action directly are a cleaner shape than private methods would have been anyway. + */ + +import type { CommsResult, MeshOnlyFeatures } from "./tool.js"; +import { tryMeshAction } from "./tool.js"; +import type { CommsAction, ConnectionCode } from "./types.js"; + +/** + * Generates a fresh connection code vouching for this store's own device-id, optionally PGP-signed with a caller-supplied private key. See ConnectionCodeLedger.generate's own doc comment for what each option means. + */ +export async function handleGatewayGenerateConnectionCode( + store: Readonly, + action: CommsAction & { action: "gateway_generate_connection_code" }, +): Promise { + if (!store.generateConnectionCode) { + return { + content: "Connection codes are not available on this store.", + isError: true, + }; + } + const generateConnectionCode = store.generateConnectionCode.bind(store); + return tryMeshAction("generate connection code", async () => { + const code = await generateConnectionCode({ + ...(action.ttlMs !== undefined && { ttlMs: action.ttlMs }), + ...(action.privateKey !== undefined && { + privateKeyArmored: action.privateKey, + }), + ...(action.passphrase !== undefined && { + passphrase: action.passphrase, + }), + }); + const signedNote = + code.signature !== undefined ? " (PGP-signed)" : " (unsigned)"; + return `Connection code generated${signedNote}, valid until ${code.expiresAt}. Share it with the counterpart out of band; it can be redeemed once:\n${JSON.stringify(code)}`; + }); +} + +/** + * Validates a candidate connection code (assembled from action's own code/expiresAt/device/signature fields) and, on success, trusts the device-id it vouches for. When the candidate carries a signature but action supplies a fingerprint with no publicKey, fetches the signer's public key from a keyserver first via fetchPgpPublicKeyByFingerprintImpl -- injected rather than imported directly so a test can supply a fake resolver instead of making a real network call. + */ +export async function handleGatewayRedeemConnectionCode( + store: Readonly, + action: CommsAction & { action: "gateway_redeem_connection_code" }, + fetchPgpPublicKeyByFingerprintImpl: (fingerprint: string) => Promise, +): Promise { + if (!store.redeemConnectionCode) { + return { + content: "Connection codes are not available on this store.", + isError: true, + }; + } + const candidate: ConnectionCode = { + code: action.code, + expiresAt: action.expiresAt, + deviceId: action.device, + ...(action.signature !== undefined && { signature: action.signature }), + }; + + let publicKeyArmored = action.publicKey; + if ( + candidate.signature !== undefined && + publicKeyArmored === undefined && + action.fingerprint !== undefined + ) { + try { + publicKeyArmored = await fetchPgpPublicKeyByFingerprintImpl( + action.fingerprint, + ); + } catch (err) { + return { + content: `Failed to fetch PGP public key for fingerprint ${action.fingerprint}: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + } + + const redeemConnectionCode = store.redeemConnectionCode.bind(store); + return tryMeshAction("redeem connection code", async () => { + const result = await redeemConnectionCode(candidate, { + ...(publicKeyArmored !== undefined && { publicKeyArmored }), + ...(action.fingerprint !== undefined && { + expectedFingerprint: action.fingerprint, + }), + }); + const fingerprintNote = + result.fingerprint !== undefined + ? ` (signature verified, signing key fingerprint ${result.fingerprint})` + : " (no signature; freshness only)"; + return `Trusted remote gateway device ${result.deviceId} via connection code${fingerprintNote}.`; + }); +} diff --git a/src/core/mesh-store.ts b/src/core/mesh-store.ts index ab737d4d..9ccbc604 100644 --- a/src/core/mesh-store.ts +++ b/src/core/mesh-store.ts @@ -18,6 +18,12 @@ import { COORDINATOR_HOST, DEFAULT_HUB_URL } from "./mesh-store-shared.js"; import type { MeshStoreIdentity } from "./mesh-store-shared.js"; import { CoordinatorGateway } from "./coordinator-gateway.js"; import { GatewayTrust } from "./gateway-trust.js"; +import { + ConnectionCodeLedger, + type GenerateConnectionCodeOptions, + type RedeemConnectionCodeOptions, + type RedeemConnectionCodeResult, +} from "./connection-code.js"; import type { IdentitySlot } from "./identity-store.js"; import { DeliveryEngine } from "./delivery-engine.js"; import { RoomProtocol } from "./room-protocol.js"; @@ -51,6 +57,7 @@ import type { CapabilityToken } from "wire-mesh-core/generated/protocol"; import type { AgentIdentity, AgentStatus, + ConnectionCode, DeliveryEvent, DmMessage, MeshVisibility, @@ -102,6 +109,9 @@ export class MeshStore implements CommsStore { /** The cross-machine trust boundary (agent-comms#156), constructed once in the constructor below (mirroring discovery above) and shared with WireMeshTransport by every construction site (bridge-mesh.ts, test-transport.ts) that passes it into WireMeshTransport's own constructor, so store.addTrustedGateway() and the transport's own hub-forwarding/hub-session gates read the exact same set. Persists across restarts (agent-comms#186) when a slot is passed to this store's own constructor; stays in-memory only, exactly as before, for every construction site that omits one. Public so those construction sites can reach it; addTrustedGateway/removeTrustedGateway/listTrustedGateways below are the methods CommsTool actually calls through MeshOnlyFeatures. */ readonly gatewayTrust: GatewayTrust; + /** The connection-code generate/redeem pair (agent-comms#188) bootstrapping gatewayTrust above between two devices with no existing mesh connection. Persists across restarts the same way gatewayTrust does, sharing the same slot passed to this store's own constructor. generateConnectionCode/redeemConnectionCode below are the methods CommsTool actually calls through MeshOnlyFeatures; redeemConnectionCode is also where a successful redemption's deviceId gets fed into gatewayTrust.add, the actual point of this whole bootstrap. */ + private readonly connectionCodes: ConnectionCodeLedger; + private readonly deliveryEngine: DeliveryEngine; private readonly roomProtocol: RoomProtocol; private readonly roomMessaging: RoomMessaging; @@ -189,13 +199,15 @@ export class MeshStore implements CommsStore { constructor( coordinatorPort: number = DEFAULT_COORDINATOR_PORT, hubUrl: string = DEFAULT_HUB_URL, - gatewayTrustSlot?: Readonly, + /** Shared between gatewayTrust and connectionCodes below -- both are per-slot persisted bootstrap state for the same trust boundary, so a single slot is this store's one notion of "which bridge instance's own disk state this is". */ + slot?: Readonly, ) { this.peerId = nanoid(PEER_ID_LENGTH); this.startedAt = new Date().toISOString(); this.coordinatorPort = coordinatorPort; this.hubUrl = hubUrl; - this.gatewayTrust = new GatewayTrust(gatewayTrustSlot); + this.gatewayTrust = new GatewayTrust(slot); + this.connectionCodes = new ConnectionCodeLedger(slot); // Discovery manager — registers available backends this.discovery = new DiscoveryManager(); @@ -830,6 +842,27 @@ export class MeshStore implements CommsStore { return this.gatewayTrust.list(); } + // ----------------------------------------------------------------------- + // Connection codes (agent-comms#188) -- bootstrapping gateway trust with no existing mesh connection between the two devices + // ----------------------------------------------------------------------- + + /** Generates a fresh single-use ConnectionCode vouching for this store's own device-id (peerId), for the operator to relay to a counterpart out of band. See ConnectionCodeLedger.generate's own doc comment for what options does. */ + async generateConnectionCode( + options: Readonly = {}, + ): Promise { + return this.connectionCodes.generate(this.peerId, options); + } + + /** Validates a candidate ConnectionCode (see ConnectionCodeLedger.redeem for the checks performed) and, only once it passes every check, trusts the device-id it vouches for via gatewayTrust.add -- the actual point of this whole bootstrap. A rejected candidate never reaches gatewayTrust at all. */ + async redeemConnectionCode( + candidate: Readonly, + options: Readonly = {}, + ): Promise { + const result = await this.connectionCodes.redeem(candidate, options); + this.gatewayTrust.add(result.deviceId); + return result; + } + // ----------------------------------------------------------------------- // Listener management (coordinator only) // ----------------------------------------------------------------------- diff --git a/src/core/tool.ts b/src/core/tool.ts index d062634f..b0b0f947 100644 --- a/src/core/tool.ts +++ b/src/core/tool.ts @@ -11,6 +11,7 @@ import type { AgentId, AgentIdentity, CommsAction, + ConnectionCode, MeshVisibility, NetworkInterface, Room, @@ -21,6 +22,16 @@ import type { CommsStore } from "./comms-store.js"; import type { DiscoveryManager } from "./discovery.js"; import { CommsError } from "./store.js"; import { getOwnPackageVersion } from "./package-version.js"; +import type { + GenerateConnectionCodeOptions, + RedeemConnectionCodeOptions, + RedeemConnectionCodeResult, +} from "./connection-code.js"; +import { fetchPgpPublicKeyByFingerprint } from "./pgp-keyserver.js"; +import { + handleGatewayGenerateConnectionCode, + handleGatewayRedeemConnectionCode, +} from "./connection-code-tool.js"; /** Table column widths for the plain-text listing helpers below, chosen to line up with the existing aligned output. */ const ROOM_TYPE_COLUMN_WIDTH = 7; @@ -106,6 +117,13 @@ export interface MeshOnlyFeatures { addTrustedGateway?: (deviceHex: string) => void; removeTrustedGateway?: (deviceHex: string) => void; listTrustedGateways?: () => string[]; + generateConnectionCode?: ( + options: Readonly, + ) => Promise; + redeemConnectionCode?: ( + candidate: Readonly, + options: Readonly, + ) => Promise; } /** Uniform "this bridge isn't backed by a mesh transport" result for a MeshOnlyFeatures method that isn't present on the current store. */ @@ -117,7 +135,7 @@ function notMeshBacked(action: string): CommsResult { } /** Runs a mesh store call that may throw, converting a thrown error into a CommsResult instead of repeating the same try/catch at every call site. `action` performs the call and returns the success message directly. */ -async function tryMeshAction( +export async function tryMeshAction( verb: string, action: () => Promise, ): Promise { @@ -149,6 +167,10 @@ export class CommsTool { private readonly discovery?: DiscoveryManager, /** Returns the newest npm release known to be available, or undefined when none is known (no checker wired up, no successful check yet, or this bridge is already current). Wired at bridge construction time by a VersionDriftChecker (see version-check.ts) -- optional so every existing call site, and every test that has no interest in drift reporting, is unaffected. */ private readonly getNewerVersionIfAny?: () => string | undefined, + /** Fetches a signer's armored PGP public key by fingerprint, used by gateway_redeem_connection_code when the caller supplies a fingerprint but not the key text itself. Defaults to the real keys.openpgp.org lookup (pgp-keyserver.ts); a test that would otherwise trigger real network I/O injects a fake resolver instead, the same reasoning bridge-mesh.ts's own fetchLatestVersion override already establishes for getNewerVersionIfAny above. */ + private readonly fetchPgpPublicKeyByFingerprintImpl: ( + fingerprint: string, + ) => Promise = fetchPgpPublicKeyByFingerprint, ) {} /** The "Update available: ..." line appended to whoami/update output when a newer release is known, or undefined when there's nothing to report. */ @@ -238,6 +260,14 @@ export class CommsTool { return this.gatewayUntrust(action); case "gateway_list_trusted": return this.gatewayListTrusted(); + case "gateway_generate_connection_code": + return await handleGatewayGenerateConnectionCode(this.store, action); + case "gateway_redeem_connection_code": + return await handleGatewayRedeemConnectionCode( + this.store, + action, + this.fetchPgpPublicKeyByFingerprintImpl, + ); default: return { content: `Unknown action: ${JSON.stringify(action).slice(0, UNKNOWN_ACTION_PREVIEW_LENGTH)}`, From 9d460a7116a3ff2879f96c603cdd4f0eeb262694 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 18 Sep 2026 09:42:10 +0100 Subject: [PATCH 5/6] test(core): cover gateway_generate_connection_code and gateway_redeem_connection_code end to end Exercises CommsTool.handle -> MeshStore -> ConnectionCodeLedger for both actions, mirroring gateway-trust-tool.test.ts's own pattern: an issuer store generates a code vouching for its own peerId, a separate redeemer store redeems it and ends up trusting that exact device via gatewayTrust. Covers the bare, PGP-signed, pasted-public-key, and keyserver-fingerprint-lookup paths, plus buildAction parsing and required-field validation for both actions. --- src/test/connection-code-tool.test.ts | 406 ++++++++++++++++++++++++++ 1 file changed, 406 insertions(+) create mode 100644 src/test/connection-code-tool.test.ts diff --git a/src/test/connection-code-tool.test.ts b/src/test/connection-code-tool.test.ts new file mode 100644 index 00000000..251af5c4 --- /dev/null +++ b/src/test/connection-code-tool.test.ts @@ -0,0 +1,406 @@ +/** + * CommsTool's gateway_generate_connection_code/gateway_redeem_connection_code actions (agent-comms#188), mirroring gateway-trust-tool.test.ts's own pattern for the sibling gateway-trust actions -- an end-to-end pass through CommsTool.handle, MeshStore, and ConnectionCodeLedger, ending in a real gatewayTrust.add on successful redemption. + */ +import { test, describe, expect } from "vitest"; +import * as openpgp from "openpgp"; +import { MeshStore } from "../core/mesh-store.js"; +import { CommsTool } from "../core/tool.js"; +import { buildAction } from "../core/bridge.js"; +import { wireTestTransport } from "./test-transport.js"; + +const TEST_PORT = 0; +/** A generic "far enough in the future"/"far enough in the past" offset, used wherever a test needs an expired or not-yet-expired timestamp without caring about the exact margin. */ +const ONE_MINUTE_MS = 60_000; + +async function registerAgent(store: MeshStore, name: string) { + return store.registerAgent({ + name, + harness: "test", + cwd: "/test", + pid: process.pid, + visibility: "visible", + tags: [], + }); +} + +describe("CommsTool connection-code actions", () => { + test("gateway_generate_connection_code returns a bare code vouching for this store's own device-id", async () => { + const store = new MeshStore(TEST_PORT); + await wireTestTransport(store); + await store.init(); + const agent = await registerAgent(store, "connection-code-generate-test"); + const tool = new CommsTool(store); + + const result = await tool.handle( + { agentId: agent.id, harness: "test", cwd: "/test", pid: process.pid }, + { action: "gateway_generate_connection_code" }, + ); + + expect(result.isError, result.content).toBe(false); + expect(result.content).toContain("unsigned"); + expect(result.content).toContain(`"deviceId":"${store.peerId}"`); + + await store.shutdown(); + }); + + test("gateway_redeem_connection_code trusts the vouched-for device on success", async () => { + const issuerStore = new MeshStore(TEST_PORT); + await wireTestTransport(issuerStore); + await issuerStore.init(); + const issuerTool = new CommsTool(issuerStore); + const issuerAgent = await registerAgent(issuerStore, "issuer"); + + const generateResult = await issuerTool.handle( + { + agentId: issuerAgent.id, + harness: "test", + cwd: "/test", + pid: process.pid, + }, + { action: "gateway_generate_connection_code" }, + ); + const code: { code: string; expiresAt: string; deviceId: string } = + JSON.parse(generateResult.content.split("\n").at(-1) ?? "{}"); + + const redeemerStore = new MeshStore(TEST_PORT); + await wireTestTransport(redeemerStore); + await redeemerStore.init(); + const redeemerTool = new CommsTool(redeemerStore); + const redeemerAgent = await registerAgent(redeemerStore, "redeemer"); + + const redeemResult = await redeemerTool.handle( + { + agentId: redeemerAgent.id, + harness: "test", + cwd: "/test", + pid: process.pid, + }, + { + action: "gateway_redeem_connection_code", + code: code.code, + expiresAt: code.expiresAt, + device: code.deviceId, + }, + ); + + expect(redeemResult.isError, redeemResult.content).toBe(false); + expect(redeemerStore.listTrustedGateways()).toEqual([issuerStore.peerId]); + + await issuerStore.shutdown(); + await redeemerStore.shutdown(); + }); + + test("gateway_redeem_connection_code rejects an expired code without trusting anything", async () => { + const store = new MeshStore(TEST_PORT); + await wireTestTransport(store); + await store.init(); + const tool = new CommsTool(store); + const agent = await registerAgent(store, "expired-redeem-test"); + + const result = await tool.handle( + { agentId: agent.id, harness: "test", cwd: "/test", pid: process.pid }, + { + action: "gateway_redeem_connection_code", + code: "some-nonce", + expiresAt: new Date(Date.now() - ONE_MINUTE_MS).toISOString(), + device: "aabbcc", + }, + ); + + expect(result.isError, result.content).toBe(true); + expect(result.content).toContain("expired"); + expect(store.listTrustedGateways()).toEqual([]); + + await store.shutdown(); + }); + + test("gateway_generate_connection_code signs the code when a private key is supplied", async () => { + const store = new MeshStore(TEST_PORT); + await wireTestTransport(store); + await store.init(); + const tool = new CommsTool(store); + const agent = await registerAgent(store, "signed-generate-test"); + const { privateKey } = await openpgp.generateKey({ + type: "ecc", + curve: "curve25519Legacy", + userIDs: [{ name: "Alice", email: "alice@example.com" }], + format: "armored", + }); + + const result = await tool.handle( + { agentId: agent.id, harness: "test", cwd: "/test", pid: process.pid }, + { action: "gateway_generate_connection_code", privateKey }, + ); + + expect(result.isError, result.content).toBe(false); + expect(result.content).toContain("PGP-signed"); + + await store.shutdown(); + }); + + test("gateway_redeem_connection_code verifies a signature against a pasted public key", async () => { + const { privateKey, publicKey } = await openpgp.generateKey({ + type: "ecc", + curve: "curve25519Legacy", + userIDs: [{ name: "Alice", email: "alice@example.com" }], + format: "armored", + }); + + const issuerStore = new MeshStore(TEST_PORT); + await wireTestTransport(issuerStore); + await issuerStore.init(); + const issuerTool = new CommsTool(issuerStore); + const issuerAgent = await registerAgent(issuerStore, "signed-issuer"); + + const generateResult = await issuerTool.handle( + { + agentId: issuerAgent.id, + harness: "test", + cwd: "/test", + pid: process.pid, + }, + { action: "gateway_generate_connection_code", privateKey }, + ); + const code: { + code: string; + expiresAt: string; + deviceId: string; + signature: string; + } = JSON.parse(generateResult.content.split("\n").at(-1) ?? "{}"); + + const redeemerStore = new MeshStore(TEST_PORT); + await wireTestTransport(redeemerStore); + await redeemerStore.init(); + const redeemerTool = new CommsTool(redeemerStore); + const redeemerAgent = await registerAgent(redeemerStore, "signed-redeemer"); + + const redeemResult = await redeemerTool.handle( + { + agentId: redeemerAgent.id, + harness: "test", + cwd: "/test", + pid: process.pid, + }, + { + action: "gateway_redeem_connection_code", + code: code.code, + expiresAt: code.expiresAt, + device: code.deviceId, + signature: code.signature, + publicKey, + }, + ); + + expect(redeemResult.isError, redeemResult.content).toBe(false); + expect(redeemResult.content).toContain("signature verified"); + expect(redeemerStore.listTrustedGateways()).toEqual([issuerStore.peerId]); + + await issuerStore.shutdown(); + await redeemerStore.shutdown(); + }); + + test("gateway_redeem_connection_code fetches the public key from a keyserver when only a fingerprint is supplied", async () => { + const { privateKey, publicKey } = await openpgp.generateKey({ + type: "ecc", + curve: "curve25519Legacy", + userIDs: [{ name: "Alice", email: "alice@example.com" }], + format: "armored", + }); + const readKey = await openpgp.readKey({ armoredKey: publicKey }); + const fingerprint = readKey.getFingerprint(); + + const issuerStore = new MeshStore(TEST_PORT); + await wireTestTransport(issuerStore); + await issuerStore.init(); + const issuerTool = new CommsTool(issuerStore); + const issuerAgent = await registerAgent(issuerStore, "keyserver-issuer"); + + const generateResult = await issuerTool.handle( + { + agentId: issuerAgent.id, + harness: "test", + cwd: "/test", + pid: process.pid, + }, + { action: "gateway_generate_connection_code", privateKey }, + ); + const code: { + code: string; + expiresAt: string; + deviceId: string; + signature: string; + } = JSON.parse(generateResult.content.split("\n").at(-1) ?? "{}"); + + const redeemerStore = new MeshStore(TEST_PORT); + await wireTestTransport(redeemerStore); + await redeemerStore.init(); + const fetchPgpPublicKeyByFingerprintImpl = async ( + requestedFingerprint: string, + ): Promise => { + expect(requestedFingerprint).toBe(fingerprint); + return publicKey; + }; + const redeemerTool = new CommsTool( + redeemerStore, + undefined, + undefined, + fetchPgpPublicKeyByFingerprintImpl, + ); + const redeemerAgent = await registerAgent( + redeemerStore, + "keyserver-redeemer", + ); + + const redeemResult = await redeemerTool.handle( + { + agentId: redeemerAgent.id, + harness: "test", + cwd: "/test", + pid: process.pid, + }, + { + action: "gateway_redeem_connection_code", + code: code.code, + expiresAt: code.expiresAt, + device: code.deviceId, + signature: code.signature, + fingerprint, + }, + ); + + expect(redeemResult.isError, redeemResult.content).toBe(false); + expect(redeemerStore.listTrustedGateways()).toEqual([issuerStore.peerId]); + + await issuerStore.shutdown(); + await redeemerStore.shutdown(); + }); + + test("gateway_redeem_connection_code reports a keyserver lookup failure without trusting anything", async () => { + const redeemerStore = new MeshStore(TEST_PORT); + await wireTestTransport(redeemerStore); + await redeemerStore.init(); + const failingFetch = async (): Promise => { + throw new Error("HTTP 404"); + }; + const redeemerTool = new CommsTool( + redeemerStore, + undefined, + undefined, + failingFetch, + ); + const redeemerAgent = await registerAgent( + redeemerStore, + "keyserver-failure-redeemer", + ); + + const redeemResult = await redeemerTool.handle( + { + agentId: redeemerAgent.id, + harness: "test", + cwd: "/test", + pid: process.pid, + }, + { + action: "gateway_redeem_connection_code", + code: "some-nonce", + expiresAt: new Date(Date.now() + ONE_MINUTE_MS).toISOString(), + device: "aabbcc", + signature: + "-----BEGIN PGP SIGNATURE-----\nfake\n-----END PGP SIGNATURE-----", + fingerprint: "0000000000000000000000000000000000000000", + }, + ); + + expect(redeemResult.isError, redeemResult.content).toBe(true); + expect(redeemResult.content).toContain("Failed to fetch PGP public key"); + expect(redeemerStore.listTrustedGateways()).toEqual([]); + + await redeemerStore.shutdown(); + }); +}); + +describe("buildAction connection-code parsing", () => { + test("buildAction parses gateway_generate_connection_code with no fields", () => { + const action = buildAction({ action: "gateway_generate_connection_code" }); + expect(action.action).toBe("gateway_generate_connection_code"); + }); + + test("buildAction parses gateway_generate_connection_code's optional fields", () => { + const action = buildAction({ + action: "gateway_generate_connection_code", + ttlMs: ONE_MINUTE_MS, + privateKey: "armored-key", + passphrase: "secret", + }); + expect(action.action).toBe("gateway_generate_connection_code"); + if (action.action === "gateway_generate_connection_code") { + expect(action.ttlMs).toBe(ONE_MINUTE_MS); + expect(action.privateKey).toBe("armored-key"); + expect(action.passphrase).toBe("secret"); + } + }); + + test("buildAction parses gateway_redeem_connection_code's required fields", () => { + const action = buildAction({ + action: "gateway_redeem_connection_code", + code: "nonce", + expiresAt: "2026-01-01T00:00:00.000Z", + device: "aabbcc", + }); + expect(action.action).toBe("gateway_redeem_connection_code"); + if (action.action === "gateway_redeem_connection_code") { + expect(action.code).toBe("nonce"); + expect(action.expiresAt).toBe("2026-01-01T00:00:00.000Z"); + expect(action.device).toBe("aabbcc"); + expect(action.signature).toBeUndefined(); + } + }); + + test("buildAction parses gateway_redeem_connection_code's optional signature/publicKey/fingerprint", () => { + const action = buildAction({ + action: "gateway_redeem_connection_code", + code: "nonce", + expiresAt: "2026-01-01T00:00:00.000Z", + device: "aabbcc", + signature: "sig", + publicKey: "pubkey", + fingerprint: "fpr", + }); + expect(action.action).toBe("gateway_redeem_connection_code"); + if (action.action === "gateway_redeem_connection_code") { + expect(action.signature).toBe("sig"); + expect(action.publicKey).toBe("pubkey"); + expect(action.fingerprint).toBe("fpr"); + } + }); + + test("buildAction throws for gateway_redeem_connection_code without code", () => { + expect(() => + buildAction({ + action: "gateway_redeem_connection_code", + expiresAt: "2026-01-01T00:00:00.000Z", + device: "aabbcc", + }), + ).toThrow(/code/); + }); + + test("buildAction throws for gateway_redeem_connection_code without expiresAt", () => { + expect(() => + buildAction({ + action: "gateway_redeem_connection_code", + code: "nonce", + device: "aabbcc", + }), + ).toThrow(/expiresAt/); + }); + + test("buildAction throws for gateway_redeem_connection_code without device", () => { + expect(() => + buildAction({ + action: "gateway_redeem_connection_code", + code: "nonce", + expiresAt: "2026-01-01T00:00:00.000Z", + }), + ).toThrow(/device/); + }); +}); From 692262c0c49d364a3b5274b69f9a70ddd6765092 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 18 Sep 2026 09:43:52 +0100 Subject: [PATCH 6/6] docs: document gateway trust and connection codes Adds a Gateway trust and connection codes section covering the existing gateway_trust/gateway_untrust/gateway_list_trusted actions (previously undocumented) alongside the new gateway_generate_connection_code/gateway_redeem_connection_code pair, with usage examples for the bare, PGP-signed, pasted-public-key, and keyserver-fingerprint paths. --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/README.md b/README.md index abb7d991..d9ece301 100644 --- a/README.md +++ b/README.md @@ -322,3 +322,54 @@ This works for both room messages and DMs. ## Stale agent cleanup The coordinator probes registered agent PIDs every 5 seconds using signal 0 (existence check). Dead agents are marked offline and the status is broadcast to all peers. Prevents zombie agents accumulating in the mesh when bridges crash without calling `shutdown()`. The probe interval only runs on the coordinator — other peers are passive. + +## Gateway trust and connection codes + +Two devices on different machines can only relay traffic through each other's hub once each side explicitly trusts the other's device-id — a deny-by-default, pin-the-key model with no directory lookup, deliberately mirroring how an ordinary peer connection is already pinned. + +``` +# Trust a remote device you already know the device-id for +agent_comms({ action: "gateway_trust", device: "1a2b3c..." }) + +# Stop trusting it +agent_comms({ action: "gateway_untrust", device: "1a2b3c..." }) + +# List every currently trusted device +agent_comms({ action: "gateway_list_trusted" }) +``` + +The device-id itself normally has to be learned out of band (Slack, email, a phone call) before it can be pasted into `gateway_trust`. A connection code is a single-use, short-lived artifact that makes that hand-off itself verifiable instead of a bare, unauthenticated string: + +``` +ConnectionCode { + code: nonce # single-use + expiresAt: timestamp # short-lived + deviceId: hex # the device being vouched for + signature?: PgpSignature over (code, expiresAt, deviceId) +} +``` + +`code`/`expiresAt` are the always-checked half: proof that whoever redeems this was on the other end of this exact exchange, recently. `signature` is optional and answers a different question — this claim was made by whoever holds a specific long-lived PGP key. It's checked only when present, and never required: a device with no PGP identity still generates and redeems a bare code. + +``` +# Generate a code vouching for this device, unsigned +agent_comms({ action: "gateway_generate_connection_code" }) + +# ...or signed with a PGP private key you already have (agent-comms never generates or stores PGP key material itself -- you supply it per call, the same way you would to `gpg --sign` directly) +agent_comms({ action: "gateway_generate_connection_code", privateKey: "-----BEGIN PGP PRIVATE KEY BLOCK-----..." }) + +# Relay the printed code to the counterpart out of band, then redeem it on their side -- a successful redemption trusts the device it vouches for, exactly as if gateway_trust had been called directly +agent_comms({ + action: "gateway_redeem_connection_code", + code: "...", expiresAt: "2026-01-01T00:15:00.000Z", device: "1a2b3c...", +}) + +# Redeeming a signed code needs the signer's public key to verify against -- paste it directly, or supply a fingerprint you already trust and let the redeemer fetch it from keys.openpgp.org +agent_comms({ + action: "gateway_redeem_connection_code", + code: "...", expiresAt: "...", device: "1a2b3c...", signature: "...", + fingerprint: "aabbccddeeff00112233445566778899aabbccd", +}) +``` + +The fingerprint is never a trust anchor supplied by the keyserver — it's the thing the redeemer already independently trusts (from a business card, a prior verification, wherever), and the redeemer's own check is that the key actually fetched or pasted verifies to that exact fingerprint, not merely that *some* key was found. Redemption never calls back to whoever generated the code: the whole point is bootstrapping trust before any connection between the two devices exists.