@coderifts/agent-guard 15.0.0 → 16.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/cjs/adapters/crewai.d.ts +88 -0
- package/dist/cjs/adapters/crewai.d.ts.map +1 -0
- package/dist/cjs/adapters/crewai.js +99 -0
- package/dist/cjs/adapters/crewai.js.map +1 -0
- package/dist/cjs/adapters/langchain.d.ts +93 -0
- package/dist/cjs/adapters/langchain.d.ts.map +1 -0
- package/dist/cjs/adapters/langchain.js +99 -0
- package/dist/cjs/adapters/langchain.js.map +1 -0
- package/dist/cjs/deploy-gate.d.ts +18 -0
- package/dist/cjs/deploy-gate.d.ts.map +1 -1
- package/dist/cjs/deploy-gate.js +18 -6
- package/dist/cjs/deploy-gate.js.map +1 -1
- package/dist/cjs/deploy-receipt-token.d.ts +7 -0
- package/dist/cjs/deploy-receipt-token.d.ts.map +1 -1
- package/dist/cjs/deploy-receipt-token.js +6 -0
- package/dist/cjs/deploy-receipt-token.js.map +1 -1
- package/dist/cjs/execution-grant.d.ts +4 -2
- package/dist/cjs/execution-grant.d.ts.map +1 -1
- package/dist/cjs/execution-grant.js +6 -0
- package/dist/cjs/execution-grant.js.map +1 -1
- package/dist/cjs/guard.d.ts.map +1 -1
- package/dist/cjs/guard.js +63 -0
- package/dist/cjs/guard.js.map +1 -1
- package/dist/cjs/index.d.ts +10 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +33 -3
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/next-agent-step.d.ts +50 -0
- package/dist/cjs/next-agent-step.d.ts.map +1 -0
- package/dist/cjs/next-agent-step.js +53 -0
- package/dist/cjs/next-agent-step.js.map +1 -0
- package/dist/cjs/offline-verify.d.ts +75 -0
- package/dist/cjs/offline-verify.d.ts.map +1 -0
- package/dist/cjs/offline-verify.js +74 -0
- package/dist/cjs/offline-verify.js.map +1 -0
- package/dist/cjs/policy.d.ts.map +1 -1
- package/dist/cjs/policy.js +1 -1
- package/dist/cjs/policy.js.map +1 -1
- package/dist/cjs/types.d.ts +64 -1
- package/dist/cjs/types.d.ts.map +1 -1
- package/dist/cjs/unguarded-mutation.d.ts +76 -0
- package/dist/cjs/unguarded-mutation.d.ts.map +1 -0
- package/dist/cjs/unguarded-mutation.js +101 -0
- package/dist/cjs/unguarded-mutation.js.map +1 -0
- package/dist/cjs/vendor/VENDOR.sha256 +13 -0
- package/dist/cjs/vendor/arity.js +53 -0
- package/dist/cjs/vendor/verify.js +454 -0
- package/dist/cjs/with-coderifts.d.ts +21 -0
- package/dist/cjs/with-coderifts.d.ts.map +1 -1
- package/dist/cjs/with-coderifts.js +42 -1
- package/dist/cjs/with-coderifts.js.map +1 -1
- package/dist/esm/adapters/crewai.d.ts +88 -0
- package/dist/esm/adapters/crewai.d.ts.map +1 -0
- package/dist/esm/adapters/crewai.js +95 -0
- package/dist/esm/adapters/crewai.js.map +1 -0
- package/dist/esm/adapters/langchain.d.ts +93 -0
- package/dist/esm/adapters/langchain.d.ts.map +1 -0
- package/dist/esm/adapters/langchain.js +94 -0
- package/dist/esm/adapters/langchain.js.map +1 -0
- package/dist/esm/deploy-gate.d.ts +18 -0
- package/dist/esm/deploy-gate.d.ts.map +1 -1
- package/dist/esm/deploy-gate.js +18 -6
- package/dist/esm/deploy-gate.js.map +1 -1
- package/dist/esm/deploy-receipt-token.d.ts +7 -0
- package/dist/esm/deploy-receipt-token.d.ts.map +1 -1
- package/dist/esm/deploy-receipt-token.js +6 -0
- package/dist/esm/deploy-receipt-token.js.map +1 -1
- package/dist/esm/execution-grant.d.ts +4 -2
- package/dist/esm/execution-grant.d.ts.map +1 -1
- package/dist/esm/execution-grant.js +6 -0
- package/dist/esm/execution-grant.js.map +1 -1
- package/dist/esm/guard.d.ts.map +1 -1
- package/dist/esm/guard.js +63 -0
- package/dist/esm/guard.js.map +1 -1
- package/dist/esm/index.d.ts +10 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +18 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/next-agent-step.d.ts +50 -0
- package/dist/esm/next-agent-step.d.ts.map +1 -0
- package/dist/esm/next-agent-step.js +49 -0
- package/dist/esm/next-agent-step.js.map +1 -0
- package/dist/esm/offline-verify.d.ts +75 -0
- package/dist/esm/offline-verify.d.ts.map +1 -0
- package/dist/esm/offline-verify.js +70 -0
- package/dist/esm/offline-verify.js.map +1 -0
- package/dist/esm/policy.d.ts.map +1 -1
- package/dist/esm/policy.js +1 -1
- package/dist/esm/policy.js.map +1 -1
- package/dist/esm/types.d.ts +64 -1
- package/dist/esm/types.d.ts.map +1 -1
- package/dist/esm/unguarded-mutation.d.ts +76 -0
- package/dist/esm/unguarded-mutation.d.ts.map +1 -0
- package/dist/esm/unguarded-mutation.js +97 -0
- package/dist/esm/unguarded-mutation.js.map +1 -0
- package/dist/esm/vendor/VENDOR.sha256 +13 -0
- package/dist/esm/vendor/arity.js +53 -0
- package/dist/esm/vendor/verify.js +454 -0
- package/dist/esm/with-coderifts.d.ts +21 -0
- package/dist/esm/with-coderifts.d.ts.map +1 -1
- package/dist/esm/with-coderifts.js +40 -0
- package/dist/esm/with-coderifts.js.map +1 -1
- package/package.json +5 -4
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1356 — the host is the FIRST gate in the inescapability order (host → model → registry → CI),
|
|
3
|
+
* and until now it was the only one of the four that let a mutating call through by default.
|
|
4
|
+
*
|
|
5
|
+
* ── WHAT WAS ALREADY FAIL-CLOSED, MEASURED 2026-09-04 ───────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* Three of the four cases in the brief were ALREADY blocked by default, and the flip must not be
|
|
8
|
+
* described as if it closed them:
|
|
9
|
+
*
|
|
10
|
+
* contract call + NO receipt UNAVAILABLE / RECEIPT_MISSING guard.ts:981,1067
|
|
11
|
+
* contract call + unknown key UNAVAILABLE / RECEIPT_UNVERIFIED guard.ts:813
|
|
12
|
+
* contract call + STOP BLOCK guard.ts:854
|
|
13
|
+
*
|
|
14
|
+
* `enforceable = receiptVerified && …` (guard.ts:981) is the invariant that does it: the guard
|
|
15
|
+
* never runs the factory as enforced without a bound receipt. `requireReceipt: 'fail-closed'`
|
|
16
|
+
* (1307) fires earlier on the same fact; it is not what stood between a stranger and execution.
|
|
17
|
+
*
|
|
18
|
+
* ── THE GAP THAT WAS REAL ───────────────────────────────────────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* All three depend on the DETECTOR having triggered. The detector fires on contract artifacts —
|
|
21
|
+
* OpenAPI, GraphQL, protobuf, MCP manifests. A call it does not recognise takes the SKIPPED path
|
|
22
|
+
* (guard.ts:600) and executes with `preflighted:false, enforced:false`. Measured, by default:
|
|
23
|
+
*
|
|
24
|
+
* Read README.md SKIPPED → executed (correct)
|
|
25
|
+
* Edit src/app.py old_string/new_string SKIPPED → executed ← mutation, unguarded
|
|
26
|
+
* Write deploy.sh content: 'rm -rf /' SKIPPED → executed ← mutation, unguarded
|
|
27
|
+
*
|
|
28
|
+
* "Not a contract change" is a true statement about the ARTIFACT and it was being used as a
|
|
29
|
+
* decision about the ACTION. The host wrapped the tool, the guard saw the call, and the write
|
|
30
|
+
* happened anyway with nothing signed.
|
|
31
|
+
*
|
|
32
|
+
* ── THE FLIP ────────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
*
|
|
34
|
+
* A mutating call that reaches the end of the guard with no verified receipt is BLOCKED by
|
|
35
|
+
* default. Advisory remains available and is now a NAMED choice — `CODERIFTS_ADVISORY=1`, or
|
|
36
|
+
* `requireGuardedMutation: 'advisory'` — and it WARNS every time. `verify_ssl=false` is the
|
|
37
|
+
* comparison: a real option, spelled out loud, never the silent default.
|
|
38
|
+
*
|
|
39
|
+
* ── WHAT THIS DELIBERATELY DOES NOT DO ──────────────────────────────────────────────────────
|
|
40
|
+
*
|
|
41
|
+
* It does not widen `isMutatingCall` (freshness.ts:433). That predicate is shared with the
|
|
42
|
+
* conditional-write policy (guard.ts:495), so widening it here would silently change a second,
|
|
43
|
+
* unrelated gate. The consequence is a MEASURED RESIDUAL, and it should be read as a limit of
|
|
44
|
+
* this change rather than a property of it: `Bash { command: 'kubectl apply -f prod.yaml' }` is
|
|
45
|
+
* NOT classified mutating — the predicate reads contents/content/new_string/old_string/patch/
|
|
46
|
+
* edits, and `command` is not among them. A shell command that mutates production still takes the
|
|
47
|
+
* SKIPPED path. Closing that needs the predicate widened WITH the conditional-write blast radius
|
|
48
|
+
* measured, which is a separate change.
|
|
49
|
+
*
|
|
50
|
+
* An explicit `requireGuardedMutation: 'fail-closed'` is NOT overridable by the environment. An
|
|
51
|
+
* escape hatch that can undo a decision the host wrote down is not an escape hatch, it is a
|
|
52
|
+
* bypass, and anyone who can set an environment variable would hold it.
|
|
53
|
+
*/
|
|
54
|
+
/** The named opt-out. Compared against exactly these, so `CODERIFTS_ADVISORY=0` is not advisory. */
|
|
55
|
+
const ADVISORY_TRUE = new Set(['1', 'true', 'yes', 'on']);
|
|
56
|
+
export const ADVISORY_ENV_VAR = 'CODERIFTS_ADVISORY';
|
|
57
|
+
const RISK = 'A mutating tool call reached the guard with no verified receipt: the detector did not '
|
|
58
|
+
+ 'recognise a contract artifact, so nothing was preflighted and nothing was signed. "Not a '
|
|
59
|
+
+ 'contract change" describes the artifact, not the action — the write still happens.';
|
|
60
|
+
/**
|
|
61
|
+
* @param mutating isMutatingCall() over the REDACTED descriptor — never the raw one.
|
|
62
|
+
* @param policy host configuration; undefined means the new default.
|
|
63
|
+
* @param env process.env, injected so this stays a pure function and is testable.
|
|
64
|
+
*/
|
|
65
|
+
export function decideUnguardedMutation(mutating, policy, env) {
|
|
66
|
+
// Read tools are the whole reason this is gated on mutation rather than on "was it preflighted".
|
|
67
|
+
if (!mutating)
|
|
68
|
+
return { stop: false };
|
|
69
|
+
if (policy === 'advisory') {
|
|
70
|
+
return {
|
|
71
|
+
stop: false,
|
|
72
|
+
source: 'config',
|
|
73
|
+
detail: RISK,
|
|
74
|
+
warn: 'CodeRifts: ADVISORY — executing an unguarded mutating tool call. '
|
|
75
|
+
+ 'requireGuardedMutation is set to \'advisory\', so the guard is reporting rather than '
|
|
76
|
+
+ 'enforcing. ' + RISK,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// An explicit fail-closed outranks the environment. Checked BEFORE reading env on purpose.
|
|
80
|
+
if (policy !== 'fail-closed' && ADVISORY_TRUE.has(String(env[ADVISORY_ENV_VAR] ?? '').toLowerCase())) {
|
|
81
|
+
return {
|
|
82
|
+
stop: false,
|
|
83
|
+
source: 'environment',
|
|
84
|
+
detail: RISK,
|
|
85
|
+
warn: `CodeRifts: ADVISORY — executing an unguarded mutating tool call because `
|
|
86
|
+
+ `${ADVISORY_ENV_VAR} is set. This is the documented opt-out and it is not the default. `
|
|
87
|
+
+ RISK,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
stop: true,
|
|
92
|
+
detail: RISK + ' Blocked by default. To proceed without a receipt, choose it explicitly: set '
|
|
93
|
+
+ `${ADVISORY_ENV_VAR}=1 or requireGuardedMutation: 'advisory'. To proceed WITH one, send `
|
|
94
|
+
+ 'the change through preflight so there is something to verify.',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=unguarded-mutation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unguarded-mutation.js","sourceRoot":"","sources":["../../src/unguarded-mutation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoDG;AAQH,oGAAoG;AACpG,MAAM,aAAa,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC,CAAC;AAE1D,MAAM,CAAC,MAAM,gBAAgB,GAAG,oBAAoB,CAAC;AAarD,MAAM,IAAI,GACR,wFAAwF;MACtF,2FAA2F;MAC3F,oFAAoF,CAAC;AAEzF;;;;GAIG;AACH,MAAM,UAAU,uBAAuB,CACrC,QAAiB,EACjB,MAAyC,EACzC,GAAuC;IAEvC,iGAAiG;IACjG,IAAI,CAAC,QAAQ;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;IAEtC,IAAI,MAAM,KAAK,UAAU,EAAE,CAAC;QAC1B,OAAO;YACL,IAAI,EAAE,KAAK;YACX,MAAM,EAAE,QAAQ;YAChB,MAAM,EAAE,IAAI;YACZ,IAAI,EACF,mEAAmE;kBACjE,uFAAuF;kBACvF,aAAa,GAAG,IAAI;SACzB,CAAC;IACJ,CAAC;IAED,2FAA2F;IAC3F,IAAI,MAAM,KAAK,aAAa,IAAI,aAAa,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,gBAAgB,CAAC,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC,EAAE,CAAC;QACrG,OAAO;YACL,IAAI,EAAE,KAAK;YACX,MAAM,EAAE,aAAa;YACrB,MAAM,EAAE,IAAI;YACZ,IAAI,EACF,0EAA0E;kBACxE,GAAG,gBAAgB,qEAAqE;kBACxF,IAAI;SACT,CAAC;IACJ,CAAC;IAED,OAAO;QACL,IAAI,EAAE,IAAI;QACV,MAAM,EACJ,IAAI,GAAG,+EAA+E;cACpF,GAAG,gBAAgB,sEAAsE;cACzF,+DAA+D;KACpE,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Vendored verify core — the OFFLINE receipt verifier (1307).
|
|
2
|
+
#
|
|
3
|
+
# Byte copies from the public receipt-verifier, pinned. The guard used to verify over the
|
|
4
|
+
# NETWORK via config.client.verifyReceipt(), which the other three enforcement points do not:
|
|
5
|
+
# contract-gate, k8s-admission and gateway-verifier all verify offline against a pinned keyring.
|
|
6
|
+
# A verifier that needs the network fails differently from one that does not, and the sentence
|
|
7
|
+
# "the same proof at all four points, offline" was false because of this one.
|
|
8
|
+
#
|
|
9
|
+
# Do not edit these files here. Recopy from receipt-verifier and re-pin.
|
|
10
|
+
# source: receipt-verifier e11d3f5d9f5444d8b2a07428c922d2040f4819ce
|
|
11
|
+
|
|
12
|
+
verify.js 363e52c72ec4b913e1f3edc8e2029848feaeacb1efa6c07960fe5e5f195c23b3
|
|
13
|
+
arity.js 721fcac58b960b5e2aa1af91edb6cfc87ebe828a6da73879967ef79a1ca474e8
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 1129 — unified (token, opts) with opts.ctx / opts.intended.
|
|
5
|
+
* 3-ary (token, ctx, opts) wrappers warn once and forward.
|
|
6
|
+
* 2-ary verifiers keep the 1128 throw on a third argument.
|
|
7
|
+
*/
|
|
8
|
+
const warned = new Set();
|
|
9
|
+
|
|
10
|
+
function warnOnce(name) {
|
|
11
|
+
if (warned.has(name)) return;
|
|
12
|
+
warned.add(name);
|
|
13
|
+
const msg = `DEPRECATION: ${name}(token, ctx, opts) is deprecated; use ${name}(token, { ctx, intended }). This warning is emitted once.`;
|
|
14
|
+
if (typeof process !== 'undefined' && typeof process.emitWarning === 'function') {
|
|
15
|
+
process.emitWarning(msg, 'DeprecationWarning');
|
|
16
|
+
} else {
|
|
17
|
+
console.warn(msg);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function throw1128(name, n, consequence) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`${name}(token, opts) — pass intended via opts.intended. `
|
|
24
|
+
+ `Received ${n} arguments; the third would be ignored and the cross-check `
|
|
25
|
+
+ `would silently not run, grading a mismatched artifact ${consequence}.`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function looksUnified(obj) {
|
|
30
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false;
|
|
31
|
+
// perSlot is the bundle-dispatch unified shape: verifyBundle(bundle, { ctx, perSlot }).
|
|
32
|
+
// Without it, `{ perSlot }` was treated as a bare ctx and opts.perSlot vanished.
|
|
33
|
+
return obj.ctx != null || obj.intended != null || obj.envelope != null || obj.perSlot != null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function split3ary(name, nArgs, second, third) {
|
|
37
|
+
if (nArgs > 3) throw1128(name, nArgs, 'VALID');
|
|
38
|
+
if (nArgs === 3) {
|
|
39
|
+
warnOnce(name);
|
|
40
|
+
return { ctx: second || {}, opts: third && typeof third === 'object' ? third : {} };
|
|
41
|
+
}
|
|
42
|
+
const o = second && typeof second === 'object' ? second : {};
|
|
43
|
+
if (looksUnified(o)) {
|
|
44
|
+
return { ctx: o.ctx || {}, opts: o };
|
|
45
|
+
}
|
|
46
|
+
return { ctx: o, opts: {} };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function _resetWarnedForTest() {
|
|
50
|
+
warned.clear();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { warnOnce, throw1128, split3ary, looksUnified, _resetWarnedForTest };
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* CodeRifts chain-receipt verifier — PURE LIBRARY. Node >= 20, zero dependencies (node:crypto only).
|
|
5
|
+
*
|
|
6
|
+
* Verify the receipt yourself — offline, no live CodeRifts API call needed.
|
|
7
|
+
* The reference format is frozen in ./RECEIPT_FORMAT.md.
|
|
8
|
+
*
|
|
9
|
+
* THE SPLIT (1282-A'). This file used to be both the library and the command. The command half
|
|
10
|
+
* carried the parts a library must not have: a shebang, a `require.main` block, argument parsing,
|
|
11
|
+
* and `fetchKeyInfo` — a network call whose LEGACY single-key branch returns a key document with
|
|
12
|
+
* no status field, so a receipt signed by a key that was later revoked verified as current. Four
|
|
13
|
+
* repositories vendor this file as `src/verify.js`; every one of them was carrying that command,
|
|
14
|
+
* and none of them could ever run it.
|
|
15
|
+
*
|
|
16
|
+
* The command now lives in ./cli.js. `node cli.js <receipt> …` is the same CLI it always was.
|
|
17
|
+
* Library behaviour is BYTE-IDENTICAL: the cross-language corpus and the envelope-step vectors
|
|
18
|
+
* pass unmodified, which is the proof that this was a move and not a rewrite.
|
|
19
|
+
*
|
|
20
|
+
* WHAT STAYED, and why it is not an inconsistency: `loadKeyring` reads a registry from a URL **or
|
|
21
|
+
* a local file**, and three of the four vendoring repos call it with a FILE path — it is part of
|
|
22
|
+
* the library surface they depend on. `fetchKeyInfo` has no file branch and no consumer outside
|
|
23
|
+
* the command, so it left.
|
|
24
|
+
*
|
|
25
|
+
* CLI usage now:
|
|
26
|
+
* node cli.js <receipt> [--key pub.pem | --keys <url|file>] [--kid <kid>] [--fetch <url>]
|
|
27
|
+
* node cli.js --chain receipts.txt [--key pub.pem | --keys <url|file>] [--kid <kid>] [--fetch <url>]
|
|
28
|
+
*
|
|
29
|
+
* Key discovery: with no --key/--keys, keys are fetched from
|
|
30
|
+
* https://app.coderifts.com/.well-known/coderifts-keys.json (override with --fetch <url>).
|
|
31
|
+
* The fetch-and-resolve path accepts BOTH the registry array (active + retired)
|
|
32
|
+
* and the legacy single-key body from /api/v1/attestation/public-key.
|
|
33
|
+
* --keys resolves each receipt's key by kid from a registry
|
|
34
|
+
* ({ keys: [{ kid, public_key_pem, status, valid_from, retired_at }] }); accepts a URL or file.
|
|
35
|
+
*
|
|
36
|
+
* Output: JSON { valid, reason?, payload?, chain? } to stdout.
|
|
37
|
+
* Exit codes: 0 valid, 1 invalid, 2 usage error.
|
|
38
|
+
*
|
|
39
|
+
* Verification order (matches the reference taxonomy exactly):
|
|
40
|
+
* structure -> json -> kid -> signature
|
|
41
|
+
* Reasons: malformed_structure | bad_json | unknown_kid | signature_error | signature_mismatch
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const crypto = require('node:crypto');
|
|
45
|
+
const fs = require('node:fs');
|
|
46
|
+
const { split3ary } = require('./arity');
|
|
47
|
+
|
|
48
|
+
const DEFAULT_FETCH_URL = 'https://app.coderifts.com/.well-known/coderifts-keys.json';
|
|
49
|
+
const SIGNING_PREFIX = 'crchain.v1';
|
|
50
|
+
const MAX_SUPPORTED_V = 4;
|
|
51
|
+
/** ID104 — verification expiry leeway (ms). `exp + leeway < now` → VERIFIED_EXPIRED. */
|
|
52
|
+
const CLOCK_SKEW_LEEWAY_MS = 30_000;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 0s grace only when context DECLARES destructive AND production.
|
|
56
|
+
* Public verifier has `--environment` / envelope.environment; no `destructive`
|
|
57
|
+
* / `operation_class` field — never guess from operation labels.
|
|
58
|
+
*/
|
|
59
|
+
function expiryLeewayMs(_context) {
|
|
60
|
+
return CLOCK_SKEW_LEEWAY_MS;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isExpiredAt(expiresAtMs, nowMs, context) {
|
|
64
|
+
if (!Number.isFinite(expiresAtMs) || !Number.isFinite(nowMs)) return false;
|
|
65
|
+
return (expiresAtMs + expiryLeewayMs(context)) < nowMs;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Signed fields per the max version — the anti-downgrade delimiter guard rejects any that contain '|'.
|
|
69
|
+
const SIGNED_FIELDS = ['kid', 'fp', 'prev', 'caller', 'ts', 'reg', 'ir', 'expires_at', 'bh'];
|
|
70
|
+
|
|
71
|
+
function sha256hex(str) {
|
|
72
|
+
return crypto.createHash('sha256').update(String(str), 'utf8').digest('hex');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* RFC 8785 (JCS) canonical JSON for our data domain — MUST match the issuer's src/canonical-json.js
|
|
77
|
+
* and verify.py's canonical_json byte-for-byte (ASCII keys, JSON.stringify scalars, sorted keys, no
|
|
78
|
+
* whitespace, reject NaN/Infinity/undefined). Used to recompute decision_body_hash from --envelope.
|
|
79
|
+
*/
|
|
80
|
+
function canonicalJson(value) {
|
|
81
|
+
if (value === null) return 'null';
|
|
82
|
+
const t = typeof value;
|
|
83
|
+
if (t === 'boolean' || t === 'string') return JSON.stringify(value);
|
|
84
|
+
if (t === 'number') {
|
|
85
|
+
if (!Number.isFinite(value)) throw new TypeError('canonicalJson: non-finite number');
|
|
86
|
+
return JSON.stringify(value);
|
|
87
|
+
}
|
|
88
|
+
if (t === 'undefined') throw new TypeError('canonicalJson: undefined');
|
|
89
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
90
|
+
if (t === 'object') {
|
|
91
|
+
const keys = Object.keys(value).sort();
|
|
92
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
|
|
93
|
+
}
|
|
94
|
+
throw new TypeError(`canonicalJson: unsupported type ${t}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Reconstruct the exact signed bytes from a parsed receipt body.
|
|
99
|
+
* v1 (v absent or 1): the base string.
|
|
100
|
+
* v2 (v === 2): base + '|' + reg.
|
|
101
|
+
* v3 (v === 3): base + '|' + reg + '|' + ir.
|
|
102
|
+
* v4 (v === 4): base + '|' + reg + '|' + ir + '|' + expires_at + '|' + bh.
|
|
103
|
+
* Field order matches the CodeRifts issuer exactly (chain-attestation.js signingInputV4).
|
|
104
|
+
* This string must be byte-identical to the Python implementation.
|
|
105
|
+
*/
|
|
106
|
+
function reconstructSignedInput(payload) {
|
|
107
|
+
const base = `${SIGNING_PREFIX}|${payload.kid}|${payload.fp}|${payload.prev}|${payload.caller}|${payload.ts}`;
|
|
108
|
+
if (payload.v === 4) return `${base}|${payload.reg}|${payload.ir}|${payload.expires_at}|${payload.bh}`;
|
|
109
|
+
if (payload.v === 3) return `${base}|${payload.reg}|${payload.ir}`;
|
|
110
|
+
if (payload.v === 2) return `${base}|${payload.reg}`;
|
|
111
|
+
return base;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve the verification key for a parsed payload.
|
|
116
|
+
* Two modes:
|
|
117
|
+
* - keyring (from --keys): pick the entry whose kid matches payload.kid; an
|
|
118
|
+
* unlisted kid resolves to null (=> unknown_kid). A retired key still verifies
|
|
119
|
+
* so receipts issued before a rotation stay checkable.
|
|
120
|
+
* - single key (default): the one loaded key, gated by expectedKid when known.
|
|
121
|
+
* Returns a KeyObject, or null when the kid is not accepted.
|
|
122
|
+
*/
|
|
123
|
+
/**
|
|
124
|
+
* 1306(a) — accept the SERVED registry shape, not only a Map.
|
|
125
|
+
*
|
|
126
|
+
* MEASURED black-box 2026-09-02. `https://app.coderifts.com/.well-known/coderifts-keys.json`
|
|
127
|
+
* serves `{keys:[{kid, public_key_pem, status, ...}]}`. Passing that document straight into
|
|
128
|
+
* `verifyReceipt(token, {ctx:{keyring}})` threw `TypeError: ctx.keyring.get is not a function` —
|
|
129
|
+
* the library path rejected the exact bytes this project publishes for it, while the CLI's
|
|
130
|
+
* `--keys <url>` worked because it converts first.
|
|
131
|
+
*
|
|
132
|
+
* The fix is here rather than in a caller: every consumer that fetches the public registry would
|
|
133
|
+
* otherwise have to know to convert, and the ones that do not find out with a TypeError rather
|
|
134
|
+
* than a verdict. `expectedKid` is a separate trap on the same path and is NOT papered over —
|
|
135
|
+
* `{keyring}` with no `expectedKid` still resolves to null by design (see resolveEntry), because
|
|
136
|
+
* silently defaulting a kid gate is a different and worse behaviour than a clear TypeError.
|
|
137
|
+
*/
|
|
138
|
+
function coerceKeyring(k, source) {
|
|
139
|
+
if (!k) return k;
|
|
140
|
+
if (typeof k.get === 'function') return k; // already a Map
|
|
141
|
+
if (Array.isArray(k.keys) || Array.isArray(k)) {
|
|
142
|
+
return keyringFromDocument(Array.isArray(k) ? { keys: k } : k, source || 'ctx.keyring');
|
|
143
|
+
}
|
|
144
|
+
return k;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function resolveEntry(ctx, payload) {
|
|
148
|
+
if (ctx.keyring) {
|
|
149
|
+
const entry = ctx.keyring.get(payload.kid);
|
|
150
|
+
if (!entry) return null;
|
|
151
|
+
if (ctx.expectedKid !== null && payload.kid !== ctx.expectedKid) return null;
|
|
152
|
+
return entry; // { publicKey, status, retired_at }
|
|
153
|
+
}
|
|
154
|
+
if (ctx.expectedKid !== null && payload.kid !== ctx.expectedKid) return null;
|
|
155
|
+
return { publicKey: ctx.publicKey, status: null, retired_at: null, revoked_at: null, compromised_at: null };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Derive the 12-status taxonomy verdict for an already-signature-valid receipt.
|
|
160
|
+
* RETIRED_KEY_VALID_AT_ISSUE — retired key, receipt ts predates retired_at (else INVALID_SIGNATURE).
|
|
161
|
+
* UNSUPPORTED_VERSION — payload.v beyond MAX_SUPPORTED_V.
|
|
162
|
+
* VERIFIED_EXPIRED — v4 receipt whose signed expires_at + 30s leeway is in the past.
|
|
163
|
+
* VERIFIED_WRONG_AUDIENCE / _WRONG_ENVIRONMENT — dormant: only when --envelope carries the field
|
|
164
|
+
* AND a check input (--audience/--environment) is supplied.
|
|
165
|
+
* VERIFIED_SUPERSEDED / _SCOPE_MISMATCH — dormant: no check input defined this round.
|
|
166
|
+
* VERIFIED_CURRENT — otherwise.
|
|
167
|
+
*/
|
|
168
|
+
function deriveStatus(payload, entry, opts) {
|
|
169
|
+
if (typeof payload.v === 'number' && payload.v > MAX_SUPPORTED_V) return 'UNSUPPORTED_VERSION';
|
|
170
|
+
// FAIL CLOSED ON A STATUS WE DO NOT UNDERSTAND.
|
|
171
|
+
//
|
|
172
|
+
// MEASURED 2026-08-26: a registry entry with status "revoked" returned
|
|
173
|
+
// { valid: true, status: "VERIFIED_CURRENT" } here — the status was read for 'retired' and
|
|
174
|
+
// otherwise ignored, so anything else fell through to the healthy path. An operator who marked a
|
|
175
|
+
// stolen key revoked would have believed they had acted while this verifier kept accepting it.
|
|
176
|
+
// The app kernel and verify-attest/verify-toolset already reject an unknown status; these two
|
|
177
|
+
// did not, so the fleet disagreed about the same registry.
|
|
178
|
+
//
|
|
179
|
+
// This is a bug fix, not revocation: the revocation RULE (compromised_at, REVOKED_KEY /
|
|
180
|
+
// REVOKED_KEY_UNDECIDABLE) is a separate, larger change across eight verifiers. What lands here
|
|
181
|
+
// is only the direction of the unknown case. Safe by measurement: the live registry publishes
|
|
182
|
+
// 'active' only, so no real consumer changes behaviour.
|
|
183
|
+
const KNOWN_STATUSES = new Set(['active', 'retired', 'revoked', null, undefined]);
|
|
184
|
+
if (!KNOWN_STATUSES.has(entry.status)) {
|
|
185
|
+
return 'UNKNOWN_KEY_STATUS';
|
|
186
|
+
}
|
|
187
|
+
// 1079 B — OPTIONAL timestamps, additive. Absent both fields → this function continues
|
|
188
|
+
// exactly as before. Signing time is payload.ts (receipts have no iat).
|
|
189
|
+
// revoked_at = compromise: EVERY receipt under the key is invalid, including those
|
|
190
|
+
// whose ts predates revoked_at (the attacker chooses ts).
|
|
191
|
+
// retired_at = planned rotation: ts < retired_at stays on the existing path;
|
|
192
|
+
// ts >= retired_at is KEY_RETIRED_AFTER_SIGNING.
|
|
193
|
+
if (typeof entry.revoked_at === 'string' && entry.revoked_at.length > 0) {
|
|
194
|
+
return 'KEY_REVOKED';
|
|
195
|
+
}
|
|
196
|
+
if (typeof entry.retired_at === 'string' && entry.retired_at.length > 0 && payload.ts) {
|
|
197
|
+
const issued = Date.parse(payload.ts);
|
|
198
|
+
const retired = Date.parse(entry.retired_at);
|
|
199
|
+
if (Number.isFinite(issued) && Number.isFinite(retired) && issued >= retired) {
|
|
200
|
+
return 'KEY_RETIRED_AFTER_SIGNING';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// REVOKED — RECEIPT_FORMAT.md §7.1 (normative). The attacker chooses ts, so no timestamp may
|
|
204
|
+
// rehabilitate a revoked key's signature: BOTH outcomes are valid:false. UNDECIDABLE is not a
|
|
205
|
+
// softer valid; it reports that we cannot tell a legitimate pre-compromise receipt from a
|
|
206
|
+
// backdated forgery. A missing compromised_at means the whole key history is suspect.
|
|
207
|
+
if (entry.status === 'revoked') {
|
|
208
|
+
const at = entry.compromised_at;
|
|
209
|
+
if (typeof at !== 'string' || at.length === 0) return 'REVOKED_KEY_UNDECIDABLE';
|
|
210
|
+
const boundary = Date.parse(at);
|
|
211
|
+
const issued = Date.parse(payload.ts);
|
|
212
|
+
if (!Number.isFinite(boundary) || !Number.isFinite(issued)) return 'REVOKED_KEY_UNDECIDABLE';
|
|
213
|
+
return issued >= boundary ? 'REVOKED_KEY' : 'REVOKED_KEY_UNDECIDABLE';
|
|
214
|
+
}
|
|
215
|
+
if (entry.status === 'retired') {
|
|
216
|
+
if (entry.retired_at && payload.ts
|
|
217
|
+
&& Date.parse(payload.ts) < Date.parse(entry.retired_at)) {
|
|
218
|
+
return 'RETIRED_KEY_VALID_AT_ISSUE';
|
|
219
|
+
}
|
|
220
|
+
return 'INVALID_SIGNATURE'; // signed by a key already retired at issue -> reject
|
|
221
|
+
}
|
|
222
|
+
const now = opts.now != null ? opts.now : Date.now();
|
|
223
|
+
if (payload.v === 4 && typeof payload.expires_at === 'string') {
|
|
224
|
+
const exp = Date.parse(payload.expires_at);
|
|
225
|
+
const context = opts.envelope || { environment: opts.expectedEnvironment };
|
|
226
|
+
if (isExpiredAt(exp, now, context)) return 'VERIFIED_EXPIRED';
|
|
227
|
+
}
|
|
228
|
+
if (opts.envelope) {
|
|
229
|
+
const env = opts.envelope;
|
|
230
|
+
if (opts.expectedAudience != null && env.audience != null && env.audience !== opts.expectedAudience) {
|
|
231
|
+
return 'VERIFIED_WRONG_AUDIENCE';
|
|
232
|
+
}
|
|
233
|
+
if (opts.expectedEnvironment != null && env.environment != null && env.environment !== opts.expectedEnvironment) {
|
|
234
|
+
return 'VERIFIED_WRONG_ENVIRONMENT';
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return 'VERIFIED_CURRENT';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Verify a single receipt token against a public key (or keyring) + expected kid.
|
|
242
|
+
* @param {string} token
|
|
243
|
+
* @param {{ publicKey?: import('crypto').KeyObject, keyring?: Map<string,{publicKey:import('crypto').KeyObject}>, expectedKid: (string|null) }} ctx
|
|
244
|
+
* @returns {{ valid: boolean, reason?: string, payload?: object }}
|
|
245
|
+
*/
|
|
246
|
+
function verifyReceiptInner(token, ctx, opts = {}) {
|
|
247
|
+
// 1. structure
|
|
248
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
249
|
+
return { valid: false, status: 'MALFORMED', reason: 'malformed_structure' };
|
|
250
|
+
}
|
|
251
|
+
const segments = token.split('.');
|
|
252
|
+
if (segments.length !== 2 || segments.some((s) => !s)) {
|
|
253
|
+
return { valid: false, status: 'MALFORMED', reason: 'malformed_structure' };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 2. json
|
|
257
|
+
let payload;
|
|
258
|
+
try {
|
|
259
|
+
payload = JSON.parse(Buffer.from(segments[0], 'base64url').toString('utf8'));
|
|
260
|
+
} catch (_) {
|
|
261
|
+
return { valid: false, status: 'MALFORMED', reason: 'bad_json' };
|
|
262
|
+
}
|
|
263
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
264
|
+
return { valid: false, status: 'MALFORMED', reason: 'bad_json' };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 3. kid -- resolve the entry by kid (keyring) or gate the single key by expectedKid.
|
|
268
|
+
// 1306(a): a served `{keys:[...]}` document is accepted here as well as a Map.
|
|
269
|
+
const entry = resolveEntry(
|
|
270
|
+
ctx.keyring ? { ...ctx, keyring: coerceKeyring(ctx.keyring, 'ctx.keyring') } : ctx,
|
|
271
|
+
payload,
|
|
272
|
+
);
|
|
273
|
+
if (!entry) {
|
|
274
|
+
return { valid: false, status: 'UNKNOWN_KEY', reason: 'unknown_kid', payload };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 4. signature (raw Ed25519 over the reconstructed UTF-8 bytes)
|
|
278
|
+
const sig = Buffer.from(segments[1], 'base64url');
|
|
279
|
+
let ok = false;
|
|
280
|
+
try {
|
|
281
|
+
ok = crypto.verify(null, Buffer.from(reconstructSignedInput(payload), 'utf8'), entry.publicKey, sig);
|
|
282
|
+
} catch (_) {
|
|
283
|
+
return { valid: false, status: 'INVALID_SIGNATURE', reason: 'signature_error', payload };
|
|
284
|
+
}
|
|
285
|
+
if (!ok) return { valid: false, status: 'INVALID_SIGNATURE', reason: 'signature_mismatch', payload };
|
|
286
|
+
|
|
287
|
+
// 5. anti-downgrade delimiter guard — a signed field containing '|' could re-split into a lower
|
|
288
|
+
// version whose reconstructed bytes collide with these. Legitimate fields never contain '|'.
|
|
289
|
+
for (const k of SIGNED_FIELDS) {
|
|
290
|
+
if (typeof payload[k] === 'string' && payload[k].includes('|')) {
|
|
291
|
+
return { valid: false, status: 'INVALID_SIGNATURE', reason: 'delimiter_in_field', payload };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 6. envelope binding (v4): a supplied envelope's canonical body_hash MUST equal payload.bh.
|
|
296
|
+
if (opts.envelope && payload.v === 4) {
|
|
297
|
+
const rest = { ...opts.envelope };
|
|
298
|
+
delete rest.receipt;
|
|
299
|
+
delete rest.decision_body_hash;
|
|
300
|
+
const recomputed = 'sha256:' + sha256hex(canonicalJson(rest));
|
|
301
|
+
if (recomputed !== payload.bh) {
|
|
302
|
+
return { valid: false, status: 'INVALID_SIGNATURE', reason: 'body_hash_mismatch', payload };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 7. taxonomy status (freshness / retirement / dormant field checks).
|
|
307
|
+
const status = deriveStatus(payload, entry, opts);
|
|
308
|
+
if (status === 'KEY_REVOKED') {
|
|
309
|
+
return { valid: false, status, reason: 'KEY_REVOKED', payload };
|
|
310
|
+
}
|
|
311
|
+
if (status === 'KEY_RETIRED_AFTER_SIGNING') {
|
|
312
|
+
return { valid: false, status, reason: 'KEY_RETIRED_AFTER_SIGNING', payload };
|
|
313
|
+
}
|
|
314
|
+
if (status === 'INVALID_SIGNATURE') {
|
|
315
|
+
return { valid: false, status, reason: 'retired_key_after_issue', payload };
|
|
316
|
+
}
|
|
317
|
+
const valid = status === 'VERIFIED_CURRENT' || status === 'RETIRED_KEY_VALID_AT_ISSUE';
|
|
318
|
+
// UNKNOWN_KEY_STATUS is deliberately absent from the valid set — see deriveStatus.
|
|
319
|
+
return { valid, status, payload };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Verify a chain of tokens (oldest first): every signature valid AND every non-genesis
|
|
324
|
+
* link's prev == 'sha256:' + sha256hex(previous token string).
|
|
325
|
+
*/
|
|
326
|
+
function verifyChainInner(tokens, ctx, opts = {}) {
|
|
327
|
+
const links = [];
|
|
328
|
+
let allValid = true;
|
|
329
|
+
let first = null;
|
|
330
|
+
|
|
331
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
332
|
+
const token = tokens[i];
|
|
333
|
+
const res = verifyReceiptInner(token, ctx, opts);
|
|
334
|
+
const link = { index: i, signature_valid: res.valid };
|
|
335
|
+
if (!res.valid) link.reason = res.reason;
|
|
336
|
+
const prev = res.payload ? res.payload.prev : undefined;
|
|
337
|
+
if (res.payload) link.prev = prev;
|
|
338
|
+
|
|
339
|
+
if (i === 0) {
|
|
340
|
+
first = prev === 'null' ? 'genesis' : 'continuation';
|
|
341
|
+
link.role = first;
|
|
342
|
+
// A genesis needs prev === 'null'; a continuation links to a token we do not hold.
|
|
343
|
+
link.prev_ok = prev === 'null' ? true : null;
|
|
344
|
+
} else {
|
|
345
|
+
const expected = `sha256:${sha256hex(tokens[i - 1])}`;
|
|
346
|
+
link.expected_prev = expected;
|
|
347
|
+
link.prev_ok = prev === expected;
|
|
348
|
+
if (!link.prev_ok) allValid = false;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!res.valid) allValid = false;
|
|
352
|
+
links.push(link);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return { valid: allValid, chain: { length: tokens.length, first, links } };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// Key loading
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
function keyFromPem(pem) {
|
|
363
|
+
return crypto.createPublicKey(pem);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Build a kid -> key map from a registry document
|
|
368
|
+
* ({ keys: [{ kid, public_key_pem, status, valid_from, retired_at }] }).
|
|
369
|
+
* Returns null when `keys` is missing/empty so the caller can try the legacy
|
|
370
|
+
* single-key body. --keys still requires a non-empty keys[] (throws).
|
|
371
|
+
*/
|
|
372
|
+
function keyringFromDocument(doc, source) {
|
|
373
|
+
const keys = doc && Array.isArray(doc.keys) ? doc.keys : null;
|
|
374
|
+
if (!keys || keys.length === 0) return null;
|
|
375
|
+
const keyring = new Map();
|
|
376
|
+
for (const k of keys) {
|
|
377
|
+
if (!k || !k.kid || !k.public_key_pem) throw new Error(`registry entry missing kid/public_key_pem in ${source}`);
|
|
378
|
+
// compromised_at MUST be carried through: deriveStatus reads it for the revoked rule
|
|
379
|
+
// (RECEIPT_FORMAT.md 7.1). Dropping it here made the rule inert — every revoked key
|
|
380
|
+
// returned UNDECIDABLE regardless of ts, which looks implemented and decides nothing.
|
|
381
|
+
keyring.set(k.kid, {
|
|
382
|
+
publicKey: keyFromPem(k.public_key_pem),
|
|
383
|
+
status: k.status || null,
|
|
384
|
+
retired_at: k.retired_at || null,
|
|
385
|
+
revoked_at: k.revoked_at || null,
|
|
386
|
+
compromised_at: k.compromised_at || null,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return keyring;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function pickActiveFromKeyring(keyring) {
|
|
393
|
+
for (const [kid, entry] of keyring) {
|
|
394
|
+
if (entry.status === 'active') return { kid, entry };
|
|
395
|
+
}
|
|
396
|
+
const first = keyring.entries().next().value;
|
|
397
|
+
return first ? { kid: first[0], entry: first[1] } : null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Build a kid -> key map from a CodeRifts key registry
|
|
402
|
+
* ({ keys: [{ kid, public_key_pem, status, valid_from, retired_at }] }). A --keys source may be
|
|
403
|
+
* an http(s) URL or a local file path. Both active and retired keys are loaded.
|
|
404
|
+
*/
|
|
405
|
+
async function loadKeyring(source) {
|
|
406
|
+
let text;
|
|
407
|
+
if (/^https?:\/\//i.test(source)) {
|
|
408
|
+
const res = await fetch(source, { headers: { Accept: 'application/json' } });
|
|
409
|
+
if (!res.ok) throw new Error(`fetch ${source} -> HTTP ${res.status}`);
|
|
410
|
+
text = await res.text();
|
|
411
|
+
} else {
|
|
412
|
+
text = fs.readFileSync(source, 'utf8');
|
|
413
|
+
}
|
|
414
|
+
const doc = JSON.parse(text);
|
|
415
|
+
const keyring = keyringFromDocument(doc, source);
|
|
416
|
+
if (!keyring) throw new Error(`no keys[] in registry ${source}`);
|
|
417
|
+
return keyring;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function verifyReceipt(token, second, third) {
|
|
421
|
+
const { ctx, opts } = split3ary('verifyReceipt', arguments.length, second, third);
|
|
422
|
+
return verifyReceiptInner(token, ctx, opts);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function verifyChain(tokens, second, third) {
|
|
426
|
+
const { ctx, opts } = split3ary('verifyChain', arguments.length, second, third);
|
|
427
|
+
return verifyChainInner(tokens, ctx, opts);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Reusable API — require('./verify') imports the pure verify logic WITHOUT running the CLI. The
|
|
431
|
+
// GitHub Action + other embedders use these directly (verifyReceipt/verifyChain/deriveStatus/…);
|
|
432
|
+
// the receipt format + taxonomy are frozen in RECEIPT_FORMAT.md.
|
|
433
|
+
module.exports = {
|
|
434
|
+
verifyReceipt,
|
|
435
|
+
verifyChain,
|
|
436
|
+
deriveStatus,
|
|
437
|
+
resolveEntry,
|
|
438
|
+
reconstructSignedInput,
|
|
439
|
+
canonicalJson,
|
|
440
|
+
loadKeyring,
|
|
441
|
+
keyFromPem,
|
|
442
|
+
keyringFromDocument,
|
|
443
|
+
// Pure keyring helper. Exported for the command (cli.js fetchKeyInfo) after the split —
|
|
444
|
+
// it takes a keyring and returns the active entry; it performs no I/O.
|
|
445
|
+
pickActiveFromKeyring,
|
|
446
|
+
sha256hex,
|
|
447
|
+
DEFAULT_FETCH_URL,
|
|
448
|
+
SIGNING_PREFIX,
|
|
449
|
+
MAX_SUPPORTED_V,
|
|
450
|
+
SIGNED_FIELDS,
|
|
451
|
+
CLOCK_SKEW_LEEWAY_MS,
|
|
452
|
+
expiryLeewayMs,
|
|
453
|
+
isExpiredAt,
|
|
454
|
+
};
|
|
@@ -418,6 +418,27 @@ export type ReceiptThreadHandle = {
|
|
|
418
418
|
*/
|
|
419
419
|
lastSkipReason: () => ReceiptCursorSkipReason | null;
|
|
420
420
|
};
|
|
421
|
+
/**
|
|
422
|
+
* The unversioned spelling still resolves to `_V1`, and always will — see the type doc above:
|
|
423
|
+
* re-pointing it at a future `_V2` would silently move every existing caller, which is the
|
|
424
|
+
* migration this design refuses.
|
|
425
|
+
*
|
|
426
|
+
* SO THIS NOTICE PROMISES NOTHING. It does not say deprecated, it does not name a removal
|
|
427
|
+
* version, and it must not: the alias is permanent by design, and a notice announcing a removal
|
|
428
|
+
* that will never happen is a false statement shipped to every consumer on the alias. What it
|
|
429
|
+
* does is point at the name that carries the version, so a caller who wants the guarantee the
|
|
430
|
+
* suffix gives can ask for it by name.
|
|
431
|
+
*
|
|
432
|
+
* ONCE PER PROCESS, and the flag is module-level for the same reason `policy.ts` uses one: a
|
|
433
|
+
* per-call notice on a construction path that a host may run per request is noise, and noise is
|
|
434
|
+
* how a line stops being read.
|
|
435
|
+
*
|
|
436
|
+
* The wire value is untouched. `GUARD_PROFILE_WIRE_VALUE` stays the suffix-less string that four
|
|
437
|
+
* downstream modules compare against; this observes the spelling, it does not rewrite it.
|
|
438
|
+
*/
|
|
439
|
+
export declare const UNVERSIONED_PROFILE_NOTICE = "ENFORCING_STRICT is the unversioned spelling of ENFORCING_STRICT_V1; prefer the versioned name.";
|
|
440
|
+
/** Test-only reset. Exported so a test can assert the once-per-process behaviour more than once. */
|
|
441
|
+
export declare function _resetUnversionedProfileNoticeForTest(): void;
|
|
421
442
|
/**
|
|
422
443
|
* Wrap guardToolRegistry with a mandatory operation and a separately-computed composition assurance.
|
|
423
444
|
* Fails at CONSTRUCTION (never at first tool call) for a missing client, a missing/empty operation, an
|