diff --git a/docs/architecture/remote-peer-primitives.md b/docs/architecture/remote-peer-primitives.md new file mode 100644 index 00000000..ef581c69 --- /dev/null +++ b/docs/architecture/remote-peer-primitives.md @@ -0,0 +1,140 @@ +# Remote Peer Primitives (M-053 / MS-008 P-009, batch C1) + +> Status: implementation in an independent worktree, not committed, not merged. +> This document freezes the semantics of the C1 primitives. It does not +> authorize a signing entry point, a bridge production wiring, a Windows +> action, or a service deployment. + +## 1. Why these primitives exist + +The cross-machine permit chain needs a peer identity that is *not* the local +managed caller identity. `lib/governed/managed-caller-session.js` proves +identity with an OS process handle, an inherited anonymous IPC channel and a +secret that never leaves a second pipe. None of that crosses a machine +boundary, and a local fork session must never be reused to impersonate a +remote peer. + +So the remote path gets its own type. `lib/governed/remote-peer-trust.js` +turns a signed fact set into an opaque `VerifiedRemotePeer`, and +`lib/governed/remote-peer-session.js` owns the life of one proven connection +and the per-call authority gate. + +## 2. Write set + +| File | Responsibility | +| --- | --- | +| `lib/governed/remote-peer-trust.js` | Trust configuration validation, signed fact schema, field-by-field verification, remote peer construction | +| `lib/governed/remote-peer-session.js` | Connection binding, challenge handshake, request and ticket binding, replay rules, lifecycle, per-call authority gate | +| `tests/governed/remote-peer-primitives.test.js` | Positive, negative and lifecycle coverage for both modules | +| `docs/architecture/remote-peer-primitives.md` | This contract | + +Both modules are pure logic. They create no key, open no socket and read no +machine state, so the whole rejection matrix is testable on one host. + +## 3. Field-by-field verification matrix + +Every refusal is a `RemotePeerRejectedError` with a stable `code` and the +offending `field`. There is no partial acceptance path. + +### 3.1 Trust configuration + +| Field | Requirement | Rejection code | +| --- | --- | --- | +| `instanceId` | non-empty string | `TRUST_FIELD_MISSING` | +| `audience` | non-empty string | `TRUST_FIELD_MISSING` | +| `peerScope` | non-empty list of action names | `TRUST_SCOPE_EMPTY` | +| `pinnedKey.keyId` | non-empty string | `TRUST_KEY_NOT_PINNED` | +| `pinnedKey.keyDigest` | non-empty string | `TRUST_KEY_NOT_PINNED` | +| `epoch` | integer, not negative | `TRUST_EPOCH_INVALID` | +| `notBeforeMs` / `expiresAtMs` | integers, expiry after the start | `TRUST_CONFIG_INVALID` | +| validity window | `notBeforeMs <= now < expiresAtMs` | `TRUST_NOT_YET_VALID`, `TRUST_EXPIRED` | +| `limits.*` | positive integer | `TRUST_LIMIT_INVALID` | +| `limits.*` | not looser than the frozen ceiling | `TRUST_LIMIT_EXCEEDED` | + +Frozen ceilings (`REMOTE_PEER_LIMIT`): handshake 5000 ms, fact age 1000 ms, +ticket TTL 2000 ms, request 5000 ms, revocation staleness 15000 ms, clock skew +2000 ms. A configuration may tighten a bound, never widen it. Widening is +refused rather than clamped, so a misconfigured peer cannot silently widen +the window. + +### 3.2 Signed facts + +| Field | Requirement | Rejection code | +| --- | --- | --- | +| `factVersion` | equals 1 | `REMOTE_FACT_VERSION_UNSUPPORTED` | +| `signature` | verifies under the pinned key | `REMOTE_FACT_SIGNATURE_INVALID` | +| `signerKeyId` | equals `pinnedKey.keyId` | `REMOTE_FACT_KEY_NOT_PINNED` | +| `instanceId` | equals the pinned instance | `REMOTE_FACT_INSTANCE_MISMATCH` | +| `epoch` | equals the pinned epoch | `REMOTE_FACT_EPOCH_MISMATCH` | +| `runId`, `generationText` | non-empty | `REMOTE_FACT_FIELD_MISSING` | +| `host.pid`, `host.startToken`, `host.commandDigest` | present and well formed | `REMOTE_FACT_HOST_UNBOUND` | +| `connection.connectionId` | equals the live connection | `REMOTE_FACT_CONNECTION_MISMATCH` | +| `connection.tlsPublicKeyDigest` | equals the live connection key | `REMOTE_FACT_CONNECTION_KEY_MISMATCH` | +| `challenge` | equals the outstanding challenge | `REMOTE_FACT_CHALLENGE_MISMATCH` | +| `issuedAtMs` | not more than the skew in the future | `REMOTE_FACT_CLOCK_SKEW` | +| `issuedAtMs` | not older than the fact age | `REMOTE_FACT_STALE` | +| `expiresAtMs` | in the future | `REMOTE_FACT_EXPIRED` | +| `expiresAtMs - issuedAtMs` | not wider than the fact age | `REMOTE_FACT_WINDOW_EXCEEDED` | +| `scope` | contained in the pinned scope | `REMOTE_FACT_SCOPE_ESCALATION` | + +The verified peer is tagged `kind: "remote-peer"` and carries the host +binding inside `host`. It has no top-level `pid` or `startToken`, so it +cannot be confused with the local managed caller identity. + +### 3.3 Session, requests and tickets + +| Situation | Rejection code | +| --- | --- | +| Session started twice | `REMOTE_SESSION_ALREADY_STARTED` | +| Facts outside the handshake bound | `REMOTE_HANDSHAKE_TIMEOUT` | +| Facts presented twice, or after a failed verification | `REMOTE_HANDSHAKE_CONSUMED` | +| Request identifier reused | `REMOTE_REQUEST_REPLAY` | +| Sequence number not advancing | `REMOTE_REQUEST_SEQUENCE` | +| Replay registry at its hard bound | `REMOTE_REPLAY_REGISTRY_FULL` | +| Action outside the peer scope | `REMOTE_ACTION_OUT_OF_SCOPE` | +| Response bound to another request or payload | `REMOTE_REQUEST_UNKNOWN`, `REMOTE_REQUEST_PAYLOAD_MISMATCH` | +| Ticket not registered, already consumed, expired, over the TTL, or bound to another peer or connection | `REMOTE_TICKET_UNKNOWN`, `REMOTE_TICKET_REPLAY`, `REMOTE_TICKET_EXPIRED`, `REMOTE_TICKET_TTL_EXCEEDED`, `REMOTE_TICKET_PEER_MISMATCH`, `REMOTE_TICKET_CONNECTION_MISMATCH` | + +The replay registry never evicts. When it reaches its bound the session fails +closed instead of forgetting an old request and accepting it again. + +### 3.4 Per-call authority gate + +`assertPerCallAuthority()` runs for every call that may reach a signing path. + +| Check | Rejection code | +| --- | --- | +| Session established and peer present | `REMOTE_SESSION_INVALID_STATE` | +| `authorityHeld` is true | `REMOTE_AUTHORITY_NOT_HELD` | +| Authority epoch equals the peer epoch | `REMOTE_AUTHORITY_EPOCH_MISMATCH` | +| Lease fencing equals the expected fencing | `REMOTE_AUTHORITY_FENCING_MISMATCH` | +| Authority lease not expired | `REMOTE_AUTHORITY_LEASE_EXPIRED` | +| Action allowed by the authority and by the peer scope | `REMOTE_AUTHORITY_ACTION_NOT_ALLOWED`, `REMOTE_ACTION_OUT_OF_SCOPE` | +| Revocation list applied within the staleness bound | `REMOTE_REVOCATION_STALE` | +| Request bound on this session, same action and payload | `REMOTE_REQUEST_UNKNOWN`, `REMOTE_REQUEST_PAYLOAD_MISMATCH` | +| Deadline in the future and inside the request bound | `REMOTE_REQUEST_DEADLINE_PASSED`, `REMOTE_REQUEST_DEADLINE_EXCEEDED` | +| Deadline clipped to the remaining authority lifetime | `REMOTE_AUTHORITY_LIFETIME_TOO_SHORT` | +| Ticket registered, unused, same action, same peer, same connection | `REMOTE_TICKET_*` | + +## 4. Test entry + +```bash +node --test tests/governed/remote-peer-primitives.test.js +node scripts/test-runner.cjs --scope governed +node scripts/test-runner.cjs --scope coordination +``` + +## 5. Boundaries + +- No signing, no permit issuance, no socket, no key material, no Windows + action, no service and no ACL change. +- Not wired into `permit-bridge-*` yet. The assembly layer that connects these + primitives to the bridge is a separate slice with its own owned files. +- The remote peer and the local managed caller identity are different types + and must never be substituted for one another. +- Approved decisions this implements: `D-M053-MS008-P009-R1-TRUSTED-W-OPEN`, + `D-M053-MS008-P009-R2R3-CONNECTION-TOPOLOGY-OPEN`, + `D-M053-MS008-P009-R4-TRUSTED-CONTEXT-OPEN`, + `D-M053-MS008-P009-R5-TIMING-REVOCATION-OPEN`, + `D-M053-MS008-G0-CONNECTION-BINDING-BASIS-OPEN`, + `D-M053-MS008-G0-A-SCOPE-NARROW-OPEN`. diff --git a/lib/governed/remote-peer-session.js b/lib/governed/remote-peer-session.js new file mode 100644 index 00000000..f37a8cdc --- /dev/null +++ b/lib/governed/remote-peer-session.js @@ -0,0 +1,731 @@ +"use strict"; + +// ─── Remote Peer Session (M-053 / MS-008 P-009, C1) ──────────────────────── +// +// The remote counterpart of managed-caller-session.js. It owns the life of +// one proven remote connection: the challenge handshake, the opaque +// VerifiedRemotePeer, the direction and replay rules for the requests that +// arrive over it, and the per-call authority check that has to pass before +// any request may reach a signing path. +// +// What this module guarantees +// ---------------------------- +// 1. A session only exists on one connection. The connection id and the TLS +// key digest are bound at begin() and every later fact must match them. +// 2. The handshake challenge is single use and expires on the frozen +// handshake bound. A second presentation of the same facts fails. +// 3. Requests are bound to the connection and to the peer, carry a strictly +// increasing sequence number, and can be presented only once. The replay +// registry never evicts: when it is full the session fails closed +// instead of forgetting an old request and accepting it again. +// 4. Tickets are issued by this session, bound to the peer and the +// connection, expire on the frozen ticket bound, and can be consumed at +// most once. +// 5. assertPerCallAuthority() is the gate that has to run for every single +// call. It re-checks the session state, the peer epoch, the authority +// lease and its fencing token, the revocation freshness and the ticket, +// so a decision taken once for a connection cannot be reused after the +// authority changed. +// 6. Loss of the session is terminal. There is no degraded mode in which +// requests are served without a verified peer. +// +// Approved decisions this implements +// ---------------------------------- +// - D-M053-MS008-P009-R2R3-CONNECTION-TOPOLOGY-OPEN (A): one proven +// connection per remote request process. +// - D-M053-MS008-P009-R5-TIMING-REVOCATION-OPEN (A): the frozen bounds and +// the requirement to clip every deadline to the remaining authority +// lifetime. +// - D-M053-MS008-G0-A-SCOPE-NARROW-OPEN (B): the .NET side connects +// directly, the Node side is delegated; both end up here as scoped peers. +// +// Hard boundaries +// --------------- +// - This module never signs, never issues a permit and never talks to a +// socket. It is the policy half of the remote path; the transport half is +// wired by the assembly layer that is still under review. +// - No real key, certificate or machine is needed to exercise it, so the +// whole rejection matrix can be tested on one host. + +const crypto = require("node:crypto"); + +const { + REMOTE_PEER_REJECTION, + RemotePeerRejectedError, + validateRemoteTrustConfig, + verifyRemoteFacts, +} = require("./remote-peer-trust"); + +/** The life of a remote session. The transitions are one way. */ +const REMOTE_SESSION_STATE = Object.freeze({ + STOPPED: "stopped", + STARTING: "starting", + ESTABLISHED: "established", + DRAINING: "draining", + CLOSED: "closed", +}); + +/** Stable rejection codes for the session and the authority gate. */ +const REMOTE_SESSION_REJECTION = Object.freeze({ + REMOTE_SESSION_INVALID_STATE: "REMOTE_SESSION_INVALID_STATE", + REMOTE_SESSION_ALREADY_STARTED: "REMOTE_SESSION_ALREADY_STARTED", + REMOTE_SESSION_CONNECTION_INVALID: "REMOTE_SESSION_CONNECTION_INVALID", + REMOTE_HANDSHAKE_TIMEOUT: "REMOTE_HANDSHAKE_TIMEOUT", + REMOTE_HANDSHAKE_CONSUMED: "REMOTE_HANDSHAKE_CONSUMED", + REMOTE_REQUEST_REPLAY: "REMOTE_REQUEST_REPLAY", + REMOTE_REQUEST_UNKNOWN: "REMOTE_REQUEST_UNKNOWN", + REMOTE_REQUEST_SEQUENCE: "REMOTE_REQUEST_SEQUENCE", + REMOTE_REQUEST_PAYLOAD_MISMATCH: "REMOTE_REQUEST_PAYLOAD_MISMATCH", + REMOTE_ACTION_OUT_OF_SCOPE: "REMOTE_ACTION_OUT_OF_SCOPE", + REMOTE_REPLAY_REGISTRY_FULL: "REMOTE_REPLAY_REGISTRY_FULL", + REMOTE_TICKET_UNKNOWN: "REMOTE_TICKET_UNKNOWN", + REMOTE_TICKET_REPLAY: "REMOTE_TICKET_REPLAY", + REMOTE_TICKET_ACTION_MISMATCH: "REMOTE_TICKET_ACTION_MISMATCH", + REMOTE_TICKET_EXPIRED: "REMOTE_TICKET_EXPIRED", + REMOTE_TICKET_TTL_EXCEEDED: "REMOTE_TICKET_TTL_EXCEEDED", + REMOTE_TICKET_PEER_MISMATCH: "REMOTE_TICKET_PEER_MISMATCH", + REMOTE_TICKET_CONNECTION_MISMATCH: "REMOTE_TICKET_CONNECTION_MISMATCH", + REMOTE_AUTHORITY_NOT_HELD: "REMOTE_AUTHORITY_NOT_HELD", + REMOTE_AUTHORITY_EPOCH_MISMATCH: "REMOTE_AUTHORITY_EPOCH_MISMATCH", + REMOTE_AUTHORITY_FENCING_MISMATCH: "REMOTE_AUTHORITY_FENCING_MISMATCH", + REMOTE_AUTHORITY_LEASE_EXPIRED: "REMOTE_AUTHORITY_LEASE_EXPIRED", + REMOTE_AUTHORITY_LIFETIME_TOO_SHORT: "REMOTE_AUTHORITY_LIFETIME_TOO_SHORT", + REMOTE_AUTHORITY_ACTION_NOT_ALLOWED: "REMOTE_AUTHORITY_ACTION_NOT_ALLOWED", + REMOTE_REVOCATION_STALE: "REMOTE_REVOCATION_STALE", + REMOTE_REQUEST_DEADLINE_PASSED: "REMOTE_REQUEST_DEADLINE_PASSED", + REMOTE_REQUEST_DEADLINE_EXCEEDED: "REMOTE_REQUEST_DEADLINE_EXCEEDED", +}); + +/** The replay registry is a hard bound: it fails closed instead of evicting. */ +const DEFAULT_MAX_TRACKED_REQUESTS = 4_096; + +function reject(code, field, message) { + throw new RemotePeerRejectedError(code, field, message); +} + +function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function digestPayload(payload) { + const canonical = payload === undefined ? "\u0000undefined" : JSON.stringify(payload); + return crypto.createHash("sha256").update(String(canonical)).digest("hex"); +} + +/** + * One remote session on one proven connection. + */ +class RemotePeerSession { + /** + * @param {object} options + * @param {object} options.trust The pinned trust configuration. + * @param {Function} options.verifySignature Injected signature verifier. + * @param {Function} [options.now] Clock, injected for deterministic tests. + * @param {number} [options.maxTrackedRequests] Hard bound of the replay registry. + */ + constructor(options = {}) { + const now = typeof options.now === "function" ? options.now : () => Date.now(); + this.trust = validateRemoteTrustConfig(options.trust, { nowMs: now() }); + if (typeof options.verifySignature !== "function") { + reject( + REMOTE_PEER_REJECTION.TRUST_CONFIG_INVALID, + "verifySignature", + "a signature verifier must be injected", + ); + } + this._verifySignature = options.verifySignature; + this._now = now; + this._maxTrackedRequests = Number.isInteger(options.maxTrackedRequests) && options.maxTrackedRequests > 0 + ? options.maxTrackedRequests + : DEFAULT_MAX_TRACKED_REQUESTS; + + this.state = REMOTE_SESSION_STATE.STOPPED; + this.connectionId = null; + this.tlsPublicKeyDigest = null; + this.peer = null; + this.invalidatedReason = null; + + this._challenge = null; + this._challengeExpiresAtMs = null; + this._challengeConsumed = false; + this._inboundSeq = 0; + this._seenRequestIds = new Set(); + this._pendingRequests = new Map(); + this._tickets = new Map(); + } + + get established() { + return this.state === REMOTE_SESSION_STATE.ESTABLISHED && this.peer !== null; + } + + /** + * Start the handshake on a proven connection and issue the challenge. + * + * @param {object} params + * @param {string} params.connectionId The live connection identifier. + * @param {string} params.tlsPublicKeyDigest The digest of the connection key. + * @returns {object} The challenge handed to the remote side. + */ + begin(params = {}) { + if (this.state !== REMOTE_SESSION_STATE.STOPPED) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_SESSION_ALREADY_STARTED, + "state", + "a session in state " + this.state + " cannot be started again", + ); + } + const connectionId = params.connectionId; + const tlsPublicKeyDigest = params.tlsPublicKeyDigest; + if (typeof connectionId !== "string" || connectionId.length === 0) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_SESSION_CONNECTION_INVALID, + "connectionId", + "expected a non-empty connection identifier", + ); + } + if (typeof tlsPublicKeyDigest !== "string" || tlsPublicKeyDigest.length === 0) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_SESSION_CONNECTION_INVALID, + "tlsPublicKeyDigest", + "expected the digest of the connection key", + ); + } + + this.state = REMOTE_SESSION_STATE.STARTING; + this.connectionId = connectionId; + this.tlsPublicKeyDigest = tlsPublicKeyDigest; + this._challenge = crypto.randomBytes(16).toString("hex"); + this._challengeExpiresAtMs = this._now() + this.trust.limits.handshakeTimeoutMs; + this._challengeConsumed = false; + + return Object.freeze({ + challenge: this._challenge, + connectionId: this.connectionId, + expiresAtMs: this._challengeExpiresAtMs, + timeoutMs: this.trust.limits.handshakeTimeoutMs, + }); + } + + /** + * Verify the signed facts and establish the remote peer. + * + * @param {object} facts The signed fact set from the remote side. + * @returns {object} The opaque VerifiedRemotePeer. + */ + acceptFacts(facts) { + if (this.state !== REMOTE_SESSION_STATE.STARTING) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_SESSION_INVALID_STATE, + "state", + "facts cannot be accepted in state " + this.state, + ); + } + if (this._challengeConsumed || this._challenge === null) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_HANDSHAKE_CONSUMED, + "challenge", + "the handshake challenge was already consumed", + ); + } + const nowMs = this._now(); + if (nowMs >= this._challengeExpiresAtMs) { + this.invalidate("handshake_timeout"); + reject( + REMOTE_SESSION_REJECTION.REMOTE_HANDSHAKE_TIMEOUT, + "challenge", + "the handshake challenge expired before the facts arrived", + ); + } + + // The challenge is burned as soon as it is answered, before the facts are + // verified. A fact set that does not verify must not be retried with the + // same nonce, so a failed handshake cannot be turned into a second try. + const answeredChallenge = this._challenge; + this._challengeConsumed = true; + this._challenge = null; + + const peer = verifyRemoteFacts({ + facts, + trust: this.trust, + connection: { connectionId: this.connectionId, tlsPublicKeyDigest: this.tlsPublicKeyDigest }, + expectedChallenge: answeredChallenge, + nowMs, + verifySignature: this._verifySignature, + }); + + this.peer = peer; + this.state = REMOTE_SESSION_STATE.ESTABLISHED; + return peer; + } + + /** + * Bind an inbound request to this session. + * + * @param {object} request + * @param {string} request.requestId The request identifier. + * @param {string} request.action The requested action. + * @param {*} request.payload The request payload. + * @param {number} request.seq The strictly increasing sequence number. + * @returns {object} The bound request descriptor. + */ + bindInboundRequest(request = {}) { + if (!this.established) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_SESSION_INVALID_STATE, + "state", + "requests are only accepted on an established session", + ); + } + const requestId = request.requestId; + if (typeof requestId !== "string" || requestId.length === 0) { + reject( + REMOTE_PEER_REJECTION.REMOTE_FACT_FIELD_MISSING, + "request.requestId", + "expected a non-empty request identifier", + ); + } + if (this._seenRequestIds.has(requestId)) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_REPLAY, + "request.requestId", + "the request identifier was already used on this session", + ); + } + if (this._seenRequestIds.size >= this._maxTrackedRequests) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_REPLAY_REGISTRY_FULL, + "request.requestId", + "the replay registry reached its hard bound; the session fails closed", + ); + } + const action = request.action; + if (typeof action !== "string" || action.length === 0) { + reject( + REMOTE_PEER_REJECTION.REMOTE_FACT_FIELD_MISSING, + "request.action", + "expected a non-empty action name", + ); + } + if (!this.peer.scope.includes(action)) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_ACTION_OUT_OF_SCOPE, + "request.action", + "the peer is not scoped for this action", + ); + } + if (!Number.isInteger(request.seq)) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_SEQUENCE, + "request.seq", + "expected an integer sequence number", + ); + } + if (request.seq <= this._inboundSeq) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_SEQUENCE, + "request.seq", + "sequence " + request.seq + " does not advance past " + this._inboundSeq, + ); + } + + this._inboundSeq = request.seq; + this._seenRequestIds.add(requestId); + const bound = Object.freeze({ + requestId, + action, + seq: request.seq, + payloadDigest: digestPayload(request.payload), + peerId: this.peer.peerId, + connectionId: this.connectionId, + }); + this._pendingRequests.set(requestId, bound); + return bound; + } + + /** + * Complete a bound inbound request. The response must match the request it + * answers in sequence and payload digest. + */ + completeInboundRequest(request = {}) { + const bound = this._pendingRequests.get(request.requestId); + if (!bound) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_UNKNOWN, + "request.requestId", + "the request was never bound on this session", + ); + } + if (!Number.isInteger(request.seq) || request.seq !== bound.seq) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_SEQUENCE, + "request.seq", + "the response sequence does not match the request it answers", + ); + } + if (request.payloadDigest !== undefined && request.payloadDigest !== bound.payloadDigest) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_PAYLOAD_MISMATCH, + "request.payloadDigest", + "the response is bound to a different payload", + ); + } + this._pendingRequests.delete(request.requestId); + return bound; + } + + /** + * Register a ticket that this session issues to its peer. + * + * The ticket is bound to the peer and to the connection, so a ticket that + * is stolen and replayed on another connection is refused. + */ + registerTicket(ticket = {}) { + if (!this.established) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_SESSION_INVALID_STATE, + "state", + "tickets are only issued on an established session", + ); + } + const ticketId = ticket.ticketId; + if (typeof ticketId !== "string" || ticketId.length === 0) { + reject( + REMOTE_PEER_REJECTION.REMOTE_FACT_FIELD_MISSING, + "ticket.ticketId", + "expected a non-empty ticket identifier", + ); + } + if (this._tickets.has(ticketId)) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_REPLAY, + "ticket.ticketId", + "the ticket identifier was already registered", + ); + } + const action = ticket.action; + if (typeof action !== "string" || action.length === 0 || !this.peer.scope.includes(action)) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_ACTION_OUT_OF_SCOPE, + "ticket.action", + "a ticket may only cover an action the peer is scoped for", + ); + } + const nowMs = this._now(); + if (!Number.isInteger(ticket.expiresAtMs)) { + reject( + REMOTE_PEER_REJECTION.REMOTE_FACT_FIELD_MISSING, + "ticket.expiresAtMs", + "expected an integer expiry", + ); + } + if (ticket.expiresAtMs <= nowMs) { + reject(REMOTE_SESSION_REJECTION.REMOTE_TICKET_EXPIRED, "ticket.expiresAtMs", "the ticket is already expired"); + } + if (ticket.expiresAtMs - nowMs > this.trust.limits.ticketTtlMs) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_TICKET_TTL_EXCEEDED, + "ticket.expiresAtMs", + "the ticket lifetime exceeds the frozen bound " + this.trust.limits.ticketTtlMs + "ms", + ); + } + if (ticket.connectionId !== undefined && ticket.connectionId !== this.connectionId) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_TICKET_CONNECTION_MISMATCH, + "ticket.connectionId", + "the ticket is bound to another connection", + ); + } + if (ticket.peerId !== undefined && ticket.peerId !== this.peer.peerId) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_TICKET_PEER_MISMATCH, + "ticket.peerId", + "the ticket is bound to another peer", + ); + } + + const registered = Object.freeze({ + ticketId, + action, + peerId: this.peer.peerId, + connectionId: this.connectionId, + expiresAtMs: ticket.expiresAtMs, + consumed: false, + }); + this._tickets.set(ticketId, registered); + return registered; + } + + /** Consume a registered ticket. A ticket is usable at most once. */ + consumeTicket(ticketId) { + const ticket = this._tickets.get(ticketId); + if (!ticket) { + reject(REMOTE_SESSION_REJECTION.REMOTE_TICKET_UNKNOWN, "ticketId", "the ticket was never registered on this session"); + } + if (ticket.consumed) { + reject(REMOTE_SESSION_REJECTION.REMOTE_TICKET_REPLAY, "ticketId", "the ticket was already consumed"); + } + if (ticket.expiresAtMs <= this._now()) { + reject(REMOTE_SESSION_REJECTION.REMOTE_TICKET_EXPIRED, "ticketId", "the ticket has expired"); + } + const consumed = Object.freeze({ ...ticket, consumed: true, consumedAtMs: this._now() }); + this._tickets.set(ticketId, consumed); + return consumed; + } + + /** Stop accepting new work and release the peer. */ + drain() { + if (this.state === REMOTE_SESSION_STATE.CLOSED) return; + this.state = REMOTE_SESSION_STATE.DRAINING; + this.peer = null; + this._pendingRequests.clear(); + this._tickets.clear(); + } + + /** Terminate the session. Terminal. */ + close() { + this.state = REMOTE_SESSION_STATE.CLOSED; + this.peer = null; + this._challenge = null; + this._pendingRequests.clear(); + this._tickets.clear(); + } + + /** + * Invalidate the session because the peer identity or the connection + * changed. The epoch of this session stops being usable immediately, and + * every request and ticket that was still open is dropped. + */ + invalidate(reason) { + this.invalidatedReason = typeof reason === "string" && reason.length > 0 ? reason : "invalidated"; + this.peer = null; + this._challenge = null; + this._challengeConsumed = true; + this._pendingRequests.clear(); + this._tickets.clear(); + this.state = REMOTE_SESSION_STATE.CLOSED; + } +} + +/** + * The per-call authority gate. + * + * This is the check that has to run for every single call that may reach a + * signing path. It never trusts the request body for identity: the peer comes + * from the session, the scope comes from the verified peer, and the authority + * facts come from the caller's own snapshot of the current authority state. + * + * @param {object} params + * @param {RemotePeerSession} params.session The session the call belongs to. + * @param {string} params.action The action being requested. + * @param {object} [params.request] Optional binding: requestId, payloadDigest, ticketId, deadlineAtMs. + * @param {object} params.authority The authority snapshot for this call. + * @param {number} [params.nowMs] Current time. + * @returns {object} A frozen record of the accepted call. + */ +function assertPerCallAuthority(params = {}) { + const { session, action, request, authority } = params; + const nowMs = Number.isFinite(params.nowMs) ? params.nowMs : Date.now(); + + if (!(session instanceof RemotePeerSession)) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_SESSION_INVALID_STATE, + "session", + "expected a remote peer session", + ); + } + if (!session.established) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_SESSION_INVALID_STATE, + "session.state", + "the session is not established", + ); + } + if (!isPlainObject(authority)) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_AUTHORITY_NOT_HELD, + "authority", + "expected an authority snapshot", + ); + } + if (authority.authorityHeld !== true) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_AUTHORITY_NOT_HELD, + "authority.authorityHeld", + "the signing authority is not held", + ); + } + if (authority.epoch !== session.peer.epoch) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_AUTHORITY_EPOCH_MISMATCH, + "authority.epoch", + "the authority epoch " + authority.epoch + " does not match the peer epoch " + session.peer.epoch, + ); + } + if ( + authority.expectedLeaseFencing !== undefined + && authority.leaseFencing !== authority.expectedLeaseFencing + ) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_AUTHORITY_FENCING_MISMATCH, + "authority.leaseFencing", + "the lease fencing token does not match the expected one", + ); + } + if (!Number.isInteger(authority.leaseExpiresAtMs)) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_AUTHORITY_LEASE_EXPIRED, + "authority.leaseExpiresAtMs", + "expected an integer lease expiry", + ); + } + if (authority.leaseExpiresAtMs <= nowMs) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_AUTHORITY_LEASE_EXPIRED, + "authority.leaseExpiresAtMs", + "the authority lease has expired", + ); + } + if (!Array.isArray(authority.allowedActions) || !authority.allowedActions.includes(action)) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_AUTHORITY_ACTION_NOT_ALLOWED, + "authority.allowedActions", + "the authority does not allow this action", + ); + } + if (!session.peer.scope.includes(action)) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_ACTION_OUT_OF_SCOPE, + "action", + "the peer is not scoped for this action", + ); + } + if (!Number.isInteger(authority.revocationAppliedAtMs)) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_REVOCATION_STALE, + "authority.revocationAppliedAtMs", + "expected the moment the revocation list was last applied", + ); + } + if (nowMs - authority.revocationAppliedAtMs > session.trust.limits.revocationMaxStalenessMs) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_REVOCATION_STALE, + "authority.revocationAppliedAtMs", + "the revocation list is older than the accepted staleness bound", + ); + } + + const binding = isPlainObject(request) ? request : {}; + if (binding.requestId !== undefined) { + const bound = session._pendingRequests.get(binding.requestId); + if (!bound) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_UNKNOWN, + "request.requestId", + "the request is not bound on this session", + ); + } + if (bound.action !== action) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_TICKET_ACTION_MISMATCH, + "request.action", + "the call does not match the action of the bound request", + ); + } + if (binding.payloadDigest !== undefined && binding.payloadDigest !== bound.payloadDigest) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_PAYLOAD_MISMATCH, + "request.payloadDigest", + "the call does not match the payload of the bound request", + ); + } + } + if (binding.deadlineAtMs !== undefined) { + if (!Number.isInteger(binding.deadlineAtMs)) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_DEADLINE_PASSED, + "request.deadlineAtMs", + "expected an integer deadline", + ); + } + if (binding.deadlineAtMs <= nowMs) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_DEADLINE_PASSED, + "request.deadlineAtMs", + "the call deadline has passed", + ); + } + if (binding.deadlineAtMs - nowMs > session.trust.limits.requestTimeoutMs) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_DEADLINE_EXCEEDED, + "request.deadlineAtMs", + "the call deadline exceeds the frozen request bound", + ); + } + } + if (binding.ticketId !== undefined) { + const ticket = session._tickets.get(binding.ticketId); + if (!ticket) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_TICKET_UNKNOWN, + "request.ticketId", + "the ticket was never registered on this session", + ); + } + if (ticket.consumed) { + reject(REMOTE_SESSION_REJECTION.REMOTE_TICKET_REPLAY, "request.ticketId", "the ticket was already consumed"); + } + if (ticket.action !== action) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_TICKET_ACTION_MISMATCH, + "request.ticketId", + "the ticket covers another action", + ); + } + if (ticket.connectionId !== session.connectionId) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_TICKET_CONNECTION_MISMATCH, + "request.ticketId", + "the ticket is bound to another connection", + ); + } + if (ticket.peerId !== session.peer.peerId) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_TICKET_PEER_MISMATCH, + "request.ticketId", + "the ticket is bound to another peer", + ); + } + if (ticket.expiresAtMs <= nowMs) { + reject(REMOTE_SESSION_REJECTION.REMOTE_TICKET_EXPIRED, "request.ticketId", "the ticket has expired"); + } + } + + // Every deadline is clipped to the remaining authority lifetime, so a call + // may never outlive the lease that authorizes it. + const authorityRemainingMs = authority.leaseExpiresAtMs - nowMs; + if (binding.deadlineAtMs !== undefined && binding.deadlineAtMs - nowMs > authorityRemainingMs) { + reject( + REMOTE_SESSION_REJECTION.REMOTE_AUTHORITY_LIFETIME_TOO_SHORT, + "request.deadlineAtMs", + "the call would outlive the authority lease", + ); + } + + return Object.freeze({ + peerId: session.peer.peerId, + instanceId: session.peer.instanceId, + connectionId: session.connectionId, + epoch: session.peer.epoch, + action, + authorityLeaseFencing: authority.leaseFencing, + checkedAtMs: nowMs, + }); +} + +module.exports = { + DEFAULT_MAX_TRACKED_REQUESTS, + REMOTE_SESSION_REJECTION, + REMOTE_SESSION_STATE, + RemotePeerSession, + assertPerCallAuthority, +}; diff --git a/lib/governed/remote-peer-trust.js b/lib/governed/remote-peer-trust.js new file mode 100644 index 00000000..91245735 --- /dev/null +++ b/lib/governed/remote-peer-trust.js @@ -0,0 +1,481 @@ +"use strict"; + +// ─── Remote Peer Trust (M-053 / MS-008 P-009, C1) ────────────────────────── +// +// Independent primitives for the *remote* half of the cross-machine permit +// chain. They live next to the local managed-caller primitives on purpose and +// are deliberately NOT an extension of them. +// +// Why they cannot reuse the local session +// -------------------------------------- +// `managed-caller-session.js` answers one narrow question: "is this request +// really from the child process I supervise?" It does that with an OS process +// handle, an inherited anonymous IPC channel and a session secret that never +// leaves a second pipe. None of those facts exist across a machine boundary: +// a remote peer has no handle, and a local fork session must never be used to +// impersonate a remote one. The design record makes that explicit: the remote +// path needs its own session type and its own opaque peer source. +// +// What this module guarantees +// ---------------------------- +// 1. A trust configuration is validated before anything else happens. It +// pins the instance, the audience, the signing key and the epoch, and it +// refuses any time bound that is looser than the frozen ceilings. +// 2. A remote peer is produced only by `verifyRemoteFacts`, and only from a +// fact set that passes every field check: signature, pinned key, +// instance, epoch, host binding, connection binding, challenge, freshness, +// clock skew and scope containment. +// 3. The produced peer is tagged `kind: "remote-peer"` and is a different +// object type from the local managed caller identity, so the two can +// never be substituted for one another by accident. +// 4. Every failure is a rejection with a stable code and the offending +// field. There is no degraded or partial acceptance path. +// +// Approved decisions this implements +// ---------------------------------- +// - `D-M053-MS008-P009-R1-TRUSTED-W-OPEN` (A): W is the protected broker; +// the fact signing key and the TLS key are separate purposes. +// - `D-M053-MS008-P009-R2R3-CONNECTION-TOPOLOGY-OPEN` (A): each remote +// request process holds its own proven connection. +// - `D-M053-MS008-P009-R4-TRUSTED-CONTEXT-OPEN` (A): versioned, protected +// deployment snapshot; self-reported values are rejected. +// - `D-M053-MS008-P009-R5-TIMING-REVOCATION-OPEN` (A): the frozen time +// bounds below, each of them clipped to the remaining lease/permit life. +// - `D-M053-MS008-G0-CONNECTION-BINDING-BASIS-OPEN` (A): the keyholder signs +// the handshake digest; the signer verifies with the pinned instance key. +// +// Hard boundaries +// --------------- +// - No signing key material is created, read or stored here. Verification is +// delegated to an injected verifier, so this module can be exercised +// without a real key, a real TLS connection or a real machine. +// - Nothing in this module authorizes an action. It only answers "is this a +// real, current, correctly scoped remote peer on this connection?". + +const crypto = require("node:crypto"); + +/** + * The frozen time bounds, in milliseconds. + * + * These are the ceilings approved for the permit chain. A trust + * configuration may tighten them but never loosen them: a configuration that + * asks for a wider window is rejected rather than clamped, because silently + * clamping would hide a misconfigured peer. + */ +const REMOTE_PEER_LIMIT = Object.freeze({ + handshakeTimeoutMs: 5_000, + factMaxAgeMs: 1_000, + ticketTtlMs: 2_000, + requestTimeoutMs: 5_000, + revocationMaxStalenessMs: 15_000, + maxClockSkewMs: 2_000, +}); + +/** The only fact version this implementation understands. */ +const REMOTE_FACT_VERSION = 1; + +/** Stable rejection codes. Every refusal reports one of these. */ +const REMOTE_PEER_REJECTION = Object.freeze({ + TRUST_CONFIG_INVALID: "TRUST_CONFIG_INVALID", + TRUST_FIELD_MISSING: "TRUST_FIELD_MISSING", + TRUST_INSTANCE_MISMATCH: "TRUST_INSTANCE_MISMATCH", + TRUST_KEY_NOT_PINNED: "TRUST_KEY_NOT_PINNED", + TRUST_EPOCH_INVALID: "TRUST_EPOCH_INVALID", + TRUST_NOT_YET_VALID: "TRUST_NOT_YET_VALID", + TRUST_EXPIRED: "TRUST_EXPIRED", + TRUST_LIMIT_INVALID: "TRUST_LIMIT_INVALID", + TRUST_LIMIT_EXCEEDED: "TRUST_LIMIT_EXCEEDED", + TRUST_SCOPE_EMPTY: "TRUST_SCOPE_EMPTY", + REMOTE_FACT_MALFORMED: "REMOTE_FACT_MALFORMED", + REMOTE_FACT_FIELD_MISSING: "REMOTE_FACT_FIELD_MISSING", + REMOTE_FACT_VERSION_UNSUPPORTED: "REMOTE_FACT_VERSION_UNSUPPORTED", + REMOTE_FACT_SIGNATURE_INVALID: "REMOTE_FACT_SIGNATURE_INVALID", + REMOTE_FACT_KEY_NOT_PINNED: "REMOTE_FACT_KEY_NOT_PINNED", + REMOTE_FACT_INSTANCE_MISMATCH: "REMOTE_FACT_INSTANCE_MISMATCH", + REMOTE_FACT_EPOCH_MISMATCH: "REMOTE_FACT_EPOCH_MISMATCH", + REMOTE_FACT_HOST_UNBOUND: "REMOTE_FACT_HOST_UNBOUND", + REMOTE_FACT_CONNECTION_MISMATCH: "REMOTE_FACT_CONNECTION_MISMATCH", + REMOTE_FACT_CONNECTION_KEY_MISMATCH: "REMOTE_FACT_CONNECTION_KEY_MISMATCH", + REMOTE_FACT_CHALLENGE_MISMATCH: "REMOTE_FACT_CHALLENGE_MISMATCH", + REMOTE_FACT_STALE: "REMOTE_FACT_STALE", + REMOTE_FACT_CLOCK_SKEW: "REMOTE_FACT_CLOCK_SKEW", + REMOTE_FACT_EXPIRED: "REMOTE_FACT_EXPIRED", + REMOTE_FACT_WINDOW_EXCEEDED: "REMOTE_FACT_WINDOW_EXCEEDED", + REMOTE_FACT_SCOPE_ESCALATION: "REMOTE_FACT_SCOPE_ESCALATION", +}); + +/** + * A rejection. The code is stable so that a caller can assert on it, and the + * field names the exact input that was refused. + */ +class RemotePeerRejectedError extends Error { + constructor(code, field, message) { + super(field ? `${code} [${field}]: ${message}` : `${code}: ${message}`); + this.name = "RemotePeerRejectedError"; + this.code = code; + this.field = field || null; + } +} + +function reject(code, field, message) { + throw new RemotePeerRejectedError(code, field, message); +} + +function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function requireString(value, field, code) { + if (typeof value !== "string" || value.length === 0) { + reject(code || REMOTE_PEER_REJECTION.TRUST_FIELD_MISSING, field, "expected a non-empty string"); + } + return value; +} + +function requireInteger(value, field, code) { + if (!Number.isInteger(value)) { + reject(code || REMOTE_PEER_REJECTION.TRUST_FIELD_MISSING, field, "expected an integer"); + } + return value; +} + +function requireScope(value, field, code) { + if (!Array.isArray(value) || value.length === 0) { + reject(code || REMOTE_PEER_REJECTION.TRUST_SCOPE_EMPTY, field, "expected a non-empty array of action names"); + } + for (const entry of value) { + if (typeof entry !== "string" || entry.length === 0) { + reject(code || REMOTE_PEER_REJECTION.TRUST_SCOPE_EMPTY, field, "scope entries must be non-empty strings"); + } + } + return Object.freeze([...new Set(value)]); +} + +/** + * Canonical bytes a fact signature is computed over. + * + * The signature field itself is excluded, and the key order is fixed, so the + * signer and the verifier always agree on the exact byte string. + */ +function canonicalizeRemoteFacts(facts) { + if (!isPlainObject(facts)) { + reject(REMOTE_PEER_REJECTION.REMOTE_FACT_MALFORMED, "facts", "expected an object"); + } + const payload = { ...facts }; + delete payload.signature; + return JSON.stringify([ + payload.factVersion, + payload.instanceId, + payload.runId, + payload.generationText, + payload.epoch, + payload.scope, + payload.challenge, + payload.issuedAtMs, + payload.expiresAtMs, + payload.signerKeyId, + payload.host, + payload.connection, + ]); +} + +/** Stable digest of a fact set, used as the peer identity fingerprint. */ +function digestRemoteFacts(facts) { + return crypto.createHash("sha256").update(canonicalizeRemoteFacts(facts)).digest("hex"); +} + +/** True when every action of `scope` is contained in `allowed`. */ +function scopeContains(scope, allowed) { + const allow = new Set(Array.isArray(allowed) ? allowed : []); + return Array.isArray(scope) && scope.every((action) => allow.has(action)); +} + +/** + * Validate a trust configuration. + * + * @param {object} trust The pinned configuration for one remote instance. + * @param {object} [options] + * @param {number} [options.nowMs] Current time, for the validity window. + * @returns {object} A frozen, normalized copy of the configuration. + */ +function validateRemoteTrustConfig(trust, options = {}) { + if (!isPlainObject(trust)) { + reject(REMOTE_PEER_REJECTION.TRUST_CONFIG_INVALID, "trust", "expected an object"); + } + const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now(); + + const instanceId = requireString(trust.instanceId, "trust.instanceId"); + const audience = requireString(trust.audience, "trust.audience"); + const peerScope = requireScope(trust.peerScope, "trust.peerScope"); + + const pinnedKey = trust.pinnedKey; + if (!isPlainObject(pinnedKey)) { + reject(REMOTE_PEER_REJECTION.TRUST_KEY_NOT_PINNED, "trust.pinnedKey", "expected a pinned key object"); + } + const keyId = requireString(pinnedKey.keyId, "trust.pinnedKey.keyId", REMOTE_PEER_REJECTION.TRUST_KEY_NOT_PINNED); + const keyDigest = requireString( + pinnedKey.keyDigest, + "trust.pinnedKey.keyDigest", + REMOTE_PEER_REJECTION.TRUST_KEY_NOT_PINNED, + ); + + const epoch = requireInteger(trust.epoch, "trust.epoch", REMOTE_PEER_REJECTION.TRUST_EPOCH_INVALID); + if (epoch < 0) { + reject(REMOTE_PEER_REJECTION.TRUST_EPOCH_INVALID, "trust.epoch", "epoch must not be negative"); + } + + const notBeforeMs = requireInteger(trust.notBeforeMs, "trust.notBeforeMs"); + const expiresAtMs = requireInteger(trust.expiresAtMs, "trust.expiresAtMs"); + if (expiresAtMs <= notBeforeMs) { + reject(REMOTE_PEER_REJECTION.TRUST_CONFIG_INVALID, "trust.expiresAtMs", "expiry must be after the validity start"); + } + if (nowMs < notBeforeMs) { + reject(REMOTE_PEER_REJECTION.TRUST_NOT_YET_VALID, "trust.notBeforeMs", "trust configuration is not valid yet"); + } + if (nowMs >= expiresAtMs) { + reject(REMOTE_PEER_REJECTION.TRUST_EXPIRED, "trust.expiresAtMs", "trust configuration has expired"); + } + + const limits = isPlainObject(trust.limits) ? trust.limits : {}; + const normalizedLimits = {}; + for (const [name, ceiling] of Object.entries(REMOTE_PEER_LIMIT)) { + const value = limits[name]; + if (!Number.isInteger(value) || value <= 0) { + reject(REMOTE_PEER_REJECTION.TRUST_LIMIT_INVALID, `trust.limits.${name}`, "expected a positive integer"); + } + if (value > ceiling) { + reject( + REMOTE_PEER_REJECTION.TRUST_LIMIT_EXCEEDED, + `trust.limits.${name}`, + `value ${value} exceeds the frozen ceiling ${ceiling}`, + ); + } + normalizedLimits[name] = value; + } + + return Object.freeze({ + instanceId, + audience, + peerScope, + pinnedKey: Object.freeze({ keyId, keyDigest }), + epoch, + notBeforeMs, + expiresAtMs, + limits: Object.freeze(normalizedLimits), + }); +} + +/** + * Verify a signed fact set and produce an opaque remote peer. + * + * @param {object} params + * @param {object} params.facts The signed fact set from the remote side. + * @param {object} params.trust The pinned trust configuration. + * @param {object} params.connection The live connection the facts must bind to. + * @param {string} params.expectedChallenge The outstanding challenge nonce. + * @param {number} [params.nowMs] Current time. + * @param {Function} params.verifySignature Verifier injected by the caller: + * `({ keyId, payload, signature }) => boolean`. No key material exists here. + * @returns {object} A frozen `VerifiedRemotePeer`. + */ +function verifyRemoteFacts(params) { + const { facts, trust, connection, expectedChallenge, verifySignature } = params || {}; + const nowMs = Number.isFinite(params && params.nowMs) ? params.nowMs : Date.now(); + + if (!isPlainObject(facts)) { + reject(REMOTE_PEER_REJECTION.REMOTE_FACT_MALFORMED, "facts", "expected an object"); + } + if (typeof verifySignature !== "function") { + reject(REMOTE_PEER_REJECTION.TRUST_CONFIG_INVALID, "verifySignature", "a signature verifier must be injected"); + } + if (!isPlainObject(connection)) { + reject(REMOTE_PEER_REJECTION.REMOTE_FACT_CONNECTION_MISMATCH, "connection", "expected the live connection binding"); + } + + if (facts.factVersion !== REMOTE_FACT_VERSION) { + reject( + REMOTE_PEER_REJECTION.REMOTE_FACT_VERSION_UNSUPPORTED, + "facts.factVersion", + `unsupported fact version ${String(facts.factVersion)}`, + ); + } + + const signerKeyId = requireString( + facts.signerKeyId, + "facts.signerKeyId", + REMOTE_PEER_REJECTION.REMOTE_FACT_FIELD_MISSING, + ); + const instanceId = requireString( + facts.instanceId, + "facts.instanceId", + REMOTE_PEER_REJECTION.REMOTE_FACT_FIELD_MISSING, + ); + const runId = requireString(facts.runId, "facts.runId", REMOTE_PEER_REJECTION.REMOTE_FACT_FIELD_MISSING); + const generationText = requireString( + facts.generationText, + "facts.generationText", + REMOTE_PEER_REJECTION.REMOTE_FACT_FIELD_MISSING, + ); + requireString(facts.challenge, "facts.challenge", REMOTE_PEER_REJECTION.REMOTE_FACT_FIELD_MISSING); + requireString(facts.signature, "facts.signature", REMOTE_PEER_REJECTION.REMOTE_FACT_FIELD_MISSING); + requireInteger(facts.issuedAtMs, "facts.issuedAtMs", REMOTE_PEER_REJECTION.REMOTE_FACT_FIELD_MISSING); + requireInteger(facts.expiresAtMs, "facts.expiresAtMs", REMOTE_PEER_REJECTION.REMOTE_FACT_FIELD_MISSING); + requireInteger(facts.epoch, "facts.epoch", REMOTE_PEER_REJECTION.REMOTE_FACT_FIELD_MISSING); + + const host = facts.host; + if (!isPlainObject(host)) { + reject(REMOTE_PEER_REJECTION.REMOTE_FACT_HOST_UNBOUND, "facts.host", "expected the host process binding"); + } + requireInteger(host.pid, "facts.host.pid", REMOTE_PEER_REJECTION.REMOTE_FACT_HOST_UNBOUND); + requireString(host.startToken, "facts.host.startToken", REMOTE_PEER_REJECTION.REMOTE_FACT_HOST_UNBOUND); + requireString(host.commandDigest, "facts.host.commandDigest", REMOTE_PEER_REJECTION.REMOTE_FACT_HOST_UNBOUND); + + const factConnection = facts.connection; + if (!isPlainObject(factConnection)) { + reject( + REMOTE_PEER_REJECTION.REMOTE_FACT_CONNECTION_MISMATCH, + "facts.connection", + "expected the connection binding", + ); + } + requireString( + factConnection.connectionId, + "facts.connection.connectionId", + REMOTE_PEER_REJECTION.REMOTE_FACT_CONNECTION_MISMATCH, + ); + requireString( + factConnection.tlsPublicKeyDigest, + "facts.connection.tlsPublicKeyDigest", + REMOTE_PEER_REJECTION.REMOTE_FACT_CONNECTION_KEY_MISMATCH, + ); + + // ── Trust matching ─────────────────────────────────────────────────────── + if (instanceId !== trust.instanceId) { + reject( + REMOTE_PEER_REJECTION.REMOTE_FACT_INSTANCE_MISMATCH, + "facts.instanceId", + "the facts were produced for a different instance", + ); + } + if (facts.epoch !== trust.epoch) { + reject( + REMOTE_PEER_REJECTION.REMOTE_FACT_EPOCH_MISMATCH, + "facts.epoch", + `fact epoch ${facts.epoch} does not match the pinned epoch ${trust.epoch}`, + ); + } + if (signerKeyId !== trust.pinnedKey.keyId) { + reject( + REMOTE_PEER_REJECTION.REMOTE_FACT_KEY_NOT_PINNED, + "facts.signerKeyId", + "the facts were signed by a key that is not pinned for this instance", + ); + } + + // ── Signature ──────────────────────────────────────────────────────────── + let signatureOk = false; + try { + signatureOk = verifySignature({ + keyId: signerKeyId, + payload: canonicalizeRemoteFacts(facts), + signature: facts.signature, + }) === true; + } catch (_) { + signatureOk = false; + } + if (!signatureOk) { + reject( + REMOTE_PEER_REJECTION.REMOTE_FACT_SIGNATURE_INVALID, + "facts.signature", + "the fact signature did not verify", + ); + } + + // ── Connection binding ─────────────────────────────────────────────────── + if (factConnection.connectionId !== connection.connectionId) { + reject( + REMOTE_PEER_REJECTION.REMOTE_FACT_CONNECTION_MISMATCH, + "facts.connection.connectionId", + "the facts are bound to a different connection", + ); + } + if (factConnection.tlsPublicKeyDigest !== connection.tlsPublicKeyDigest) { + reject( + REMOTE_PEER_REJECTION.REMOTE_FACT_CONNECTION_KEY_MISMATCH, + "facts.connection.tlsPublicKeyDigest", + "the facts are bound to a different TLS key", + ); + } + + // ── Challenge (anti-replay of the handshake) ──────────────────────────── + if (facts.challenge !== expectedChallenge) { + reject( + REMOTE_PEER_REJECTION.REMOTE_FACT_CHALLENGE_MISMATCH, + "facts.challenge", + "the facts do not answer the outstanding challenge", + ); + } + + // ── Freshness and clock skew ───────────────────────────────────────────── + if (facts.issuedAtMs - nowMs > trust.limits.maxClockSkewMs) { + reject( + REMOTE_PEER_REJECTION.REMOTE_FACT_CLOCK_SKEW, + "facts.issuedAtMs", + "the facts are dated too far in the future", + ); + } + if (nowMs - facts.issuedAtMs > trust.limits.factMaxAgeMs) { + reject( + REMOTE_PEER_REJECTION.REMOTE_FACT_STALE, + "facts.issuedAtMs", + "the facts are older than the accepted fact age", + ); + } + if (facts.expiresAtMs <= nowMs) { + reject(REMOTE_PEER_REJECTION.REMOTE_FACT_EXPIRED, "facts.expiresAtMs", "the facts have expired"); + } + if (facts.expiresAtMs - facts.issuedAtMs > trust.limits.factMaxAgeMs) { + reject( + REMOTE_PEER_REJECTION.REMOTE_FACT_WINDOW_EXCEEDED, + "facts.expiresAtMs", + "the validity window of the facts is wider than the accepted fact age", + ); + } + + // ── Scope containment ──────────────────────────────────────────────────── + const scope = requireScope(facts.scope, "facts.scope", REMOTE_PEER_REJECTION.REMOTE_FACT_SCOPE_ESCALATION); + if (!scopeContains(scope, trust.peerScope)) { + reject( + REMOTE_PEER_REJECTION.REMOTE_FACT_SCOPE_ESCALATION, + "facts.scope", + "the facts claim actions that the trust configuration does not grant", + ); + } + + const factsDigest = digestRemoteFacts(facts); + return Object.freeze({ + kind: "remote-peer", + peerId: `remote-${factsDigest.slice(0, 16)}`, + instanceId, + runId, + generationText, + scope, + epoch: facts.epoch, + connectionId: factConnection.connectionId, + host: Object.freeze({ pid: host.pid, startToken: host.startToken, commandDigest: host.commandDigest }), + factsDigest, + verifiedAtMs: nowMs, + }); +} + +module.exports = { + REMOTE_FACT_VERSION, + REMOTE_PEER_LIMIT, + REMOTE_PEER_REJECTION, + RemotePeerRejectedError, + canonicalizeRemoteFacts, + digestRemoteFacts, + scopeContains, + validateRemoteTrustConfig, + verifyRemoteFacts, +}; diff --git a/pr-bodies/m053-p009-c1-remote-primitives.md b/pr-bodies/m053-p009-c1-remote-primitives.md new file mode 100644 index 00000000..5a041223 --- /dev/null +++ b/pr-bodies/m053-p009-c1-remote-primitives.md @@ -0,0 +1,50 @@ +## Problem + +跨机受管许可链需要一种**不能与本机 managed caller 身份互换**的远端对端身份: +签发端必须能把「这台机器上的这个进程」与「另一台机器上的那个进程」区分开, +并且对远端对端的信任事实要有可验证的落点。 + +本 PR 引入两个**纯逻辑原语**,为跨机信任的 Owner 裁定提供可评审的实现基础。 + +## Change + +- `lib/governed/remote-peer-trust.js`:校验钉住的信任配置,逐字段核验远端签名事实 + (钉住公钥、实例、epoch、宿主进程绑定、连接与 TLS 密钥绑定、挑战、新鲜度、 + 钟差、scope 包含),产出 `kind = remote-peer` 的不透明远端对端对象。 +- `lib/governed/remote-peer-session.js`:单连接会话(挑战握手、挑战一次性、请求与 + 票据绑定、序号严格递增、重放登记不淘汰而 fail-closed、生命周期),以及每次调用 + 都要经过的逐调用 authority 闸门(会话状态、peer epoch、权威 lease 与 fencing、 + 撤销新鲜度、票据绑定、请求绑定、截止时间裁剪到 lease 剩余寿命)。 +- 时间上界按 R5 冻结(握手 5s、事实龄 1s、票 2s、请求 5s、撤销陈旧 15s、钟差 2s): + 信任配置可收紧,**放宽一律拒绝**而非静默截断。所有拒绝都带稳定拒绝码与出错字段名, + 没有部分接受路径。 +- `docs/architecture/remote-peer-primitives.md`:原语的职责边界与冻结口径。 + +## Why a separate branch from PR #178 + +原语变更与 PR #178(跨机票据代理)是**不同改动面**:本 PR 只提供**纯逻辑原语**, +不装配、不开 socket、不接线。PR #178 的票据代理是装配层切片。合入顺序不影响 +PR #178 的可读性。 + +本分支由 `87a50523` cherry-pick 到最新 `main`(`f7e163d3`)而来,以消除旧基线。 + +## Validation + +- `node --test tests/governed/remote-peer-primitives.test.js` → **65/65**。 +- `node scripts/check-l3-boundary.js` → 0 violations。 +- GitHub Actions 由本 PR 的 Pipeline 覆盖(与仓库既有门禁一致)。 + +## Boundary + +- 本 PR **不签名、不签发许可、不开 socket**,不涉及 Windows 设备动作、服务 / ACL / + 账号或桌面敏感操作。 +- 两个原语**尚未接入 permit-bridge 装配层**;装配层是独立切片(PR #178)。 +- 跨机受管对端信任的 Owner 门(INV-13 / G-Q2)**未解除**。本 PR 只提供原语, + 不代表跨机信任已被接受,也不构成生产准入路径。 +- 结论上限:测试域可签发、可取件、可验签;不宣称生产可用、不宣称受保护身份已验证。 + +## 对应裁定 + +`D-M053-MS008-P009-R1-TRUSTED-W-OPEN`、`R2R3-CONNECTION-TOPOLOGY-OPEN`、 +`R4-TRUSTED-CONTEXT-OPEN`、`R5-TIMING-REVOCATION-OPEN`、 +`D-M053-MS008-G0-CONNECTION-BINDING-BASIS-OPEN`、`D-M053-MS008-G0-A-SCOPE-NARROW-OPEN`。 diff --git a/tests/governed/remote-peer-primitives.test.js b/tests/governed/remote-peer-primitives.test.js new file mode 100644 index 00000000..95616c2a --- /dev/null +++ b/tests/governed/remote-peer-primitives.test.js @@ -0,0 +1,783 @@ +"use strict"; + +const { test, describe } = require("node:test"); +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); + +const { + REMOTE_PEER_LIMIT, + REMOTE_PEER_REJECTION, + canonicalizeRemoteFacts, + validateRemoteTrustConfig, + verifyRemoteFacts, +} = require("../../lib/governed/remote-peer-trust"); + +const { + REMOTE_SESSION_REJECTION, + REMOTE_SESSION_STATE, + RemotePeerSession, + assertPerCallAuthority, +} = require("../../lib/governed/remote-peer-session"); + +const SIGNING_SECRET = "remote-peer-primitives-test-secret"; +const CONNECTION_ID = "conn-1"; +const TLS_DIGEST = "tls-key-digest-1"; +const ACTION = "permit.issue"; + +let clockNow = 1_700_000_000_000; +const clock = () => clockNow; + +function sign(payload) { + return crypto.createHmac("sha256", SIGNING_SECRET).update(payload).digest("hex"); +} + +function verifySignature({ payload, signature }) { + if (typeof payload !== "string" || typeof signature !== "string") return false; + const expected = Buffer.from(sign(payload), "hex"); + const given = Buffer.from(signature, "hex"); + if (expected.length !== given.length || expected.length === 0) return false; + return crypto.timingSafeEqual(expected, given); +} + +function makeTrust(overrides = {}) { + return { + instanceId: "samhmi-instance-1", + audience: "permit-signing", + peerScope: [ACTION], + pinnedKey: { keyId: "w-key-1", keyDigest: "w-key-digest-1" }, + epoch: 7, + notBeforeMs: clockNow - 1_000, + expiresAtMs: clockNow + 600_000, + limits: { + handshakeTimeoutMs: REMOTE_PEER_LIMIT.handshakeTimeoutMs, + factMaxAgeMs: REMOTE_PEER_LIMIT.factMaxAgeMs, + ticketTtlMs: REMOTE_PEER_LIMIT.ticketTtlMs, + requestTimeoutMs: REMOTE_PEER_LIMIT.requestTimeoutMs, + revocationMaxStalenessMs: REMOTE_PEER_LIMIT.revocationMaxStalenessMs, + maxClockSkewMs: REMOTE_PEER_LIMIT.maxClockSkewMs, + }, + ...overrides, + }; +} + +function makeFacts(overrides = {}, trust = makeTrust()) { + const facts = { + factVersion: 1, + instanceId: trust.instanceId, + runId: "run-1", + generationText: "gen-text-1", + epoch: trust.epoch, + scope: [ACTION], + challenge: "challenge-1", + issuedAtMs: clockNow, + expiresAtMs: clockNow + 500, + signerKeyId: trust.pinnedKey.keyId, + host: { pid: 4242, startToken: "start-token-1", commandDigest: "cmd-digest-1" }, + connection: { connectionId: CONNECTION_ID, tlsPublicKeyDigest: TLS_DIGEST }, + ...overrides, + }; + facts.signature = sign(canonicalizeRemoteFacts(facts)); + return facts; +} + +function rejectionCode(fn) { + try { + fn(); + } catch (error) { + assert.ok( + error && error.name === "RemotePeerRejectedError", + "expected a RemotePeerRejectedError, got " + (error && error.name), + ); + return error.code; + } + throw new Error("expected the call to be rejected"); +} + +function makeSession(options = {}) { + const trust = options.trust || makeTrust(); + const session = new RemotePeerSession({ + trust, + verifySignature, + now: clock, + ...options, + }); + const challenge = session.begin({ connectionId: CONNECTION_ID, tlsPublicKeyDigest: TLS_DIGEST }); + return { session, challenge, trust }; +} + +function makeEstablishedSession(options = {}) { + const built = makeSession(options); + const facts = makeFacts({ challenge: built.challenge.challenge }, built.trust); + const peer = built.session.acceptFacts(facts); + return { ...built, peer }; +} + +function makeAuthority(overrides = {}) { + return { + authorityHeld: true, + epoch: 7, + leaseFencing: 12, + expectedLeaseFencing: 12, + leaseExpiresAtMs: clockNow + 120_000, + revocationAppliedAtMs: clockNow - 1_000, + allowedActions: [ACTION], + ...overrides, + }; +} + +describe("governed/remote-peer-trust - trust configuration", () => { + test("a valid configuration is normalized and frozen", () => { + const trust = validateRemoteTrustConfig(makeTrust(), { nowMs: clockNow }); + assert.equal(trust.instanceId, "samhmi-instance-1"); + assert.equal(trust.epoch, 7); + assert.deepEqual(trust.peerScope, [ACTION]); + assert.ok(Object.isFrozen(trust)); + assert.ok(Object.isFrozen(trust.limits)); + }); + + test("a missing instance is refused", () => { + const trust = makeTrust(); + delete trust.instanceId; + assert.equal( + rejectionCode(() => validateRemoteTrustConfig(trust, { nowMs: clockNow })), + REMOTE_PEER_REJECTION.TRUST_FIELD_MISSING, + ); + }); + + test("a configuration without a pinned key is refused", () => { + const trust = makeTrust(); + delete trust.pinnedKey.keyDigest; + assert.equal( + rejectionCode(() => validateRemoteTrustConfig(trust, { nowMs: clockNow })), + REMOTE_PEER_REJECTION.TRUST_KEY_NOT_PINNED, + ); + }); + + test("an empty peer scope is refused", () => { + assert.equal( + rejectionCode(() => validateRemoteTrustConfig(makeTrust({ peerScope: [] }), { nowMs: clockNow })), + REMOTE_PEER_REJECTION.TRUST_SCOPE_EMPTY, + ); + }); + + test("a negative epoch is refused", () => { + assert.equal( + rejectionCode(() => validateRemoteTrustConfig(makeTrust({ epoch: -1 }), { nowMs: clockNow })), + REMOTE_PEER_REJECTION.TRUST_EPOCH_INVALID, + ); + }); + + test("an expired configuration is refused", () => { + const trust = makeTrust({ notBeforeMs: clockNow - 10_000, expiresAtMs: clockNow - 1 }); + assert.equal( + rejectionCode(() => validateRemoteTrustConfig(trust, { nowMs: clockNow })), + REMOTE_PEER_REJECTION.TRUST_EXPIRED, + ); + }); + + test("a configuration that is not valid yet is refused", () => { + const trust = makeTrust({ notBeforeMs: clockNow + 1_000, expiresAtMs: clockNow + 600_000 }); + assert.equal( + rejectionCode(() => validateRemoteTrustConfig(trust, { nowMs: clockNow })), + REMOTE_PEER_REJECTION.TRUST_NOT_YET_VALID, + ); + }); + + test("a limit that is looser than the frozen ceiling is refused rather than clamped", () => { + const trust = makeTrust(); + trust.limits.factMaxAgeMs = REMOTE_PEER_LIMIT.factMaxAgeMs + 1; + assert.equal( + rejectionCode(() => validateRemoteTrustConfig(trust, { nowMs: clockNow })), + REMOTE_PEER_REJECTION.TRUST_LIMIT_EXCEEDED, + ); + }); + + test("a limit of zero or less is refused", () => { + const trust = makeTrust(); + trust.limits.ticketTtlMs = 0; + assert.equal( + rejectionCode(() => validateRemoteTrustConfig(trust, { nowMs: clockNow })), + REMOTE_PEER_REJECTION.TRUST_LIMIT_INVALID, + ); + }); + + test("a missing limit is refused", () => { + const trust = makeTrust(); + delete trust.limits.requestTimeoutMs; + assert.equal( + rejectionCode(() => validateRemoteTrustConfig(trust, { nowMs: clockNow })), + REMOTE_PEER_REJECTION.TRUST_LIMIT_INVALID, + ); + }); +}); + +describe("governed/remote-peer-trust - signed facts", () => { + const trust = makeTrust(); + const connection = { connectionId: CONNECTION_ID, tlsPublicKeyDigest: TLS_DIGEST }; + + function verify(facts, overrides = {}) { + return verifyRemoteFacts({ + facts, + trust, + connection, + expectedChallenge: "challenge-1", + nowMs: clockNow, + verifySignature, + ...overrides, + }); + } + + test("a complete fact set produces an opaque remote peer", () => { + const peer = verify(makeFacts()); + assert.equal(peer.kind, "remote-peer"); + assert.equal(peer.instanceId, "samhmi-instance-1"); + assert.equal(peer.runId, "run-1"); + assert.equal(peer.generationText, "gen-text-1"); + assert.equal(peer.epoch, 7); + assert.equal(peer.connectionId, CONNECTION_ID); + assert.deepEqual(peer.scope, [ACTION]); + assert.ok(Object.isFrozen(peer)); + }); + + test("the remote peer is not shaped like a local managed caller identity", () => { + const peer = verify(makeFacts()); + assert.equal(peer.kind, "remote-peer"); + assert.equal(peer.pid, undefined); + assert.equal(peer.startToken, undefined); + assert.equal(peer.host.startToken, "start-token-1"); + }); + + test("a tampered field breaks the signature", () => { + const facts = makeFacts(); + facts.runId = "run-2"; + assert.equal(rejectionCode(() => verify(facts)), REMOTE_PEER_REJECTION.REMOTE_FACT_SIGNATURE_INVALID); + }); + + test("a fact set signed by another key is refused", () => { + const facts = makeFacts({ signerKeyId: "other-key" }); + assert.equal(rejectionCode(() => verify(facts)), REMOTE_PEER_REJECTION.REMOTE_FACT_KEY_NOT_PINNED); + }); + + test("facts of another instance are refused", () => { + const facts = makeFacts({ instanceId: "samhmi-instance-2" }); + assert.equal(rejectionCode(() => verify(facts)), REMOTE_PEER_REJECTION.REMOTE_FACT_INSTANCE_MISMATCH); + }); + + test("facts of an older epoch are refused", () => { + const facts = makeFacts({ epoch: 6 }); + assert.equal(rejectionCode(() => verify(facts)), REMOTE_PEER_REJECTION.REMOTE_FACT_EPOCH_MISMATCH); + }); + + test("facts bound to another connection are refused", () => { + const facts = makeFacts({ connection: { connectionId: "conn-2", tlsPublicKeyDigest: TLS_DIGEST } }); + assert.equal(rejectionCode(() => verify(facts)), REMOTE_PEER_REJECTION.REMOTE_FACT_CONNECTION_MISMATCH); + }); + + test("facts bound to another TLS key are refused", () => { + const facts = makeFacts({ connection: { connectionId: CONNECTION_ID, tlsPublicKeyDigest: "other-digest" } }); + assert.equal(rejectionCode(() => verify(facts)), REMOTE_PEER_REJECTION.REMOTE_FACT_CONNECTION_KEY_MISMATCH); + }); + + test("facts that do not answer the outstanding challenge are refused", () => { + const facts = makeFacts({ challenge: "other-challenge" }); + assert.equal(rejectionCode(() => verify(facts)), REMOTE_PEER_REJECTION.REMOTE_FACT_CHALLENGE_MISMATCH); + }); + + test("stale facts are refused", () => { + const facts = makeFacts({ issuedAtMs: clockNow - REMOTE_PEER_LIMIT.factMaxAgeMs - 1, expiresAtMs: clockNow + 100 }); + assert.equal(rejectionCode(() => verify(facts)), REMOTE_PEER_REJECTION.REMOTE_FACT_STALE); + }); + + test("facts dated too far in the future are refused", () => { + const facts = makeFacts({ issuedAtMs: clockNow + REMOTE_PEER_LIMIT.maxClockSkewMs + 1, expiresAtMs: clockNow + 3_000 }); + assert.equal(rejectionCode(() => verify(facts)), REMOTE_PEER_REJECTION.REMOTE_FACT_CLOCK_SKEW); + }); + + test("expired facts are refused", () => { + const facts = makeFacts({ issuedAtMs: clockNow - 100, expiresAtMs: clockNow - 1 }); + assert.equal(rejectionCode(() => verify(facts)), REMOTE_PEER_REJECTION.REMOTE_FACT_EXPIRED); + }); + + test("a validity window wider than the accepted fact age is refused", () => { + const facts = makeFacts({ issuedAtMs: clockNow, expiresAtMs: clockNow + REMOTE_PEER_LIMIT.factMaxAgeMs + 1 }); + assert.equal(rejectionCode(() => verify(facts)), REMOTE_PEER_REJECTION.REMOTE_FACT_WINDOW_EXCEEDED); + }); + + test("a scope that exceeds the pinned scope is refused", () => { + const facts = makeFacts({ scope: [ACTION, "permit.revoke"] }); + assert.equal(rejectionCode(() => verify(facts)), REMOTE_PEER_REJECTION.REMOTE_FACT_SCOPE_ESCALATION); + }); + + test("facts without a host binding are refused", () => { + const facts = makeFacts({ host: undefined }); + assert.equal(rejectionCode(() => verify(facts)), REMOTE_PEER_REJECTION.REMOTE_FACT_HOST_UNBOUND); + }); + + test("facts without a process start token are refused", () => { + const facts = makeFacts({ host: { pid: 1, startToken: "", commandDigest: "c" } }); + assert.equal(rejectionCode(() => verify(facts)), REMOTE_PEER_REJECTION.REMOTE_FACT_HOST_UNBOUND); + }); + + test("an unsupported fact version is refused", () => { + const facts = makeFacts({ factVersion: 2 }); + assert.equal(rejectionCode(() => verify(facts)), REMOTE_PEER_REJECTION.REMOTE_FACT_VERSION_UNSUPPORTED); + }); + + test("a missing verifier is refused instead of silently accepting", () => { + assert.equal( + rejectionCode(() => verify(makeFacts(), { verifySignature: undefined })), + REMOTE_PEER_REJECTION.TRUST_CONFIG_INVALID, + ); + }); +}); + +describe("governed/remote-peer-session - handshake", () => { + test("the handshake challenge is bound to the connection and the frozen bound", () => { + const { session, challenge } = makeSession(); + assert.equal(challenge.connectionId, CONNECTION_ID); + assert.equal(challenge.timeoutMs, REMOTE_PEER_LIMIT.handshakeTimeoutMs); + assert.equal(challenge.expiresAtMs, clockNow + REMOTE_PEER_LIMIT.handshakeTimeoutMs); + assert.ok(challenge.challenge.length >= 16); + assert.equal(session.state, REMOTE_SESSION_STATE.STARTING); + }); + + test("a session cannot be started twice", () => { + const { session } = makeSession(); + assert.equal( + rejectionCode(() => session.begin({ connectionId: CONNECTION_ID, tlsPublicKeyDigest: TLS_DIGEST })), + REMOTE_SESSION_REJECTION.REMOTE_SESSION_ALREADY_STARTED, + ); + }); + + test("a connection without a key digest cannot be bound", () => { + const session = new RemotePeerSession({ trust: makeTrust(), verifySignature, now: clock }); + assert.equal( + rejectionCode(() => session.begin({ connectionId: CONNECTION_ID })), + REMOTE_SESSION_REJECTION.REMOTE_SESSION_CONNECTION_INVALID, + ); + }); + + test("valid facts establish the peer", () => { + const { session, challenge } = makeSession(); + const peer = session.acceptFacts(makeFacts({ challenge: challenge.challenge })); + assert.equal(peer.kind, "remote-peer"); + assert.equal(session.state, REMOTE_SESSION_STATE.ESTABLISHED); + assert.equal(session.established, true); + }); + + test("the challenge is single use: a second presentation is refused", () => { + const { session, challenge } = makeSession(); + const facts = makeFacts({ challenge: challenge.challenge }); + session.acceptFacts(facts); + assert.equal( + rejectionCode(() => session.acceptFacts(facts)), + REMOTE_SESSION_REJECTION.REMOTE_SESSION_INVALID_STATE, + ); + }); + + test("a failed verification burns the challenge", () => { + const { session, challenge } = makeSession(); + const bad = makeFacts({ challenge: challenge.challenge }); + bad.runId = "tampered-after-signing"; + assert.equal( + rejectionCode(() => session.acceptFacts(bad)), + REMOTE_PEER_REJECTION.REMOTE_FACT_SIGNATURE_INVALID, + ); + assert.equal( + rejectionCode(() => session.acceptFacts(makeFacts({ challenge: challenge.challenge }))), + REMOTE_SESSION_REJECTION.REMOTE_HANDSHAKE_CONSUMED, + ); + }); + + test("facts that arrive after the handshake bound are refused and the session dies", () => { + const { session, challenge } = makeSession(); + clockNow += REMOTE_PEER_LIMIT.handshakeTimeoutMs; + assert.equal( + rejectionCode(() => session.acceptFacts(makeFacts({ challenge: challenge.challenge }))), + REMOTE_SESSION_REJECTION.REMOTE_HANDSHAKE_TIMEOUT, + ); + assert.equal(session.state, REMOTE_SESSION_STATE.CLOSED); + assert.equal(session.established, false); + }); +}); + +describe("governed/remote-peer-session - request binding and replay", () => { + test("a request is bound to the peer and the connection", () => { + const { session, peer } = makeEstablishedSession(); + const bound = session.bindInboundRequest({ requestId: "r1", action: ACTION, payload: { a: 1 }, seq: 1 }); + assert.equal(bound.peerId, peer.peerId); + assert.equal(bound.connectionId, CONNECTION_ID); + assert.match(bound.payloadDigest, /^[0-9a-f]{64}$/); + }); + + test("the same request identifier cannot be used twice", () => { + const { session } = makeEstablishedSession(); + session.bindInboundRequest({ requestId: "r1", action: ACTION, payload: {}, seq: 1 }); + assert.equal( + rejectionCode(() => session.bindInboundRequest({ requestId: "r1", action: ACTION, payload: {}, seq: 2 })), + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_REPLAY, + ); + }); + + test("the sequence number has to advance", () => { + const { session } = makeEstablishedSession(); + session.bindInboundRequest({ requestId: "r1", action: ACTION, payload: {}, seq: 5 }); + assert.equal( + rejectionCode(() => session.bindInboundRequest({ requestId: "r2", action: ACTION, payload: {}, seq: 5 })), + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_SEQUENCE, + ); + }); + + test("an action outside the peer scope is refused", () => { + const { session } = makeEstablishedSession(); + assert.equal( + rejectionCode(() => session.bindInboundRequest({ requestId: "r1", action: "permit.revoke", payload: {}, seq: 1 })), + REMOTE_SESSION_REJECTION.REMOTE_ACTION_OUT_OF_SCOPE, + ); + }); + + test("the replay registry fails closed instead of evicting", () => { + const { session } = makeEstablishedSession({ maxTrackedRequests: 2 }); + session.bindInboundRequest({ requestId: "r1", action: ACTION, payload: {}, seq: 1 }); + session.bindInboundRequest({ requestId: "r2", action: ACTION, payload: {}, seq: 2 }); + assert.equal( + rejectionCode(() => session.bindInboundRequest({ requestId: "r3", action: ACTION, payload: {}, seq: 3 })), + REMOTE_SESSION_REJECTION.REMOTE_REPLAY_REGISTRY_FULL, + ); + }); + + test("a response has to match the request it answers", () => { + const { session } = makeEstablishedSession(); + const bound = session.bindInboundRequest({ requestId: "r1", action: ACTION, payload: { a: 1 }, seq: 1 }); + assert.equal( + rejectionCode(() => session.completeInboundRequest({ requestId: "r1", seq: 2 })), + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_SEQUENCE, + ); + assert.equal( + rejectionCode(() => session.completeInboundRequest({ requestId: "r1", seq: 1, payloadDigest: "0".repeat(64) })), + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_PAYLOAD_MISMATCH, + ); + assert.ok(session.completeInboundRequest({ requestId: "r1", seq: 1, payloadDigest: bound.payloadDigest })); + }); + + test("an unknown request cannot be completed", () => { + const { session } = makeEstablishedSession(); + assert.equal( + rejectionCode(() => session.completeInboundRequest({ requestId: "nope", seq: 1 })), + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_UNKNOWN, + ); + }); +}); + +describe("governed/remote-peer-session - tickets", () => { + test("a ticket is bound to the peer and the connection", () => { + const { session, peer } = makeEstablishedSession(); + const ticket = session.registerTicket({ ticketId: "t1", action: ACTION, expiresAtMs: clockNow + 1_000 }); + assert.equal(ticket.peerId, peer.peerId); + assert.equal(ticket.connectionId, CONNECTION_ID); + assert.equal(ticket.consumed, false); + }); + + test("a ticket that outlives the frozen ticket bound is refused", () => { + const { session } = makeEstablishedSession(); + assert.equal( + rejectionCode(() => session.registerTicket({ + ticketId: "t1", + action: ACTION, + expiresAtMs: clockNow + REMOTE_PEER_LIMIT.ticketTtlMs + 1, + })), + REMOTE_SESSION_REJECTION.REMOTE_TICKET_TTL_EXCEEDED, + ); + }); + + test("a ticket for another peer or another connection is refused", () => { + const { session, peer } = makeEstablishedSession(); + assert.equal( + rejectionCode(() => session.registerTicket({ + ticketId: "t1", + action: ACTION, + expiresAtMs: clockNow + 1_000, + peerId: "other-peer", + })), + REMOTE_SESSION_REJECTION.REMOTE_TICKET_PEER_MISMATCH, + ); + assert.equal( + rejectionCode(() => session.registerTicket({ + ticketId: "t2", + action: ACTION, + expiresAtMs: clockNow + 1_000, + connectionId: "conn-2", + })), + REMOTE_SESSION_REJECTION.REMOTE_TICKET_CONNECTION_MISMATCH, + ); + assert.ok(peer); + }); + + test("a ticket can be consumed only once", () => { + const { session } = makeEstablishedSession(); + session.registerTicket({ ticketId: "t1", action: ACTION, expiresAtMs: clockNow + 1_000 }); + session.consumeTicket("t1"); + assert.equal( + rejectionCode(() => session.consumeTicket("t1")), + REMOTE_SESSION_REJECTION.REMOTE_TICKET_REPLAY, + ); + }); + + test("an unregistered ticket is refused", () => { + const { session } = makeEstablishedSession(); + assert.equal( + rejectionCode(() => session.consumeTicket("nope")), + REMOTE_SESSION_REJECTION.REMOTE_TICKET_UNKNOWN, + ); + }); +}); + +describe("governed/remote-peer-session - per-call authority", () => { + test("a call with a healthy authority is accepted and recorded", () => { + const { session, peer } = makeEstablishedSession(); + const record = assertPerCallAuthority({ + session, + action: ACTION, + authority: makeAuthority(), + nowMs: clockNow, + }); + assert.equal(record.peerId, peer.peerId); + assert.equal(record.connectionId, CONNECTION_ID); + assert.equal(record.epoch, 7); + assert.equal(record.authorityLeaseFencing, 12); + }); + + test("a session that is not established cannot authorize a call", () => { + const { session } = makeSession(); + assert.equal( + rejectionCode(() => assertPerCallAuthority({ + session, + action: ACTION, + authority: makeAuthority(), + nowMs: clockNow, + })), + REMOTE_SESSION_REJECTION.REMOTE_SESSION_INVALID_STATE, + ); + }); + + test("a call without held authority is refused", () => { + const { session } = makeEstablishedSession(); + assert.equal( + rejectionCode(() => assertPerCallAuthority({ + session, + action: ACTION, + authority: makeAuthority({ authorityHeld: false }), + nowMs: clockNow, + })), + REMOTE_SESSION_REJECTION.REMOTE_AUTHORITY_NOT_HELD, + ); + }); + + test("an authority of another epoch is refused", () => { + const { session } = makeEstablishedSession(); + assert.equal( + rejectionCode(() => assertPerCallAuthority({ + session, + action: ACTION, + authority: makeAuthority({ epoch: 6 }), + nowMs: clockNow, + })), + REMOTE_SESSION_REJECTION.REMOTE_AUTHORITY_EPOCH_MISMATCH, + ); + }); + + test("a stale fencing token is refused", () => { + const { session } = makeEstablishedSession(); + assert.equal( + rejectionCode(() => assertPerCallAuthority({ + session, + action: ACTION, + authority: makeAuthority({ leaseFencing: 11, expectedLeaseFencing: 12 }), + nowMs: clockNow, + })), + REMOTE_SESSION_REJECTION.REMOTE_AUTHORITY_FENCING_MISMATCH, + ); + }); + + test("an expired authority lease is refused", () => { + const { session } = makeEstablishedSession(); + assert.equal( + rejectionCode(() => assertPerCallAuthority({ + session, + action: ACTION, + authority: makeAuthority({ leaseExpiresAtMs: clockNow - 1 }), + nowMs: clockNow, + })), + REMOTE_SESSION_REJECTION.REMOTE_AUTHORITY_LEASE_EXPIRED, + ); + }); + + test("an action the authority does not allow is refused", () => { + const { session } = makeEstablishedSession(); + assert.equal( + rejectionCode(() => assertPerCallAuthority({ + session, + action: ACTION, + authority: makeAuthority({ allowedActions: [] }), + nowMs: clockNow, + })), + REMOTE_SESSION_REJECTION.REMOTE_AUTHORITY_ACTION_NOT_ALLOWED, + ); + }); + + test("a revocation list that is too old is refused", () => { + const { session } = makeEstablishedSession(); + assert.equal( + rejectionCode(() => assertPerCallAuthority({ + session, + action: ACTION, + authority: makeAuthority({ revocationAppliedAtMs: clockNow - REMOTE_PEER_LIMIT.revocationMaxStalenessMs - 1 }), + nowMs: clockNow, + })), + REMOTE_SESSION_REJECTION.REMOTE_REVOCATION_STALE, + ); + }); + + test("a call that is not bound to a request of this session is refused", () => { + const { session } = makeEstablishedSession(); + assert.equal( + rejectionCode(() => assertPerCallAuthority({ + session, + action: ACTION, + request: { requestId: "r-unknown" }, + authority: makeAuthority(), + nowMs: clockNow, + })), + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_UNKNOWN, + ); + }); + + test("a call whose payload differs from the bound request is refused", () => { + const { session } = makeEstablishedSession(); + const bound = session.bindInboundRequest({ requestId: "r1", action: ACTION, payload: { a: 1 }, seq: 1 }); + assert.equal( + rejectionCode(() => assertPerCallAuthority({ + session, + action: ACTION, + request: { requestId: "r1", payloadDigest: "0".repeat(64) }, + authority: makeAuthority(), + nowMs: clockNow, + })), + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_PAYLOAD_MISMATCH, + ); + assert.ok(bound); + }); + + test("a call with a deadline that already passed is refused", () => { + const { session } = makeEstablishedSession(); + assert.equal( + rejectionCode(() => assertPerCallAuthority({ + session, + action: ACTION, + request: { deadlineAtMs: clockNow - 1 }, + authority: makeAuthority(), + nowMs: clockNow, + })), + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_DEADLINE_PASSED, + ); + }); + + test("a deadline beyond the frozen request bound is refused", () => { + const { session } = makeEstablishedSession(); + assert.equal( + rejectionCode(() => assertPerCallAuthority({ + session, + action: ACTION, + request: { deadlineAtMs: clockNow + REMOTE_PEER_LIMIT.requestTimeoutMs + 1 }, + authority: makeAuthority(), + nowMs: clockNow, + })), + REMOTE_SESSION_REJECTION.REMOTE_REQUEST_DEADLINE_EXCEEDED, + ); + }); + + test("a call that would outlive the authority lease is refused", () => { + const { session } = makeEstablishedSession(); + const leaseExpiresAtMs = clockNow + 1_000; + assert.equal( + rejectionCode(() => assertPerCallAuthority({ + session, + action: ACTION, + request: { deadlineAtMs: clockNow + 2_000 }, + authority: makeAuthority({ leaseExpiresAtMs }), + nowMs: clockNow, + })), + REMOTE_SESSION_REJECTION.REMOTE_AUTHORITY_LIFETIME_TOO_SHORT, + ); + }); + + test("a consumed ticket cannot authorize another call", () => { + const { session } = makeEstablishedSession(); + session.registerTicket({ ticketId: "t1", action: ACTION, expiresAtMs: clockNow + 1_000 }); + session.consumeTicket("t1"); + assert.equal( + rejectionCode(() => assertPerCallAuthority({ + session, + action: ACTION, + request: { ticketId: "t1" }, + authority: makeAuthority(), + nowMs: clockNow, + })), + REMOTE_SESSION_REJECTION.REMOTE_TICKET_REPLAY, + ); + }); + + test("a ticket that covers another action cannot authorize the call", () => { + const trust = makeTrust({ peerScope: [ACTION, "permit.revoke"] }); + const built = makeSession({ trust }); + const session = built.session; + session.acceptFacts(makeFacts( + { challenge: built.challenge.challenge, scope: [ACTION, "permit.revoke"] }, + trust, + )); + session.registerTicket({ ticketId: "t1", action: "permit.revoke", expiresAtMs: clockNow + 1_000 }); + assert.equal( + rejectionCode(() => assertPerCallAuthority({ + session, + action: ACTION, + request: { ticketId: "t1" }, + authority: makeAuthority(), + nowMs: clockNow, + })), + REMOTE_SESSION_REJECTION.REMOTE_TICKET_ACTION_MISMATCH, + ); + }); +}); + +describe("governed/remote-peer-session - lifecycle", () => { + test("draining stops the session from serving requests", () => { + const { session } = makeEstablishedSession(); + session.drain(); + assert.equal(session.state, REMOTE_SESSION_STATE.DRAINING); + assert.equal(session.established, false); + assert.equal( + rejectionCode(() => session.bindInboundRequest({ requestId: "r1", action: ACTION, payload: {}, seq: 1 })), + REMOTE_SESSION_REJECTION.REMOTE_SESSION_INVALID_STATE, + ); + }); + + test("invalidation drops the peer and the open work", () => { + const { session } = makeEstablishedSession(); + session.registerTicket({ ticketId: "t1", action: ACTION, expiresAtMs: clockNow + 1_000 }); + session.invalidate("identity_changed"); + assert.equal(session.state, REMOTE_SESSION_STATE.CLOSED); + assert.equal(session.peer, null); + assert.equal(session.invalidatedReason, "identity_changed"); + assert.equal( + rejectionCode(() => session.consumeTicket("t1")), + REMOTE_SESSION_REJECTION.REMOTE_TICKET_UNKNOWN, + ); + }); + + test("closing is terminal", () => { + const { session } = makeEstablishedSession(); + session.close(); + assert.equal(session.state, REMOTE_SESSION_STATE.CLOSED); + assert.equal(session.established, false); + }); +});