@coproduct_inc/verify 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # @coproduct_inc/verify
2
+
3
+ Offline, **zero-trust** verification for Nucleus. Two receipt families share one package:
4
+
5
+ 1. **Capability-boundary in-bounds attestation** (headline) — given an agent run's tool-call trace and a *declared* capability boundary, emit and offline-verify an Ed25519-signed, hash-chained receipt that the agent **stayed within bounds**. The boundary is keyed on a **cryptographic principal**, never a mutable display name — which is exactly what rules out OpenClaw-class trust-boundary bypass.
6
+ 2. **Auction receipts** — re-run the VCG/Vickrey clearing locally and assert the receipt's price (the package's original use case; documented at the bottom).
7
+
8
+ ---
9
+
10
+ ## In-bounds attestation — the drop-in
11
+
12
+ ```ts
13
+ import { verifyReceipt } from "@coproduct_inc/verify";
14
+
15
+ const report = verifyReceipt(receipt); // a parsed Receipt object
16
+ if (!report.ok) throw new Error(report.reason); // refuses to attest
17
+ // report.ok === signatureOk && rootHashOk && verdictConsistentOk && inBounds
18
+ ```
19
+
20
+ …or from JSON / the CLI:
21
+
22
+ ```sh
23
+ nucleus-verify run-receipt.json --expect-principal spiffe://acme/agent/billing
24
+ # exit 0 → verified and in bounds | exit 1 → refused / out of bounds
25
+ cat run-receipt.json | nucleus-verify --json
26
+ ```
27
+
28
+ In CI, via the bundled GitHub Action:
29
+
30
+ ```yaml
31
+ - uses: coproduct/nucleus-platform/packages/nucleus-verify@main
32
+ with:
33
+ receipt: ./agent-run-receipt.json
34
+ expect-principal: spiffe://acme/agent/billing-assistant
35
+ ```
36
+
37
+ > The Action resolves the verifier with `npx @coproduct_inc/verify`, so it requires the package to be published to npm (or `package-spec` pointed at a local tarball). Publishing is an operator step.
38
+
39
+ ### The OpenClaw guard, in one example
40
+
41
+ ```ts
42
+ import { makeBoundary, signReceipt, verifyReceipt, generateKeypair } from "@coproduct_inc/verify";
43
+
44
+ const principal = "spiffe://acme/agent/billing-assistant";
45
+ const boundary = makeBoundary(principal, ["read_invoice", "list_invoices", "summarize"]);
46
+ const { privateKey } = generateKeypair();
47
+
48
+ // An injected call wears the allowlisted DISPLAY NAME but a different principal:
49
+ const trace = [
50
+ { seq: 0, tool: "list_invoices", principal, displayName: "Billing Assistant" },
51
+ { seq: 1, tool: "read_invoice", principal: "spiffe://acme/agent/unknown-7f3a", displayName: "Billing Assistant" },
52
+ ];
53
+
54
+ const receipt = signReceipt(boundary, trace, privateKey);
55
+ verifyReceipt(receipt).ok; // → false: "out of bounds at event #1: … principal … but the boundary is bound to …"
56
+ ```
57
+
58
+ Run the full replay (clean attests · bypass refused · forged claim rejected):
59
+
60
+ ```sh
61
+ pnpm build && pnpm demo # examples/openclaw-replay.mjs
62
+ ```
63
+
64
+ ### Why it holds
65
+
66
+ - **`recomputeVerdict` is the soundness floor.** It is pure and deterministic: an event is in-bounds iff its **`principal`** equals the boundary's principal **and** its `tool` is in `allowedTools`. `displayName` is never consulted. A forged `inBounds: true` cannot survive an independent re-run.
67
+ - **Two independent defenses against a tampered claim.** The verifier recomputes the verdict *and* checks the Ed25519 signature over the canonical body (which commits to the verdict). Flipping `inBounds` breaks both.
68
+ - **The permission fingerprint binds identity to capability.** `permissionFingerprint` is SHA-256 over the sorted, de-duplicated `allowedTools` — mirroring the X.509 permission-fingerprint extension in `nucleus-tool-proxy`/`nucleus-identity` (`identity_fusion`: "who you are" bound to "what you can do"). Widening the tool set changes the fingerprint and invalidates the boundary.
69
+ - **The hash-chain pins trace order.** `rootHash` is `hᵢ = SHA256(hᵢ₋₁ ‖ canonical(eventᵢ))`; reorder or insert and the root changes.
70
+
71
+ ### Honest scope
72
+
73
+ This attests that the **observed trace** stayed within the **declared boundary** — integrity-axis, model-level evidence (the bar-2 IFC noninterference guarantee). It is **not**:
74
+
75
+ - an end-to-end proof of arbitrary agent behaviour, nor
76
+ - a guarantee that the trace is a *complete* record of what the agent did — completeness of capture is the instrumented runtime's responsibility.
77
+
78
+ It is the verifiable **evidence layer** that sits above probabilistic guardrails, not a replacement for them. The proof is the differentiator behind the policy; what you verify is the declared boundary.
79
+
80
+ ### Report fields
81
+
82
+ | field | meaning |
83
+ | --- | --- |
84
+ | `ok` | `signatureOk && rootHashOk && verdictConsistentOk && inBounds` (plus any pinned-key / pinned-principal checks) |
85
+ | `signatureOk` | Ed25519 signature verified under the receipt's public key |
86
+ | `rootHashOk` | SHA-256 hash-chain over `events` reproduces `receipt.rootHash` |
87
+ | `verdictConsistentOk` | independently recomputed verdict deep-equals the claimed verdict |
88
+ | `inBounds` | the recomputed verdict says every event is in bounds |
89
+ | `recomputed` | the verifier's own verdict (authoritative over the claim) |
90
+ | `reason` | first failing reason, or `null` when `ok` |
91
+
92
+ ---
93
+
94
+ ## Auction receipts
95
+
96
+ The agent never trusts the hub's clearing price: the bundled WASM core (the same `nucleus-wasm` binary the browser demo runs) **re-runs the auction clearing locally** from the *signed* bid set and asserts the recomputed price equals the price the receipt claims — on top of the Ed25519 signature + BLAKE3 root-hash check.
97
+
98
+ ```ts
99
+ import { verify } from "@coproduct_inc/verify";
100
+ const r = await verify(receiptJson, jwksJson);
101
+ if (!r.ok) throw new Error(r.reason ?? "receipt failed verification");
102
+ // r.ok === signature_ok && root_hash_ok && price_recomputed_ok
103
+ ```
104
+
105
+ A signature proves the issuer signed *those bytes*; it does not prove the price in those bytes is the price the auction rules produce. `verify()` closes that gap by recomputing the clearing from the signed bids in the caller's own process. The committed price is single-good Vickrey (what the hub's receipt route runs), pinned to the real hub by a native parity test; the Pigou-VCG figure is parity-pinned to the 0-sorry Lean theorems and reported as a cross-check. See [`docs/BET-C-VERIFY-RECOMPUTE.md`](../../docs/BET-C-VERIFY-RECOMPUTE.md).
106
+
107
+ ---
108
+
109
+ ## Build
110
+
111
+ ```sh
112
+ pnpm install
113
+ pnpm build # tsc → dist/ (attestation core + CLI, zero runtime deps)
114
+ pnpm test # node --test against dist/ (attestation + auction)
115
+ pnpm demo # OpenClaw replay
116
+ pnpm build:wasm # regenerate wasm/nodejs from crates/nucleus-wasm (auction path only)
117
+ ```
118
+
119
+ The attestation core and CLI are pure Node (`node:crypto`, no WASM, no npm deps). The WASM artifact under `wasm/nodejs/` (auction path) is committed so the package stays self-contained; `pnpm build:wasm` regenerates it byte-for-byte from the Rust crate.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * `@coproduct_inc/verify` — capability-boundary **in-bounds attestation**.
3
+ *
4
+ * The evidence layer ABOVE probabilistic guardrails: given an agent run's
5
+ * observed tool-call trace and a DECLARED capability boundary, produce and
6
+ * offline-verify an Ed25519-signed, SHA-256-hash-chained receipt that the
7
+ * agent stayed within the boundary.
8
+ *
9
+ * The boundary is keyed on a **cryptographic principal** (a SPIFFE ID) bound
10
+ * to a **SHA-256 permission fingerprint** — exactly the binding
11
+ * `nucleus-tool-proxy::identity_fusion` makes between "who you are" (SPIFFE
12
+ * ID) and "what you can do" (the permission fingerprint embedded in the
13
+ * X.509 cert). Trust is NEVER keyed on a mutable display name. That single
14
+ * discipline is what rules out the OpenClaw-class trust-boundary bypass
15
+ * (CVE-2026-25253): a call whose human-readable `displayName` matches an
16
+ * allowlisted agent but whose cryptographic `principal` differs is
17
+ * out-of-bounds by construction, and the receipt refuses to attest it.
18
+ *
19
+ * ## Honest scope
20
+ *
21
+ * This attests that the OBSERVED trace stayed within the DECLARED boundary —
22
+ * it is integrity-axis, model-level evidence (the bar-2 IFC noninterference
23
+ * guarantee), NOT an end-to-end proof of arbitrary agent behaviour, and it
24
+ * does not vouch that the trace is a complete record of what the agent did.
25
+ * It is the verifiable evidence layer; completeness of capture is the
26
+ * producer's (instrumented runtime's) responsibility.
27
+ *
28
+ * Zero runtime dependencies: Ed25519 + SHA-256 come from `node:crypto`.
29
+ */
30
+ import { type KeyObject } from "node:crypto";
31
+ /** Wire identifiers — bumped on any breaking schema change. */
32
+ export declare const RECEIPT_KIND: "nucleus.capability-boundary.attestation";
33
+ export declare const RECEIPT_VERSION: 1;
34
+ /**
35
+ * A declared capability boundary: WHICH tools a cryptographic principal is
36
+ * permitted to invoke.
37
+ *
38
+ * `permissionFingerprint` is the SHA-256 (hex) over the canonical, sorted,
39
+ * de-duplicated `allowedTools` — mirroring the X.509 permission-fingerprint
40
+ * extension in `nucleus-identity::attestation`. It binds the boundary's
41
+ * identity to its exact permission set: change the tools, change the
42
+ * fingerprint.
43
+ */
44
+ export interface CapabilityBoundary {
45
+ /** Cryptographic principal identity (SPIFFE ID convention). NOT a name. */
46
+ principal: string;
47
+ /** Tools/capabilities this principal is permitted to invoke. */
48
+ allowedTools: string[];
49
+ /** SHA-256 hex over the canonical sorted unique `allowedTools`. */
50
+ permissionFingerprint: string;
51
+ }
52
+ /** One observed tool-call event from an agent run. */
53
+ export interface TraceEvent {
54
+ /** Monotonic position in the run (0-based). */
55
+ seq: number;
56
+ /** Tool / capability actually invoked. */
57
+ tool: string;
58
+ /** Cryptographic identity PRESENTED for this call (SPIFFE ID). */
59
+ principal: string;
60
+ /**
61
+ * Human-readable label the call presented (mutable). This is the OpenClaw
62
+ * attack surface — a naive gate keys on this; we record it but NEVER trust
63
+ * it for the in-bounds decision.
64
+ */
65
+ displayName?: string;
66
+ /** Optional SHA-256 hex of the call arguments (opaque to the verdict). */
67
+ argsDigest?: string;
68
+ }
69
+ /** Why a single event is in / out of bounds. */
70
+ export type FindingCode = "ok" | "tool_not_allowed" | "principal_mismatch" | "fingerprint_mismatch";
71
+ export interface EventFinding {
72
+ seq: number;
73
+ tool: string;
74
+ inBounds: boolean;
75
+ code: FindingCode;
76
+ detail: string;
77
+ }
78
+ /** The deterministic verdict recomputed from a boundary + events. */
79
+ export interface Verdict {
80
+ /** AND over `boundaryFingerprintOk` and every event finding. */
81
+ inBounds: boolean;
82
+ /** The declared `permissionFingerprint` matches the recomputed one. */
83
+ boundaryFingerprintOk: boolean;
84
+ events: EventFinding[];
85
+ }
86
+ /** A signed in-bounds attestation receipt (the wire form). */
87
+ export interface Receipt {
88
+ version: typeof RECEIPT_VERSION;
89
+ kind: typeof RECEIPT_KIND;
90
+ boundary: CapabilityBoundary;
91
+ events: TraceEvent[];
92
+ /** The attestor's claimed verdict (re-derived and re-checked on verify). */
93
+ verdict: Verdict;
94
+ /** SHA-256 hex hash-chain over the canonical events. */
95
+ rootHash: string;
96
+ /** Ed25519 public key (base64url raw 32-byte, JWK `x` form). */
97
+ publicKey: string;
98
+ /** Ed25519 signature (base64) over the canonical signing body. */
99
+ signature: string;
100
+ /** Informational only — NOT trusted by the verifier. */
101
+ signedAt?: string;
102
+ }
103
+ /** Deterministic JSON: object keys sorted, no incidental whitespace. */
104
+ export declare function canonicalize(value: unknown): string;
105
+ /** SHA-256 hex over the canonical sorted, de-duplicated tool set. */
106
+ export declare function permissionFingerprint(allowedTools: string[]): string;
107
+ /**
108
+ * SHA-256 hash-chain over the events, in order:
109
+ * h₀ = SHA256(genesis); hᵢ = SHA256(hᵢ₋₁ ‖ canonical(eventᵢ)).
110
+ * Order-sensitive: reordering or inserting an event changes the root.
111
+ */
112
+ export declare function rootHash(events: TraceEvent[]): string;
113
+ /**
114
+ * Recompute the in-bounds verdict. Pure and deterministic — no crypto, no
115
+ * I/O. This is the soundness floor: a forged `inBounds: true` claim cannot
116
+ * survive a re-run here.
117
+ *
118
+ * An event is in-bounds iff:
119
+ * 1. its `principal` equals the boundary's `principal` (cryptographic
120
+ * identity match — the OpenClaw guard), AND
121
+ * 2. its `tool` is in the boundary's `allowedTools`.
122
+ * `displayName` is deliberately never consulted.
123
+ */
124
+ export declare function recomputeVerdict(boundary: CapabilityBoundary, events: TraceEvent[]): Verdict;
125
+ /** Build a well-formed boundary, computing its permission fingerprint. */
126
+ export declare function makeBoundary(principal: string, allowedTools: string[]): CapabilityBoundary;
127
+ /** Generate an ephemeral Ed25519 keypair (e.g. for tests/demos). */
128
+ export declare function generateKeypair(): {
129
+ publicKey: KeyObject;
130
+ privateKey: KeyObject;
131
+ };
132
+ /** Export a public KeyObject to the receipt's base64url raw form (JWK `x`). */
133
+ export declare function exportPublicKey(publicKey: KeyObject): string;
134
+ /**
135
+ * Attest a trace against a boundary, signing the receipt with `privateKey`.
136
+ *
137
+ * The attestor signs the TRUE recomputed verdict — it never fabricates an
138
+ * `inBounds: true`. For an out-of-bounds trace the receipt faithfully
139
+ * records `verdict.inBounds === false`; the verifier (and CLI) then treat
140
+ * that as a refusal-to-attest. "Refuses to attest" = does not issue a
141
+ * passing receipt, NOT silently signs a green one.
142
+ */
143
+ export declare function signReceipt(boundary: CapabilityBoundary, events: TraceEvent[], privateKey: KeyObject, opts?: {
144
+ signedAt?: string;
145
+ }): Receipt;
146
+ /** Structured verification result. `ok` is the single boolean to gate on. */
147
+ export interface VerifyReport {
148
+ /** `signatureOk && rootHashOk && verdictConsistentOk && inBounds`. */
149
+ ok: boolean;
150
+ /** Ed25519 signature verified under the receipt's public key. */
151
+ signatureOk: boolean;
152
+ /** Hash-chain over `events` reproduces `receipt.rootHash`. */
153
+ rootHashOk: boolean;
154
+ /** Independently recomputed verdict deep-equals the claimed one. */
155
+ verdictConsistentOk: boolean;
156
+ /** The recomputed verdict says every event is in bounds. */
157
+ inBounds: boolean;
158
+ /** Pinned-key check (only meaningful when `opts.expectPublicKey` given). */
159
+ publicKeyOk: boolean;
160
+ /** Pinned-principal check (only meaningful when `opts.expectPrincipal` given). */
161
+ principalOk: boolean;
162
+ /** The verifier's own recomputation (authoritative over the claim). */
163
+ recomputed: Verdict;
164
+ /** First failing reason, or null when `ok`. */
165
+ reason: string | null;
166
+ }
167
+ export interface VerifyOptions {
168
+ /** Pin the expected signer public key (base64url raw form). */
169
+ expectPublicKey?: string;
170
+ /** Pin the expected boundary principal (SPIFFE ID). */
171
+ expectPrincipal?: string;
172
+ }
173
+ /**
174
+ * Verify an in-bounds attestation receipt fully and offline: signature +
175
+ * hash-chain + an INDEPENDENT recomputation of the verdict. Never throws on
176
+ * a malformed receipt — failures surface as `ok: false` with a `reason`.
177
+ */
178
+ export declare function verifyReceipt(receipt: Receipt, opts?: VerifyOptions): VerifyReport;
179
+ /** Parse a receipt from JSON text, then verify. Convenience for the CLI. */
180
+ export declare function verifyReceiptJson(receiptJson: string, opts?: VerifyOptions): VerifyReport;
181
+ /** Load an Ed25519 private key from PEM text (PKCS#8). */
182
+ export declare function privateKeyFromPem(pem: string): KeyObject;
@@ -0,0 +1,295 @@
1
+ /**
2
+ * `@coproduct_inc/verify` — capability-boundary **in-bounds attestation**.
3
+ *
4
+ * The evidence layer ABOVE probabilistic guardrails: given an agent run's
5
+ * observed tool-call trace and a DECLARED capability boundary, produce and
6
+ * offline-verify an Ed25519-signed, SHA-256-hash-chained receipt that the
7
+ * agent stayed within the boundary.
8
+ *
9
+ * The boundary is keyed on a **cryptographic principal** (a SPIFFE ID) bound
10
+ * to a **SHA-256 permission fingerprint** — exactly the binding
11
+ * `nucleus-tool-proxy::identity_fusion` makes between "who you are" (SPIFFE
12
+ * ID) and "what you can do" (the permission fingerprint embedded in the
13
+ * X.509 cert). Trust is NEVER keyed on a mutable display name. That single
14
+ * discipline is what rules out the OpenClaw-class trust-boundary bypass
15
+ * (CVE-2026-25253): a call whose human-readable `displayName` matches an
16
+ * allowlisted agent but whose cryptographic `principal` differs is
17
+ * out-of-bounds by construction, and the receipt refuses to attest it.
18
+ *
19
+ * ## Honest scope
20
+ *
21
+ * This attests that the OBSERVED trace stayed within the DECLARED boundary —
22
+ * it is integrity-axis, model-level evidence (the bar-2 IFC noninterference
23
+ * guarantee), NOT an end-to-end proof of arbitrary agent behaviour, and it
24
+ * does not vouch that the trace is a complete record of what the agent did.
25
+ * It is the verifiable evidence layer; completeness of capture is the
26
+ * producer's (instrumented runtime's) responsibility.
27
+ *
28
+ * Zero runtime dependencies: Ed25519 + SHA-256 come from `node:crypto`.
29
+ */
30
+ import { createHash, createPublicKey, createPrivateKey, generateKeyPairSync, sign as edSign, verify as edVerify, } from "node:crypto";
31
+ // ---------------------------------------------------------------------------
32
+ // Schema
33
+ // ---------------------------------------------------------------------------
34
+ /** Wire identifiers — bumped on any breaking schema change. */
35
+ export const RECEIPT_KIND = "nucleus.capability-boundary.attestation";
36
+ export const RECEIPT_VERSION = 1;
37
+ // ---------------------------------------------------------------------------
38
+ // Canonicalization & hashing (deterministic, dependency-free)
39
+ // ---------------------------------------------------------------------------
40
+ /** Deterministic JSON: object keys sorted, no incidental whitespace. */
41
+ export function canonicalize(value) {
42
+ if (value === null || typeof value !== "object") {
43
+ return JSON.stringify(value);
44
+ }
45
+ if (Array.isArray(value)) {
46
+ return "[" + value.map(canonicalize).join(",") + "]";
47
+ }
48
+ const obj = value;
49
+ const keys = Object.keys(obj).sort();
50
+ return ("{" +
51
+ keys
52
+ .filter((k) => obj[k] !== undefined)
53
+ .map((k) => JSON.stringify(k) + ":" + canonicalize(obj[k]))
54
+ .join(",") +
55
+ "}");
56
+ }
57
+ function sha256Hex(input) {
58
+ return createHash("sha256").update(input).digest("hex");
59
+ }
60
+ /** SHA-256 hex over the canonical sorted, de-duplicated tool set. */
61
+ export function permissionFingerprint(allowedTools) {
62
+ const sorted = [...new Set(allowedTools)].sort();
63
+ return sha256Hex(canonicalize(sorted));
64
+ }
65
+ const CHAIN_GENESIS = "nucleus-capability-boundary-receipt-v1";
66
+ /**
67
+ * SHA-256 hash-chain over the events, in order:
68
+ * h₀ = SHA256(genesis); hᵢ = SHA256(hᵢ₋₁ ‖ canonical(eventᵢ)).
69
+ * Order-sensitive: reordering or inserting an event changes the root.
70
+ */
71
+ export function rootHash(events) {
72
+ let h = sha256Hex(CHAIN_GENESIS);
73
+ for (const ev of events) {
74
+ h = sha256Hex(h + "|" + canonicalize(ev));
75
+ }
76
+ return h;
77
+ }
78
+ /** The bytes the signature commits to: kind, version, boundary, root, verdict. */
79
+ function signingBody(receipt) {
80
+ return canonicalize({
81
+ kind: receipt.kind,
82
+ version: receipt.version,
83
+ boundary: receipt.boundary,
84
+ rootHash: receipt.rootHash,
85
+ verdict: receipt.verdict,
86
+ });
87
+ }
88
+ // ---------------------------------------------------------------------------
89
+ // The SOUND core: recompute the verdict from boundary + events
90
+ // ---------------------------------------------------------------------------
91
+ /**
92
+ * Recompute the in-bounds verdict. Pure and deterministic — no crypto, no
93
+ * I/O. This is the soundness floor: a forged `inBounds: true` claim cannot
94
+ * survive a re-run here.
95
+ *
96
+ * An event is in-bounds iff:
97
+ * 1. its `principal` equals the boundary's `principal` (cryptographic
98
+ * identity match — the OpenClaw guard), AND
99
+ * 2. its `tool` is in the boundary's `allowedTools`.
100
+ * `displayName` is deliberately never consulted.
101
+ */
102
+ export function recomputeVerdict(boundary, events) {
103
+ const recomputedFp = permissionFingerprint(boundary.allowedTools);
104
+ const boundaryFingerprintOk = recomputedFp === boundary.permissionFingerprint;
105
+ const allowed = new Set(boundary.allowedTools);
106
+ const findings = events.map((ev) => {
107
+ if (!boundaryFingerprintOk) {
108
+ return {
109
+ seq: ev.seq,
110
+ tool: ev.tool,
111
+ inBounds: false,
112
+ code: "fingerprint_mismatch",
113
+ detail: `boundary permissionFingerprint does not match its allowedTools (declared ${boundary.permissionFingerprint.slice(0, 12)}…, computed ${recomputedFp.slice(0, 12)}…)`,
114
+ };
115
+ }
116
+ if (ev.principal !== boundary.principal) {
117
+ return {
118
+ seq: ev.seq,
119
+ tool: ev.tool,
120
+ inBounds: false,
121
+ code: "principal_mismatch",
122
+ detail: `call presented principal ${JSON.stringify(ev.principal)}${ev.displayName ? ` (displayName ${JSON.stringify(ev.displayName)})` : ""} but the boundary is bound to ${JSON.stringify(boundary.principal)}`,
123
+ };
124
+ }
125
+ if (!allowed.has(ev.tool)) {
126
+ return {
127
+ seq: ev.seq,
128
+ tool: ev.tool,
129
+ inBounds: false,
130
+ code: "tool_not_allowed",
131
+ detail: `tool ${JSON.stringify(ev.tool)} is not in the declared boundary`,
132
+ };
133
+ }
134
+ return { seq: ev.seq, tool: ev.tool, inBounds: true, code: "ok", detail: "in bounds" };
135
+ });
136
+ const inBounds = boundaryFingerprintOk && findings.every((f) => f.inBounds);
137
+ return { inBounds, boundaryFingerprintOk, events: findings };
138
+ }
139
+ /** Build a well-formed boundary, computing its permission fingerprint. */
140
+ export function makeBoundary(principal, allowedTools) {
141
+ return {
142
+ principal,
143
+ allowedTools: [...allowedTools],
144
+ permissionFingerprint: permissionFingerprint(allowedTools),
145
+ };
146
+ }
147
+ // ---------------------------------------------------------------------------
148
+ // Ed25519 key handling
149
+ // ---------------------------------------------------------------------------
150
+ /** Generate an ephemeral Ed25519 keypair (e.g. for tests/demos). */
151
+ export function generateKeypair() {
152
+ return generateKeyPairSync("ed25519");
153
+ }
154
+ /** Export a public KeyObject to the receipt's base64url raw form (JWK `x`). */
155
+ export function exportPublicKey(publicKey) {
156
+ const jwk = publicKey.export({ format: "jwk" });
157
+ if (!jwk.x)
158
+ throw new Error("not an Ed25519 public key");
159
+ return jwk.x;
160
+ }
161
+ function importPublicKey(b64url) {
162
+ return createPublicKey({
163
+ key: { kty: "OKP", crv: "Ed25519", x: b64url },
164
+ format: "jwk",
165
+ });
166
+ }
167
+ // ---------------------------------------------------------------------------
168
+ // Producer side: sign a receipt
169
+ // ---------------------------------------------------------------------------
170
+ /**
171
+ * Attest a trace against a boundary, signing the receipt with `privateKey`.
172
+ *
173
+ * The attestor signs the TRUE recomputed verdict — it never fabricates an
174
+ * `inBounds: true`. For an out-of-bounds trace the receipt faithfully
175
+ * records `verdict.inBounds === false`; the verifier (and CLI) then treat
176
+ * that as a refusal-to-attest. "Refuses to attest" = does not issue a
177
+ * passing receipt, NOT silently signs a green one.
178
+ */
179
+ export function signReceipt(boundary, events, privateKey, opts = {}) {
180
+ const verdict = recomputeVerdict(boundary, events);
181
+ const root = rootHash(events);
182
+ const pub = createPublicKey(privateKey);
183
+ const body = signingBody({ kind: RECEIPT_KIND, version: RECEIPT_VERSION, boundary, rootHash: root, verdict });
184
+ const signature = edSign(null, Buffer.from(body, "utf8"), privateKey).toString("base64");
185
+ return {
186
+ version: RECEIPT_VERSION,
187
+ kind: RECEIPT_KIND,
188
+ boundary,
189
+ events,
190
+ verdict,
191
+ rootHash: root,
192
+ publicKey: exportPublicKey(pub),
193
+ signature,
194
+ ...(opts.signedAt ? { signedAt: opts.signedAt } : {}),
195
+ };
196
+ }
197
+ /**
198
+ * Verify an in-bounds attestation receipt fully and offline: signature +
199
+ * hash-chain + an INDEPENDENT recomputation of the verdict. Never throws on
200
+ * a malformed receipt — failures surface as `ok: false` with a `reason`.
201
+ */
202
+ export function verifyReceipt(receipt, opts = {}) {
203
+ const recomputed = recomputeVerdict(receipt.boundary, receipt.events);
204
+ // 1. Hash-chain.
205
+ const recomputedRoot = rootHash(receipt.events);
206
+ const rootHashOk = recomputedRoot === receipt.rootHash;
207
+ // 2. Signature over the canonical body (using the verifier's recomputed
208
+ // root, so a tampered rootHash field can't smuggle a valid signature).
209
+ let signatureOk = false;
210
+ try {
211
+ const body = signingBody({
212
+ kind: receipt.kind,
213
+ version: receipt.version,
214
+ boundary: receipt.boundary,
215
+ rootHash: receipt.rootHash,
216
+ verdict: receipt.verdict,
217
+ });
218
+ const pub = importPublicKey(receipt.publicKey);
219
+ signatureOk = edVerify(null, Buffer.from(body, "utf8"), pub, Buffer.from(receipt.signature, "base64"));
220
+ }
221
+ catch {
222
+ signatureOk = false;
223
+ }
224
+ // 3. The claimed verdict must equal an independent recomputation.
225
+ const verdictConsistentOk = canonicalize(recomputed) === canonicalize(receipt.verdict);
226
+ // 4. The authoritative (recomputed) verdict must be in-bounds.
227
+ const inBounds = recomputed.inBounds;
228
+ const publicKeyOk = opts.expectPublicKey === undefined || opts.expectPublicKey === receipt.publicKey;
229
+ const principalOk = opts.expectPrincipal === undefined || opts.expectPrincipal === receipt.boundary.principal;
230
+ let reason = null;
231
+ if (receipt.kind !== RECEIPT_KIND)
232
+ reason = `unexpected receipt kind ${JSON.stringify(receipt.kind)}`;
233
+ else if (receipt.version !== RECEIPT_VERSION)
234
+ reason = `unsupported version ${receipt.version}`;
235
+ else if (!publicKeyOk)
236
+ reason = "signer public key does not match the pinned key";
237
+ else if (!principalOk)
238
+ reason = "boundary principal does not match the pinned principal";
239
+ else if (!rootHashOk)
240
+ reason = "event hash-chain does not reproduce rootHash (trace tampered)";
241
+ else if (!signatureOk)
242
+ reason = "Ed25519 signature did not verify";
243
+ else if (!verdictConsistentOk)
244
+ reason = "claimed verdict disagrees with the recomputed verdict";
245
+ else if (!inBounds) {
246
+ const first = recomputed.events.find((e) => !e.inBounds);
247
+ reason = first
248
+ ? `out of bounds at event #${first.seq}: ${first.detail}`
249
+ : "boundary fingerprint mismatch";
250
+ }
251
+ const ok = receipt.kind === RECEIPT_KIND &&
252
+ receipt.version === RECEIPT_VERSION &&
253
+ publicKeyOk &&
254
+ principalOk &&
255
+ rootHashOk &&
256
+ signatureOk &&
257
+ verdictConsistentOk &&
258
+ inBounds;
259
+ return {
260
+ ok,
261
+ signatureOk,
262
+ rootHashOk,
263
+ verdictConsistentOk,
264
+ inBounds,
265
+ publicKeyOk,
266
+ principalOk,
267
+ recomputed,
268
+ reason: ok ? null : reason,
269
+ };
270
+ }
271
+ /** Parse a receipt from JSON text, then verify. Convenience for the CLI. */
272
+ export function verifyReceiptJson(receiptJson, opts = {}) {
273
+ let receipt;
274
+ try {
275
+ receipt = JSON.parse(receiptJson);
276
+ }
277
+ catch (e) {
278
+ return {
279
+ ok: false,
280
+ signatureOk: false,
281
+ rootHashOk: false,
282
+ verdictConsistentOk: false,
283
+ inBounds: false,
284
+ publicKeyOk: false,
285
+ principalOk: false,
286
+ recomputed: { inBounds: false, boundaryFingerprintOk: false, events: [] },
287
+ reason: `receipt is not valid JSON: ${e.message}`,
288
+ };
289
+ }
290
+ return verifyReceipt(receipt, opts);
291
+ }
292
+ /** Load an Ed25519 private key from PEM text (PKCS#8). */
293
+ export function privateKeyFromPem(pem) {
294
+ return createPrivateKey(pem);
295
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `nucleus-verify` — offline verifier for capability-boundary in-bounds
4
+ * attestation receipts. Exit code is the gate:
5
+ * 0 → receipt verified AND the agent stayed in bounds
6
+ * 1 → receipt failed verification, or the agent went out of bounds
7
+ * 2 → usage / I/O error
8
+ *
9
+ * Usage:
10
+ * nucleus-verify <receipt.json> [--expect-principal <spiffe-id>]
11
+ * [--expect-key <base64url>] [--json] [--quiet]
12
+ * nucleus-verify --help
13
+ *
14
+ * Reads from stdin if `<receipt.json>` is "-" or omitted with piped input.
15
+ * Designed as a drop-in CI / GitHub Action step.
16
+ */
17
+ export {};