@coproduct_inc/verify 0.2.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 +14 -0
- package/README.md +5 -0
- package/WHY-VERIFY.md +81 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -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/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 +12 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@coproduct_inc/verify`.
|
|
4
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
|
+
|
|
5
19
|
## 0.2.0
|
|
6
20
|
|
|
7
21
|
### Added
|
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.
|
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
|
@@ -95,3 +95,4 @@ export declare function recomputeClearing(receiptJson: string): Promise<Recomput
|
|
|
95
95
|
export declare function verifySignature(receiptJson: string, jwksJson: string): Promise<unknown>;
|
|
96
96
|
export * from "./attestation.js";
|
|
97
97
|
export * from "./toolproxy.js";
|
|
98
|
+
export * from "./license.js";
|
package/dist/index.js
CHANGED
|
@@ -70,3 +70,6 @@ export * from "./attestation.js";
|
|
|
70
70
|
// Producer-side instrumentation: turn a nucleus-tool-proxy trace into a signed
|
|
71
71
|
// in-bounds receipt. See `./toolproxy` and the `nucleus-attest` CLI.
|
|
72
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,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,6 +1,6 @@
|
|
|
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",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
9
|
"bin": {
|
|
10
10
|
"nucleus-verify": "dist/cli.js",
|
|
11
|
-
"nucleus-attest": "dist/producer-cli.js"
|
|
11
|
+
"nucleus-attest": "dist/producer-cli.js",
|
|
12
|
+
"nucleus-license": "dist/license-cli.js"
|
|
12
13
|
},
|
|
13
14
|
"exports": {
|
|
14
15
|
".": {
|
|
@@ -22,6 +23,10 @@
|
|
|
22
23
|
"./toolproxy": {
|
|
23
24
|
"types": "./dist/toolproxy.d.ts",
|
|
24
25
|
"import": "./dist/toolproxy.js"
|
|
26
|
+
},
|
|
27
|
+
"./license": {
|
|
28
|
+
"types": "./dist/license.d.ts",
|
|
29
|
+
"import": "./dist/license.js"
|
|
25
30
|
}
|
|
26
31
|
},
|
|
27
32
|
"files": [
|
|
@@ -32,7 +37,12 @@
|
|
|
32
37
|
"wasm/nodejs/nucleus_wasm_bg.wasm",
|
|
33
38
|
"wasm/nodejs/nucleus_wasm_bg.wasm.d.ts",
|
|
34
39
|
"examples/openclaw-replay.mjs",
|
|
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",
|
|
35
44
|
"README.md",
|
|
45
|
+
"WHY-VERIFY.md",
|
|
36
46
|
"CHANGELOG.md"
|
|
37
47
|
],
|
|
38
48
|
"scripts": {
|