fix(runner): close phase 3 security integrity gaps

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-07 23:51:38 +00:00
1 parent 486069e369
commit 313c40cd9b
7 files changed
+2141 -319

No files matched your search

+230
View File
@@ -2,24 +2,200 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core",
"typenum",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "paperclip-runner-core"
version = "0.0.0"
dependencies = [
"aes-gcm",
"hmac",
"serde",
"serde_json",
"sha2",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
@@ -40,6 +216,15 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "serde"
version = "1.0.229"
@@ -83,6 +268,23 @@ dependencies = [
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "3.0.3"
@@ -94,12 +296,40 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "zmij"
version = "1.0.23"
@@ -9,5 +9,8 @@ license = "MIT"
publish = false
[workspace.dependencies]
aes-gcm = "0.10"
hmac = "0.12"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.10"
@@ -6,8 +6,11 @@ license.workspace = true
publish.workspace = true
[dependencies]
aes-gcm.workspace = true
hmac.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
[[bin]]
name = "phase0-tracer"
File diff suppressed because it is too large. Load diff
@@ -62,6 +62,8 @@ export interface Phase3RunnerState extends Phase3Identity {
peakOutboxBytes: number;
outbox: Phase3StoredEvent[];
processedCommands: Record<string, Phase3ProcessedCommand>;
compactedCommandFilter: string;
compactedCommandCount: number;
diagnostics: string[];
backpressure: boolean;
recoverableFailure: string | null;
@@ -1,3 +1,4 @@
import { spawn } from "node:child_process";
import {
chmodSync,
lstatSync,
@@ -20,38 +21,7 @@ import {
runPhase3Recovery,
} from "./phase3-recovery.js";
async function upgradeStatus(url: string, ticket: string): Promise<number> {
const parsed = new URL(url);
return new Promise((resolveStatus, rejectStatus) => {
const socket = connect(Number(parsed.port), parsed.hostname);
let response = "";
socket.once("error", rejectStatus);
socket.once("connect", () => {
socket.write(
[
`GET ${parsed.pathname} HTTP/1.1`,
`Host: ${parsed.host}`,
"Upgrade: websocket",
"Connection: Upgrade",
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
"Sec-WebSocket-Version: 13",
`Authorization: Bearer ${ticket}`,
"\r\n",
].join("\r\n"),
);
});
socket.on("data", (chunk) => {
response += chunk.toString("utf8");
if (response.includes("\r\n\r\n")) {
const status = Number(response.match(/^HTTP\/1\.1 (\d{3})/)?.[1]);
socket.destroy();
resolveStatus(status);
}
});
});
}
async function upgradeSocket(url: string, ticket: string): Promise<Socket> {
async function upgradeSocket(url: string): Promise<Socket> {
const parsed = new URL(url);
return new Promise((resolveSocket, rejectSocket) => {
const socket = connect(Number(parsed.port), parsed.hostname);
@@ -81,7 +51,6 @@ async function upgradeSocket(url: string, ticket: string): Promise<Socket> {
"Connection: Upgrade",
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
"Sec-WebSocket-Version: 13",
`Authorization: Bearer ${ticket}`,
"\r\n",
].join("\r\n"),
);
@@ -90,6 +59,39 @@ async function upgradeSocket(url: string, ticket: string): Promise<Socket> {
});
}
async function receiveServerJson(socket: Socket): Promise<Record<string, unknown>> {
return new Promise((resolveValue, rejectValue) => {
let buffer = Buffer.alloc(0);
const timer = setTimeout(() => rejectValue(new Error("Server frame timed out.")), 2_000);
const onData = (chunk: Buffer): void => {
buffer = Buffer.concat([buffer, chunk]);
if (buffer.length < 2) return;
let length = buffer[1]! & 0x7f;
let cursor = 2;
if (length === 126) {
if (buffer.length < 4) return;
length = buffer.readUInt16BE(2);
cursor = 4;
} else if (length === 127) {
if (buffer.length < 10) return;
length = Number(buffer.readBigUInt64BE(2));
cursor = 10;
}
if (buffer.length < cursor + length) return;
clearTimeout(timer);
socket.off("data", onData);
resolveValue(
JSON.parse(buffer.subarray(cursor, cursor + length).toString("utf8")) as Record<
string,
unknown
>,
);
};
socket.on("data", onData);
socket.once("error", rejectValue);
});
}
function sendMaskedJson(socket: Socket, value: unknown): void {
const payload = Buffer.from(JSON.stringify(value));
const mask = Buffer.from([0x11, 0x22, 0x33, 0x44]);
@@ -116,23 +118,93 @@ async function expectSocketClosed(socket: Socket): Promise<void> {
});
}
function hello(identity: ReturnType<typeof phase3Internals.phase3Identity>): Record<string, unknown> {
function authHello(
identity: ReturnType<typeof phase3Internals.phase3Identity>,
credentialId: string,
): Record<string, unknown> {
return {
protocol: "paperclip.runner",
version: 1,
envelopeId: "hello_auth_regression",
kind: "hello",
runnerInstanceId: identity.runnerInstanceId,
kind: "auth_hello",
payload: {
credentialId,
clientNonce: "client_nonce_auth_regression",
protocolMin: 1,
protocolMax: 1,
runnerInstanceId: identity.runnerInstanceId,
runnerVersion: "0.3.0",
runnerDigest: "sha256:phase3-approved",
environmentLeaseId: identity.environmentLeaseId,
runId: identity.runId,
normalizedSessionId: identity.normalizedSessionId,
turnId: identity.turnId,
itemId: identity.itemId,
},
};
}
async function runRunnerProcess(options: {
connectUrl: string;
stateDirectory: string;
identity: ReturnType<typeof phase3Internals.phase3Identity>;
ticket: string;
maxRuntimeMs: number;
}): Promise<{ code: number | null; stderr: string }> {
const child = spawn(
phase3Internals.runnerBinary,
[
"--connect-url",
options.connectUrl,
"--state-dir",
options.stateDirectory,
"--runner-id",
options.identity.runnerInstanceId,
"--environment-lease-id",
options.identity.environmentLeaseId,
"--run-id",
options.identity.runId,
"--session-id",
options.identity.normalizedSessionId,
"--turn-id",
options.identity.turnId,
"--item-id",
options.identity.itemId,
"--runner-version",
"0.3.0",
"--runner-digest",
"sha256:phase3-approved",
"--max-outbox-bytes",
String(64 * 1024),
"--p0-reserve-bytes",
String(32 * 1024),
"--reconnect-delay-ms",
"10",
"--max-runtime-ms",
String(options.maxRuntimeMs),
],
{
env: phase3Internals.runnerEnvironment(options.ticket),
stdio: "pipe",
},
);
let stderr = "";
child.stderr.setEncoding("utf8").on("data", (chunk: string) => {
stderr += chunk;
});
const code = await new Promise<number | null>((resolveExit, rejectExit) => {
const timer = setTimeout(() => {
child.kill("SIGKILL");
rejectExit(new Error("Phase 3 runner process did not terminate."));
}, options.maxRuntimeMs + 3_000);
child.once("error", rejectExit);
child.once("exit", (exitCode) => {
clearTimeout(timer);
resolveExit(exitCode);
});
});
return { code, stderr };
}
describe.sequential("Phase 3 durable transport and recovery", () => {
it("accepts a short-lived bootstrap ticket once and rejects its reuse", async () => {
const scratch =
@@ -147,19 +219,50 @@ describe.sequential("Phase 3 durable transport and recovery", () => {
});
try {
await core.start();
core.queueCommand("runner.shutdown", {});
const ticket = core.issueBootstrapTicket();
const stateDirectory = resolve(root, "runner");
const first = await runRunnerProcess({
connectUrl: core.connectUrl,
stateDirectory,
identity: core.identity,
ticket,
maxRuntimeMs: 2_000,
});
expect(first.code, first.stderr).toBe(0);
expect(core.store.state.connectionCount).toBe(1);
const reused = await runRunnerProcess({
connectUrl: core.connectUrl,
stateDirectory,
identity: core.identity,
ticket,
maxRuntimeMs: 100,
});
expect(reused.code).not.toBe(0);
expect(core.store.state.connectionCount).toBe(1);
await expect(upgradeStatus(core.connectUrl, ticket)).resolves.toBe(101);
await expect(upgradeStatus(core.connectUrl, ticket)).resolves.toBe(401);
const expiredTicket = core.issueBootstrapTicket(-1);
await expect(upgradeStatus(core.connectUrl, expiredTicket)).resolves.toBe(401);
const expired = await runRunnerProcess({
connectUrl: core.connectUrl,
stateDirectory: resolve(root, "expired-runner"),
identity: core.identity,
ticket: expiredTicket,
maxRuntimeMs: 100,
});
expect(expired.code).not.toBe(0);
expect(core.store.state.connectionCount).toBe(1);
expect(
Object.values(core.store.state.tickets).filter((record) => record.usedAt !== null),
).toHaveLength(1);
} finally {
await core.stop();
rmSync(root, { recursive: true, force: true });
}
});
it("rejects bootstrap and resumed lease credentials bound to a different runner identity", async () => {
it("requires proof before consuming a ticket and rejects mismatched session identity", async () => {
const scratch =
process.env.PAPERCLIP_RUN_SCRATCH_DIR ??
process.env.PAPERCLIP_SCRATCH_DIR ??
@@ -175,26 +278,31 @@ describe.sequential("Phase 3 durable transport and recovery", () => {
await core.start();
const bootstrap = core.issueBootstrapTicket();
const bootstrapSocket = await upgradeSocket(core.connectUrl, bootstrap);
sendMaskedJson(bootstrapSocket, {
...hello(identity),
runnerInstanceId: "runner_wrong_identity",
});
await expectSocketClosed(bootstrapSocket);
const credentialId = phase3Internals.credentialMaterial(bootstrap).credentialId;
const wrongIdentitySocket = await upgradeSocket(core.connectUrl);
const wrongHello = authHello(identity, credentialId);
(wrongHello.payload as Record<string, unknown>).runId = "run_wrong_identity";
sendMaskedJson(wrongIdentitySocket, wrongHello);
await expectSocketClosed(wrongIdentitySocket);
expect(core.store.state.tickets[credentialId]?.usedAt).toBeNull();
const leaseToken = "lease_wrong_identity_regression";
const digest = phase3Internals.tokenDigest(leaseToken);
core.store.state.leases[digest] = {
digest,
leaseId: "lease_wrong_identity",
runnerInstanceId: "runner_other_identity",
expiresAt: new Date(Date.now() + 30_000).toISOString(),
revokedAt: null,
};
core.store.save();
const leaseSocket = await upgradeSocket(core.connectUrl, leaseToken);
sendMaskedJson(leaseSocket, hello(identity));
await expectSocketClosed(leaseSocket);
const forgedProofSocket = await upgradeSocket(core.connectUrl);
sendMaskedJson(forgedProofSocket, authHello(identity, credentialId));
const challenge = await receiveServerJson(forgedProofSocket);
const challengePayload = challenge.payload as Record<string, unknown>;
sendMaskedJson(forgedProofSocket, {
protocol: "paperclip.runner",
version: 1,
kind: "auth_response",
payload: {
credentialId,
clientNonce: challengePayload.clientNonce,
serverNonce: challengePayload.serverNonce,
clientProof: "00".repeat(32),
},
});
await expectSocketClosed(forgedProofSocket);
expect(core.store.state.tickets[credentialId]?.usedAt).toBeNull();
} finally {
await core.stop();
rmSync(root, { recursive: true, force: true });
@@ -295,6 +403,100 @@ describe.sequential("Phase 3 durable transport and recovery", () => {
expect(trace.assertions.sourceCursorContinuous).toBe(true);
});
it("keeps a restarted revoked runner network-silent and byte-stable with a fresh ticket", async () => {
const scratch =
process.env.PAPERCLIP_RUN_SCRATCH_DIR ??
process.env.PAPERCLIP_SCRATCH_DIR ??
tmpdir();
const root = mkdtempSync(resolve(scratch, "phase3-revoked-restart-test-"));
const runnerStateDirectory = resolve(root, "runner");
const identity = phase3Internals.phase3Identity();
mkdirSync(runnerStateDirectory, { recursive: true, mode: 0o700 });
const envelope = {
protocol: "paperclip.runner",
version: 1,
envelopeId: "envelope_event_000004",
kind: "event",
runnerInstanceId: identity.runnerInstanceId,
payload: {
sourceEventId: `event_${identity.runnerInstanceId}_000004`,
sourceSeq: 4,
eventType: "run.terminal",
priority: 0,
payload: { status: "succeeded" },
},
};
const byteSize = Buffer.byteLength(JSON.stringify(envelope));
const statePath = resolve(runnerStateDirectory, "runner-state.json");
writeFileSync(
statePath,
JSON.stringify(
{
schema: "paperclip.runner.phase3.state.v1",
...identity,
lifecycle: "revoked",
nextSourceSeq: 5,
ackedSourceSeq: 3,
lastControllerCommandSeq: 2,
reconnectCount: 7,
maxOutboxBytes: 64 * 1024,
peakOutboxBytes: byteSize,
outbox: [
{
sourceSeq: 4,
sourceEventId: `event_${identity.runnerInstanceId}_000004`,
priority: 0,
eventType: "run.terminal",
itemId: null,
envelope,
byteSize,
},
],
processedCommands: {},
compactedCommandFilter: "00".repeat(4_096),
compactedCommandCount: 0,
diagnostics: ["connection lease revoked; preserving durable events"],
backpressure: false,
recoverableFailure: null,
unrecoverableOutcome: null,
harnessGeneration: 1,
stopAfterFlush: true,
},
null,
2,
),
{ mode: 0o600 },
);
const before = readFileSync(statePath);
const core = new Phase3MockCore({
stateDirectory: resolve(root, "mock-core"),
identity,
fault: "none",
});
try {
await core.start();
const ticket = core.issueBootstrapTicket();
const connectionsBefore = core.store.state.connectionCount;
const result = await runRunnerProcess({
connectUrl: core.connectUrl,
stateDirectory: runnerStateDirectory,
identity,
ticket,
maxRuntimeMs: 1_000,
});
expect(result.code, result.stderr).toBe(0);
expect(core.store.state.connectionCount).toBe(connectionsBefore);
expect(Object.values(core.store.state.tickets).map((record) => record.usedAt)).toEqual([
null,
]);
expect(readFileSync(statePath)).toEqual(before);
} finally {
await core.stop();
rmSync(root, { recursive: true, force: true });
}
});
it("reconciles a restarted harness without replacing session, turn, or item IDs", async () => {
const trace = await runPhase3Recovery({ fault: "harness-restart" });
const reconciled = trace.diagnostics.committedEvents.find(
@@ -1,5 +1,12 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import {
createCipheriv,
createDecipheriv,
createHash,
createHmac,
randomUUID,
timingSafeEqual,
} from "node:crypto";
import {
chmodSync,
closeSync,
@@ -51,22 +58,31 @@ const fakeHarnessScript = resolve(
);
const protocol = "paperclip.runner";
const protocolVersion = 1;
const secureFrameSchema = "paperclip.runner.secure-frame.v1";
const websocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const coreStateSchema = "paperclip.runner.phase3.mock-core-state.v1";
const maxFrameBytes = 1024 * 1024;
interface BootstrapTicketRecord {
digest: string;
runnerInstanceId: string;
credentialId: string;
authKeyDigest: string;
identity: Phase3Identity;
runnerVersion: string;
runnerDigest: string;
expiresAt: string;
expiresAtUnixMs: number;
usedAt: string | null;
}
interface ConnectionLeaseRecord {
digest: string;
credentialId: string;
authKeyDigest: string;
leaseId: string;
runnerInstanceId: string;
identity: Phase3Identity;
protocolVersion: number;
expiresAt: string;
expiresAtUnixMs: number;
revocationEpoch: number;
revokedAt: string | null;
}
@@ -88,20 +104,34 @@ interface StoredCoreState {
lastLeaseExpiresAt: string | null;
}
type Authorization =
type PendingAuthorization =
| {
kind: "bootstrap";
digest: string;
rawToken: string;
authKey: Buffer;
ticket: BootstrapTicketRecord;
}
| {
kind: "lease";
digest: string;
rawToken: string;
authKey: Buffer;
lease: ConnectionLeaseRecord;
};
interface PendingChallenge {
authorization: PendingAuthorization;
canonicalChallenge: string;
serverProof: string;
clientNonce: string;
serverNonce: string;
}
interface SecureChannel {
sendKey: Buffer;
receiveKey: Buffer;
sendCounter: bigint;
receiveCounter: bigint;
sessionId: string;
}
interface MockCoreOptions {
stateDirectory: string;
identity: Phase3Identity;
@@ -122,8 +152,36 @@ interface RunnerProcessHandle {
completion: Promise<RunnerProcessResult>;
}
function domainDigest(domain: string, parts: readonly Buffer[]): Buffer {
const digest = createHash("sha256").update(domain).update(Buffer.from([0]));
for (const part of parts) {
const length = Buffer.alloc(8);
length.writeBigUInt64BE(BigInt(part.length));
digest.update(length).update(part);
}
return digest.digest();
}
function domainHmac(key: Buffer, domain: string, parts: readonly Buffer[]): Buffer {
const digest = createHmac("sha256", key).update(domain).update(Buffer.from([0]));
for (const part of parts) {
const length = Buffer.alloc(8);
length.writeBigUInt64BE(BigInt(part.length));
digest.update(length).update(part);
}
return digest.digest();
}
function credentialMaterial(token: string): { credentialId: string; authKey: Buffer } {
const bytes = Buffer.from(token);
return {
credentialId: `sha256:${domainDigest("paperclip-runner-credential-id-v1", [bytes]).toString("hex")}`,
authKey: domainDigest("paperclip-runner-auth-key-v1", [bytes]),
};
}
function tokenDigest(token: string): string {
return `sha256:${createHash("sha256").update(token).digest("hex")}`;
return `sha256:${credentialMaterial(token).authKey.toString("hex")}`;
}
function canonicalJson(value: unknown): string {
@@ -144,6 +202,103 @@ function safeDate(offsetMs: number): string {
return new Date(Date.now() + offsetMs).toISOString();
}
function authKeyFromDigest(digest: string): Buffer {
const hex = digest.match(/^sha256:([0-9a-f]{64})$/)?.[1];
if (hex === undefined) throw new Error("Stored transport authentication key is malformed.");
return Buffer.from(hex, "hex");
}
function proofMatches(expected: Buffer, supplied: unknown): boolean {
if (typeof supplied !== "string" || !/^[0-9a-f]{64}$/.test(supplied)) return false;
return timingSafeEqual(expected, Buffer.from(supplied, "hex"));
}
function createSecureChannel(
authKey: Buffer,
canonicalChallenge: string,
serverProof: string,
clientProof: string,
): SecureChannel {
const parts = [
Buffer.from(canonicalChallenge),
Buffer.from(serverProof),
Buffer.from(clientProof),
];
const binding = domainDigest("paperclip-runner-session-binding-v1", parts);
return {
sendKey: domainHmac(authKey, "paperclip-runner-core-to-client-key-v1", [binding]),
receiveKey: domainHmac(authKey, "paperclip-runner-client-to-core-key-v1", [binding]),
sendCounter: 0n,
receiveCounter: 0n,
sessionId: `sha256:${binding.toString("hex")}`,
};
}
function secureNonce(prefix: "P3C1" | "P3S1", counter: bigint): Buffer {
const nonce = Buffer.alloc(12);
nonce.write(prefix, 0, "ascii");
nonce.writeBigUInt64BE(counter, 4);
return nonce;
}
function secureAad(
channel: SecureChannel,
direction: "client_to_core" | "core_to_client",
counter: bigint,
): Buffer {
return Buffer.from(`${secureFrameSchema}\0${channel.sessionId}\0${direction}\0${counter}`);
}
function encryptSecureJson(channel: SecureChannel, value: unknown): Record<string, unknown> {
const counter = channel.sendCounter;
const cipher = createCipheriv("aes-256-gcm", channel.sendKey, secureNonce("P3S1", counter));
cipher.setAAD(secureAad(channel, "core_to_client", counter));
const ciphertext = Buffer.concat([
cipher.update(Buffer.from(JSON.stringify(value))),
cipher.final(),
cipher.getAuthTag(),
]);
channel.sendCounter += 1n;
return {
schema: secureFrameSchema,
counter: Number(counter),
ciphertext: ciphertext.toString("hex"),
};
}
function decryptSecureJson(channel: SecureChannel, value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value === null) {
throw new Error("Secure frame must be an object.");
}
const frame = value as Record<string, unknown>;
if (
frame.schema !== secureFrameSchema ||
typeof frame.counter !== "number" ||
!Number.isSafeInteger(frame.counter) ||
BigInt(frame.counter) !== channel.receiveCounter ||
typeof frame.ciphertext !== "string" ||
!/^[0-9a-f]+$/.test(frame.ciphertext) ||
frame.ciphertext.length % 2 !== 0
) {
throw new Error("Secure frame metadata or counter is invalid.");
}
const sealed = Buffer.from(frame.ciphertext, "hex");
if (sealed.length < 16) throw new Error("Secure frame authentication tag is missing.");
const ciphertext = sealed.subarray(0, -16);
const tag = sealed.subarray(-16);
const counter = channel.receiveCounter;
const decipher = createDecipheriv(
"aes-256-gcm",
channel.receiveKey,
secureNonce("P3C1", counter),
);
decipher.setAAD(secureAad(channel, "client_to_core", counter));
decipher.setAuthTag(tag);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
channel.receiveCounter += 1n;
return JSON.parse(plaintext.toString("utf8")) as Record<string, unknown>;
}
function initialCoreState(identity: Phase3Identity): StoredCoreState {
return {
schema: coreStateSchema,
@@ -304,8 +459,10 @@ class DurableCoreStore {
class MockWebSocketConnection {
readonly socket: Duplex;
readonly authorization: Authorization;
pendingChallenge: PendingChallenge | null = null;
secureChannel: SecureChannel | null = null;
lease: ConnectionLeaseRecord | null = null;
connectionId: string | null = null;
#buffer = Buffer.alloc(0);
#closed = false;
#onText: (text: string) => void;
@@ -313,12 +470,10 @@ class MockWebSocketConnection {
constructor(
socket: Duplex,
authorization: Authorization,
onText: (text: string) => void,
onClose: () => void,
) {
this.socket = socket;
this.authorization = authorization;
this.#onText = onText;
this.#onClose = onClose;
socket.on("data", (chunk: Buffer) => this.#consume(chunk));
@@ -332,7 +487,8 @@ class MockWebSocketConnection {
}
sendJson(value: unknown): void {
this.sendText(JSON.stringify(value));
const wire = this.secureChannel === null ? value : encryptSecureJson(this.secureChannel, value);
this.sendText(JSON.stringify(wire));
}
sendText(text: string): void {
@@ -492,11 +648,16 @@ export class Phase3MockCore {
issueBootstrapTicket(ttlMs = 5_000): string {
const ticket = `bootstrap_${randomUUID()}`;
const digest = tokenDigest(ticket);
this.store.state.tickets[digest] = {
digest,
runnerInstanceId: this.identity.runnerInstanceId,
expiresAt: safeDate(ttlMs),
const material = credentialMaterial(ticket);
const expiresAtUnixMs = Date.now() + ttlMs;
this.store.state.tickets[material.credentialId] = {
credentialId: material.credentialId,
authKeyDigest: `sha256:${material.authKey.toString("hex")}`,
identity: structuredClone(this.identity),
runnerVersion: this.#expectedRunnerVersion,
runnerDigest: this.#expectedRunnerDigest,
expiresAt: new Date(expiresAtUnixMs).toISOString(),
expiresAtUnixMs,
usedAt: null,
};
this.store.state.freshBootstraps += 1;
@@ -542,12 +703,6 @@ export class Phase3MockCore {
socket.destroy();
return;
}
const authorization = this.#authorize(request.headers.authorization);
if (authorization === null) {
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
socket.destroy();
return;
}
const websocketKey = request.headers["sec-websocket-key"];
if (typeof websocketKey !== "string") {
socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
@@ -568,38 +723,20 @@ export class Phase3MockCore {
);
const connection = new MockWebSocketConnection(
socket,
authorization,
(text) => this.#handleText(connection, text),
() => this.#connections.delete(connection),
);
this.#connections.add(connection);
}
#authorize(header: string | undefined): Authorization | null {
const rawToken = header?.match(/^Bearer (.+)$/i)?.[1];
if (rawToken === undefined) return null;
const digest = tokenDigest(rawToken);
const ticket = this.store.state.tickets[digest];
if (ticket !== undefined && ticket.usedAt === null && Date.parse(ticket.expiresAt) > Date.now()) {
ticket.usedAt = new Date().toISOString();
this.store.save();
return { kind: "bootstrap", digest, rawToken, ticket };
}
const lease = this.store.state.leases[digest];
if (
lease !== undefined &&
lease.revokedAt === null &&
Date.parse(lease.expiresAt) > Date.now()
) {
return { kind: "lease", digest, rawToken, lease };
}
return null;
}
#handleText(connection: MockWebSocketConnection, text: string): void {
let envelope: Record<string, unknown>;
try {
envelope = JSON.parse(text) as Record<string, unknown>;
const wire = JSON.parse(text) as unknown;
envelope =
connection.secureChannel === null
? (wire as Record<string, unknown>)
: decryptSecureJson(connection.secureChannel, wire);
} catch {
this.store.state.malformedFrames += 1;
this.store.save();
@@ -611,11 +748,15 @@ export class Phase3MockCore {
return;
}
const kind = envelope.kind;
if (kind === "hello") {
this.#hello(connection, envelope);
if (connection.secureChannel === null && kind === "auth_hello") {
this.#authHello(connection, envelope);
return;
}
if (connection.lease === null) {
if (connection.secureChannel === null && kind === "auth_response") {
this.#authResponse(connection, envelope);
return;
}
if (connection.secureChannel === null || connection.lease === null) {
connection.close();
return;
}
@@ -632,42 +773,165 @@ export class Phase3MockCore {
}
}
#hello(connection: MockWebSocketConnection, envelope: Record<string, unknown>): void {
const payload = envelope.payload as Record<string, unknown> | undefined;
const authorizedRunnerInstanceId =
connection.authorization.kind === "bootstrap"
? connection.authorization.ticket.runnerInstanceId
: connection.authorization.lease.runnerInstanceId;
#authorizeHello(payload: Record<string, unknown>): PendingAuthorization | null {
const credentialId = payload.credentialId;
if (typeof credentialId !== "string") return null;
const ticket = this.store.state.tickets[credentialId];
const lease = this.store.state.leases[credentialId];
const authorization: PendingAuthorization | null =
ticket !== undefined && ticket.usedAt === null && ticket.expiresAtUnixMs > Date.now()
? { kind: "bootstrap", authKey: authKeyFromDigest(ticket.authKeyDigest), ticket }
: lease !== undefined &&
lease.revokedAt === null &&
lease.expiresAtUnixMs > Date.now()
? { kind: "lease", authKey: authKeyFromDigest(lease.authKeyDigest), lease }
: null;
if (authorization === null) return null;
const binding = authorization.kind === "bootstrap" ? authorization.ticket : authorization.lease;
const identity = binding.identity;
if (
envelope.runnerInstanceId !== this.identity.runnerInstanceId ||
envelope.runnerInstanceId !== authorizedRunnerInstanceId ||
payload?.environmentLeaseId !== this.identity.environmentLeaseId ||
payload?.runnerVersion !== this.#expectedRunnerVersion ||
payload?.runnerDigest !== this.#expectedRunnerDigest ||
payload?.protocolMin !== 1 ||
payload.protocolMax !== 1
payload.runnerInstanceId !== identity.runnerInstanceId ||
payload.environmentLeaseId !== identity.environmentLeaseId ||
payload.runId !== identity.runId ||
payload.normalizedSessionId !== identity.normalizedSessionId ||
payload.turnId !== identity.turnId ||
payload.itemId !== identity.itemId ||
payload.runnerVersion !== this.#expectedRunnerVersion ||
payload.runnerDigest !== this.#expectedRunnerDigest ||
payload.protocolMin !== 1 ||
payload.protocolMax !== 1 ||
(authorization.kind === "lease" && authorization.lease.protocolVersion !== protocolVersion)
) {
return null;
}
return authorization;
}
#authHello(connection: MockWebSocketConnection, envelope: Record<string, unknown>): void {
if (connection.pendingChallenge !== null) {
connection.close();
return;
}
const payload = envelope.payload as Record<string, unknown> | undefined;
if (payload === undefined || typeof payload.clientNonce !== "string") {
connection.close();
return;
}
const authorization = this.#authorizeHello(payload);
if (authorization === null) {
connection.close();
return;
}
const credential = authorization.kind === "bootstrap" ? authorization.ticket : authorization.lease;
const serverNonce = randomUUID();
const challengePayload: Record<string, unknown> = {
credentialId: credential.credentialId,
credentialKind: authorization.kind,
clientNonce: payload.clientNonce,
serverNonce,
runnerInstanceId: payload.runnerInstanceId,
environmentLeaseId: payload.environmentLeaseId,
runId: payload.runId,
normalizedSessionId: payload.normalizedSessionId,
turnId: payload.turnId,
itemId: payload.itemId,
runnerVersion: payload.runnerVersion,
runnerDigest: payload.runnerDigest,
selectedVersion: protocolVersion,
credentialLeaseId: authorization.kind === "lease" ? authorization.lease.leaseId : null,
credentialExpiresAt: credential.expiresAt,
credentialExpiresAtUnixMs: credential.expiresAtUnixMs,
revocationEpoch: authorization.kind === "lease" ? authorization.lease.revocationEpoch : 0,
};
const canonicalChallenge = canonicalJson(challengePayload);
const serverProof = domainHmac(
authorization.authKey,
"paperclip-runner-server-proof-v1",
[Buffer.from(canonicalChallenge)],
).toString("hex");
connection.pendingChallenge = {
authorization,
canonicalChallenge,
serverProof,
clientNonce: payload.clientNonce,
serverNonce,
};
connection.sendJson({
protocol,
version: protocolVersion,
kind: "auth_challenge",
payload: { ...challengePayload, serverProof },
});
}
#authResponse(connection: MockWebSocketConnection, envelope: Record<string, unknown>): void {
const pending = connection.pendingChallenge;
const payload = envelope.payload as Record<string, unknown> | undefined;
if (
pending === null ||
payload === undefined ||
payload.credentialId !==
(pending.authorization.kind === "bootstrap"
? pending.authorization.ticket.credentialId
: pending.authorization.lease.credentialId) ||
payload.clientNonce !== pending.clientNonce ||
payload.serverNonce !== pending.serverNonce
) {
connection.close();
return;
}
let leaseToken = connection.authorization.rawToken;
const expectedClientProof = domainHmac(
pending.authorization.authKey,
"paperclip-runner-client-proof-v1",
[Buffer.from(pending.canonicalChallenge), Buffer.from(pending.serverProof)],
);
if (!proofMatches(expectedClientProof, payload.clientProof)) {
connection.close();
return;
}
const clientProof = expectedClientProof.toString("hex");
let leaseToken: string | null = null;
let lease: ConnectionLeaseRecord;
if (connection.authorization.kind === "bootstrap") {
if (pending.authorization.kind === "bootstrap") {
pending.authorization.ticket.usedAt = new Date().toISOString();
leaseToken = `lease_${randomUUID()}`;
const digest = tokenDigest(leaseToken);
const material = credentialMaterial(leaseToken);
const ttlMs = this.fault === "lease-expiry" && !this.#faultTriggered ? 50 : 30_000;
const expiresAtUnixMs = Date.now() + ttlMs;
lease = {
digest,
credentialId: material.credentialId,
authKeyDigest: `sha256:${material.authKey.toString("hex")}`,
leaseId: `connection_lease_${randomUUID()}`,
runnerInstanceId: this.identity.runnerInstanceId,
expiresAt: safeDate(this.fault === "lease-expiry" && !this.#faultTriggered ? 50 : 30_000),
identity: structuredClone(this.identity),
protocolVersion,
expiresAt: new Date(expiresAtUnixMs).toISOString(),
expiresAtUnixMs,
revocationEpoch: 0,
revokedAt: null,
};
this.store.state.leases[digest] = lease;
this.store.state.leases[material.credentialId] = lease;
} else {
lease = connection.authorization.lease;
lease = pending.authorization.lease;
}
connection.pendingChallenge = null;
connection.lease = lease;
connection.connectionId = `connection_${this.store.state.connectionCount + 1}`;
connection.secureChannel = createSecureChannel(
pending.authorization.authKey,
pending.canonicalChallenge,
pending.serverProof,
clientProof,
);
this.#welcome(connection, leaseToken);
}
#welcome(connection: MockWebSocketConnection, leaseToken: string | null): void {
const lease = connection.lease;
if (lease === null || connection.connectionId === null) {
connection.close();
return;
}
this.store.state.connectionCount += 1;
this.store.state.lastLeaseId = lease.leaseId;
this.store.state.lastLeaseExpiresAt = lease.expiresAt;
@@ -691,14 +955,29 @@ export class Phase3MockCore {
envelopeId: `welcome_${this.store.state.connectionCount}`,
kind: "welcome",
runnerInstanceId: this.identity.runnerInstanceId,
connectionId: `connection_${this.store.state.connectionCount}`,
environmentLeaseId: this.identity.environmentLeaseId,
runId: this.identity.runId,
normalizedSessionId: this.identity.normalizedSessionId,
turnId: this.identity.turnId,
itemId: this.identity.itemId,
connectionId: connection.connectionId,
connectionLeaseId: lease.leaseId,
sentAt: new Date().toISOString(),
payload: {
selectedVersion: 1,
heartbeatIntervalMs: 250,
connectionLeaseId: lease.leaseId,
connectionLeaseToken: leaseToken,
...(leaseToken === null ? {} : { connectionLeaseToken: leaseToken }),
connectionLeaseExpiresAt: lease.expiresAt,
connectionLeaseExpiresAtUnixMs: lease.expiresAtUnixMs,
connectionLeaseRevocationEpoch: lease.revocationEpoch,
leaseBinding: {
runnerInstanceId: this.identity.runnerInstanceId,
environmentLeaseId: this.identity.environmentLeaseId,
runId: this.identity.runId,
normalizedSessionId: this.identity.normalizedSessionId,
protocolVersion,
},
maxFrameBytes,
maxBatchEvents: 100,
ackedSourceSeq: reportedAck,
@@ -715,6 +994,7 @@ export class Phase3MockCore {
}
if (this.fault === "lease-expiry") {
lease.expiresAt = safeDate(-1);
lease.expiresAtUnixMs = Date.now() - 1;
this.store.state.lastLeaseExpiresAt = lease.expiresAt;
this.store.save();
}
@@ -732,23 +1012,47 @@ export class Phase3MockCore {
return command === undefined ? [] : [command];
}
#controlEnvelope(
connection: MockWebSocketConnection,
envelopeId: string,
kind: string,
payload: Record<string, unknown>,
): Record<string, unknown> {
if (connection.lease === null || connection.connectionId === null) {
throw new Error("Cannot send control data before transport authentication.");
}
return {
protocol,
version: protocolVersion,
envelopeId,
kind,
runnerInstanceId: this.identity.runnerInstanceId,
environmentLeaseId: this.identity.environmentLeaseId,
runId: this.identity.runId,
normalizedSessionId: this.identity.normalizedSessionId,
turnId: this.identity.turnId,
itemId: this.identity.itemId,
connectionId: connection.connectionId,
connectionLeaseId: connection.lease.leaseId,
sentAt: new Date().toISOString(),
payload,
};
}
#sendNextCommand(connection: MockWebSocketConnection): void {
const [command] = this.#nextPendingCommand();
if (command === undefined) return;
this.store.state.commandDeliveryCounts[command.commandId] =
(this.store.state.commandDeliveryCounts[command.commandId] ?? 0) + 1;
this.store.save();
connection.sendJson({
protocol,
version: protocolVersion,
envelopeId: `command_${command.commandId}_${this.store.state.commandDeliveryCounts[command.commandId]}`,
kind: "command",
runnerInstanceId: this.identity.runnerInstanceId,
runId: this.identity.runId,
normalizedSessionId: this.identity.normalizedSessionId,
sentAt: new Date().toISOString(),
payload: this.#wireCommand(command),
});
connection.sendJson(
this.#controlEnvelope(
connection,
`command_${command.commandId}_${this.store.state.commandDeliveryCounts[command.commandId]}`,
"command",
this.#wireCommand(command),
),
);
}
#commandResult(
@@ -834,6 +1138,7 @@ export class Phase3MockCore {
if (this.fault === "revoke" && eventType === "run.terminal" && !this.#faultTriggered) {
if (connection.lease !== null) {
connection.lease.revokedAt = new Date().toISOString();
connection.lease.revocationEpoch += 1;
}
this.queueCommand(
"turn.start",
@@ -843,15 +1148,13 @@ export class Phase3MockCore {
this.#triggerFault();
this.store.save();
// Revoke before ACK so the runner must flush a genuinely non-empty durable outbox.
connection.sendJson({
protocol,
version: protocolVersion,
envelopeId: "revoke_phase3",
kind: "revoke",
runnerInstanceId: this.identity.runnerInstanceId,
sentAt: new Date().toISOString(),
payload: { reason: "fault_injection_revoke", drain: true },
});
connection.sendJson(
this.#controlEnvelope(connection, "revoke_phase3", "revoke", {
reason: "fault_injection_revoke",
drain: true,
revocationEpoch: connection.lease?.revocationEpoch,
}),
);
this.#sendNextCommand(connection);
return;
}
@@ -867,15 +1170,11 @@ export class Phase3MockCore {
return;
}
connection.sendJson({
protocol,
version: protocolVersion,
envelopeId: `ack_${this.store.state.ackedSourceSeq}`,
kind: "ack",
runnerInstanceId: this.identity.runnerInstanceId,
sentAt: new Date().toISOString(),
payload: { ackedSourceSeq: this.store.state.ackedSourceSeq },
});
connection.sendJson(
this.#controlEnvelope(connection, `ack_${this.store.state.ackedSourceSeq}`, "ack", {
ackedSourceSeq: this.store.state.ackedSourceSeq,
}),
);
}
}
@@ -1249,6 +1548,7 @@ export async function runPhase3Recovery(
export const phase3Internals = {
runnerBinary,
runnerEnvironment,
credentialMaterial,
tokenDigest,
canonicalJson,
phase3Identity,