From c6987fb34335ff080c817e3a34f8011fc4564e58 Mon Sep 17 00:00:00 2001 From: cortex-agent Date: Mon, 9 Nov 2026 09:35:06 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(permit):=20=E5=A2=9E=E5=8A=A0=E8=B7=A8?= =?UTF-8?q?=E6=9C=BA=E5=8F=97=E7=AE=A1=E5=AF=B9=E7=AB=AF=E7=9A=84=E7=A5=A8?= =?UTF-8?q?=E6=8D=AE=E4=BB=A3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 票据此前只能在本机铸造并只发给本机已验证对端,跨机对端即使能到达签发端也永远拿不到票据。 新增票据代理:独立的回环监听器,按部署侧钉住的公钥做挑战握手, 握手成功后为该远端对端铸造一张绑定其签名身份事实的票据并当场交付。 远端对端的票据与本机对端的票据走同一条端点路径,仍是一次性的。 --- lib/commands/permit-bridge-service-cli.js | 57 +++ lib/coordination/permit-bridge-host.js | 49 +- lib/coordination/permit-bridge-service.js | 7 + lib/coordination/permit-remote-peer-client.js | 148 ++++++ .../permit-remote-peer-registry.js | 95 ++++ lib/coordination/permit-ticket-agent.js | 475 ++++++++++++++++++ .../coordination/permit-ticket-agent.test.js | 352 +++++++++++++ 7 files changed, 1181 insertions(+), 2 deletions(-) create mode 100644 lib/coordination/permit-remote-peer-client.js create mode 100644 lib/coordination/permit-remote-peer-registry.js create mode 100644 lib/coordination/permit-ticket-agent.js create mode 100644 tests/coordination/permit-ticket-agent.test.js diff --git a/lib/commands/permit-bridge-service-cli.js b/lib/commands/permit-bridge-service-cli.js index e9737b26..f734af94 100644 --- a/lib/commands/permit-bridge-service-cli.js +++ b/lib/commands/permit-bridge-service-cli.js @@ -9,9 +9,43 @@ const USAGE = "Usage: cortex-agent permit-bridge serve --config "; const CONFIG_KEYS = new Set([ "storePath", "anchorDir", "controlledAncestorPath", "decisionId", "serviceIdentity", "leaseTtlMs", "permitTtlMs", "ticketTtlMs", "requestTimeoutMs", "port", "revocationPort", + "remotePeerTrustFile", "agentPort", ]); const EXECUTOR_PATH = path.join(__dirname, "permit-bridge-executor.js"); +// The cap on the trust file. A trust file holds a handful of pinned public +// keys; anything larger is not a trust file and must not be buffered. +const MAX_TRUST_FILE_BYTES = 64 * 1024; + +/** + * Read the pinned remote peer trust file. + * + * The trust root is a DEPLOYMENT fact: it comes from a file the deployment + * owns, never from the peer that connects. The shape is checked here so a + * malformed file is refused as a configuration error rather than as a start + * failure; the cryptographic validation stays with the agent. + * + * @param {string} file + * @returns {object[]} + */ +function readRemotePeerTrust(file) { + const stat = fs.statSync(file); + if (!stat.isFile() || stat.size > MAX_TRUST_FILE_BYTES) throw new Error("invalid trust file"); + const parsed = JSON.parse(fs.readFileSync(file, "utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !Array.isArray(parsed.peers)) { + throw new Error("invalid trust file"); + } + for (const peer of parsed.peers) { + if (!peer || typeof peer !== "object" || Array.isArray(peer) || + typeof peer.peerId !== "string" || !peer.peerId || + typeof peer.instanceId !== "string" || !peer.instanceId || + typeof peer.publicKeyPem !== "string" || !peer.publicKeyPem) { + throw new Error("invalid trust file"); + } + } + return parsed.peers; +} + function readConfig(file) { if (!Number.isInteger(fs.constants.O_NOFOLLOW)) throw new Error("invalid config"); const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); @@ -53,11 +87,31 @@ function readConfig(file) { throw new Error("invalid config"); } } + if (value.agentPort !== undefined && + (!Number.isSafeInteger(value.agentPort) || value.agentPort < 0 || value.agentPort > 65535)) { + throw new Error("invalid config"); + } if (value.port && value.revocationPort && value.port === value.revocationPort) { throw new Error("invalid config"); } + if (value.agentPort !== undefined) { + if (value.port && value.agentPort === value.port) throw new Error("invalid config"); + if (value.revocationPort && value.agentPort === value.revocationPort) throw new Error("invalid config"); + } + if (value.remotePeerTrustFile !== undefined) { + if (typeof value.remotePeerTrustFile !== "string" || !path.isAbsolute(value.remotePeerTrustFile)) { + throw new Error("invalid config"); + } + } + // The trust file is read here, not at start time: a configuration that names + // a trust file that cannot be read is a configuration error, and the exit + // code for it must not be the one reserved for a start failure. + const remotePeers = value.remotePeerTrustFile === undefined + ? undefined + : readRemotePeerTrust(value.remotePeerTrustFile); return { ...value, + remotePeers, executorPath: EXECUTOR_PATH, anchorMode: "strict", bindAddress: "127.0.0.1", @@ -103,6 +157,9 @@ async function permitBridgeServiceCli(ctx, dependencies = {}) { action: "serve", port: service.address.port, revocationPort: service.revocationAddress.port, + // The ticket agent's port. It is null when no remote peer trust is + // configured, because then no agent is started at all. + agentPort: service.agentAddress ? service.agentAddress.port : null, })); const signalSource = dependencies.signalSource || process; diff --git a/lib/coordination/permit-bridge-host.js b/lib/coordination/permit-bridge-host.js index a84087d5..4651fd49 100644 --- a/lib/coordination/permit-bridge-host.js +++ b/lib/coordination/permit-bridge-host.js @@ -42,8 +42,9 @@ const { assemblePermitIssuer } = require("./permit-bridge-assembly"); const { attachPermitBridgeEndpoint } = require("./permit-bridge-assembly"); const { mapIssueRequest } = require("./permit-bridge-request"); const { createTicketRegistry } = require("./permit-bridge-ticket"); +const { startPermitTicketAgent } = require("./permit-ticket-agent"); +const { createRemotePeerRegistry } = require("./permit-remote-peer-registry"); const { PERMIT_BRIDGE_BOUNDS } = require("./permit-bridge-bounds"); -const { identityMatches } = require("./permit-bridge-policy"); function fail(code) { throw new CoordinationError(code); @@ -73,6 +74,10 @@ const COLLECT_OPERATION = "permit.collect"; * @param {number} [options.leaseTtlMs] The desktop lease window. * @param {number} [options.permitTtlMs] The one-shot permit validity. * @param {number} [options.ticketTtlMs] How long a client ticket stays usable. + * @param {object[]} [options.remotePeers] Pinned remote peer entries. When present, + * the bridge also starts the ticket agent so a remote managed peer can obtain + * a ticket over a cross-machine stream. + * @param {number} [options.agentPort] The ticket agent's port. 0 selects a free port. * @param {function} [options.onDiagnostic] * @returns {Promise} The running bridge. */ @@ -90,6 +95,8 @@ async function startPermitBridgeHost(options = {}) { leaseTtlMs, permitTtlMs, ticketTtlMs = PERMIT_BRIDGE_BOUNDS.ticketTtlMs, + remotePeers, + agentPort = 0, onDiagnostic, } = options; @@ -103,6 +110,7 @@ async function startPermitBridgeHost(options = {}) { fail("ERR_PERMIT_INPUT_INVALID"); } if (typeof revocationStateSource !== "function") fail("ERR_PERMIT_INPUT_INVALID"); + if (remotePeers !== undefined && !Array.isArray(remotePeers)) fail("ERR_PERMIT_INPUT_INVALID"); // The TTLs are the bridge's own configuration. They are not request data, // and the bridge refuses to start without them rather than inventing a // default at request time. @@ -236,7 +244,7 @@ async function startPermitBridgeHost(options = {}) { // disagree, the request is arriving over a channel that is not the one // the ticket was issued for — so it is refused here rather than being // dispatched on a peer's authority. - if (!ticketFacts || !assembly || !identityMatches(ticketFacts, assembly.peerIdentity)) { + if (!ticketFacts || !assembly || !peerMatchesTicket(ticketFacts)) { diagnose("ticket_peer_mismatch", null); fail("ERR_PERMIT_INPUT_INVALID"); } @@ -264,6 +272,23 @@ async function startPermitBridgeHost(options = {}) { // minted for the session's verified peer, so only a client that the bridge // itself established can obtain one. let tickets; + // The ticket agent lets a REMOTE managed peer obtain a ticket of its own. + // A remote peer's ticket is bound to the identity facts that peer signed + // during the handshake, so the registry below tracks those peers for as + // long as a ticket minted for them can still be presented. + let agent; + let agentAddress; + const remotePeerRegistry = createRemotePeerRegistry(); + + // A ticket is accepted when it was minted for a peer that is still live: + // the local session's pinned identity, or a remote peer whose handshake was + // verified and whose registration has not expired. A ticket for a peer that + // is neither is refused — a ticket that outlives its peer is exactly the + // captured-ticket case the one-shot rule is meant to bound. + function peerMatchesTicket(ticketFacts) { + if (!assembly) return false; + return remotePeerRegistry.matches(ticketFacts, assembly.peerIdentity); + } // Everything that owns a resource is created inside one transaction. The // session is already live here, so a failure in assembly, registry, @@ -274,6 +299,9 @@ async function startPermitBridgeHost(options = {}) { const attempt = async (label, action) => { try { await action(); } catch (error) { failures.push({ label, error }); } }; + await attempt("agent", async () => { + if (agent) await agent.close(); + }); await attempt("revocation_endpoint", async () => { if (revocationEndpoint) await revocationEndpoint.close(); }); @@ -332,6 +360,21 @@ async function startPermitBridgeHost(options = {}) { onDiagnostic: (event, detail) => diagnose("revocation_endpoint:" + event, detail), }); revocationAddress = await revocationEndpoint.listen(); + // The ticket agent starts LAST: it can only mint tickets once the ticket + // registry and the peer registry exist, and it must not be reachable + // before the endpoint it hands out tickets for is reachable. + if (Array.isArray(remotePeers) && remotePeers.length) { + agent = await startPermitTicketAgent({ + peers: remotePeers, + mintTicket: (identity) => tickets.mint(identity), + registerPeer: (identity, ttlMs) => remotePeerRegistry.register(identity, ttlMs), + bindAddress, + port: agentPort, + peerTtlMs: ticketTtlMs, + onDiagnostic: (event, detail) => diagnose("agent:" + event, detail), + }); + agentAddress = agent.address; + } } catch (error) { // The session is already live at this point. A failure here has to take // the session down with it, or the executor would stay alive with a @@ -353,9 +396,11 @@ async function startPermitBridgeHost(options = {}) { return Object.freeze({ endpoint, revocationEndpoint, + agent, session, address, revocationAddress, + agentAddress, /** * Mint a ticket for the session's verified peer. * diff --git a/lib/coordination/permit-bridge-service.js b/lib/coordination/permit-bridge-service.js index 3d698844..804410b2 100644 --- a/lib/coordination/permit-bridge-service.js +++ b/lib/coordination/permit-bridge-service.js @@ -61,6 +61,8 @@ function fail(code) { * @param {string} [options.bindAddress] * @param {number} [options.port] * @param {number} [options.revocationPort] + * @param {object[]} [options.remotePeers] Pinned remote peer entries for the ticket agent. + * @param {number} [options.agentPort] * @param {function} [options.onDiagnostic] * @returns {Promise} The running service. */ @@ -81,6 +83,8 @@ async function startPermitBridgeService(options = {}) { bindAddress = "127.0.0.1", port = 0, revocationPort = 0, + remotePeers, + agentPort = 0, requestTimeoutMs, onDiagnostic, } = options; @@ -145,6 +149,8 @@ async function startPermitBridgeService(options = {}) { bindAddress, port, revocationPort, + remotePeers, + agentPort, requestTimeoutMs, onDiagnostic, }); @@ -179,6 +185,7 @@ async function startPermitBridgeService(options = {}) { revocationBatchSession, address: bridge.address, revocationAddress: bridge.revocationAddress, + agentAddress: bridge.agentAddress, /** * Mint a client ticket for the verified peer. * diff --git a/lib/coordination/permit-remote-peer-client.js b/lib/coordination/permit-remote-peer-client.js new file mode 100644 index 00000000..9842225e --- /dev/null +++ b/lib/coordination/permit-remote-peer-client.js @@ -0,0 +1,148 @@ +"use strict"; + +// ─── Permit Remote Peer Client (M-053 / R2-c) ─────────────────────────────── +// +// WHAT THIS MODULE IS +// The remote managed peer's half of the ticket-agent handshake. It runs on +// the peer machine (the Windows side), asks the issuing bridge's agent for a +// challenge, signs the handshake with the peer's pinned private key and +// receives a ticket that is bound to the peer's own attested identity. +// +// WHY THE CLIENT SIGNS AND THE AGENT VERIFIES +// The agent holds the peer's PUBLIC key; the peer holds the private key. +// The trust therefore travels one way only: the agent can verify, and the +// peer can never make the agent believe a fact it did not sign. +// +// WHY THE CLIENT SENDS ITS OWN PROCESS FACTS +// `pid`, `startToken` and `commandDigest` are the facts the ticket is bound +// to. They are signed, so they are attested rather than asserted — but they +// are still reported BY the peer. A deployment that wants them checked +// against something else has to check them on the peer side; the issuing +// side can only see the peer's own signed word about them. + +const crypto = require("node:crypto"); +const http = require("node:http"); +const { handshakeBytes, handshakeFacts } = require("./permit-ticket-agent"); + +const DEFAULT_TIMEOUT_MS = 5_000; + +function fail(code) { + throw Object.assign(new Error(code), { code }); +} + +/** + * Sign the handshake facts with the peer's private key. + * + * @param {string} privateKeyPem The peer's private key (PEM). + * @param {object} facts The handshake facts. + * @returns {string} The Base64 signature. + */ +function signHandshake(privateKeyPem, facts) { + if (typeof privateKeyPem !== "string" || !privateKeyPem) fail("ERR_PERMIT_INPUT_INVALID"); + let key; + try { + key = crypto.createPrivateKey(privateKeyPem); + } catch { + fail("ERR_PERMIT_INPUT_INVALID: privateKeyPem"); + } + if (key.asymmetricKeyType !== "ed25519") fail("ERR_PERMIT_INPUT_INVALID: privateKeyPem"); + const signature = crypto.sign(null, handshakeBytes(facts), key); + return Buffer.from(signature).toString("base64"); +} + +/** + * Perform one HTTP request and read the response body. + * + * @param {object} options + * @returns {Promise<{ status: number, body: string }>} + */ +function request(options) { + const { url, method, headers = {}, body, timeoutMs = DEFAULT_TIMEOUT_MS } = options; + const target = new URL(url); + const payload = body === undefined ? null : Buffer.from(body, "utf8"); + const finalHeaders = Object.assign({}, headers); + if (payload) finalHeaders["content-length"] = String(payload.length); + + return new Promise((resolve, reject) => { + const req = http.request({ + protocol: target.protocol, + hostname: target.hostname, + port: target.port, + path: target.pathname + target.search, + method, + headers: finalHeaders, + }, (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => resolve({ + status: response.statusCode, + body: Buffer.concat(chunks).toString("utf8"), + })); + }); + req.setTimeout(timeoutMs, () => { + req.destroy(Object.assign(new Error("request timed out"), { code: "ERR_PERMIT_REQUEST_TIMEOUT" })); + }); + req.on("error", reject); + if (payload) req.write(payload); + req.end(); + }); +} + +/** + * Ask the ticket agent for a client ticket. + * + * @param {object} options + * @param {string} options.agentUrl The agent's base URL (loopback or a tunnel). + * @param {string} options.peerId The pinned peer id. + * @param {string} options.instanceId The pinned instance id. + * @param {object} options.identity `{ pid, startToken, commandDigest }`. + * @param {string} options.privateKeyPem The peer's private key (PEM). + * @param {number} [options.timeoutMs] + * @returns {Promise} `{ ticket, sessionId, peerId, expiresAtMs }`. + */ +async function requestRemoteTicket(options = {}) { + const { agentUrl, peerId, instanceId, identity, privateKeyPem, timeoutMs = DEFAULT_TIMEOUT_MS } = options; + if (typeof agentUrl !== "string" || !agentUrl) fail("ERR_PERMIT_INPUT_INVALID: agentUrl"); + if (!identity || typeof identity !== "object") fail("ERR_PERMIT_INPUT_INVALID: identity"); + + const challengeResponse = await request({ + url: agentUrl.replace(/\/$/, "") + "/challenge", + method: "GET", + timeoutMs, + }); + if (challengeResponse.status !== 200) fail("ERR_PERMIT_AGENT_UNREACHABLE"); + + let challenge; + try { challenge = JSON.parse(challengeResponse.body); } catch { fail("ERR_PERMIT_INPUT_INVALID"); } + + const facts = handshakeFacts({ + peerId, + instanceId, + pid: identity.pid, + startToken: identity.startToken, + commandDigest: identity.commandDigest, + challengeId: challenge.challengeId, + challenge: challenge.challenge, + }); + const signature = signHandshake(privateKeyPem, facts); + + const handshakeResponse = await request({ + url: agentUrl.replace(/\/$/, "") + "/handshake", + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(Object.assign({}, facts, { signature })), + timeoutMs, + }); + if (handshakeResponse.status !== 200) fail("ERR_PERMIT_AGENT_REJECTED"); + + let result; + try { result = JSON.parse(handshakeResponse.body); } catch { fail("ERR_PERMIT_INPUT_INVALID"); } + if (typeof result.ticket !== "string" || !result.ticket) fail("ERR_PERMIT_INPUT_INVALID: ticket"); + return result; +} + +module.exports = Object.freeze({ + request, + requestRemoteTicket, + signHandshake, +}); diff --git a/lib/coordination/permit-remote-peer-registry.js b/lib/coordination/permit-remote-peer-registry.js new file mode 100644 index 00000000..4ee3ab1c --- /dev/null +++ b/lib/coordination/permit-remote-peer-registry.js @@ -0,0 +1,95 @@ +"use strict"; + +// ─── Permit Remote Peer Registry (M-053 / R2-c) ───────────────────────────── +// +// WHAT THIS IS +// The list of peers that a bridge currently accepts tickets for: the local +// managed session's pinned identity, plus the remote peers whose handshake +// the ticket agent verified and whose registration has not expired. +// +// WHY A REGISTRY AND NOT A SECOND PIN +// The local peer's identity is pinned for the bridge's whole lifetime. A +// remote peer's identity only exists for as long as its ticket can still be +// presented — so it is registered with a deadline and dropped afterwards. +// A ticket that outlives its peer is exactly the captured-ticket case the +// one-shot ticket rule is meant to bound, so the registry has to end with +// the ticket's reach, not with the bridge's. +// +// WHY THE COMPARISON REUSES THE BRIDGE'S OWN RULE +// `identityMatches` is what the endpoint already used to decide whether a +// ticket's bound facts are the pinned peer's. Using a second, looser +// comparison for remote peers would mean two definitions of "the same +// peer" in one bridge — and the looser one would be the one that decides. + +const { identityMatches } = require("./permit-bridge-policy"); + +function fail(code) { + throw Object.assign(new Error(code), { code }); +} + +/** + * Create the registry. + * + * @param {object} [options] + * @param {function} [options.clock] Millisecond clock. + * @returns {object} The registry. + */ +function createRemotePeerRegistry(options = {}) { + const clock = options.clock === undefined ? Date.now : options.clock; + if (typeof clock !== "function") fail("ERR_PERMIT_INPUT_INVALID"); + + // sessionId -> { identity, expiresAt } + const peers = new Map(); + + function purge(now) { + for (const [sessionId, entry] of peers) { + if (entry.expiresAt <= now) peers.delete(sessionId); + } + } + + return Object.freeze({ + /** + * Register a remote peer for the lifetime of the tickets minted for it. + * + * @param {object} identity The peer's identity facts. + * @param {number} ttlMs How long the registration lives. + */ + register(identity, ttlMs) { + if (!identity || typeof identity !== "object" || Array.isArray(identity)) { + fail("ERR_PERMIT_INPUT_INVALID"); + } + if (typeof identity.sessionId !== "string" || !identity.sessionId) fail("ERR_PERMIT_INPUT_INVALID"); + if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) fail("ERR_PERMIT_INPUT_INVALID"); + const now = clock(); + purge(now); + peers.set(identity.sessionId, { identity, expiresAt: now + ttlMs }); + }, + /** + * Whether a ticket's bound facts belong to a peer that is still live. + * + * @param {object} ticketFacts The facts the ticket was minted for. + * @param {object} localIdentity The local session's pinned identity. + * @returns {boolean} + */ + matches(ticketFacts, localIdentity) { + if (!ticketFacts || typeof ticketFacts !== "object") return false; + if (localIdentity && identityMatches(ticketFacts, localIdentity)) return true; + purge(clock()); + for (const entry of peers.values()) { + if (identityMatches(ticketFacts, entry.identity)) return true; + } + return false; + }, + /** + * The number of live remote peers. Diagnostics only. + * + * @returns {number} + */ + size() { + purge(clock()); + return peers.size; + }, + }); +} + +module.exports = Object.freeze({ createRemotePeerRegistry }); diff --git a/lib/coordination/permit-ticket-agent.js b/lib/coordination/permit-ticket-agent.js new file mode 100644 index 00000000..38cc2740 --- /dev/null +++ b/lib/coordination/permit-ticket-agent.js @@ -0,0 +1,475 @@ +"use strict"; + +// ─── Permit Ticket Agent (M-053 / R2-c: 跨机受管对端的票据代理) ───────────── +// +// WHAT THIS MODULE IS +// The bridge's ticket agent: it lets a REMOTE managed peer obtain a client +// ticket for the issuing endpoint over a duplex stream that reaches this +// process from another machine. +// +// WHY THE TICKET EXISTED ONLY FOR THE LOCAL PEER +// `permit-bridge-host.js` mints a ticket for the identity the LOCAL managed +// session verified (`tickets.mint(assembly.peerIdentity)`), and the endpoint +// refuses any ticket whose bound facts do not match that pinned identity. +// That is the right rule — a ticket proves "the caller is the process the +// host audited" — but it means the only peer that could ever hold a ticket +// was the executor this bridge forked. A peer on another machine could +// reach the endpoint and nothing else: it had no ticket, and there was no +// path by which it could get one. +// +// WHAT THIS MODULE ADDS +// A second, separate loopback listener that performs a pinned-trust +// handshake with a remote peer and, on success, mints a ticket BOUND TO +// THAT REMOTE PEER's attested identity facts. The ticket is delivered over +// the handshake response, so the remote peer can then present it on the +// permit endpoint exactly like the local peer does. +// +// WHY THE TRUST ROOT IS A PARAMETER AND NOT A REQUEST FIELD +// The pinned peer keys come from the deployment (`peers` below), never from +// the handshake request. A peer that could name its own key would be able +// to mint a ticket for itself, which is the whole thing the ticket is for. +// +// WHY THE HANDSHAKE USES A SERVER-ISSUED ONE-SHOT CHALLENGE +// The challenge is generated here, stored, handed out once and burned on the +// first use — including a failed use. A signature over a client-chosen +// challenge would be replayable; a signature over a challenge this process +// issued and then destroyed cannot be. +// +// WHY THE AGENT IS A SEPARATE LISTENER +// The permit endpoint answers exactly one shape of response (the grant), and +// its access control is the ticket. Mounting the handshake on the same +// socket would mean the endpoint had to answer two shapes and choose +// between them by something in the request — and the request is the peer's +// to choose. A second listener keeps the permit endpoint's contract intact. +// +// THE SCOPE OF THIS MODULE +// It authenticates a remote peer and delivers a ticket. It does not sign +// permits, does not open a service, does not create accounts, does not +// touch ACLs and does not weaken the local peer's own ticket rules. + +const crypto = require("node:crypto"); +const http = require("node:http"); +const { canonicalBytes } = require("./permit-delivery"); + +// The signed byte domain of the handshake. The domain prefix keeps a +// handshake signature from ever being readable as a signature over anything +// else in this codebase. +const HANDSHAKE_DOMAIN = "cortex.permit-ticket-agent-handshake.v1"; + +// The challenge window. A remote peer has to answer within it; the window is +// an acceptance bound, not a tunable. +const DEFAULT_CHALLENGE_TTL_MS = 5_000; +const MAX_CHALLENGE_TTL_MS = 30_000; + +// How long a remote peer stays registered, and therefore how long a ticket +// minted for it can still match a live peer. +const DEFAULT_PEER_TTL_MS = 60_000; + +// The cap on simultaneously live remote peers. A peer that connects and +// never uses its ticket must not be able to grow the registry without bound. +const MAX_LIVE_PEERS = 16; + +// The request body cap. A handshake is a small JSON document. +const MAX_BODY_BYTES = 16 * 1024; + +const LOOPBACK_ADDRESSES = Object.freeze(["127.0.0.1", "::1"]); + +function fail(code) { + throw Object.assign(new Error(code), { code }); +} + +function isLoopback(host) { + if (typeof host !== "string" || !host) return false; + const normalized = host.replace(/^\[|\]$/g, "").toLowerCase(); + return LOOPBACK_ADDRESSES.includes(normalized); +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function requireText(value, name) { + if (typeof value !== "string" || !value || value !== value.trim()) { + fail("ERR_PERMIT_INPUT_INVALID: " + name); + } + return value; +} + +function requirePositiveInteger(value, name) { + if (!Number.isSafeInteger(value) || value <= 0) fail("ERR_PERMIT_INPUT_INVALID: " + name); + return value; +} + +/** + * Normalize a pinned peer entry. + * + * @param {object} peer + * @returns {object} + */ +function normalizePeer(peer) { + if (!isPlainObject(peer)) fail("ERR_PERMIT_INPUT_INVALID: peer"); + const peerId = requireText(peer.peerId, "peerId"); + const instanceId = requireText(peer.instanceId, "instanceId"); + // The PEM is NOT trim-checked: a PEM file's text ends with a newline, and + // requiring the value to equal its trimmed form would refuse every key + // that was read from a file. What matters is that it parses as an Ed25519 + // public key, which is checked below. + if (typeof peer.publicKeyPem !== "string" || !peer.publicKeyPem) { + fail("ERR_PERMIT_INPUT_INVALID: publicKeyPem"); + } + const publicKeyPem = peer.publicKeyPem; + let publicKey; + try { + publicKey = crypto.createPublicKey(publicKeyPem); + } catch (error) { + fail("ERR_PERMIT_INPUT_INVALID: publicKeyPem"); + } + if (publicKey.asymmetricKeyType !== "ed25519") fail("ERR_PERMIT_INPUT_INVALID: publicKeyPem"); + return Object.freeze({ peerId, instanceId, publicKeyPem, publicKey }); +} + +/** + * The facts a remote peer signs. + * + * Every fact that ends up in the ticket's identity binding is in here, so a + * peer cannot sign one set of facts and present another. + * + * @param {object} input + * @returns {object} + */ +function handshakeFacts(input) { + return { + peerId: requireText(input.peerId, "peerId"), + instanceId: requireText(input.instanceId, "instanceId"), + pid: requirePositiveInteger(input.pid, "pid"), + startToken: requireText(input.startToken, "startToken"), + commandDigest: requireText(input.commandDigest, "commandDigest"), + challengeId: requireText(input.challengeId, "challengeId"), + challenge: requireText(input.challenge, "challenge"), + }; +} + +/** + * The bytes a remote peer signs. + * + * @param {object} facts + * @returns {Buffer} + */ +function handshakeBytes(facts) { + return canonicalBytes([HANDSHAKE_DOMAIN, facts]); +} + +/** + * Verify a remote peer's handshake signature against its pinned key. + * + * @param {object} facts + * @param {object} peer The pinned peer. + * @param {string} signatureBase64 + * @returns {boolean} + */ +function verifyHandshakeSignature(facts, peer, signatureBase64) { + if (typeof signatureBase64 !== "string" || !signatureBase64) return false; + let signature; + try { + signature = Buffer.from(signatureBase64, "base64"); + } catch { + return false; + } + if (signature.length !== 64) return false; + try { + return crypto.verify(null, handshakeBytes(facts), peer.publicKey, signature); + } catch { + return false; + } +} + +function readBody(request, limit) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + request.on("data", (chunk) => { + size += chunk.length; + if (size > limit) { + reject(Object.assign(new Error("request body too large"), { code: "ERR_PERMIT_INPUT_INVALID" })); + request.destroy(); + return; + } + chunks.push(chunk); + }); + request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + request.on("error", reject); + }); +} + +/** + * Start the ticket agent. + * + * @param {object} options + * @param {string[]} [options.peers] Pinned peer entries: `{ peerId, instanceId, publicKeyPem }`. + * @param {function} options.mintTicket `(identity) => ticket`. The host's ticket registry. + * @param {function} options.registerPeer `(identity, ttlMs) => void`. The host's peer registry. + * @param {string} [options.bindAddress] Must be a loopback address. + * @param {number} [options.port] 0 selects a free port. + * @param {number} [options.challengeTtlMs] + * @param {number} [options.peerTtlMs] + * @param {function} [options.onDiagnostic] + * @returns {Promise} The running agent. + */ +async function startPermitTicketAgent(options = {}) { + const { + peers = [], + mintTicket, + registerPeer, + bindAddress = "127.0.0.1", + port = 0, + challengeTtlMs = DEFAULT_CHALLENGE_TTL_MS, + peerTtlMs = DEFAULT_PEER_TTL_MS, + onDiagnostic, + } = options; + + if (!Array.isArray(peers) || !peers.length) fail("ERR_PERMIT_INPUT_INVALID: peers"); + if (typeof mintTicket !== "function") fail("ERR_PERMIT_INPUT_INVALID: mintTicket"); + if (typeof registerPeer !== "function") fail("ERR_PERMIT_INPUT_INVALID: registerPeer"); + if (!isLoopback(bindAddress)) fail("ERR_PERMIT_INPUT_INVALID: bindAddress"); + if (!Number.isSafeInteger(port) || port < 0 || port > 65535) fail("ERR_PERMIT_INPUT_INVALID: port"); + if (!Number.isSafeInteger(challengeTtlMs) || challengeTtlMs <= 0 || + challengeTtlMs > MAX_CHALLENGE_TTL_MS) { + fail("ERR_PERMIT_INPUT_INVALID: challengeTtlMs"); + } + if (!Number.isSafeInteger(peerTtlMs) || peerTtlMs <= 0) fail("ERR_PERMIT_INPUT_INVALID: peerTtlMs"); + + const pinned = new Map(); + for (const peer of peers) { + const normalized = normalizePeer(peer); + if (pinned.has(normalized.peerId)) fail("ERR_PERMIT_INPUT_INVALID: peers"); + pinned.set(normalized.peerId, normalized); + } + + function diagnose(event, detail) { + if (typeof onDiagnostic !== "function") return; + try { onDiagnostic(event, detail); } catch { /* a diagnostic must not break the path */ } + } + + // challengeId -> { challenge, expiresAt } + const challenges = new Map(); + let closing = false; + let closePromise = null; + + function purgeChallenges(now) { + for (const [id, entry] of challenges) { + if (entry.expiresAt <= now) challenges.delete(id); + } + } + + async function handleChallenge(response) { + const now = Date.now(); + purgeChallenges(now); + if (challenges.size >= MAX_LIVE_PEERS * 4) { + response.writeHead(503, { "content-type": "text/plain" }); + response.end(); + return; + } + const challengeId = crypto.randomBytes(16).toString("hex"); + const challenge = crypto.randomBytes(32).toString("base64"); + challenges.set(challengeId, { challenge, expiresAt: now + challengeTtlMs }); + const body = JSON.stringify({ challengeId, challenge, expiresAtMs: now + challengeTtlMs }); + const encoded = Buffer.from(body, "utf8"); + response.writeHead(200, { + "content-type": "application/json", + "content-length": String(encoded.length), + }); + response.end(encoded); + } + + async function handleHandshake(request, response) { + let body; + try { + body = await readBody(request, MAX_BODY_BYTES); + } catch { + response.writeHead(400, { "content-type": "text/plain" }); + response.end(); + return; + } + + let payload; + try { payload = JSON.parse(body); } catch { payload = null; } + if (!isPlainObject(payload)) { + response.writeHead(400, { "content-type": "text/plain" }); + response.end(); + return; + } + + let facts; + try { + facts = handshakeFacts(payload); + } catch { + response.writeHead(400, { "content-type": "text/plain" }); + response.end(); + return; + } + + // The challenge is burned on the FIRST use, successful or not. A failed + // handshake must not leave a live challenge behind for a second try. + const now = Date.now(); + purgeChallenges(now); + const entry = challenges.get(facts.challengeId); + challenges.delete(facts.challengeId); + if (!entry || entry.expiresAt <= now || entry.challenge !== facts.challenge) { + diagnose("challenge_rejected", facts.peerId); + response.writeHead(401, { "content-type": "text/plain" }); + response.end(); + return; + } + + const peer = pinned.get(facts.peerId); + if (!peer || peer.instanceId !== facts.instanceId) { + diagnose("peer_not_pinned", facts.peerId); + response.writeHead(401, { "content-type": "text/plain" }); + response.end(); + return; + } + + if (!verifyHandshakeSignature(facts, peer, payload.signature)) { + diagnose("signature_rejected", facts.peerId); + response.writeHead(401, { "content-type": "text/plain" }); + response.end(); + return; + } + + // The remote peer's identity facts are the ones it just signed. They are + // what the ticket is bound to, so the ticket says "this request came from + // the remote process that proved this identity" and not merely "somebody + // on the far end of a tunnel". + const identity = Object.freeze({ + sessionId: "remote:" + facts.peerId + ":" + facts.challengeId, + pid: facts.pid, + startToken: facts.startToken, + commandDigest: facts.commandDigest, + }); + + let ticket; + try { + registerPeer(identity, peerTtlMs); + ticket = mintTicket(identity); + } catch (error) { + diagnose("ticket_mint_failed", error && (error.code || error.message)); + response.writeHead(502, { "content-type": "text/plain" }); + response.end(); + return; + } + + diagnose("ticket_issued", facts.peerId); + const encoded = Buffer.from(JSON.stringify({ + ticket, + peerId: facts.peerId, + sessionId: identity.sessionId, + expiresAtMs: now + peerTtlMs, + }), "utf8"); + response.writeHead(200, { + "content-type": "application/json", + "content-length": String(encoded.length), + }); + response.end(encoded); + } + + async function handle(request, response) { + if (closing) { + response.writeHead(503, { "content-type": "text/plain" }); + response.end(); + return; + } + const url = (request.url || "").split("?")[0]; + if (url === "/challenge") { + if (request.method !== "GET") { + response.writeHead(405, { "content-type": "text/plain" }); + response.end(); + return; + } + await handleChallenge(response); + return; + } + if (url === "/handshake") { + if (request.method !== "POST") { + response.writeHead(405, { "content-type": "text/plain" }); + response.end(); + return; + } + await handleHandshake(request, response); + return; + } + response.writeHead(404, { "content-type": "text/plain" }); + response.end(); + } + + const server = http.createServer((request, response) => { + handle(request, response).catch(() => { + try { + if (!response.headersSent) response.writeHead(500, { "content-type": "text/plain" }); + response.end(); + } catch { /* the socket is already gone */ } + }); + }); + + const sockets = new Set(); + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, bindAddress, () => { + server.removeListener("error", reject); + resolve(); + }); + }); + + const address = server.address(); + diagnose("agent_listening", address && address.port); + + return Object.freeze({ + server, + address, + /** + * The live challenge count. Diagnostics only. + * + * @returns {number} + */ + pendingChallenges() { + purgeChallenges(Date.now()); + return challenges.size; + }, + close() { + if (closePromise) return closePromise; + closing = true; + closePromise = new Promise((resolve, reject) => { + challenges.clear(); + for (const socket of sockets) { + try { socket.destroy(); } catch { /* the socket is already gone */ } + } + sockets.clear(); + if (!server.listening) { + resolve(); + return; + } + try { + server.close((error) => (error && error.code !== "ERR_SERVER_NOT_RUNNING" ? reject(error) : resolve())); + } catch (error) { + reject(error); + } + }); + return closePromise; + }, + }); +} + +module.exports = Object.freeze({ + HANDSHAKE_DOMAIN, + MAX_CHALLENGE_TTL_MS, + MAX_LIVE_PEERS, + handshakeBytes, + handshakeFacts, + normalizePeer, + startPermitTicketAgent, + verifyHandshakeSignature, +}); diff --git a/tests/coordination/permit-ticket-agent.test.js b/tests/coordination/permit-ticket-agent.test.js new file mode 100644 index 00000000..49a5c71a --- /dev/null +++ b/tests/coordination/permit-ticket-agent.test.js @@ -0,0 +1,352 @@ +"use strict"; + +// Tests for the cross-machine ticket agent (M-053 / R2-c). +// +// The agent is the component that lets a REMOTE managed peer obtain a client +// ticket for the issuing endpoint. These tests cover the handshake rules +// (pin, challenge, signature), the one-shot nature of both the challenge and +// the ticket, and the endpoint behaviour a remote peer's ticket produces. + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const { + startPermitTicketAgent, + handshakeFacts, +} = require("../../lib/coordination/permit-ticket-agent"); +const { + requestRemoteTicket, + signHandshake, +} = require("../../lib/coordination/permit-remote-peer-client"); +const { createRemotePeerRegistry } = require("../../lib/coordination/permit-remote-peer-registry"); +const { createTicketRegistry } = require("../../lib/coordination/permit-bridge-ticket"); +const { createPermitBridgeEndpoint } = require("../../lib/coordination/permit-bridge-endpoint"); + +const PEER_ID = "windows-test-1"; +const INSTANCE_ID = "m053-r2r6-td-20261109"; + +function keyPair() { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + return { + publicKeyPem: publicKey.export({ type: "spki", format: "pem" }), + privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }), + }; +} + +// The identity facts the remote peer reports. They are what the ticket is +// bound to, so they have to survive the handshake unchanged. +const REMOTE_IDENTITY = Object.freeze({ + pid: 5151, + startToken: "win32:133000000000000000", + commandDigest: "sha256:remote", +}); + +async function startAgent(overrides = {}) { + const keys = overrides.keys || keyPair(); + const minted = []; + const registered = []; + const agent = await startPermitTicketAgent(Object.assign({ + peers: [{ peerId: PEER_ID, instanceId: INSTANCE_ID, publicKeyPem: keys.publicKeyPem }], + mintTicket: (identity) => { + minted.push(identity); + return "ticket-" + minted.length; + }, + registerPeer: (identity, ttlMs) => registered.push({ identity, ttlMs }), + }, overrides.options || {})); + return { agent, keys, minted, registered }; +} + +async function getChallenge(agent) { + const response = await fetch("http://127.0.0.1:" + agent.address.port + "/challenge"); + assert.equal(response.status, 200); + return response.json(); +} + +async function postHandshake(agent, body) { + return fetch("http://127.0.0.1:" + agent.address.port + "/handshake", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function handshakeBody(agent, keys, challenge, overrides = {}) { + const facts = handshakeFacts(Object.assign({ + peerId: PEER_ID, + instanceId: INSTANCE_ID, + pid: REMOTE_IDENTITY.pid, + startToken: REMOTE_IDENTITY.startToken, + commandDigest: REMOTE_IDENTITY.commandDigest, + challengeId: challenge.challengeId, + challenge: challenge.challenge, + }, overrides)); + return Object.assign({}, facts, { + signature: signHandshake(keys.privateKeyPem, facts), + }); +} + +test("the agent issues a ticket to a pinned peer and binds it to the peer's attested identity", async () => { + const { agent, keys, minted, registered } = await startAgent(); + try { + const challenge = await getChallenge(agent); + const response = await postHandshake(agent, await handshakeBody(agent, keys, challenge)); + + assert.equal(response.status, 200); + const result = await response.json(); + assert.equal(result.ticket, "ticket-1"); + assert.equal(result.peerId, PEER_ID); + + // The ticket is bound to the facts the peer SIGNED, not to facts the + // agent invented: the peer id, the process id, the start token and the + // command digest all come from the handshake. + assert.equal(minted.length, 1); + assert.equal(minted[0].pid, REMOTE_IDENTITY.pid); + assert.equal(minted[0].startToken, REMOTE_IDENTITY.startToken); + assert.equal(minted[0].commandDigest, REMOTE_IDENTITY.commandDigest); + assert.match(minted[0].sessionId, /^remote:windows-test-1:/); + + // The peer is registered for as long as a ticket minted for it can be + // presented — the ticket's reach, not the bridge's lifetime. + assert.equal(registered.length, 1); + assert.ok(registered[0].ttlMs > 0); + } finally { + await agent.close(); + } +}); + +test("the agent refuses a peer that is not pinned", async () => { + const { agent, keys } = await startAgent(); + try { + const challenge = await getChallenge(agent); + const response = await postHandshake(agent, await handshakeBody(agent, keys, challenge, { + peerId: "somebody-else", + })); + assert.equal(response.status, 401); + } finally { + await agent.close(); + } +}); + +test("the agent refuses a pinned peer that claims another instance", async () => { + const { agent, keys } = await startAgent(); + try { + const challenge = await getChallenge(agent); + const response = await postHandshake(agent, await handshakeBody(agent, keys, challenge, { + instanceId: "another-instance", + })); + assert.equal(response.status, 401); + } finally { + await agent.close(); + } +}); + +test("the agent refuses a handshake signed by another key", async () => { + const { agent, keys } = await startAgent(); + const other = keyPair(); + try { + const challenge = await getChallenge(agent); + const body = await handshakeBody(agent, keys, challenge); + // Same facts, different key: the pin is on the KEY, not on the peer id. + body.signature = signHandshake(other.privateKeyPem, handshakeFacts(body)); + const response = await postHandshake(agent, body); + assert.equal(response.status, 401); + } finally { + await agent.close(); + } +}); + +test("the agent refuses a tampered challenge", async () => { + const { agent, keys } = await startAgent(); + try { + const challenge = await getChallenge(agent); + const body = await handshakeBody(agent, keys, challenge); + body.challenge = crypto.randomBytes(32).toString("base64"); + const response = await postHandshake(agent, body); + assert.equal(response.status, 401); + } finally { + await agent.close(); + } +}); + +test("the agent burns a challenge on its first use, successful or not", async () => { + const { agent, keys } = await startAgent(); + try { + const challenge = await getChallenge(agent); + const body = await handshakeBody(agent, keys, challenge); + // A first attempt with a broken signature must not leave the challenge + // usable: otherwise an attacker gets unlimited tries at one challenge. + body.signature = Buffer.alloc(64).toString("base64"); + assert.equal((await postHandshake(agent, body)).status, 401); + + const retry = await handshakeBody(agent, keys, challenge); + assert.equal((await postHandshake(agent, retry)).status, 401); + } finally { + await agent.close(); + } +}); + +test("the agent refuses a challenge it never issued", async () => { + const { agent, keys } = await startAgent(); + try { + const challenge = await getChallenge(agent); + const body = await handshakeBody(agent, keys, challenge); + body.challengeId = crypto.randomBytes(16).toString("hex"); + assert.equal((await postHandshake(agent, body)).status, 401); + } finally { + await agent.close(); + } +}); + +test("the agent answers 405 on the wrong method and 404 on an unknown path", async () => { + const { agent } = await startAgent(); + try { + const wrongMethod = await fetch("http://127.0.0.1:" + agent.address.port + "/handshake"); + assert.equal(wrongMethod.status, 405); + const wrongMethod2 = await fetch("http://127.0.0.1:" + agent.address.port + "/challenge", { + method: "POST", + }); + assert.equal(wrongMethod2.status, 405); + const unknown = await fetch("http://127.0.0.1:" + agent.address.port + "/nope"); + assert.equal(unknown.status, 404); + } finally { + await agent.close(); + } +}); + +test("the agent refuses a non-loopback bind", async () => { + await assert.rejects(() => startPermitTicketAgent({ + peers: [{ peerId: PEER_ID, instanceId: INSTANCE_ID, publicKeyPem: keyPair().publicKeyPem }], + mintTicket: () => "ticket", + registerPeer: () => {}, + bindAddress: "0.0.0.0", + }), (error) => Boolean(error) && String(error.code).startsWith("ERR_PERMIT_INPUT_INVALID")); +}); + +test("the remote peer client obtains a ticket from the agent", async () => { + const { agent, keys } = await startAgent(); + try { + const result = await requestRemoteTicket({ + agentUrl: "http://127.0.0.1:" + agent.address.port, + peerId: PEER_ID, + instanceId: INSTANCE_ID, + identity: REMOTE_IDENTITY, + privateKeyPem: keys.privateKeyPem, + }); + + assert.equal(result.ticket, "ticket-1"); + assert.equal(result.peerId, PEER_ID); + assert.match(result.sessionId, /^remote:windows-test-1:/); + } finally { + await agent.close(); + } +}); + +test("the remote peer client is refused by an agent that pins another key", async () => { + const { agent } = await startAgent(); + const stranger = keyPair(); + try { + await assert.rejects(() => requestRemoteTicket({ + agentUrl: "http://127.0.0.1:" + agent.address.port, + peerId: PEER_ID, + instanceId: INSTANCE_ID, + identity: REMOTE_IDENTITY, + privateKeyPem: stranger.privateKeyPem, + }), (error) => error && error.code === "ERR_PERMIT_AGENT_REJECTED"); + } finally { + await agent.close(); + } +}); + +test("the peer registry accepts a registered remote peer and drops it when it expires", () => { + let now = 1000; + const registry = createRemotePeerRegistry({ clock: () => now }); + const identity = { + sessionId: "remote:windows-test-1:abc", + pid: 5151, + startToken: "win32:1", + commandDigest: "sha256:remote", + }; + + assert.equal(registry.matches(identity, { sessionId: "local" }), false); + registry.register(identity, 500); + assert.equal(registry.matches(identity, { sessionId: "local" }), true); + + now = 1501; + assert.equal(registry.matches(identity, { sessionId: "local" }), false); + assert.equal(registry.size(), 0); +}); + +test("the endpoint accepts a remote peer's ticket once and refuses the replay", async () => { + const registry = createRemotePeerRegistry(); + const tickets = createTicketRegistry(); + const identity = { + sessionId: "remote:windows-test-1:abc", + pid: 5151, + startToken: "win32:1", + commandDigest: "sha256:remote", + }; + registry.register(identity, 60_000); + + const endpoint = createPermitBridgeEndpoint({ + action: "issue", + tickets, + perform: async (action, request, ticketFacts) => { + // The same rule the host applies: the ticket must belong to a live peer. + if (!registry.matches(ticketFacts, { sessionId: "local" })) { + const error = new Error("peer mismatch"); + error.code = "ERR_PERMIT_INPUT_INVALID"; + throw error; + } + return { + signedPermit: { + message: { + permit_id: "P-1", + operation: "read", + target_digest: "t", + params_digest: "p", + desktop_key: "d", + session_id: "s", + worker_identity: "w", + run_id: "r", + generation: "1", + host_identity: "h", + owned_app_id: "o", + }, + signature: "sig", + sequence: 1, + deadline: 2000, + }, + fingerprint: "f".repeat(64), + }; + }, + }); + const address = await endpoint.listen(); + try { + const ticket = tickets.mint(identity); + const headers = { "x-permit-bridge-ticket": ticket }; + + const granted = await fetch("http://127.0.0.1:" + address.port + "/permit", { + method: "POST", + headers, + body: JSON.stringify({ lease: { sessionId: "s" }, permit: { operation: "read" } }), + }); + assert.equal(granted.status, 200); + + // The same ticket again: it was consumed by the first request. + const replayed = await fetch("http://127.0.0.1:" + address.port + "/permit", { + method: "POST", + headers, + body: JSON.stringify({ lease: { sessionId: "s" }, permit: { operation: "read" } }), + }); + assert.equal(replayed.status, 401); + + // No ticket at all. + const unticketed = await fetch("http://127.0.0.1:" + address.port + "/permit", { + method: "POST", + body: JSON.stringify({ lease: { sessionId: "s" }, permit: { operation: "read" } }), + }); + assert.equal(unticketed.status, 401); + } finally { + await endpoint.close(); + } +}); From c7497e7ea65202b13589f01ac6565f59ba5951a9 Mon Sep 17 00:00:00 2001 From: cortex-agent Date: Mon, 9 Nov 2026 09:42:57 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(permit):=20=E4=BB=85=E5=9C=A8=E7=A5=A8?= =?UTF-8?q?=E6=8D=AE=E4=BB=A3=E7=90=86=E8=BF=90=E8=A1=8C=E6=97=B6=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E4=BB=A3=E7=90=86=E7=AB=AF=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 就绪输出的字段集合是既有契约,未配置远端对端时不应多出字段。 改为只有票据代理确实启动时才报告 agentPort,其余部署的输出逐字不变。 补充用例覆盖有代理与无代理两种就绪输出形状。 --- lib/commands/permit-bridge-service-cli.js | 12 ++++---- .../permit-bridge-service-cli.test.js | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/lib/commands/permit-bridge-service-cli.js b/lib/commands/permit-bridge-service-cli.js index f734af94..ab4ddeb7 100644 --- a/lib/commands/permit-bridge-service-cli.js +++ b/lib/commands/permit-bridge-service-cli.js @@ -152,15 +152,17 @@ async function permitBridgeServiceCli(ctx, dependencies = {}) { } // Readiness contains only loopback ports. Tickets remain inside the managed service. - output.log(JSON.stringify({ + // The agent port is reported only when a ticket agent actually runs, so a + // deployment without remote peer trust keeps the previous readiness shape + // byte for byte. + const readiness = { ok: true, action: "serve", port: service.address.port, revocationPort: service.revocationAddress.port, - // The ticket agent's port. It is null when no remote peer trust is - // configured, because then no agent is started at all. - agentPort: service.agentAddress ? service.agentAddress.port : null, - })); + }; + if (service.agentAddress) readiness.agentPort = service.agentAddress.port; + output.log(JSON.stringify(readiness)); const signalSource = dependencies.signalSource || process; const onStop = () => finish(); diff --git a/tests/commands/permit-bridge-service-cli.test.js b/tests/commands/permit-bridge-service-cli.test.js index 38aef3dd..92aa1a0a 100644 --- a/tests/commands/permit-bridge-service-cli.test.js +++ b/tests/commands/permit-bridge-service-cli.test.js @@ -117,6 +117,35 @@ test("permit-bridge serve emits only ports and closes its service on signal", as } finally { f.cleanup(); } }); +test("permit-bridge serve reports the ticket agent port only when an agent runs", async () => { + const f = fixture(); + const signalSource = new EventEmitter(); + const lines = []; + try { + f.write(); + const running = permitBridgeServiceCli({ + args: ["permit-bridge", "serve", "--config", f.configPath], + cwd: f.root, + }, { + signalSource, + output: { log: (line) => lines.push(line), error: (line) => lines.push(line) }, + startService: async () => ({ + address: { port: 1234 }, + revocationAddress: { port: 1235 }, + agentAddress: { port: 1236 }, + issueTicket: () => "secret-ticket", + close: async () => {}, + }), + }); + await new Promise(setImmediate); + assert.deepEqual(JSON.parse(lines[0]), + { ok: true, action: "serve", port: 1234, revocationPort: 1235, agentPort: 1236 }); + assert.ok(!lines.join("").includes("secret-ticket")); + signalSource.emit("SIGTERM"); + await running; + } finally { f.cleanup(); } +}); + test("permit-bridge serve is routed by the CLI and rejects absent protected stores", () => { const f = fixture(); try { From 4e7e5210ac08cb667a04b956051758cb9470a5fa Mon Sep 17 00:00:00 2001 From: cortex-agent Date: Mon, 9 Nov 2026 10:01:42 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs(pr):=20=E6=A0=87=E6=B3=A8=20SSH=20?= =?UTF-8?q?=E5=8F=8D=E5=90=91=E8=BD=AC=E5=8F=91=E6=89=BF=E8=BD=BD=E4=B8=BA?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=9F=9F=E4=B8=93=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 票据代理只绑回环,跨机可达性依赖承载。本轮取证的承载是既有 SSH 隧道 + 反向转发,契约 D-AUTH-001 v1.2.1 §9.1.2 已把它显式标注为测试域专用: 可用性依赖 SSH 会话与端口转发存活,对端进程事实的可核实性受限于通道本身。 生产承载尚未选定:契约列出候选与待评估项,候选不是决定,取舍属 Owner 裁定。 本 PR 的结论上限仍是测试域可签发、可取件、可验签。 --- pr-bodies/m053-r2-r6-remote-ticket-agent.md | 62 +++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 pr-bodies/m053-r2-r6-remote-ticket-agent.md diff --git a/pr-bodies/m053-r2-r6-remote-ticket-agent.md b/pr-bodies/m053-r2-r6-remote-ticket-agent.md new file mode 100644 index 00000000..0bd0a8c9 --- /dev/null +++ b/pr-bodies/m053-r2-r6-remote-ticket-agent.md @@ -0,0 +1,62 @@ +## 范围 + +票据此前只能在签发端进程内铸造、且只发给本机已验证的对端(`tickets.mint(assembly.peerIdentity)`), +端点侧只接受与本机 pin 身份一致的票据。因此**跨机受管对端即使能到达签发端,也永远拿不到票据**。 + +本 PR 增加**票据代理**:签发端进程内一个独立的回环监听器。 + +- `lib/coordination/permit-ticket-agent.js`:票据代理。信任根来自部署侧钉住的公钥(`peers` 参数), + 绝不由对端自报;服务端签发一次性挑战,对端用钉住的私钥签名握手事实(字节域前缀 + `cortex.permit-ticket-agent-handshake.v1`),验签通过后为该远端对端铸造一张**绑定其签名身份事实** + 的票据并在握手响应里当场交付。挑战一次性:成功或失败都用掉。 +- `lib/coordination/permit-remote-peer-registry.js`:远端对端登记表。本机对端的身份是全生命周期 pin; + 远端对端的身份只在票据可达的窗口内存在,随票据过期一起失效。比较规则复用端点既有的 + `identityMatches`,避免一座桥里出现两套「同一个对端」的定义。 +- `lib/coordination/permit-remote-peer-client.js`:远端对端一侧的握手客户端(只用 `node:crypto`)。 +- `permit-bridge-host.js`:端点接受「本机 pin 对端 **或** 仍在登记表内的远端对端」的票据; + 配置了 `remotePeers` 时桥内启动票据代理。 +- `permit-bridge-service.js` / `permit-bridge-service-cli.js`:配置项 `remotePeerTrustFile`(部署侧 + 信任文件)与 `agentPort`;就绪输出**仅在票据代理实际启动时**增加 `agentPort`(未配置信任文件时 + 就绪输出逐字不变)。 + +## 验证 + +- `node --test tests/coordination/permit-ticket-agent.test.js` → 13/13。 +- `node --test tests/coordination/permit-bridge.test.js` → 21/21(未受影响)。 +- `node --test tests/coordination/permit-bridge-{host,service,ticket,ticket-binding,bounds}.test.js` → 34/34。 +- `node --test tests/commands/permit-bridge-service-cli.test.js` → 8/8(含新增「有代理时报告 agentPort」用例)。 +- `node scripts/check-l3-boundary.js` → 0 violations。 +- **跨机实测(测试域;Mac 签发端 + Windows 受管对端,经 Mac→Windows 反向通道)**: + - Windows 侧:握手取票 → 带票 POST 许可端点 ⇒ **200**,返回 16 字段 grant + (`messageJson` / `signatureBase64` / `fingerprint` / `sequence` / `deadlineMs` / 11 个投影字段 / `permitId`); + - 同票重放 ⇒ **401**;无票 POST ⇒ **401**;GET ⇒ **405**(端点无 health 路由)。 + - 签发侧(本机对端,同一座桥):无票 **401** → 带票 **200** → 同票重放 **401** → 新票同派发 **200**。 + +## 承载形态:测试域专用(不是生产路径) + +- 票据代理只绑回环,因此跨机可达性**依赖承载**。本轮取证用的承载是**既有 SSH 隧道 + 反向转发** + (`ssh -N -R 127.0.0.1:18080:127.0.0.1: -R 127.0.0.1:18081:127.0.0.1:`)。 +- 该承载在契约 D-AUTH-001 **v1.2.1 §9.1.2** 起**显式标注为测试域专用**:其可用性依赖 SSH 会话存活与 + 端口转发状态,且对端进程事实的可核实性受限于通道本身。它适合测试域取证,**不适合作为生产准入路径**。 +- **生产承载尚未选定**。契约 §9.1.2 列出了候选与各自的待评估项(受管跨机对端通道 / 反向转发以外的 + 持久隧道 / 承载与信任解耦)——**候选不是决定**,取舍属 Owner 级书面裁定(§9.1.4「批什么」④⑤)。 +- 因此本 PR 的结论上限是:**测试域可签发、可取件、可验签**。不宣称生产可用、不宣称跨机信任成立、 + 不宣称受保护身份已验证。 +- 「远端对端自报进程事实仅由对端签名担保」这一信任口径**本轮未裁定**,登记在契约 v1.2.1 §9.1.4.1, + 归 Owner 门(INV-13 / G-Q2 未解除)。 + +## 已知事项 + +- 信任文件里的公钥是**部署侧**事实;对端自报的 `pid` / `startToken` / `commandDigest` 由对端签名, + 属于「对端对自己身份的承诺」,签发端只能验证签名,不能验证这些进程事实的真实性。 +- 跨机受管对端信任的 Owner 门(INV-13 / G-Q2)尚未解除;本 PR 只把「票据可跨机取得」这条路打通, + 不代表跨机信任已被接受。 +- 签发端存储的桌面槽是**单例**:同一测试域内第二个不同派发会被拒(`ERR_DESKTOP_NOT_FREE`), + 因此每轮验证用独立测试域。 +- 契约 §3.2 字段表未列 `issued_at_ms` 的缺口属另一条线(#177 已记录);契约侧已由 v1.2.1 回填该字段表, + 本 PR 未改动消息结构。 + +## 边界 + +不签发许可、不改许可消息结构、不改票据一次性语义、不开非回环监听、不碰服务/ACL/账号、 +不部署共享 broker、不改共享 broker 的服务名与身份配置。 From 5848beec305c63b0f0f7853c14460603503aa00e Mon Sep 17 00:00:00 2001 From: cortex-agent Date: Mon, 9 Nov 2026 10:01:46 +0800 Subject: [PATCH 4/4] =?UTF-8?q?docs(permit):=20=E5=9C=A8=E7=A5=A8=E6=8D=AE?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E5=A4=84=E6=A0=87=E6=B3=A8=E6=89=BF=E8=BD=BD?= =?UTF-8?q?=E4=B8=BA=E6=B5=8B=E8=AF=95=E5=9F=9F=E4=B8=93=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 代理只绑回环,跨机可达性依赖承载。文件头写明 SSH 反向转发承载是测试域专用, 生产承载未选定,结论上限是测试域可签发、可取件、可验签。 --- lib/coordination/permit-ticket-agent.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/coordination/permit-ticket-agent.js b/lib/coordination/permit-ticket-agent.js index 38cc2740..df9c2caa 100644 --- a/lib/coordination/permit-ticket-agent.js +++ b/lib/coordination/permit-ticket-agent.js @@ -46,6 +46,24 @@ // It authenticates a remote peer and delivers a ticket. It does not sign // permits, does not open a service, does not create accounts, does not // touch ACLs and does not weaken the local peer's own ticket rules. +// +// TEST DOMAIN ONLY — THE CARRIAGE IS NOT A PRODUCTION PATH +// This agent binds to loopback only, so reaching it from another machine +// depends on a carrier. The carrier used for the M-053 evidence is the +// existing SSH tunnel with reverse forwarding (`ssh -R`), and that carrier +// is EXPLICITLY test-domain-only (D-AUTH-001 v1.2.1 §9.1.2): its availability +// depends on the SSH session staying alive and the forwarding staying up, +// and what can be independently verified about the remote peer's process +// facts is limited by that channel itself. +// The production carrier is NOT chosen. The candidates and what each one +// still has to be evaluated on are listed in D-AUTH-001 v1.2.1 §9.1.2 — a +// candidate there is not a decision, and picking one belongs to the Owner +// ruling described in §9.1.4. +// Consequently the strongest statement this module supports is: in the TEST +// DOMAIN a permit can be issued, taken and verified. It does NOT support +// "production ready", "cross-machine trust established", or "protected +// identity verified" — the cross-machine trust gate (INV-13 / G-Q2) is +// still closed, and that ruling is not the execution line's to make. const crypto = require("node:crypto"); const http = require("node:http");