@coproduct_inc/verify 0.2.0 → 0.5.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 +35 -0
- package/README.md +69 -0
- package/WHY-VERIFY.md +81 -0
- package/dist/attestation.browser.d.ts +1 -0
- package/dist/attestation.browser.js +3 -0
- package/dist/cage.d.ts +54 -0
- package/dist/cage.js +66 -0
- package/dist/claim-cli.d.ts +23 -0
- package/dist/claim-cli.js +185 -0
- package/dist/claim.d.ts +98 -0
- package/dist/claim.js +186 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +13 -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/workflow.d.ts +85 -0
- package/dist/workflow.js +179 -0
- package/examples/cage-quickstart.mjs +37 -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/examples/workflow-leak.mjs +40 -0
- package/package.json +30 -4
package/dist/claim.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `claim.ts` — a **recompute-verified claim** façade.
|
|
3
|
+
*
|
|
4
|
+
* One uniform call — `verifyClaim(claim) → ClaimVerdict` — over the package's
|
|
5
|
+
* existing recompute engines, so any surface (a comment-box widget, a CI
|
|
6
|
+
* check, an MCP tool) can render a single ✓/✗ plus a human-readable recompute
|
|
7
|
+
* trace next to a claim, without knowing which engine adjudicated it.
|
|
8
|
+
*
|
|
9
|
+
* ## The honest boundary
|
|
10
|
+
*
|
|
11
|
+
* This verifies **only** claims that reduce to a *deterministic recomputation
|
|
12
|
+
* over committed evidence* — never arbitrary prose. A claim is adjudicable iff
|
|
13
|
+
* its value is the output of a pure function the caller can re-run from the
|
|
14
|
+
* cited inputs:
|
|
15
|
+
*
|
|
16
|
+
* - `clearing` — re-run an auction's clearing from its SIGNED bid set and
|
|
17
|
+
* check the receipt's price/winner (WASM kernel; the exact
|
|
18
|
+
* `verify` / `recomputeClearing` path).
|
|
19
|
+
* - `in_bounds` — re-run an agent run's capability verdict from its tool
|
|
20
|
+
* trace + declared boundary (pure-node attestation engine).
|
|
21
|
+
* - `signature` — authenticity only: Ed25519 + BLAKE3 root hash, no
|
|
22
|
+
* value recompute.
|
|
23
|
+
*
|
|
24
|
+
* Anything NOT reducible to a recompute (e.g. "10k users downloaded this")
|
|
25
|
+
* returns `verified: false` with an explicit `unsupported`/`reason` — it is
|
|
26
|
+
* never silently passed. `eval_score` and `dataset_aggregate` are the planned
|
|
27
|
+
* next kinds (see docs/RECOMPUTE-VERIFIED-CLAIMS.md); until wired they report
|
|
28
|
+
* unsupported rather than faking a check.
|
|
29
|
+
*
|
|
30
|
+
* ## Proof-carrying numbers
|
|
31
|
+
*
|
|
32
|
+
* When a claim carries an `asserted` value (the number a human wrote in prose,
|
|
33
|
+
* e.g. "cleared at 400000 µ$"), it is cross-checked against the recomputed
|
|
34
|
+
* value. A receipt that is authentic but whose prose number was altered fails
|
|
35
|
+
* (`verified: false`, `asserted.matched: false`) — the receipt can't lie, and
|
|
36
|
+
* neither can the sentence quoting it.
|
|
37
|
+
*/
|
|
38
|
+
import { verify, recomputeClearing } from "./index.js";
|
|
39
|
+
import { verifyReceipt } from "./attestation.js";
|
|
40
|
+
function receiptJson(ev) {
|
|
41
|
+
return typeof ev.receipt === "string" ? ev.receipt : JSON.stringify(ev.receipt);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Adjudicate a claim by recomputation. Never throws — a malformed receipt,
|
|
45
|
+
* an unknown kind, or an engine error surface as `verified: false` with a
|
|
46
|
+
* `reason`, so the widget can always render a definite ✓/✗.
|
|
47
|
+
*/
|
|
48
|
+
export async function verifyClaim(claim) {
|
|
49
|
+
const base = { kind: claim.kind, statement: claim.statement };
|
|
50
|
+
try {
|
|
51
|
+
switch (claim.kind) {
|
|
52
|
+
case "clearing":
|
|
53
|
+
return await verifyClearingClaim(claim, base);
|
|
54
|
+
case "in_bounds":
|
|
55
|
+
return verifyInBoundsClaim(claim, base);
|
|
56
|
+
case "signature":
|
|
57
|
+
return await verifySignatureClaim(claim, base);
|
|
58
|
+
default:
|
|
59
|
+
return {
|
|
60
|
+
...base,
|
|
61
|
+
verified: false,
|
|
62
|
+
recomputed: null,
|
|
63
|
+
asserted: null,
|
|
64
|
+
trace: [{ check: "kind-supported", ok: false, detail: `unsupported kind: ${claim.kind}` }],
|
|
65
|
+
reason: `unsupported claim kind: ${claim.kind} (not recomputable)`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
return {
|
|
71
|
+
...base,
|
|
72
|
+
verified: false,
|
|
73
|
+
recomputed: null,
|
|
74
|
+
asserted: null,
|
|
75
|
+
trace: [{ check: "engine", ok: false, detail: String(e) }],
|
|
76
|
+
reason: `recompute engine error: ${e instanceof Error ? e.message : String(e)}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function verifyClearingClaim(claim, base) {
|
|
81
|
+
const json = receiptJson(claim.evidence);
|
|
82
|
+
const trace = [];
|
|
83
|
+
let recomputedPrice;
|
|
84
|
+
let coreOk;
|
|
85
|
+
let reason;
|
|
86
|
+
if (claim.evidence.jwks) {
|
|
87
|
+
// Full path: signature + root hash + price recompute.
|
|
88
|
+
const r = await verify(json, claim.evidence.jwks);
|
|
89
|
+
trace.push({ check: "signature", ok: r.signature_ok });
|
|
90
|
+
trace.push({ check: "root-hash", ok: r.root_hash_ok });
|
|
91
|
+
trace.push({ check: "price-recomputed", ok: r.price_recomputed_ok });
|
|
92
|
+
recomputedPrice = r.recomputed_price_micro_usd;
|
|
93
|
+
coreOk = r.ok;
|
|
94
|
+
reason = r.reason;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
// Recompute-only path (no pinned JWKS supplied).
|
|
98
|
+
const r = await recomputeClearing(json);
|
|
99
|
+
trace.push({ check: "price-recomputed", ok: r.matches_receipt });
|
|
100
|
+
trace.push({ check: "winner-recomputed", ok: r.winner_matches });
|
|
101
|
+
recomputedPrice = r.recomputed_price_micro_usd;
|
|
102
|
+
coreOk = r.matches_receipt;
|
|
103
|
+
reason = r.reason;
|
|
104
|
+
}
|
|
105
|
+
const asserted = crossCheck(claim.asserted, recomputedPrice, trace, "asserted-price");
|
|
106
|
+
return {
|
|
107
|
+
...base,
|
|
108
|
+
verified: coreOk && (asserted?.matched ?? true),
|
|
109
|
+
recomputed: { label: "clearing_price_micro_usd", value: recomputedPrice },
|
|
110
|
+
asserted,
|
|
111
|
+
trace,
|
|
112
|
+
reason: !coreOk ? (reason ?? "clearing did not recompute") : assertedReason(asserted),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function verifyInBoundsClaim(claim, base) {
|
|
116
|
+
const receipt = typeof claim.evidence.receipt === "string"
|
|
117
|
+
? JSON.parse(claim.evidence.receipt)
|
|
118
|
+
: claim.evidence.receipt;
|
|
119
|
+
const r = verifyReceipt(receipt, {
|
|
120
|
+
expectPrincipal: claim.evidence.expectPrincipal,
|
|
121
|
+
expectPublicKey: claim.evidence.expectPublicKey,
|
|
122
|
+
});
|
|
123
|
+
const trace = [
|
|
124
|
+
{ check: "signature", ok: r.signatureOk },
|
|
125
|
+
{ check: "root-hash", ok: r.rootHashOk },
|
|
126
|
+
{ check: "verdict-consistent", ok: r.verdictConsistentOk },
|
|
127
|
+
{ check: "in-bounds", ok: r.inBounds },
|
|
128
|
+
];
|
|
129
|
+
if (claim.evidence.expectPrincipal !== undefined) {
|
|
130
|
+
trace.push({ check: "principal-pinned", ok: r.principalOk ?? false });
|
|
131
|
+
}
|
|
132
|
+
const asserted = crossCheck(claim.asserted, r.inBounds, trace, "asserted-in-bounds");
|
|
133
|
+
return {
|
|
134
|
+
...base,
|
|
135
|
+
verified: r.ok && (asserted?.matched ?? true),
|
|
136
|
+
recomputed: { label: "in_bounds", value: r.inBounds },
|
|
137
|
+
asserted,
|
|
138
|
+
trace,
|
|
139
|
+
reason: !r.ok ? (r.reason ?? "attestation did not verify") : assertedReason(asserted),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
async function verifySignatureClaim(claim, base) {
|
|
143
|
+
if (!claim.evidence.jwks) {
|
|
144
|
+
return {
|
|
145
|
+
...base,
|
|
146
|
+
verified: false,
|
|
147
|
+
recomputed: null,
|
|
148
|
+
asserted: null,
|
|
149
|
+
trace: [{ check: "jwks-present", ok: false }],
|
|
150
|
+
reason: "signature claim requires evidence.jwks",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
// Reuse the full verifier but gate on authenticity only (signature + root
|
|
154
|
+
// hash), not price correctness — the `signature` kind asserts provenance.
|
|
155
|
+
const r = await verify(receiptJson(claim.evidence), claim.evidence.jwks);
|
|
156
|
+
const ok = r.signature_ok && r.root_hash_ok;
|
|
157
|
+
return {
|
|
158
|
+
...base,
|
|
159
|
+
verified: ok,
|
|
160
|
+
recomputed: { label: "authentic", value: ok },
|
|
161
|
+
asserted: null,
|
|
162
|
+
trace: [
|
|
163
|
+
{ check: "signature", ok: r.signature_ok },
|
|
164
|
+
{ check: "root-hash", ok: r.root_hash_ok },
|
|
165
|
+
],
|
|
166
|
+
reason: ok ? null : (r.reason ?? "signature or root hash failed"),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/** Cross-check a prose-asserted value against the recomputed value. */
|
|
170
|
+
function crossCheck(asserted, recomputed, trace, check) {
|
|
171
|
+
if (asserted === undefined)
|
|
172
|
+
return null;
|
|
173
|
+
const matched = asserted.value === recomputed;
|
|
174
|
+
trace.push({
|
|
175
|
+
check,
|
|
176
|
+
ok: matched,
|
|
177
|
+
detail: matched ? undefined : `asserted ${String(asserted.value)} ≠ recomputed ${String(recomputed)}`,
|
|
178
|
+
});
|
|
179
|
+
return { value: asserted.value, matched };
|
|
180
|
+
}
|
|
181
|
+
function assertedReason(asserted) {
|
|
182
|
+
if (asserted && !asserted.matched) {
|
|
183
|
+
return `asserted value (${String(asserted.value)}) does not match the recompute`;
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -95,3 +95,7 @@ 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 "./cage.js";
|
|
99
|
+
export * from "./workflow.js";
|
|
100
|
+
export * from "./license.js";
|
|
101
|
+
export * from "./claim.js";
|
package/dist/index.js
CHANGED
|
@@ -70,3 +70,16 @@ 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
|
+
// The 5-minute drop-in: a runtime `Cage` wraps an agent's tool calls (enforce +
|
|
74
|
+
// record) and emits a signed receipt the lines above verify. See `./cage`.
|
|
75
|
+
export * from "./cage.js";
|
|
76
|
+
// The DAG cage: compose a multi-agent run into one verifiable `WorkflowReceipt`,
|
|
77
|
+
// including the cross-node information flow ("locally fine, globally leaks").
|
|
78
|
+
export * from "./workflow.js";
|
|
79
|
+
// Offline Ed25519 license keys: entitlement with no user DB / no callback.
|
|
80
|
+
// See `./license` and the `nucleus-license` CLI.
|
|
81
|
+
export * from "./license.js";
|
|
82
|
+
// Recompute-verified CLAIM façade: one `verifyClaim(claim) → ClaimVerdict`
|
|
83
|
+
// over all of the above engines, for embeddable ✓/✗ + recompute-trace
|
|
84
|
+
// widgets. See `./claim` and docs/RECOMPUTE-VERIFIED-CLAIMS.md.
|
|
85
|
+
export * from "./claim.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,85 @@
|
|
|
1
|
+
import { type KeyObject } from "node:crypto";
|
|
2
|
+
import { Cage } from "./cage.js";
|
|
3
|
+
import { type TraceEvent } from "./attestation.js";
|
|
4
|
+
declare const WF_KIND = "nucleus.workflow-receipt.v1";
|
|
5
|
+
export interface WorkflowNodeSpec {
|
|
6
|
+
/** Unique node id within the workflow. */
|
|
7
|
+
id: string;
|
|
8
|
+
/** This node's capability boundary (the tools it may call). */
|
|
9
|
+
boundary: string[];
|
|
10
|
+
/** Parent node ids — the DAG edges (data flows parent → this node). */
|
|
11
|
+
after?: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface WorkflowReceiptNode {
|
|
14
|
+
id: string;
|
|
15
|
+
after: string[];
|
|
16
|
+
boundary: string[];
|
|
17
|
+
events: TraceEvent[];
|
|
18
|
+
/** Independently recomputed by the verifier; the emitter's claim is not trusted. */
|
|
19
|
+
inBounds: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface WorkflowReceipt {
|
|
22
|
+
kind: typeof WF_KIND;
|
|
23
|
+
principal: string;
|
|
24
|
+
nodes: WorkflowReceiptNode[];
|
|
25
|
+
rootHash: string;
|
|
26
|
+
publicKey: string;
|
|
27
|
+
signature: string;
|
|
28
|
+
signedAt?: string;
|
|
29
|
+
}
|
|
30
|
+
/** An information-flow policy checked across the whole DAG. */
|
|
31
|
+
export interface FlowPolicy {
|
|
32
|
+
/** Tools that introduce taint (e.g. reading a secret). */
|
|
33
|
+
sources: string[];
|
|
34
|
+
/** Tools that must never be reached while tainted (e.g. an external write). */
|
|
35
|
+
sinks: string[];
|
|
36
|
+
/** Nodes that sanitize — they clear incoming taint (a sanitizer in the chain). */
|
|
37
|
+
declassifiers?: string[];
|
|
38
|
+
}
|
|
39
|
+
export interface FlowLeak {
|
|
40
|
+
node: string;
|
|
41
|
+
sink: string;
|
|
42
|
+
taintedVia: "reads-source" | "upstream";
|
|
43
|
+
}
|
|
44
|
+
export interface WorkflowVerifyReport {
|
|
45
|
+
/** `signatureOk && dagOk && nodesOk && flowOk`. */
|
|
46
|
+
ok: boolean;
|
|
47
|
+
/** Ed25519 signature verifies AND the recomputed rootHash matches. */
|
|
48
|
+
signatureOk: boolean;
|
|
49
|
+
/** Edges reference real nodes and the graph is acyclic. */
|
|
50
|
+
dagOk: boolean;
|
|
51
|
+
/** Every node's recorded calls were within its declared boundary. */
|
|
52
|
+
nodesOk: boolean;
|
|
53
|
+
/** No source→sink information flow across the DAG (per the flow policy). */
|
|
54
|
+
flowOk: boolean;
|
|
55
|
+
/** The source→sink leaks found (empty when `flowOk`). */
|
|
56
|
+
leaks: FlowLeak[];
|
|
57
|
+
/** Nodes whose recompute found an out-of-boundary call. */
|
|
58
|
+
outOfBoundsNodes: string[];
|
|
59
|
+
reason: string | null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A DAG cage. Register nodes (each gets a {@link Cage}), run your agents through
|
|
63
|
+
* the caged tools, then {@link Workflow.attest} a single signed receipt over the
|
|
64
|
+
* whole graph — verifiable offline, including the cross-node information flow.
|
|
65
|
+
*/
|
|
66
|
+
export declare class Workflow {
|
|
67
|
+
private readonly principal;
|
|
68
|
+
private readonly nodes;
|
|
69
|
+
constructor(principal: string);
|
|
70
|
+
/** Register a node and return its cage. The cage's tool calls are recorded
|
|
71
|
+
* under this node; `after` declares the edges into it. */
|
|
72
|
+
node(spec: WorkflowNodeSpec): Cage;
|
|
73
|
+
private receiptNodes;
|
|
74
|
+
/** Sign one workflow receipt over the whole DAG. */
|
|
75
|
+
attest(privateKey: KeyObject, opts?: {
|
|
76
|
+
signedAt?: string;
|
|
77
|
+
}): WorkflowReceipt;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Verify a workflow receipt fully and offline: the signature + the per-node
|
|
81
|
+
* in-bounds recompute + the DAG (acyclic, real edges) + the cross-node
|
|
82
|
+
* information flow. Never throws — failures surface as `ok:false` + `reason`.
|
|
83
|
+
*/
|
|
84
|
+
export declare function verifyWorkflowReceipt(wr: WorkflowReceipt, flow?: FlowPolicy): WorkflowVerifyReport;
|
|
85
|
+
export {};
|