@coproduct_inc/verify 0.1.0 → 0.3.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/CHANGELOG.md +47 -0
- package/README.md +29 -0
- package/WHY-VERIFY.md +81 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/license-cli.d.ts +18 -0
- package/dist/license-cli.js +96 -0
- package/dist/license.d.ts +59 -0
- package/dist/license.js +94 -0
- package/dist/producer-cli.d.ts +16 -0
- package/dist/producer-cli.js +84 -0
- package/dist/toolproxy.d.ts +75 -0
- package/dist/toolproxy.js +74 -0
- package/examples/converting-demo.sh +117 -0
- package/examples/sample-toolproxy-trace.bypass.json +34 -0
- package/examples/sample-toolproxy-trace.clean.json +41 -0
- package/examples/sample-toolproxy-trace.openclaw.json +24 -0
- package/package.json +19 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@coproduct_inc/verify`.
|
|
4
|
+
|
|
5
|
+
## 0.3.0
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Offline Ed25519 license keys** (`./license` export + `nucleus-license` bin) —
|
|
9
|
+
entitlement with no user database and no callback: the vendor signs a tiny
|
|
10
|
+
claims payload; the Pro path verifies it against a pinned public key.
|
|
11
|
+
`keygen` / `issue` / `verify`; expiry enforced; tamper-evident.
|
|
12
|
+
- **Converting demo** (`examples/converting-demo.sh`) — recordable 4-beat demo
|
|
13
|
+
(clean attests · OpenClaw impersonation refused · forged verdict caught two
|
|
14
|
+
ways · CI gate), runs against the published package or `LOCAL=1`.
|
|
15
|
+
- **`WHY-VERIFY.md`** — positioning one-pager (the independent-recompute
|
|
16
|
+
differentiator + honest Microsoft-AGT contrast) and a static `site/` landing
|
|
17
|
+
page (zero-JS, zero-backend) wired for a Merchant-of-Record checkout.
|
|
18
|
+
|
|
19
|
+
## 0.2.0
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- **Producer-side instrumentation** — turn a nucleus-tool-proxy trace into a
|
|
23
|
+
signed in-bounds attestation receipt.
|
|
24
|
+
- `./toolproxy` export: `attestToolProxyRun`, `boundaryFromExport`,
|
|
25
|
+
`traceEventsFromRecords`, `parseExport`. Maps the tool-proxy's verdict
|
|
26
|
+
records (`actor`/`operation`/`verdict`/`subject`/`deny_reason`, mirroring
|
|
27
|
+
the `verdict_sink` `tool_call` span) into the receipt schema and signs it.
|
|
28
|
+
Includes a cross-check that the proxy's own allow/deny agrees with the
|
|
29
|
+
receipt's independently recomputed verdict (surfaces boundary drift).
|
|
30
|
+
- `nucleus-attest` bin: read a trace export (stdin or file), sign a receipt.
|
|
31
|
+
With `--key <pkcs8.pem>` uses a real key; otherwise an ephemeral in-memory
|
|
32
|
+
key (no custody), printing the public key to stderr.
|
|
33
|
+
- Release workflow: OIDC trusted publishing to npm (no stored token).
|
|
34
|
+
|
|
35
|
+
## 0.1.0
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
- **Capability-boundary in-bounds attestation** — the headline evidence layer.
|
|
39
|
+
- `verifyReceipt` / `verifyReceiptJson`: offline, zero-trust verification of
|
|
40
|
+
an Ed25519-signed, SHA-256-hash-chained receipt that an agent run stayed
|
|
41
|
+
within a declared capability boundary, keyed on a cryptographic principal
|
|
42
|
+
(SPIFFE id + permission fingerprint), never a mutable display name.
|
|
43
|
+
- `signReceipt`, `recomputeVerdict` (the pure soundness floor), `makeBoundary`.
|
|
44
|
+
- `nucleus-verify` CLI (exit 0 in-bounds / 1 refused / 2 usage) and a
|
|
45
|
+
composite GitHub Action.
|
|
46
|
+
- **Auction receipts** (original use case): `verify`, `recomputeClearing`,
|
|
47
|
+
`verifySignature` over the bundled `nucleus-wasm` core.
|
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# @coproduct_inc/verify
|
|
2
2
|
|
|
3
|
+
> **New here?** Read [WHY-VERIFY.md](./WHY-VERIFY.md) ("verify, don't trust" — the
|
|
4
|
+
> one-pager + Microsoft-AGT contrast), or run the 90-second demo:
|
|
5
|
+
> `bash examples/converting-demo.sh` (clean attests · OpenClaw bypass refused ·
|
|
6
|
+
> forged verdict caught · CI gate).
|
|
7
|
+
|
|
3
8
|
Offline, **zero-trust** verification for Nucleus. Two receipt families share one package:
|
|
4
9
|
|
|
5
10
|
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.
|
|
@@ -68,6 +73,30 @@ pnpm build && pnpm demo # examples/openclaw-replay.mjs
|
|
|
68
73
|
- **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
74
|
- **The hash-chain pins trace order.** `rootHash` is `hᵢ = SHA256(hᵢ₋₁ ‖ canonical(eventᵢ))`; reorder or insert and the root changes.
|
|
70
75
|
|
|
76
|
+
### Producer-side: from a real nucleus-tool-proxy trace
|
|
77
|
+
|
|
78
|
+
The receipt is emitted from the tool-proxy's actual authorization output, not a hand-authored fixture. A trace export pairs the **declared boundary** (a SPIFFE principal + the task-shield allowlist — `portcullis_core::task_shield::TaskWitness`'s allow-set) with one record per observed call mirroring the `verdict_sink` `tool_call` span fields (`actor`, `operation`, `verdict`, `subject`, `deny_reason`):
|
|
79
|
+
|
|
80
|
+
```jsonc
|
|
81
|
+
{
|
|
82
|
+
"boundary": { "principal": "spiffe://acme/agent/billing", "allowedTools": ["read_files","glob_search","grep_search"] },
|
|
83
|
+
"records": [
|
|
84
|
+
{ "actor": "spiffe://acme/agent/billing", "operation": "read_files", "verdict": "allow", "subject": "invoices/2026-06.pdf" }
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Pipe it through the `nucleus-attest` producer to get a signed receipt, then verify:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
# emit a REAL trace from portcullis (TaskWitness::permits decides each verdict):
|
|
93
|
+
cargo run -q -p nucleus-policy --example emit_toolproxy_trace -- clean \
|
|
94
|
+
| nucleus-attest \
|
|
95
|
+
| nucleus-verify - # exit 0: in bounds · exit 1: refused
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`nucleus-attest` also runs a **cross-check**: the proxy's own `allow`/`deny` per call must agree with the receipt's independently recomputed in-bounds verdict — a mismatch surfaces boundary drift. The producer never asserts in-bounds itself; it only records what happened (`attestToolProxyRun`/`./toolproxy`). The Rust emitter lives at `crates/nucleus-policy/examples/emit_toolproxy_trace.rs`.
|
|
99
|
+
|
|
71
100
|
### Honest scope
|
|
72
101
|
|
|
73
102
|
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**:
|
package/WHY-VERIFY.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Verify, don't trust.
|
|
2
|
+
|
|
3
|
+
**`@coproduct_inc/verify`** turns an agent run into **verifiable evidence** that it
|
|
4
|
+
stayed inside a declared capability boundary — evidence a third party can check
|
|
5
|
+
**offline, without trusting the agent or the platform that produced it.**
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx @coproduct_inc/verify nucleus-verify run-receipt.json
|
|
9
|
+
# exit 0 → in bounds · exit 1 → refused
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## The problem everyone now has
|
|
13
|
+
|
|
14
|
+
97% of security leaders expect a material AI-agent incident in the next 12 months;
|
|
15
|
+
6% of budgets address it. The dominant failure mode has a name: the **lethal
|
|
16
|
+
trifecta** — an agent with private data + untrusted content + an exfiltration sink
|
|
17
|
+
gets turned into a *confused deputy*, exercising its authority for an attacker
|
|
18
|
+
(OpenClaw, CVE-2026-25253; Invariant Labs' GitHub-MCP exploit). Regulators are
|
|
19
|
+
arriving too: the EU AI Act's high-risk regime (Aug 2, 2026) demands **productized
|
|
20
|
+
evidence tied to real runs and operator identity** — not a dashboard, an artifact.
|
|
21
|
+
|
|
22
|
+
The runtime's job is to make that trifecta safe by *non-interference*. This
|
|
23
|
+
package's job is to **prove it happened**: an offline-verifiable receipt that the
|
|
24
|
+
agent stayed inside its declared boundary — i.e. was *not* a confused deputy.
|
|
25
|
+
|
|
26
|
+
## The one thing that's different: we recompute the verdict
|
|
27
|
+
|
|
28
|
+
Most "agent receipts" — including Microsoft's Agent Governance Toolkit — are a
|
|
29
|
+
**signed record of what the platform's own engine decided.** You trust the engine.
|
|
30
|
+
|
|
31
|
+
`@coproduct_inc/verify` is a **second, independent check.** Given the declared
|
|
32
|
+
boundary and the observed trace, it **re-derives** the in-bounds verdict and
|
|
33
|
+
confirms it matches the receipt — then checks an Ed25519 signature and a SHA-256
|
|
34
|
+
hash-chain. A **forged or mistaken** verdict is caught two ways: the recompute
|
|
35
|
+
disagrees, *and* the signature (taken over the honest verdict) breaks.
|
|
36
|
+
|
|
37
|
+
> A platform vendor is structurally incentivized to be *trusted*. A neutral
|
|
38
|
+
> verifier is incentivized to be *checkable*. That's the wedge.
|
|
39
|
+
|
|
40
|
+
| | Microsoft Agent Governance Toolkit | `@coproduct_inc/verify` |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| Capability manifest / policy | ✅ (denies outside the manifest) | relies on yours (any runtime) |
|
|
43
|
+
| Signed, hash-chained receipts | ✅ Ed25519 decision receipts | ✅ Ed25519 + SHA-256 chain |
|
|
44
|
+
| **Independent verdict recompute** | ❌ (trust the engine's decision) | ✅ **soundness floor — catches a forged claim** |
|
|
45
|
+
| Keyed on cryptographic principal, not display name | partial | ✅ rules out OpenClaw by construction |
|
|
46
|
+
| Verdict logic backed by machine-checked theorems | ❌ | ✅ (IFC noninterference, leanchecker-rechecked) |
|
|
47
|
+
| Footprint | a governance framework | one `npx` / one GitHub Action, **zero runtime deps** |
|
|
48
|
+
| Position | the platform | the **neutral verifier above any platform** (incl. AGT) |
|
|
49
|
+
|
|
50
|
+
We don't compete with the guardrail. We're the evidence layer **above** it — and
|
|
51
|
+
we verify *their* agents too.
|
|
52
|
+
|
|
53
|
+
## See it (90 seconds)
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
npx @coproduct_inc/verify --help # or: bash examples/converting-demo.sh
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
1. **Clean run → attests** (exit 0). Auditor-grade evidence the agent stayed in bounds.
|
|
60
|
+
2. **OpenClaw bypass** — a call wearing an allowlisted *display name* but a different
|
|
61
|
+
cryptographic principal → **refused** (exit 1). The CVE class, ruled out by construction.
|
|
62
|
+
3. **Forge the verdict** (claim `inBounds:true`) → **caught** by the independent recompute
|
|
63
|
+
*and* the broken signature. This is the difference between a signed log and verifiable evidence.
|
|
64
|
+
4. **CI gate** — drop the Action in a PR; an out-of-bounds run **fails the build.** A merge gate, not a dashboard.
|
|
65
|
+
|
|
66
|
+
## Who it's for
|
|
67
|
+
|
|
68
|
+
- **AppSec** — a 30-second drop-in CI/PR gate, no platform migration, works above your existing guardrails.
|
|
69
|
+
- **GRC / SOC 2 / EU AI Act** — the receipt **is** the exportable audit artifact: offline-verifiable, tied to real runs and operator identity.
|
|
70
|
+
|
|
71
|
+
## Honest scope
|
|
72
|
+
|
|
73
|
+
It attests that the **observed trace** stayed within the **declared boundary** —
|
|
74
|
+
integrity-axis, model-level evidence. It is **not** a proof of arbitrary agent
|
|
75
|
+
behavior, and it does not vouch that the trace is a *complete* record of what the
|
|
76
|
+
agent did (completeness of capture is the runtime's job). It's the verifiable
|
|
77
|
+
evidence layer above probabilistic guardrails, not a replacement for them.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
Sources for the market claims: [VentureBeat / Arkose 2026 agent-security survey](https://venturebeat.com/security/most-enterprises-cant-stop-stage-three-ai-agent-threats-venturebeat-survey-finds), [EU AI Act Art. 16 / Aug 2026](https://artificialintelligenceact.eu/article/16/), [Microsoft Agent Governance Toolkit](https://opensource.microsoft.com/blog/2026/04/02/introducing-the-agent-governance-toolkit-open-source-runtime-security-for-ai-agents/), [PipeLab — mediator receipts / independent attestation](https://pipelab.org/blog/independent-attestation-mediator-receipts/).
|
package/dist/index.d.ts
CHANGED
|
@@ -94,3 +94,5 @@ export declare function recomputeClearing(receiptJson: string): Promise<Recomput
|
|
|
94
94
|
*/
|
|
95
95
|
export declare function verifySignature(receiptJson: string, jwksJson: string): Promise<unknown>;
|
|
96
96
|
export * from "./attestation.js";
|
|
97
|
+
export * from "./toolproxy.js";
|
|
98
|
+
export * from "./license.js";
|
package/dist/index.js
CHANGED
|
@@ -67,3 +67,9 @@ export async function verifySignature(receiptJson, jwksJson) {
|
|
|
67
67
|
// Pure Node (`node:crypto`), no WASM — runs in CI, the CLI, and the Action.
|
|
68
68
|
// ---------------------------------------------------------------------------
|
|
69
69
|
export * from "./attestation.js";
|
|
70
|
+
// Producer-side instrumentation: turn a nucleus-tool-proxy trace into a signed
|
|
71
|
+
// in-bounds receipt. See `./toolproxy` and the `nucleus-attest` CLI.
|
|
72
|
+
export * from "./toolproxy.js";
|
|
73
|
+
// Offline Ed25519 license keys: entitlement with no user DB / no callback.
|
|
74
|
+
// See `./license` and the `nucleus-license` CLI.
|
|
75
|
+
export * from "./license.js";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `nucleus-license` — issue + verify offline Ed25519 license keys.
|
|
4
|
+
*
|
|
5
|
+
* nucleus-license keygen [--out-key vendor.pem] [--out-pub vendor.pub]
|
|
6
|
+
* → generate a vendor keypair; prints the base64url public key to PIN.
|
|
7
|
+
*
|
|
8
|
+
* nucleus-license issue --key vendor.pem --sub a@b.com --tier pro \
|
|
9
|
+
* [--features dashboard,export] [--exp 2027-06-01] [--iat 2026-06-04]
|
|
10
|
+
* → prints a license token (deliver this string as the MoR "license key").
|
|
11
|
+
*
|
|
12
|
+
* nucleus-license verify <token> --pubkey <base64url> [--now 2026-06-04]
|
|
13
|
+
* → exit 0 if valid, 1 if not.
|
|
14
|
+
*
|
|
15
|
+
* No server, no DB: the Merchant of Record holds the customer; this issues and
|
|
16
|
+
* checks a signature. Keep the vendor private key secret.
|
|
17
|
+
*/
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `nucleus-license` — issue + verify offline Ed25519 license keys.
|
|
4
|
+
*
|
|
5
|
+
* nucleus-license keygen [--out-key vendor.pem] [--out-pub vendor.pub]
|
|
6
|
+
* → generate a vendor keypair; prints the base64url public key to PIN.
|
|
7
|
+
*
|
|
8
|
+
* nucleus-license issue --key vendor.pem --sub a@b.com --tier pro \
|
|
9
|
+
* [--features dashboard,export] [--exp 2027-06-01] [--iat 2026-06-04]
|
|
10
|
+
* → prints a license token (deliver this string as the MoR "license key").
|
|
11
|
+
*
|
|
12
|
+
* nucleus-license verify <token> --pubkey <base64url> [--now 2026-06-04]
|
|
13
|
+
* → exit 0 if valid, 1 if not.
|
|
14
|
+
*
|
|
15
|
+
* No server, no DB: the Merchant of Record holds the customer; this issues and
|
|
16
|
+
* checks a signature. Keep the vendor private key secret.
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { generateLicenseKeypair, exportLicensePublicKey, signLicense, verifyLicense, licensePrivateKeyFromPem, } from "./license.js";
|
|
20
|
+
function opt(flag) {
|
|
21
|
+
const i = process.argv.indexOf(flag);
|
|
22
|
+
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
23
|
+
}
|
|
24
|
+
function keygen() {
|
|
25
|
+
const { publicKey, privateKey } = generateLicenseKeypair();
|
|
26
|
+
const pub = exportLicensePublicKey(publicKey);
|
|
27
|
+
const pem = privateKey.export({ type: "pkcs8", format: "pem" });
|
|
28
|
+
const outKey = opt("--out-key");
|
|
29
|
+
const outPub = opt("--out-pub");
|
|
30
|
+
if (outKey)
|
|
31
|
+
writeFileSync(outKey, pem, { mode: 0o600 });
|
|
32
|
+
if (outPub)
|
|
33
|
+
writeFileSync(outPub, pub + "\n");
|
|
34
|
+
process.stdout.write(`vendor public key (PIN this in the verifier):\n${pub}\n`);
|
|
35
|
+
if (!outKey) {
|
|
36
|
+
process.stdout.write("\nvendor PRIVATE key (keep SECRET — store in your MoR webhook secret):\n");
|
|
37
|
+
process.stdout.write(pem);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
process.stderr.write(`wrote private key → ${outKey} (chmod 600)\n`);
|
|
41
|
+
}
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
function issue() {
|
|
45
|
+
const keyPath = opt("--key");
|
|
46
|
+
const sub = opt("--sub");
|
|
47
|
+
const tier = (opt("--tier") ?? "pro");
|
|
48
|
+
if (!keyPath || !sub) {
|
|
49
|
+
process.stderr.write("error: issue needs --key <vendor.pem> and --sub <id>\n");
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
const features = (opt("--features") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
53
|
+
const claims = {
|
|
54
|
+
sub,
|
|
55
|
+
tier,
|
|
56
|
+
features,
|
|
57
|
+
iat: opt("--iat") ?? new Date().toISOString().slice(0, 10),
|
|
58
|
+
...(opt("--exp") ? { exp: opt("--exp") } : {}),
|
|
59
|
+
};
|
|
60
|
+
const key = licensePrivateKeyFromPem(readFileSync(keyPath, "utf8"));
|
|
61
|
+
process.stdout.write(signLicense(claims, key) + "\n");
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
function verify() {
|
|
65
|
+
const token = process.argv[3];
|
|
66
|
+
const pubkey = opt("--pubkey");
|
|
67
|
+
if (!token || token.startsWith("--") || !pubkey) {
|
|
68
|
+
process.stderr.write("error: verify needs <token> and --pubkey <base64url>\n");
|
|
69
|
+
return 2;
|
|
70
|
+
}
|
|
71
|
+
const r = verifyLicense(token, pubkey, opt("--now"));
|
|
72
|
+
if (r.valid) {
|
|
73
|
+
process.stdout.write(`✓ valid — ${r.license.tier} for ${r.license.sub}` + (r.license.exp ? ` (exp ${r.license.exp})` : "") + `\n`);
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
process.stderr.write(`✗ invalid — ${r.reason}\n`);
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
function main() {
|
|
80
|
+
const cmd = process.argv[2];
|
|
81
|
+
switch (cmd) {
|
|
82
|
+
case "keygen":
|
|
83
|
+
return keygen();
|
|
84
|
+
case "issue":
|
|
85
|
+
return issue();
|
|
86
|
+
case "verify":
|
|
87
|
+
return verify();
|
|
88
|
+
default:
|
|
89
|
+
process.stdout.write("nucleus-license — offline Ed25519 license keys\n\n" +
|
|
90
|
+
" nucleus-license keygen [--out-key vendor.pem] [--out-pub vendor.pub]\n" +
|
|
91
|
+
" nucleus-license issue --key vendor.pem --sub a@b.com --tier pro [--features x,y] [--exp ISO]\n" +
|
|
92
|
+
" nucleus-license verify <token> --pubkey <base64url> [--now ISO]\n");
|
|
93
|
+
return cmd ? 2 : 0;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
process.exit(main());
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline Ed25519 license keys — entitlement without a user database or a
|
|
3
|
+
* callback. The vendor signs a tiny claims payload with a private key; the CLI
|
|
4
|
+
* (or a Pro feature path) verifies it against a PINNED public key. No server,
|
|
5
|
+
* no license API, no phone-home: the Merchant of Record (Polar / Lemon Squeezy)
|
|
6
|
+
* holds the customer record and delivers the signed string as the "license
|
|
7
|
+
* key"; this module is the verifier.
|
|
8
|
+
*
|
|
9
|
+
* Token format (compact, copy-pasteable):
|
|
10
|
+
* nvlic1.<base64url(canonical-json payload)>.<base64url(ed25519 sig)>
|
|
11
|
+
* The signature is over the payload SEGMENT bytes (the base64url string), so
|
|
12
|
+
* verification never re-serializes — it checks the exact bytes that were signed.
|
|
13
|
+
*
|
|
14
|
+
* On-brand: a verification company gating its own Pro tier by a verifiable,
|
|
15
|
+
* offline-checkable signature rather than a trust-the-server license call.
|
|
16
|
+
*/
|
|
17
|
+
import { type KeyObject } from "node:crypto";
|
|
18
|
+
export declare const LICENSE_PREFIX: "nvlic1";
|
|
19
|
+
/** The claims a license asserts. Keep it small — it's pasted by hand. */
|
|
20
|
+
export interface LicenseClaims {
|
|
21
|
+
/** Who the license is for (email / customer id from the MoR). */
|
|
22
|
+
sub: string;
|
|
23
|
+
/** Entitlement tier. */
|
|
24
|
+
tier: "pro" | "team" | "enterprise";
|
|
25
|
+
/** Unlocked features (free-form; checked by the Pro path). */
|
|
26
|
+
features: string[];
|
|
27
|
+
/** Issued-at (ISO date). Informational. */
|
|
28
|
+
iat: string;
|
|
29
|
+
/** Optional expiry (ISO date). Absent = perpetual. */
|
|
30
|
+
exp?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface LicenseReport {
|
|
33
|
+
valid: boolean;
|
|
34
|
+
/** The verified claims, when valid. */
|
|
35
|
+
license: LicenseClaims | null;
|
|
36
|
+
/** First failing reason, or null when valid. */
|
|
37
|
+
reason: string | null;
|
|
38
|
+
}
|
|
39
|
+
/** Generate an Ed25519 vendor keypair. Keep the private key secret; ship the public. */
|
|
40
|
+
export declare function generateLicenseKeypair(): {
|
|
41
|
+
publicKey: KeyObject;
|
|
42
|
+
privateKey: KeyObject;
|
|
43
|
+
};
|
|
44
|
+
/** Export a public key to the base64url raw form the verifier pins. */
|
|
45
|
+
export declare function exportLicensePublicKey(publicKey: KeyObject): string;
|
|
46
|
+
/** Sign a license token with the vendor private key. */
|
|
47
|
+
export declare function signLicense(claims: LicenseClaims, privateKey: KeyObject): string;
|
|
48
|
+
/** Load an Ed25519 private key from PEM (PKCS#8). */
|
|
49
|
+
export declare function licensePrivateKeyFromPem(pem: string): KeyObject;
|
|
50
|
+
/**
|
|
51
|
+
* Verify a license token offline against the pinned vendor public key
|
|
52
|
+
* (base64url raw Ed25519). Checks format + signature + expiry. Never throws.
|
|
53
|
+
*
|
|
54
|
+
* @param now optional ISO timestamp for expiry checks (defaults to absent =
|
|
55
|
+
* skip expiry; pass an explicit time to enforce it deterministically).
|
|
56
|
+
*/
|
|
57
|
+
export declare function verifyLicense(token: string, vendorPublicKeyB64url: string, now?: string): LicenseReport;
|
|
58
|
+
/** Convenience: does a verified license grant a given feature (or tier-implied)? */
|
|
59
|
+
export declare function licenseGrants(report: LicenseReport, feature: string): boolean;
|
package/dist/license.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline Ed25519 license keys — entitlement without a user database or a
|
|
3
|
+
* callback. The vendor signs a tiny claims payload with a private key; the CLI
|
|
4
|
+
* (or a Pro feature path) verifies it against a PINNED public key. No server,
|
|
5
|
+
* no license API, no phone-home: the Merchant of Record (Polar / Lemon Squeezy)
|
|
6
|
+
* holds the customer record and delivers the signed string as the "license
|
|
7
|
+
* key"; this module is the verifier.
|
|
8
|
+
*
|
|
9
|
+
* Token format (compact, copy-pasteable):
|
|
10
|
+
* nvlic1.<base64url(canonical-json payload)>.<base64url(ed25519 sig)>
|
|
11
|
+
* The signature is over the payload SEGMENT bytes (the base64url string), so
|
|
12
|
+
* verification never re-serializes — it checks the exact bytes that were signed.
|
|
13
|
+
*
|
|
14
|
+
* On-brand: a verification company gating its own Pro tier by a verifiable,
|
|
15
|
+
* offline-checkable signature rather than a trust-the-server license call.
|
|
16
|
+
*/
|
|
17
|
+
import { createPublicKey, createPrivateKey, sign as edSign, verify as edVerify, generateKeyPairSync } from "node:crypto";
|
|
18
|
+
import { canonicalize } from "./attestation.js";
|
|
19
|
+
export const LICENSE_PREFIX = "nvlic1";
|
|
20
|
+
function b64url(buf) {
|
|
21
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
22
|
+
}
|
|
23
|
+
function fromB64url(s) {
|
|
24
|
+
return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
25
|
+
}
|
|
26
|
+
function importPublicKey(b64urlX) {
|
|
27
|
+
return createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: b64urlX }, format: "jwk" });
|
|
28
|
+
}
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Issuer side (run once per sale, or from a MoR webhook).
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
/** Generate an Ed25519 vendor keypair. Keep the private key secret; ship the public. */
|
|
33
|
+
export function generateLicenseKeypair() {
|
|
34
|
+
return generateKeyPairSync("ed25519");
|
|
35
|
+
}
|
|
36
|
+
/** Export a public key to the base64url raw form the verifier pins. */
|
|
37
|
+
export function exportLicensePublicKey(publicKey) {
|
|
38
|
+
const jwk = publicKey.export({ format: "jwk" });
|
|
39
|
+
if (!jwk.x)
|
|
40
|
+
throw new Error("not an Ed25519 public key");
|
|
41
|
+
return jwk.x;
|
|
42
|
+
}
|
|
43
|
+
/** Sign a license token with the vendor private key. */
|
|
44
|
+
export function signLicense(claims, privateKey) {
|
|
45
|
+
const payloadB64 = b64url(Buffer.from(canonicalize(claims), "utf8"));
|
|
46
|
+
const sig = edSign(null, Buffer.from(payloadB64, "utf8"), privateKey);
|
|
47
|
+
return `${LICENSE_PREFIX}.${payloadB64}.${b64url(sig)}`;
|
|
48
|
+
}
|
|
49
|
+
/** Load an Ed25519 private key from PEM (PKCS#8). */
|
|
50
|
+
export function licensePrivateKeyFromPem(pem) {
|
|
51
|
+
return createPrivateKey(pem);
|
|
52
|
+
}
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Verifier side (ships in the CLI / Pro path; pins the vendor public key).
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
/**
|
|
57
|
+
* Verify a license token offline against the pinned vendor public key
|
|
58
|
+
* (base64url raw Ed25519). Checks format + signature + expiry. Never throws.
|
|
59
|
+
*
|
|
60
|
+
* @param now optional ISO timestamp for expiry checks (defaults to absent =
|
|
61
|
+
* skip expiry; pass an explicit time to enforce it deterministically).
|
|
62
|
+
*/
|
|
63
|
+
export function verifyLicense(token, vendorPublicKeyB64url, now) {
|
|
64
|
+
const parts = token.trim().split(".");
|
|
65
|
+
if (parts.length !== 3 || parts[0] !== LICENSE_PREFIX) {
|
|
66
|
+
return { valid: false, license: null, reason: "malformed license token" };
|
|
67
|
+
}
|
|
68
|
+
const [, payloadB64, sigB64] = parts;
|
|
69
|
+
let sigOk = false;
|
|
70
|
+
try {
|
|
71
|
+
const pub = importPublicKey(vendorPublicKeyB64url);
|
|
72
|
+
sigOk = edVerify(null, Buffer.from(payloadB64, "utf8"), pub, fromB64url(sigB64));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
sigOk = false;
|
|
76
|
+
}
|
|
77
|
+
if (!sigOk)
|
|
78
|
+
return { valid: false, license: null, reason: "signature did not verify under the pinned key" };
|
|
79
|
+
let claims;
|
|
80
|
+
try {
|
|
81
|
+
claims = JSON.parse(fromB64url(payloadB64).toString("utf8"));
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
return { valid: false, license: null, reason: `license payload not valid JSON: ${e.message}` };
|
|
85
|
+
}
|
|
86
|
+
if (claims.exp && now && now > claims.exp) {
|
|
87
|
+
return { valid: false, license: claims, reason: `license expired on ${claims.exp}` };
|
|
88
|
+
}
|
|
89
|
+
return { valid: true, license: claims, reason: null };
|
|
90
|
+
}
|
|
91
|
+
/** Convenience: does a verified license grant a given feature (or tier-implied)? */
|
|
92
|
+
export function licenseGrants(report, feature) {
|
|
93
|
+
return report.valid && !!report.license && report.license.features.includes(feature);
|
|
94
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `nucleus-attest` — producer-side CLI. Reads a nucleus-tool-proxy trace
|
|
4
|
+
* export (from the `emit_toolproxy_trace` example, or any equivalent
|
|
5
|
+
* instrumentation), signs an in-bounds attestation receipt, and writes it to
|
|
6
|
+
* stdout. The cross-check + signer public key go to stderr.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* nucleus-attest [export.json] [--key <pkcs8.pem>] [--signed-at <iso>]
|
|
10
|
+
* emit_toolproxy_trace ... | nucleus-attest > receipt.json
|
|
11
|
+
*
|
|
12
|
+
* With no --key, an EPHEMERAL Ed25519 keypair is generated in memory (no key
|
|
13
|
+
* custody) — fine for demos/CI dry-runs; the public key is printed to stderr
|
|
14
|
+
* so a verifier can pin it. Exit 0 on a produced receipt, 2 on error.
|
|
15
|
+
*/
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `nucleus-attest` — producer-side CLI. Reads a nucleus-tool-proxy trace
|
|
4
|
+
* export (from the `emit_toolproxy_trace` example, or any equivalent
|
|
5
|
+
* instrumentation), signs an in-bounds attestation receipt, and writes it to
|
|
6
|
+
* stdout. The cross-check + signer public key go to stderr.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* nucleus-attest [export.json] [--key <pkcs8.pem>] [--signed-at <iso>]
|
|
10
|
+
* emit_toolproxy_trace ... | nucleus-attest > receipt.json
|
|
11
|
+
*
|
|
12
|
+
* With no --key, an EPHEMERAL Ed25519 keypair is generated in memory (no key
|
|
13
|
+
* custody) — fine for demos/CI dry-runs; the public key is printed to stderr
|
|
14
|
+
* so a verifier can pin it. Exit 0 on a produced receipt, 2 on error.
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { generateKeypair, privateKeyFromPem, exportPublicKey } from "./attestation.js";
|
|
18
|
+
import { createPublicKey } from "node:crypto";
|
|
19
|
+
import { parseExport, attestToolProxyRun } from "./toolproxy.js";
|
|
20
|
+
function arg(flag) {
|
|
21
|
+
const i = process.argv.indexOf(flag);
|
|
22
|
+
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
23
|
+
}
|
|
24
|
+
function main() {
|
|
25
|
+
if (process.argv.includes("-h") || process.argv.includes("--help")) {
|
|
26
|
+
process.stdout.write("nucleus-attest — sign an in-bounds receipt from a tool-proxy trace\n\n" +
|
|
27
|
+
"Usage: nucleus-attest [export.json] [--key <pkcs8.pem>] [--signed-at <iso>]\n");
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
// Positional input path = first non-flag arg after argv[1].
|
|
31
|
+
const positional = process.argv.slice(2).find((a) => !a.startsWith("--") && a !== arg("--key") && a !== arg("--signed-at"));
|
|
32
|
+
let exportJson;
|
|
33
|
+
try {
|
|
34
|
+
exportJson = positional ? readFileSync(positional, "utf8") : readFileSync(0, "utf8");
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
process.stderr.write(`error: cannot read export: ${e.message}\n`);
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
let exp;
|
|
41
|
+
try {
|
|
42
|
+
exp = parseExport(exportJson);
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
process.stderr.write(`error: ${e.message}\n`);
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
48
|
+
// Key: load from PEM, or generate ephemeral.
|
|
49
|
+
let privateKey;
|
|
50
|
+
let ephemeral = false;
|
|
51
|
+
const keyPath = arg("--key");
|
|
52
|
+
try {
|
|
53
|
+
if (keyPath) {
|
|
54
|
+
privateKey = privateKeyFromPem(readFileSync(keyPath, "utf8"));
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
privateKey = generateKeypair().privateKey;
|
|
58
|
+
ephemeral = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
process.stderr.write(`error: cannot load key: ${e.message}\n`);
|
|
63
|
+
return 2;
|
|
64
|
+
}
|
|
65
|
+
const signedAt = arg("--signed-at");
|
|
66
|
+
const { receipt, crossCheck } = attestToolProxyRun(exp, privateKey, signedAt ? { signedAt } : {});
|
|
67
|
+
// Receipt → stdout (the artifact).
|
|
68
|
+
process.stdout.write(JSON.stringify(receipt, null, 2) + "\n");
|
|
69
|
+
// Diagnostics → stderr.
|
|
70
|
+
const pub = exportPublicKey(createPublicKey(privateKey));
|
|
71
|
+
process.stderr.write(`signer publicKey: ${pub}${ephemeral ? " (ephemeral)" : ""}\n` +
|
|
72
|
+
`recomputed inBounds: ${receipt.verdict.inBounds}\n`);
|
|
73
|
+
if (crossCheck.length > 0) {
|
|
74
|
+
process.stderr.write(`cross-check issues (${crossCheck.length}):\n`);
|
|
75
|
+
for (const c of crossCheck) {
|
|
76
|
+
process.stderr.write(` #${c.seq} ${c.operation}: proxy=${c.proxyVerdict} recomputed-inBounds=${c.recomputedInBounds} — ${c.note}\n`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
process.stderr.write("cross-check: proxy decisions agree with recomputed verdict\n");
|
|
81
|
+
}
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
process.exit(main());
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Producer-side instrumentation: turn a **nucleus-tool-proxy trace** into a
|
|
3
|
+
* signed capability-boundary in-bounds attestation receipt.
|
|
4
|
+
*
|
|
5
|
+
* The trace is the real output of the tool-proxy's authorization layer: a
|
|
6
|
+
* declared boundary (a SPIFFE principal + the task-shield allowlist, i.e.
|
|
7
|
+
* `TaskWitness`'s allow-set) plus one record per observed tool call mirroring
|
|
8
|
+
* the `verdict_sink` "tool_call" span fields (`actor`, `operation`,
|
|
9
|
+
* `verdict`, `subject`, `deny_reason`). See
|
|
10
|
+
* `crates/nucleus-policy/examples/emit_toolproxy_trace.rs` for the emitter.
|
|
11
|
+
*
|
|
12
|
+
* This module maps that export into the {@link Receipt} schema and signs it.
|
|
13
|
+
* The receipt's verdict is still recomputed independently by the verifier —
|
|
14
|
+
* the producer never gets to assert in-bounds; it only records what happened.
|
|
15
|
+
*/
|
|
16
|
+
import { type KeyObject } from "node:crypto";
|
|
17
|
+
import { type CapabilityBoundary, type TraceEvent, type Receipt } from "./attestation.js";
|
|
18
|
+
/** A single observed tool call, mirroring the tool-proxy `verdict_sink` span. */
|
|
19
|
+
export interface ToolProxyVerdictRecord {
|
|
20
|
+
/** SPIFFE id of the actor that made the call (`nucleus.actor`). */
|
|
21
|
+
actor: string;
|
|
22
|
+
/** Operation/tool invoked (`nucleus.operation`), canonical serde name. */
|
|
23
|
+
operation: string;
|
|
24
|
+
/** The proxy's own decision (`nucleus.verdict`). */
|
|
25
|
+
verdict: "allow" | "deny" | "error";
|
|
26
|
+
/** The call's target/argument (`nucleus.subject`), if present. */
|
|
27
|
+
subject?: string;
|
|
28
|
+
/** Proxy deny reason (`nucleus.deny_reason`), if any. */
|
|
29
|
+
deny_reason?: string;
|
|
30
|
+
/** Human-readable label, if the runtime recorded one (mutable, untrusted). */
|
|
31
|
+
displayName?: string;
|
|
32
|
+
}
|
|
33
|
+
/** The full trace export the emitter produces. */
|
|
34
|
+
export interface ToolProxyExport {
|
|
35
|
+
boundary: {
|
|
36
|
+
principal: string;
|
|
37
|
+
allowedTools: string[];
|
|
38
|
+
};
|
|
39
|
+
records: ToolProxyVerdictRecord[];
|
|
40
|
+
/** Optional task-shield hash (provenance; not part of the receipt). */
|
|
41
|
+
taskHashHex?: string;
|
|
42
|
+
}
|
|
43
|
+
/** A disagreement between the proxy's decision and the recomputed verdict. */
|
|
44
|
+
export interface CrossCheckIssue {
|
|
45
|
+
seq: number;
|
|
46
|
+
operation: string;
|
|
47
|
+
proxyVerdict: string;
|
|
48
|
+
recomputedInBounds: boolean;
|
|
49
|
+
note: string;
|
|
50
|
+
}
|
|
51
|
+
export interface AttestResult {
|
|
52
|
+
receipt: Receipt;
|
|
53
|
+
/**
|
|
54
|
+
* Sanity cross-check: the proxy's per-call decision should agree with the
|
|
55
|
+
* receipt's recomputed in-bounds finding (a proxy `deny` of an out-of-policy
|
|
56
|
+
* op should land out-of-bounds, an `allow` of an in-policy op in-bounds). A
|
|
57
|
+
* non-empty list means the trace and the declared boundary disagree — worth
|
|
58
|
+
* surfacing, though the receipt's recomputed verdict remains authoritative.
|
|
59
|
+
*/
|
|
60
|
+
crossCheck: CrossCheckIssue[];
|
|
61
|
+
}
|
|
62
|
+
/** Build the declared {@link CapabilityBoundary} from a trace export. */
|
|
63
|
+
export declare function boundaryFromExport(exp: ToolProxyExport): CapabilityBoundary;
|
|
64
|
+
/** Map the proxy's verdict records into receipt {@link TraceEvent}s. */
|
|
65
|
+
export declare function traceEventsFromRecords(records: ToolProxyVerdictRecord[]): TraceEvent[];
|
|
66
|
+
/**
|
|
67
|
+
* Attest a tool-proxy run: build the boundary + events from the export and
|
|
68
|
+
* sign the receipt. Also runs a cross-check that the proxy's own decisions
|
|
69
|
+
* agree with the recomputed in-bounds verdict.
|
|
70
|
+
*/
|
|
71
|
+
export declare function attestToolProxyRun(exp: ToolProxyExport, privateKey: KeyObject, opts?: {
|
|
72
|
+
signedAt?: string;
|
|
73
|
+
}): AttestResult;
|
|
74
|
+
/** Parse a trace export from JSON text. Accepts the emitter's pretty output. */
|
|
75
|
+
export declare function parseExport(json: string): ToolProxyExport;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Producer-side instrumentation: turn a **nucleus-tool-proxy trace** into a
|
|
3
|
+
* signed capability-boundary in-bounds attestation receipt.
|
|
4
|
+
*
|
|
5
|
+
* The trace is the real output of the tool-proxy's authorization layer: a
|
|
6
|
+
* declared boundary (a SPIFFE principal + the task-shield allowlist, i.e.
|
|
7
|
+
* `TaskWitness`'s allow-set) plus one record per observed tool call mirroring
|
|
8
|
+
* the `verdict_sink` "tool_call" span fields (`actor`, `operation`,
|
|
9
|
+
* `verdict`, `subject`, `deny_reason`). See
|
|
10
|
+
* `crates/nucleus-policy/examples/emit_toolproxy_trace.rs` for the emitter.
|
|
11
|
+
*
|
|
12
|
+
* This module maps that export into the {@link Receipt} schema and signs it.
|
|
13
|
+
* The receipt's verdict is still recomputed independently by the verifier —
|
|
14
|
+
* the producer never gets to assert in-bounds; it only records what happened.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { makeBoundary, signReceipt, recomputeVerdict, } from "./attestation.js";
|
|
18
|
+
function sha256Hex(input) {
|
|
19
|
+
return createHash("sha256").update(input).digest("hex");
|
|
20
|
+
}
|
|
21
|
+
/** Build the declared {@link CapabilityBoundary} from a trace export. */
|
|
22
|
+
export function boundaryFromExport(exp) {
|
|
23
|
+
return makeBoundary(exp.boundary.principal, exp.boundary.allowedTools);
|
|
24
|
+
}
|
|
25
|
+
/** Map the proxy's verdict records into receipt {@link TraceEvent}s. */
|
|
26
|
+
export function traceEventsFromRecords(records) {
|
|
27
|
+
return records.map((r, i) => {
|
|
28
|
+
const ev = { seq: i, tool: r.operation, principal: r.actor };
|
|
29
|
+
if (r.displayName !== undefined)
|
|
30
|
+
ev.displayName = r.displayName;
|
|
31
|
+
// Bind the event to the call's target via a digest (subject is the
|
|
32
|
+
// tool-proxy's `nucleus.subject`); opaque to the in-bounds verdict.
|
|
33
|
+
if (r.subject !== undefined && r.subject !== "")
|
|
34
|
+
ev.argsDigest = sha256Hex(r.subject);
|
|
35
|
+
return ev;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Attest a tool-proxy run: build the boundary + events from the export and
|
|
40
|
+
* sign the receipt. Also runs a cross-check that the proxy's own decisions
|
|
41
|
+
* agree with the recomputed in-bounds verdict.
|
|
42
|
+
*/
|
|
43
|
+
export function attestToolProxyRun(exp, privateKey, opts = {}) {
|
|
44
|
+
const boundary = boundaryFromExport(exp);
|
|
45
|
+
const events = traceEventsFromRecords(exp.records);
|
|
46
|
+
const receipt = signReceipt(boundary, events, privateKey, opts);
|
|
47
|
+
// Cross-check the proxy's decisions against an independent recompute.
|
|
48
|
+
const recomputed = recomputeVerdict(boundary, events);
|
|
49
|
+
const crossCheck = [];
|
|
50
|
+
recomputed.events.forEach((finding, i) => {
|
|
51
|
+
const proxyVerdict = exp.records[i]?.verdict ?? "error";
|
|
52
|
+
const proxyAllowed = proxyVerdict === "allow";
|
|
53
|
+
if (proxyAllowed !== finding.inBounds) {
|
|
54
|
+
crossCheck.push({
|
|
55
|
+
seq: finding.seq,
|
|
56
|
+
operation: finding.tool,
|
|
57
|
+
proxyVerdict,
|
|
58
|
+
recomputedInBounds: finding.inBounds,
|
|
59
|
+
note: proxyAllowed
|
|
60
|
+
? "proxy allowed a call the declared boundary does not permit (boundary drift?)"
|
|
61
|
+
: "proxy denied a call the declared boundary permits (extra restriction outside the boundary)",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
return { receipt, crossCheck };
|
|
66
|
+
}
|
|
67
|
+
/** Parse a trace export from JSON text. Accepts the emitter's pretty output. */
|
|
68
|
+
export function parseExport(json) {
|
|
69
|
+
const obj = JSON.parse(json);
|
|
70
|
+
if (!obj || typeof obj !== "object" || !obj.boundary || !Array.isArray(obj.records)) {
|
|
71
|
+
throw new Error("not a tool-proxy trace export (need { boundary, records })");
|
|
72
|
+
}
|
|
73
|
+
return obj;
|
|
74
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# converting-demo.sh — the 90-second "verify, don't trust" demo.
|
|
3
|
+
#
|
|
4
|
+
# Four beats: clean attests · OpenClaw bypass refused · FORGED verdict caught ·
|
|
5
|
+
# CI gate. The money beat is #3 — an independent recompute catches a forged
|
|
6
|
+
# claim that a *signed log* would wave through. That is the line between
|
|
7
|
+
# "we have receipts too" and verifiable evidence.
|
|
8
|
+
#
|
|
9
|
+
# Runs against the PUBLISHED package by default (the real install story):
|
|
10
|
+
# bash examples/converting-demo.sh
|
|
11
|
+
# Use the local build instead (no network):
|
|
12
|
+
# pnpm build && LOCAL=1 bash examples/converting-demo.sh
|
|
13
|
+
# Pacing for screen-recording (pause between beats):
|
|
14
|
+
# PAUSE=1 bash examples/converting-demo.sh
|
|
15
|
+
#
|
|
16
|
+
# Requires: node, and (default mode) npx with network. Trace fixtures are the
|
|
17
|
+
# real output of crates/nucleus-policy/examples/emit_toolproxy_trace.rs.
|
|
18
|
+
|
|
19
|
+
set -u
|
|
20
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
21
|
+
CLEAN_TRACE="$HERE/sample-toolproxy-trace.clean.json"
|
|
22
|
+
# Beat 2 uses the OpenClaw impersonation trace (principal mismatch under an
|
|
23
|
+
# allowlisted display name). sample-toolproxy-trace.bypass.json (a tool-not-
|
|
24
|
+
# allowed git_push) is the other refusal mode, gated by verify-gate.yml.
|
|
25
|
+
BYPASS_TRACE="$HERE/sample-toolproxy-trace.openclaw.json"
|
|
26
|
+
PRINCIPAL="spiffe://acme.example/agent/billing-assistant"
|
|
27
|
+
TMP="$(mktemp -d)"
|
|
28
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
29
|
+
|
|
30
|
+
if [ "${LOCAL:-0}" = "1" ]; then
|
|
31
|
+
DIST="$HERE/../dist"
|
|
32
|
+
ATTEST=(node "$DIST/producer-cli.js")
|
|
33
|
+
VERIFY=(node "$DIST/cli.js")
|
|
34
|
+
SRC="local build ($DIST)"
|
|
35
|
+
else
|
|
36
|
+
PKG="@coproduct_inc/verify@${VERIFY_VERSION:-latest}"
|
|
37
|
+
ATTEST=(npx --yes -p "$PKG" nucleus-attest)
|
|
38
|
+
VERIFY=(npx --yes -p "$PKG" nucleus-verify)
|
|
39
|
+
SRC="published npm package $PKG"
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
bold() { printf "\033[1m%s\033[0m\n" "$*"; }
|
|
43
|
+
dim() { printf "\033[2m%s\033[0m\n" "$*"; }
|
|
44
|
+
ok() { printf "\033[32m%s\033[0m\n" "$*"; }
|
|
45
|
+
bad() { printf "\033[31m%s\033[0m\n" "$*"; }
|
|
46
|
+
beat() { echo; bold "── $* ──"; [ "${PAUSE:-0}" = "1" ] && read -r -p " (enter)…" _ || true; }
|
|
47
|
+
|
|
48
|
+
echo
|
|
49
|
+
bold "@coproduct_inc/verify — verify, don't trust"
|
|
50
|
+
dim "source: $SRC"
|
|
51
|
+
dim "An agent run's tool-call trace + a declared capability boundary →"
|
|
52
|
+
dim "a signed, OFFLINE-verifiable in-bounds attestation. We don't trust the"
|
|
53
|
+
dim "issuer's verdict — we recompute it."
|
|
54
|
+
|
|
55
|
+
# ── Beat 1: a clean run attests ──────────────────────────────────────────────
|
|
56
|
+
beat "1/4 Clean run → ATTESTS (exit 0)"
|
|
57
|
+
dim "nucleus-attest < clean-trace → nucleus-verify"
|
|
58
|
+
"${ATTEST[@]}" "$CLEAN_TRACE" > "$TMP/clean.json" 2> "$TMP/clean.err"
|
|
59
|
+
if "${VERIFY[@]}" "$TMP/clean.json" --expect-principal "$PRINCIPAL"; then
|
|
60
|
+
ok " → exit 0. Auditor-grade evidence the agent stayed inside its boundary."
|
|
61
|
+
else
|
|
62
|
+
bad " unexpected: clean run did not verify"; exit 1
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
# ── Beat 2: the OpenClaw bypass is refused ───────────────────────────────────
|
|
66
|
+
beat "2/4 OpenClaw bypass (display-name match, principal mismatch) → REFUSED (exit 1)"
|
|
67
|
+
dim "An injected call wears the allowlisted display name but a different SPIFFE id."
|
|
68
|
+
"${ATTEST[@]}" "$BYPASS_TRACE" > "$TMP/bypass.json" 2> "$TMP/bypass.err"
|
|
69
|
+
if "${VERIFY[@]}" "$TMP/bypass.json"; then
|
|
70
|
+
bad " unexpected: bypass verified (should refuse)"; exit 1
|
|
71
|
+
else
|
|
72
|
+
ok " → exit 1. CVE-2026-25253's failure class, ruled out by construction"
|
|
73
|
+
ok " (the boundary is keyed on the cryptographic principal, never the name)."
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
# ── Beat 3: the money beat — a forged verdict is caught ───────────────────────
|
|
77
|
+
beat "3/4 FORGE the verdict (claim inBounds:true) → CAUGHT"
|
|
78
|
+
dim "A signed LOG would trust this. We recompute the verdict independently."
|
|
79
|
+
node -e '
|
|
80
|
+
const fs = require("fs");
|
|
81
|
+
const r = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
82
|
+
r.verdict.inBounds = true;
|
|
83
|
+
r.verdict.events = r.verdict.events.map(e => ({...e, inBounds:true, code:"ok", detail:"in bounds"}));
|
|
84
|
+
fs.writeFileSync(process.argv[2], JSON.stringify(r));
|
|
85
|
+
' "$TMP/bypass.json" "$TMP/forged.json"
|
|
86
|
+
"${VERIFY[@]}" "$TMP/forged.json" --json > "$TMP/forged.report.json" 2>/dev/null || true
|
|
87
|
+
SIG=$(node -e 'console.log(require(process.argv[1]).signatureOk)' "$TMP/forged.report.json")
|
|
88
|
+
CONS=$(node -e 'console.log(require(process.argv[1]).verdictConsistentOk)' "$TMP/forged.report.json")
|
|
89
|
+
OKV=$(node -e 'console.log(require(process.argv[1]).ok)' "$TMP/forged.report.json")
|
|
90
|
+
echo " signatureOk=$SIG verdictConsistentOk=$CONS ok=$OKV"
|
|
91
|
+
if [ "$OKV" = "false" ] && [ "$CONS" = "false" ]; then
|
|
92
|
+
ok " → REJECTED two independent ways: the recompute disagrees with the claim,"
|
|
93
|
+
ok " AND the signature (taken over the honest verdict) no longer verifies."
|
|
94
|
+
ok " THIS is verifiable evidence, not a signed log you have to trust."
|
|
95
|
+
else
|
|
96
|
+
bad " unexpected: forged receipt was not caught"; exit 1
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
# ── Beat 4: it's a CI merge gate, not a dashboard ────────────────────────────
|
|
100
|
+
beat "4/4 It's a merge gate, not a dashboard"
|
|
101
|
+
dim "Drop the Action in a PR; an out-of-bounds run fails the build:"
|
|
102
|
+
cat <<'YAML'
|
|
103
|
+
- uses: coproduct-private/spiffy/packages/nucleus-verify@main
|
|
104
|
+
with:
|
|
105
|
+
receipt: ./agent-run-receipt.json
|
|
106
|
+
expect-principal: spiffe://acme/agent/billing-assistant
|
|
107
|
+
YAML
|
|
108
|
+
ok " clean → check passes · out-of-bounds → check fails. Exit codes are the gate."
|
|
109
|
+
|
|
110
|
+
echo
|
|
111
|
+
bold "Summary"
|
|
112
|
+
echo " • Works above ANY runtime/guardrail (incl. Microsoft AGT) — neutral verifier."
|
|
113
|
+
echo " • Offline, zero runtime deps (node:crypto). 30-second npx drop-in."
|
|
114
|
+
echo " • The verdict logic is backed by machine-checked theorems (IFC noninterference)."
|
|
115
|
+
echo " • Honest scope: attests the DECLARED boundary over the OBSERVED trace —"
|
|
116
|
+
echo " completeness of trace capture is the runtime's job."
|
|
117
|
+
ok "DEMO OK ✓"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"boundary": {
|
|
3
|
+
"allowedTools": [
|
|
4
|
+
"read_files",
|
|
5
|
+
"glob_search",
|
|
6
|
+
"grep_search"
|
|
7
|
+
],
|
|
8
|
+
"principal": "spiffe://acme.example/agent/billing-assistant"
|
|
9
|
+
},
|
|
10
|
+
"records": [
|
|
11
|
+
{
|
|
12
|
+
"actor": "spiffe://acme.example/agent/billing-assistant",
|
|
13
|
+
"deny_reason": "",
|
|
14
|
+
"operation": "glob_search",
|
|
15
|
+
"subject": "invoices/*.pdf",
|
|
16
|
+
"verdict": "allow"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"actor": "spiffe://acme.example/agent/billing-assistant",
|
|
20
|
+
"deny_reason": "",
|
|
21
|
+
"operation": "read_files",
|
|
22
|
+
"subject": "invoices/2026-06.pdf",
|
|
23
|
+
"verdict": "allow"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"actor": "spiffe://acme.example/agent/billing-assistant",
|
|
27
|
+
"deny_reason": "operation outside declared task allowlist",
|
|
28
|
+
"operation": "git_push",
|
|
29
|
+
"subject": "refs/heads/main",
|
|
30
|
+
"verdict": "deny"
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"taskHashHex": "8aa9199f47bc66272e3a40e87d8ef6daeec2e905e5a723252e615c45c4376888"
|
|
34
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"boundary": {
|
|
3
|
+
"allowedTools": [
|
|
4
|
+
"read_files",
|
|
5
|
+
"glob_search",
|
|
6
|
+
"grep_search"
|
|
7
|
+
],
|
|
8
|
+
"principal": "spiffe://acme.example/agent/billing-assistant"
|
|
9
|
+
},
|
|
10
|
+
"records": [
|
|
11
|
+
{
|
|
12
|
+
"actor": "spiffe://acme.example/agent/billing-assistant",
|
|
13
|
+
"deny_reason": "",
|
|
14
|
+
"operation": "glob_search",
|
|
15
|
+
"subject": "invoices/*.pdf",
|
|
16
|
+
"verdict": "allow"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"actor": "spiffe://acme.example/agent/billing-assistant",
|
|
20
|
+
"deny_reason": "",
|
|
21
|
+
"operation": "read_files",
|
|
22
|
+
"subject": "invoices/2026-06.pdf",
|
|
23
|
+
"verdict": "allow"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"actor": "spiffe://acme.example/agent/billing-assistant",
|
|
27
|
+
"deny_reason": "",
|
|
28
|
+
"operation": "grep_search",
|
|
29
|
+
"subject": "TOTAL",
|
|
30
|
+
"verdict": "allow"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"actor": "spiffe://acme.example/agent/billing-assistant",
|
|
34
|
+
"deny_reason": "",
|
|
35
|
+
"operation": "read_files",
|
|
36
|
+
"subject": "invoices/2026-05.pdf",
|
|
37
|
+
"verdict": "allow"
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"taskHashHex": "8aa9199f47bc66272e3a40e87d8ef6daeec2e905e5a723252e615c45c4376888"
|
|
41
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"boundary": {
|
|
3
|
+
"principal": "spiffe://acme.example/agent/billing-assistant",
|
|
4
|
+
"allowedTools": ["read_files", "glob_search", "grep_search"]
|
|
5
|
+
},
|
|
6
|
+
"_note": "OpenClaw-class impersonation trace (CVE-2026-25253 shape): event #1 wears the allowlisted displayName 'Billing Assistant' but carries an ATTACKER SPIFFE id. Real export shape; the boundary is keyed on the cryptographic principal, so the receipt refuses to attest it.",
|
|
7
|
+
"records": [
|
|
8
|
+
{
|
|
9
|
+
"actor": "spiffe://acme.example/agent/billing-assistant",
|
|
10
|
+
"operation": "glob_search",
|
|
11
|
+
"verdict": "allow",
|
|
12
|
+
"subject": "invoices/*.pdf",
|
|
13
|
+
"deny_reason": ""
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"actor": "spiffe://acme.example/agent/unknown-7f3a",
|
|
17
|
+
"operation": "read_files",
|
|
18
|
+
"verdict": "deny",
|
|
19
|
+
"subject": "invoices/2026-06.pdf",
|
|
20
|
+
"displayName": "Billing Assistant",
|
|
21
|
+
"deny_reason": "presented identity not bound to the declared principal"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coproduct_inc/verify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Offline, zero-trust verification of agent capability-boundary in-bounds attestation receipts: emit and verify an Ed25519-signed, hash-chained receipt that an agent run stayed within a declared boundary, keyed on a cryptographic principal (not a mutable display name) — the evidence layer above probabilistic guardrails. Also verifies Nucleus auction receipts by re-running the clearing locally. Zero runtime deps for the attestation core (node:crypto).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
9
|
"bin": {
|
|
10
|
-
"nucleus-verify": "dist/cli.js"
|
|
10
|
+
"nucleus-verify": "dist/cli.js",
|
|
11
|
+
"nucleus-attest": "dist/producer-cli.js",
|
|
12
|
+
"nucleus-license": "dist/license-cli.js"
|
|
11
13
|
},
|
|
12
14
|
"exports": {
|
|
13
15
|
".": {
|
|
@@ -17,6 +19,14 @@
|
|
|
17
19
|
"./attestation": {
|
|
18
20
|
"types": "./dist/attestation.d.ts",
|
|
19
21
|
"import": "./dist/attestation.js"
|
|
22
|
+
},
|
|
23
|
+
"./toolproxy": {
|
|
24
|
+
"types": "./dist/toolproxy.d.ts",
|
|
25
|
+
"import": "./dist/toolproxy.js"
|
|
26
|
+
},
|
|
27
|
+
"./license": {
|
|
28
|
+
"types": "./dist/license.d.ts",
|
|
29
|
+
"import": "./dist/license.js"
|
|
20
30
|
}
|
|
21
31
|
},
|
|
22
32
|
"files": [
|
|
@@ -27,7 +37,13 @@
|
|
|
27
37
|
"wasm/nodejs/nucleus_wasm_bg.wasm",
|
|
28
38
|
"wasm/nodejs/nucleus_wasm_bg.wasm.d.ts",
|
|
29
39
|
"examples/openclaw-replay.mjs",
|
|
30
|
-
"
|
|
40
|
+
"examples/converting-demo.sh",
|
|
41
|
+
"examples/sample-toolproxy-trace.clean.json",
|
|
42
|
+
"examples/sample-toolproxy-trace.bypass.json",
|
|
43
|
+
"examples/sample-toolproxy-trace.openclaw.json",
|
|
44
|
+
"README.md",
|
|
45
|
+
"WHY-VERIFY.md",
|
|
46
|
+
"CHANGELOG.md"
|
|
31
47
|
],
|
|
32
48
|
"scripts": {
|
|
33
49
|
"build:wasm": "wasm-pack build ../../crates/nucleus-wasm --target nodejs --out-dir ../../packages/nucleus-verify/wasm/nodejs && rm -f wasm/nodejs/.gitignore wasm/nodejs/package.json",
|