Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions lib/commands/permit-bridge-service-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,43 @@ const USAGE = "Usage: cortex-agent permit-bridge serve --config <json-file>";
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);
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -98,12 +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,
}));
};
if (service.agentAddress) readiness.agentPort = service.agentAddress.port;
output.log(JSON.stringify(readiness));

const signalSource = dependencies.signalSource || process;
const onStop = () => finish();
Expand Down
49 changes: 47 additions & 2 deletions lib/coordination/permit-bridge-host.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<object>} The running bridge.
*/
Expand All @@ -90,6 +95,8 @@ async function startPermitBridgeHost(options = {}) {
leaseTtlMs,
permitTtlMs,
ticketTtlMs = PERMIT_BRIDGE_BOUNDS.ticketTtlMs,
remotePeers,
agentPort = 0,
onDiagnostic,
} = options;

Expand All @@ -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.
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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,
Expand All @@ -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();
});
Expand Down Expand Up @@ -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
Expand All @@ -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.
*
Expand Down
7 changes: 7 additions & 0 deletions lib/coordination/permit-bridge-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>} The running service.
*/
Expand All @@ -81,6 +83,8 @@ async function startPermitBridgeService(options = {}) {
bindAddress = "127.0.0.1",
port = 0,
revocationPort = 0,
remotePeers,
agentPort = 0,
requestTimeoutMs,
onDiagnostic,
} = options;
Expand Down Expand Up @@ -145,6 +149,8 @@ async function startPermitBridgeService(options = {}) {
bindAddress,
port,
revocationPort,
remotePeers,
agentPort,
requestTimeoutMs,
onDiagnostic,
});
Expand Down Expand Up @@ -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.
*
Expand Down
148 changes: 148 additions & 0 deletions lib/coordination/permit-remote-peer-client.js
Original file line number Diff line number Diff line change
@@ -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<object>} `{ 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,
});
Loading
Loading