@coderifts/agent-guard 17.1.0 → 17.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/dist/cjs/cas-attestation.d.ts +65 -0
- package/dist/cjs/cas-attestation.d.ts.map +1 -1
- package/dist/cjs/cas-attestation.js +185 -9
- package/dist/cjs/cas-attestation.js.map +1 -1
- package/dist/cjs/guard.d.ts.map +1 -1
- package/dist/cjs/guard.js +8 -0
- package/dist/cjs/guard.js.map +1 -1
- package/dist/cjs/vendor/VENDOR.sha256 +27 -2
- package/dist/cjs/vendor/keys/coderifts-keys.json +1 -0
- package/dist/cjs/vendor/verified-execution-binding.js +316 -0
- package/dist/cjs/vendor/verify-evidence.js +238 -0
- package/dist/cjs/vendor/verify-grant.js +564 -0
- package/dist/cjs/vendor/verify-prove-transcript.js +72 -0
- package/dist/esm/cas-attestation.d.ts +65 -0
- package/dist/esm/cas-attestation.d.ts.map +1 -1
- package/dist/esm/cas-attestation.js +184 -9
- package/dist/esm/cas-attestation.js.map +1 -1
- package/dist/esm/guard.d.ts.map +1 -1
- package/dist/esm/guard.js +8 -0
- package/dist/esm/guard.js.map +1 -1
- package/dist/esm/vendor/VENDOR.sha256 +27 -2
- package/dist/esm/vendor/keys/coderifts-keys.json +1 -0
- package/dist/esm/vendor/verified-execution-binding.js +316 -0
- package/dist/esm/vendor/verify-evidence.js +238 -0
- package/dist/esm/vendor/verify-grant.js +564 -0
- package/dist/esm/vendor/verify-prove-transcript.js +72 -0
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Vendored verify core — the OFFLINE receipt verifier (1307).
|
|
1
|
+
# Vendored verify core — the OFFLINE receipt verifier (1307) and the CANONICAL EVIDENCE core (1423).
|
|
2
2
|
#
|
|
3
3
|
# Byte copies from the public receipt-verifier, pinned. The guard used to verify over the
|
|
4
4
|
# NETWORK via config.client.verifyReceipt(), which the other three enforcement points do not:
|
|
@@ -6,8 +6,33 @@
|
|
|
6
6
|
# A verifier that needs the network fails differently from one that does not, and the sentence
|
|
7
7
|
# "the same proof at all four points, offline" was false because of this one.
|
|
8
8
|
#
|
|
9
|
+
# 1431 ADDS THE GRANT. Measured before it was changed: the guard authenticated the receipt (this
|
|
10
|
+
# verify.js) and the executor attestation (SDK verifyExecutionAttestation), and NEVER the
|
|
11
|
+
# execution grant. `parseGrantFields` in the SDK DECODES a grant to cross-check jti/scope_hash
|
|
12
|
+
# against the attestation, so a forged token whose payload copies those fields — with the word
|
|
13
|
+
# NEM-ALAIRAS where the signature belongs — was accepted as a kernel binding and upgraded
|
|
14
|
+
# ENFORCING_STRICT from authorized_not_committed to authorized_and_committed. A binding checked
|
|
15
|
+
# against an unauthenticated document is not a binding.
|
|
16
|
+
#
|
|
9
17
|
# Do not edit these files here. Recopy from receipt-verifier and re-pin.
|
|
10
|
-
#
|
|
18
|
+
# PER-FILE PROVENANCE, mixed on purpose. Collapsing this to one revision would lie: verify.js is
|
|
19
|
+
# pinned at 6048195 and has NOT been recopied, because 1306's keyring-shape fix is the behaviour
|
|
20
|
+
# this guard was tested against and recopying HEAD would change a verifier nobody measured again.
|
|
21
|
+
# The 1423 evidence core is vendored at HEAD. test/vendor-parity.test.js checks each file against
|
|
22
|
+
# ITS OWN revision, not against HEAD, so an honest mixed pin does not read as drift.
|
|
23
|
+
#
|
|
24
|
+
# verify.js 6048195c0bcf7e2594a2d78e83cfc082ea615790
|
|
25
|
+
# arity.js 724aa9ae8171f1acf629c3c6b4879dc056575f20
|
|
26
|
+
# verify-grant.js a472bf9073781cba25d83c7d1406b1a7c4af0b94
|
|
27
|
+
# verify-prove-transcript.js 724aa9ae8171f1acf629c3c6b4879dc056575f20
|
|
28
|
+
# verified-execution-binding.js 1e41542296551139c41e2d4b4cff2e67bc42aa06
|
|
29
|
+
# verify-evidence.js 724aa9ae8171f1acf629c3c6b4879dc056575f20
|
|
30
|
+
# keys/coderifts-keys.json 724aa9ae8171f1acf629c3c6b4879dc056575f20
|
|
11
31
|
|
|
12
32
|
verify.js 363e52c72ec4b913e1f3edc8e2029848feaeacb1efa6c07960fe5e5f195c23b3
|
|
13
33
|
arity.js 721fcac58b960b5e2aa1af91edb6cfc87ebe828a6da73879967ef79a1ca474e8
|
|
34
|
+
verify-grant.js 127b53de3be77196862c10a8812221de0a5112cf1d3feae12d347a6021852df7
|
|
35
|
+
verify-prove-transcript.js 3bb17bd4f16d5ee3cf3fbb754b4df9aa570a615ca94ff2a97b751c4fabd3bd0d
|
|
36
|
+
verify-evidence.js 7ca498fad4b23e4ae8f28c42c30058bc3526a00d5cc6d6f36380335bf25d3288
|
|
37
|
+
verified-execution-binding.js e48ea29b9f8b0c5b1754187788cbaf78cdaf0b505848c05d1ae3ffebb30218f2
|
|
38
|
+
keys/coderifts-keys.json bfe0c898cda3c3a4c8141726d0adb4cdb7eb9762de6264e430eff6469b90422e
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"keys":[{"kid":"2026-07-k1","public_key_pem":"-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAJh8xDXLaOCpQ+bBC9d2I+zG1qVsCpeCuWbtH+aGjC7A=\n-----END PUBLIC KEY-----\n","status":"active","valid_from":null,"retired_at":null}]}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* verifiedExecutionBinding — the ONE predicate that answers "authorized and committed".
|
|
5
|
+
*
|
|
6
|
+
* ── WHY ONE (1459) ──────────────────────────────────────────────────────────────────────────
|
|
7
|
+
*
|
|
8
|
+
* The same question was being answered in several places with different rules, and the answers
|
|
9
|
+
* disagreed. Measured on the public agent-guard 17.2.0, with a forged-signature grant and a REAL
|
|
10
|
+
* attestation from a trusted executor bound to that grant's jti and scope:
|
|
11
|
+
*
|
|
12
|
+
* ENFORCING_STRICT authorized_and_committed = false
|
|
13
|
+
* ENFORCING_ATOMIC authorized_and_committed = true
|
|
14
|
+
*
|
|
15
|
+
* Two profiles of one product, looking at one set of bytes, reaching opposite conclusions — because
|
|
16
|
+
* the Atomic formula was `receipt_verified && committed && class === 'executor_attested'` and never
|
|
17
|
+
* asked whether the GRANT was signed by anyone. A predicate that is recomputed is a predicate that
|
|
18
|
+
* drifts; this file exists so callers QUOTE the answer instead.
|
|
19
|
+
*
|
|
20
|
+
* ── FOUR AUTHORITIES, AND ONLY THEIR INTERSECTION ───────────────────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* Each is independently true or not, and each answers a different question. None of them implies
|
|
23
|
+
* another, which is exactly why the intersection — not any one of them — is the success condition:
|
|
24
|
+
*
|
|
25
|
+
* issuer_grant did CodeRifts authorize THIS change? (a signature under a pinned key)
|
|
26
|
+
* executor_attestation did the executor commit THAT grant? (a signature over the grant's ids)
|
|
27
|
+
* one_run_root are these bytes from ONE run? (cr.evidence.root.v1 digests)
|
|
28
|
+
* provider_witness did the provider record it? (a readback — UNSIGNED by nature)
|
|
29
|
+
*
|
|
30
|
+
* ── SHORTFALLS ARE NAMED, NOT FOLDED INTO `false` ───────────────────────────────────────────
|
|
31
|
+
*
|
|
32
|
+
* "Not authorized" and "authorized but the commit is unproven" are different facts with different
|
|
33
|
+
* remedies, and a boolean loses that. Every shortfall gets a state a human can act on, and the
|
|
34
|
+
* states are ORDERED by severity so the returned one is the most serious thing that is wrong.
|
|
35
|
+
*
|
|
36
|
+
* WHAT THIS DOES NOT DO. It reaches no network and holds no keys: every input is supplied by the
|
|
37
|
+
* caller, already-verified or verifiable, and the four authorities are recomputed here from those
|
|
38
|
+
* inputs rather than taken as claims. It also cannot see a lying executor — the root closes
|
|
39
|
+
* third-party splicing, not an executor misreporting its own run.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const crypto = require('node:crypto');
|
|
43
|
+
|
|
44
|
+
const { verifyExecutionGrant } = require('./verify-grant.js');
|
|
45
|
+
const { verifyEvidenceRootBinding } = require('./verify-evidence.js');
|
|
46
|
+
|
|
47
|
+
const BINDING_V = 'cr.verified-execution-binding.v1';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Ordered most-severe first. The returned `state` is the FIRST unmet one, so a caller that renders
|
|
51
|
+
* a single line renders the thing that most needs fixing.
|
|
52
|
+
*/
|
|
53
|
+
const STATE = Object.freeze({
|
|
54
|
+
UNAUTHORIZED: 'UNAUTHORIZED',
|
|
55
|
+
COMMIT_UNPROVEN: 'COMMIT_UNPROVEN',
|
|
56
|
+
ONE_RUN_UNPROVEN: 'ONE_RUN_UNPROVEN',
|
|
57
|
+
RECORDED_UNWITNESSED: 'RECORDED_UNWITNESSED',
|
|
58
|
+
NOT_COMMITTED: 'NOT_COMMITTED',
|
|
59
|
+
AUTHORIZED_AND_COMMITTED: 'AUTHORIZED_AND_COMMITTED',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** What each authority is allowed to be missing for, so a caller can choose its own strictness. */
|
|
63
|
+
const AUTHORITY = Object.freeze({
|
|
64
|
+
ISSUER_GRANT: 'issuer_grant',
|
|
65
|
+
EXECUTOR_ATTESTATION: 'executor_attestation',
|
|
66
|
+
ONE_RUN_ROOT: 'one_run_root',
|
|
67
|
+
PROVIDER_WITNESS: 'provider_witness',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const sha256pref = (v) =>
|
|
71
|
+
`sha256:${crypto.createHash('sha256').update(String(v), 'utf8').digest('hex')}`;
|
|
72
|
+
|
|
73
|
+
const b64json = (seg) => {
|
|
74
|
+
try { return JSON.parse(Buffer.from(seg, 'base64url').toString('utf8')); } catch (_) { return null; }
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** The four fields an attestation binds, read from its own signed preimage. */
|
|
78
|
+
function attestationClaims(token) {
|
|
79
|
+
if (typeof token !== 'string') return null;
|
|
80
|
+
const seg = token.split('|');
|
|
81
|
+
if (seg.length !== 4 || !seg[2]) return null;
|
|
82
|
+
const body = b64json(seg[2]);
|
|
83
|
+
return body && typeof body === 'object' ? body : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The grant's identity, in one vocabulary across v1 and v2. */
|
|
87
|
+
function grantClaims(token) {
|
|
88
|
+
if (typeof token !== 'string') return null;
|
|
89
|
+
const body = b64json(String(token).split('.')[0]);
|
|
90
|
+
if (!body || typeof body !== 'object') return null;
|
|
91
|
+
return {
|
|
92
|
+
v: body.v,
|
|
93
|
+
jti: body.grant_id || body.jti || null,
|
|
94
|
+
scope_hash: body.after_payload_hash || body.scope_hash || null,
|
|
95
|
+
receipt_hash: body.receipt_hash || body.receipt_digest || null,
|
|
96
|
+
operation: body.operation || null,
|
|
97
|
+
// The rest of what a grant SAYS, so the attestation can be checked against all of it rather
|
|
98
|
+
// than against the two fields that happened to be compared first.
|
|
99
|
+
target: body.target_uri || body.target_id || null,
|
|
100
|
+
tenant_id: body.tenant_id || null,
|
|
101
|
+
executor_id: body.executor_id || null,
|
|
102
|
+
adapter_id: body.adapter_id || null,
|
|
103
|
+
audience: body.audience_hash || body.audience || null,
|
|
104
|
+
policy_hash: body.policy_hash || null,
|
|
105
|
+
state_token: body.expected_state_token || body.state_nonce || null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {object} o
|
|
111
|
+
* @param {{verified: boolean, token?: string}} o.receipt
|
|
112
|
+
* @param {{token: string, publicKey?, keyring?, intended?: object, now?: number}} o.grant
|
|
113
|
+
* THE EXACT BYTES THE ISSUER SIGNED. Not a grant read back out of a tool result: a caller
|
|
114
|
+
* that lets the executed tool hand back its own authorization has already lost.
|
|
115
|
+
* @param {{token: string, registry?: object, verify?: Function}} [o.attestation]
|
|
116
|
+
* @param {{artifact: object, executorKey}} [o.evidenceRoot]
|
|
117
|
+
* @param {{signed: boolean}} [o.providerReadback]
|
|
118
|
+
* @param {boolean} o.committed
|
|
119
|
+
* @param {string[]} [o.required] authorities this caller demands; default: grant + attestation.
|
|
120
|
+
*/
|
|
121
|
+
function verifiedExecutionBinding(o = {}) {
|
|
122
|
+
const required = new Set(Array.isArray(o.required) && o.required.length
|
|
123
|
+
? o.required
|
|
124
|
+
: [AUTHORITY.ISSUER_GRANT, AUTHORITY.EXECUTOR_ATTESTATION]);
|
|
125
|
+
const shortfalls = [];
|
|
126
|
+
const authorities = {};
|
|
127
|
+
const note = (name, ok, detail) => {
|
|
128
|
+
authorities[name] = { ok, required: required.has(name), detail };
|
|
129
|
+
if (!ok && required.has(name)) shortfalls.push(`${name}: ${detail}`);
|
|
130
|
+
return ok;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// ── 1. THE ISSUER GRANT ────────────────────────────────────────────────────────────────
|
|
134
|
+
const g = o.grant || {};
|
|
135
|
+
let grantOk = false;
|
|
136
|
+
let gClaims = null;
|
|
137
|
+
if (typeof g.token !== 'string' || g.token.length === 0) {
|
|
138
|
+
note(AUTHORITY.ISSUER_GRANT, false, 'no execution grant was supplied');
|
|
139
|
+
} else if (!g.publicKey && !g.keyring) {
|
|
140
|
+
// FAIL-CLOSED, and named as its own thing: "we had no key" is not "the signature was bad".
|
|
141
|
+
note(AUTHORITY.ISSUER_GRANT, false,
|
|
142
|
+
'no pinned issuer keyring was supplied, so the grant could not be authenticated');
|
|
143
|
+
} else {
|
|
144
|
+
const r = verifyExecutionGrant(g.token, {
|
|
145
|
+
ctx: { publicKey: g.publicKey, keyring: g.keyring, expectedKid: g.expectedKid ?? null },
|
|
146
|
+
...(g.intended ? { intended: g.intended } : {}),
|
|
147
|
+
...(Number.isFinite(g.now) ? { now: g.now } : {}),
|
|
148
|
+
});
|
|
149
|
+
gClaims = grantClaims(g.token);
|
|
150
|
+
grantOk = note(AUTHORITY.ISSUER_GRANT, r.valid === true,
|
|
151
|
+
`${r.status}${r.reason ? `/${r.reason}` : ''}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── 2. THE EXECUTOR ATTESTATION ────────────────────────────────────────────────────────
|
|
155
|
+
const a = o.attestation || {};
|
|
156
|
+
let attOk = false;
|
|
157
|
+
if (typeof a.token !== 'string' || a.token.length === 0) {
|
|
158
|
+
note(AUTHORITY.EXECUTOR_ATTESTATION, false, 'no executor attestation was supplied');
|
|
159
|
+
} else if (typeof a.verify !== 'function') {
|
|
160
|
+
note(AUTHORITY.EXECUTOR_ATTESTATION, false,
|
|
161
|
+
'no attestation verifier was supplied, so the commit could not be checked');
|
|
162
|
+
} else {
|
|
163
|
+
const r = a.verify(a.token, { registry: a.registry });
|
|
164
|
+
const sigOk = r && r.valid === true;
|
|
165
|
+
// BOUND TO THE VERIFIED GRANT'S OWN IDS — not to ids the caller passed alongside. This is the
|
|
166
|
+
// join that makes the two signatures one statement instead of two unrelated true things.
|
|
167
|
+
const c = attestationClaims(a.token);
|
|
168
|
+
// ── THE ATTESTATION MUST BIND THE SAME EXECUTION, NOT MERELY THE SAME NAMES (1464) ────
|
|
169
|
+
//
|
|
170
|
+
// REPRODUCED before this was written. A correctly-signed grant bound to receipt R1, and a
|
|
171
|
+
// correctly-signed attestation from a trusted executor bound to receipt R2, sharing a
|
|
172
|
+
// grant_jti and a scope_hash — R1 != R2 — read AUTHORIZED_AND_COMMITTED. Both signatures are
|
|
173
|
+
// real; the two documents describe DIFFERENT executions and the join could not tell.
|
|
174
|
+
//
|
|
175
|
+
// `jti` and `scope_hash` are the two fields an attacker controls most cheaply: they are copied
|
|
176
|
+
// FROM the grant into the attestation by whoever assembles the pair. Comparing only those is
|
|
177
|
+
// comparing a value with its own copy. What binds is the receipt each side was issued against,
|
|
178
|
+
// and — the strongest available — the sha256 of the exact grant token bytes.
|
|
179
|
+
const attReceipt = c ? String(c.receipt_digest || '') : '';
|
|
180
|
+
const grantReceipt = gClaims ? String(gClaims.receipt_hash || '') : '';
|
|
181
|
+
const mismatch = (() => {
|
|
182
|
+
if (!sigOk) return `attestation ${r ? r.status : 'unverifiable'}`;
|
|
183
|
+
if (!gClaims) return 'there is no verified grant for the attestation to bind';
|
|
184
|
+
if (String(c.grant_jti || '') !== String(gClaims.jti || '')) {
|
|
185
|
+
return 'the attestation binds a different grant id than the verified grant';
|
|
186
|
+
}
|
|
187
|
+
if (String(c.scope_hash || '') !== String(gClaims.scope_hash || '')) {
|
|
188
|
+
return 'the attestation binds a different scope than the verified grant';
|
|
189
|
+
}
|
|
190
|
+
// THE CROSS-RECEIPT CHECK. Empty on either side is a mismatch: an attestation that names no
|
|
191
|
+
// receipt cannot be shown to be about this authorization, and "unstated" must not read as
|
|
192
|
+
// "the same".
|
|
193
|
+
if (!attReceipt || !grantReceipt || attReceipt !== grantReceipt) {
|
|
194
|
+
return `the grant was issued against receipt ${grantReceipt || '(none)'} and the `
|
|
195
|
+
+ `attestation commits receipt ${attReceipt || '(none)'} — two different executions`;
|
|
196
|
+
}
|
|
197
|
+
// ── WHAT cr.exec.attest.v1 CAN AND CANNOT BE ASKED ──────────────────────────────
|
|
198
|
+
//
|
|
199
|
+
// MEASURED, and it bounds this check rather than the check bounding the format: the
|
|
200
|
+
// attestation body is a CLOSED set — executor_kid, grant_jti, receipt_digest, scope_hash,
|
|
201
|
+
// committed_at, state_nonce, result_digest, meta. Any other key is refused
|
|
202
|
+
// ATTEST_MALFORMED / unknown_field by its own verifier.
|
|
203
|
+
//
|
|
204
|
+
// So target, operation, tenant, executor, adapter, audience and policy CANNOT be
|
|
205
|
+
// cross-checked here: the attestation never states them, and a comparison against a field
|
|
206
|
+
// that cannot exist is not a check — it is a line that always passes. They are named in
|
|
207
|
+
// `does_not_prove` instead, which is the honest place for a binding the format cannot carry.
|
|
208
|
+
//
|
|
209
|
+
// The same is true of the exact grant-token digest: there is no field for it. Binding the
|
|
210
|
+
// grant BYTES rather than its claims would be the tightest join available and it needs a
|
|
211
|
+
// format change (a `grant_token_digest` slot in cr.exec.attest.v2), not a check here.
|
|
212
|
+
//
|
|
213
|
+
// What the format DOES let us bind is the state nonce, and it is bound below.
|
|
214
|
+
if (gClaims.state_token != null && c.state_nonce != null
|
|
215
|
+
&& String(gClaims.state_token) !== String(c.state_nonce)) {
|
|
216
|
+
return 'the grant and the attestation disagree about the state nonce';
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
})();
|
|
220
|
+
attOk = note(AUTHORITY.EXECUTOR_ATTESTATION, mismatch === null, mismatch || 'bound');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── 3. ONE RUN ─────────────────────────────────────────────────────────────────────────
|
|
224
|
+
const er = o.evidenceRoot || null;
|
|
225
|
+
if (!er || !er.artifact) {
|
|
226
|
+
note(AUTHORITY.ONE_RUN_ROOT, false,
|
|
227
|
+
'no cr.evidence.root.v1 was supplied, so these bytes are not shown to be one run');
|
|
228
|
+
} else {
|
|
229
|
+
const r = verifyEvidenceRootBinding(er.artifact, { executorKey: er.executorKey, sidecars: er.sidecars });
|
|
230
|
+
note(AUTHORITY.ONE_RUN_ROOT, r.ok === true, r.ok ? 'bound' : (r.failures[0] || 'unbound'));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── 4. THE PROVIDER WITNESS ────────────────────────────────────────────────────────────
|
|
234
|
+
// A readback is an UNSIGNED document by nature. It is carried, not verified, and this authority
|
|
235
|
+
// is false unless a caller states it was witnessed some stronger way — never true by default.
|
|
236
|
+
// ── A CALLER BOOLEAN IS NOT EVIDENCE (1465) ────────────────────────────────────────────
|
|
237
|
+
//
|
|
238
|
+
// REPRODUCED before this was written:
|
|
239
|
+
//
|
|
240
|
+
// required: ['provider_witness'], receipt: {verified: true},
|
|
241
|
+
// providerReadback: {signed: true}, committed: true, NO grant, NO attestation, NO root
|
|
242
|
+
// → AUTHORIZED_AND_COMMITTED, shortfalls: []
|
|
243
|
+
//
|
|
244
|
+
// `signed: true` was a bare boolean the caller wrote, and this function aggregated it into a
|
|
245
|
+
// global success. Nothing was verified; a field named `signed` was believed because it was set.
|
|
246
|
+
//
|
|
247
|
+
// A witness now requires a VERIFIED witness envelope: bytes plus a verifier plus a trust anchor.
|
|
248
|
+
// No such format exists yet (phase D measured that the readback is unsigned by nature), so this
|
|
249
|
+
// authority cannot currently be satisfied at all — and saying that plainly is the honest answer.
|
|
250
|
+
// Asking for it yields RECORDED_UNWITNESSED, which is exactly what it means.
|
|
251
|
+
//
|
|
252
|
+
// NOT a breaking change for the five consumers: none of them requires `provider_witness` today
|
|
253
|
+
// (guard and contract-gate ask for issuer_grant + executor_attestation; prove and conformance for
|
|
254
|
+
// issuer_grant + one_run_root). It removes a way to LIE, not a way anyone works.
|
|
255
|
+
const pw = o.providerReadback || null;
|
|
256
|
+
const witnessVerified = !!(pw && pw.verified === true && pw.envelope && pw.verifier);
|
|
257
|
+
note(AUTHORITY.PROVIDER_WITNESS, witnessVerified,
|
|
258
|
+
pw
|
|
259
|
+
? (pw.signed === true && !witnessVerified
|
|
260
|
+
? 'the caller asserted `signed: true` and supplied no verifiable witness envelope — a '
|
|
261
|
+
+ 'boolean is not evidence, and no signed-witness format exists yet'
|
|
262
|
+
: 'the provider readback is an unsigned document (carried, not verified)')
|
|
263
|
+
: 'no provider readback was supplied');
|
|
264
|
+
|
|
265
|
+
// ── THE INTERSECTION ───────────────────────────────────────────────────────────────────
|
|
266
|
+
const committed = o.committed === true;
|
|
267
|
+
// THE RECEIPT, and what this function can honestly say about it.
|
|
268
|
+
//
|
|
269
|
+
// `verified` is the CALLER's determination: this core is not given the receipt token or a
|
|
270
|
+
// keyring, so it cannot re-establish it. That is recorded rather than hidden — a reader of the
|
|
271
|
+
// result can see whether the receipt was verified HERE or asserted by whoever called.
|
|
272
|
+
//
|
|
273
|
+
// Left as-is deliberately: making a bare boolean insufficient would change the input shape of
|
|
274
|
+
// all five consumers at once, and that belongs with the closed-profile work (1465), not
|
|
275
|
+
// half-done in a round that would leave them broken. The gap is named, not narrowed in silence.
|
|
276
|
+
const receiptOk = !!(o.receipt && o.receipt.verified === true);
|
|
277
|
+
const receiptAsserted = receiptOk && !(o.receipt.token && (o.receipt.keyring || o.receipt.publicKey));
|
|
278
|
+
if (!receiptOk) shortfalls.unshift('receipt: the decision receipt did not verify');
|
|
279
|
+
|
|
280
|
+
let state = STATE.AUTHORIZED_AND_COMMITTED;
|
|
281
|
+
if (!receiptOk || (required.has(AUTHORITY.ISSUER_GRANT) && !grantOk)) state = STATE.UNAUTHORIZED;
|
|
282
|
+
else if (required.has(AUTHORITY.EXECUTOR_ATTESTATION) && !attOk) state = STATE.COMMIT_UNPROVEN;
|
|
283
|
+
else if (required.has(AUTHORITY.ONE_RUN_ROOT) && !authorities[AUTHORITY.ONE_RUN_ROOT].ok) {
|
|
284
|
+
state = STATE.ONE_RUN_UNPROVEN;
|
|
285
|
+
} else if (required.has(AUTHORITY.PROVIDER_WITNESS) && !authorities[AUTHORITY.PROVIDER_WITNESS].ok) {
|
|
286
|
+
state = STATE.RECORDED_UNWITNESSED;
|
|
287
|
+
} else if (!committed) state = STATE.NOT_COMMITTED;
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
v: BINDING_V,
|
|
291
|
+
authorized_and_committed: state === STATE.AUTHORIZED_AND_COMMITTED,
|
|
292
|
+
state,
|
|
293
|
+
authorities,
|
|
294
|
+
shortfalls,
|
|
295
|
+
/** True when `receipt.verified` was taken on the caller's word rather than established here. */
|
|
296
|
+
receipt_caller_asserted: receiptAsserted,
|
|
297
|
+
// Said out loud so a caller cannot read success as more than it is.
|
|
298
|
+
does_not_prove: [
|
|
299
|
+
'that the executor told the truth about its own run — the evidence root closes third-party '
|
|
300
|
+
+ 'splicing, not an executor misreporting itself',
|
|
301
|
+
'that a provider merged anything; `provider_witness` is an unsigned readback unless a caller '
|
|
302
|
+
+ 'states otherwise',
|
|
303
|
+
'that the grant and the attestation agree about target, operation, tenant, executor, adapter, '
|
|
304
|
+
+ 'audience or policy — cr.exec.attest.v1 is a closed field set that states none of them, so '
|
|
305
|
+
+ 'those are UNCHECKED here rather than checked and equal (1464)',
|
|
306
|
+
'that the attestation commits the exact grant BYTES — the format carries no grant-token '
|
|
307
|
+
+ 'digest, so the join is over the grant id, scope, receipt and state nonce',
|
|
308
|
+
...(receiptAsserted
|
|
309
|
+
? ['that the decision receipt verifies — `receipt.verified` was asserted by the caller and '
|
|
310
|
+
+ 'not established here; this core is given no receipt token or keyring to check it with']
|
|
311
|
+
: []),
|
|
312
|
+
],
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
module.exports = { verifiedExecutionBinding, STATE, AUTHORITY, BINDING_V, grantClaims, attestationClaims };
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* THE CANONICAL EVIDENCE VERIFIER — every signature in a prove envelope, not just one.
|
|
5
|
+
*
|
|
6
|
+
* ── WHAT 1423 MEASURED ──────────────────────────────────────────────────────────────────────
|
|
7
|
+
*
|
|
8
|
+
* A recorded artifact is protected by two different things, and they answer different questions:
|
|
9
|
+
*
|
|
10
|
+
* the PIN (sha256) are these the bytes we vendored? → tamper-EVIDENT
|
|
11
|
+
* the SIGNATURES did the named issuers produce them? → AUTHENTIC
|
|
12
|
+
*
|
|
13
|
+
* Conformance checked the pin and exactly one signature (the correlation). So an auditor could
|
|
14
|
+
* flip the last character of `issuance.execution_grant`, recompute the pin, and the profile still
|
|
15
|
+
* graded COVERED — reproduced, both mutations, before this file existed. The pin cannot catch that
|
|
16
|
+
* on its own: whoever edits the bytes also owns the file the hash is written in.
|
|
17
|
+
*
|
|
18
|
+
* capability-demo's `prove --check` already refused both mutations. That is the shape of the bug:
|
|
19
|
+
* not a missing capability anywhere, but TWO verifiers that disagreed about what checking means.
|
|
20
|
+
* So this is a shared core rather than a third implementation — one place to fix, one place to
|
|
21
|
+
* drift from, and consumers that can be tested against each other.
|
|
22
|
+
*
|
|
23
|
+
* ── WHAT IT REFUSES TO DO ───────────────────────────────────────────────────────────────────
|
|
24
|
+
*
|
|
25
|
+
* An ABSENT token is reported ABSENT and never as verified. This matters more than it sounds: a
|
|
26
|
+
* verifier that returns "ok" for an envelope carrying no grant at all would let the strongest
|
|
27
|
+
* possible tamper — deletion — read as a pass. The caller says which slots it requires
|
|
28
|
+
* (`required`), and absence of a required slot is a failure with its own reason.
|
|
29
|
+
*
|
|
30
|
+
* It authenticates SIGNATURES. It does not decide whether the run's claims are true: a correctly
|
|
31
|
+
* signed transcript of a failing run is authentic and still a failure. Callers keep grading.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const crypto = require('node:crypto');
|
|
35
|
+
|
|
36
|
+
const { verifyReceipt, keyringFromDocument } = require('./verify.js');
|
|
37
|
+
const { verifyExecutionGrant } = require('./verify-grant.js');
|
|
38
|
+
const { verifyProveTranscript } = require('./verify-prove-transcript.js');
|
|
39
|
+
|
|
40
|
+
const CORRELATION_V = 'cr.exec.correlation.v1';
|
|
41
|
+
const US = '\x1f';
|
|
42
|
+
|
|
43
|
+
/** The slots this verifier knows how to authenticate. */
|
|
44
|
+
const SLOT = Object.freeze({
|
|
45
|
+
CHAIN_RECEIPT: 'chain_receipt',
|
|
46
|
+
EXECUTION_GRANT: 'execution_grant',
|
|
47
|
+
TRANSCRIPT_TOKEN: 'transcript_token',
|
|
48
|
+
CORRELATION: 'correlation',
|
|
49
|
+
ATTESTATION: 'atomic_attestation',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Which keyring signs which slot. Stated as data so a caller can read it, not infer it. */
|
|
53
|
+
const SIGNER = Object.freeze({
|
|
54
|
+
[SLOT.CHAIN_RECEIPT]: 'issuer',
|
|
55
|
+
[SLOT.EXECUTION_GRANT]: 'issuer',
|
|
56
|
+
[SLOT.TRANSCRIPT_TOKEN]: 'executor',
|
|
57
|
+
[SLOT.CORRELATION]: 'executor',
|
|
58
|
+
[SLOT.ATTESTATION]: 'executor',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function toKeyring(doc) {
|
|
62
|
+
if (!doc) return null;
|
|
63
|
+
if (doc instanceof Map) return doc;
|
|
64
|
+
return keyringFromDocument(doc);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function keyFor(keyring, kid) {
|
|
68
|
+
if (!keyring) return null;
|
|
69
|
+
const entry = keyring instanceof Map ? keyring.get(kid) : keyring[kid];
|
|
70
|
+
if (!entry) return null;
|
|
71
|
+
return entry.publicKey || entry.public_key || entry;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Rebuilt from the fields, never read back from `correlation_hash`. */
|
|
75
|
+
function correlationPreimage(c) {
|
|
76
|
+
return [CORRELATION_V, c.scope_hash, c.contract_commit, c.contract_path, c.readback_commit]
|
|
77
|
+
.join(US);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The correlation is a bare Ed25519 signature over a field-joined preimage rather than a token,
|
|
82
|
+
* so it gets its own small verifier here instead of a shape it does not have.
|
|
83
|
+
*/
|
|
84
|
+
function verifyCorrelation(c, publicKey) {
|
|
85
|
+
if (!c || c.v !== CORRELATION_V) {
|
|
86
|
+
return { valid: false, status: 'CORRELATION_MALFORMED', reason: 'not_a_correlation' };
|
|
87
|
+
}
|
|
88
|
+
if (!publicKey) {
|
|
89
|
+
return { valid: false, status: 'CORRELATION_UNKNOWN_KEY', reason: 'unknown_kid' };
|
|
90
|
+
}
|
|
91
|
+
const preimage = correlationPreimage(c);
|
|
92
|
+
const expected = `sha256:${crypto.createHash('sha256').update(preimage, 'utf8').digest('hex')}`;
|
|
93
|
+
// The hash is checked BEFORE the signature so a mutated binding field is named as what it is —
|
|
94
|
+
// a field that no longer matches its own digest — rather than as a generic bad signature.
|
|
95
|
+
if (c.correlation_hash && c.correlation_hash !== expected) {
|
|
96
|
+
return { valid: false, status: 'CORRELATION_UNBOUND', reason: 'correlation_hash_mismatch' };
|
|
97
|
+
}
|
|
98
|
+
let ok = false;
|
|
99
|
+
try {
|
|
100
|
+
ok = crypto.verify(
|
|
101
|
+
null, Buffer.from(preimage, 'utf8'), publicKey, Buffer.from(String(c.signature), 'base64url'),
|
|
102
|
+
);
|
|
103
|
+
} catch (_) {
|
|
104
|
+
return { valid: false, status: 'CORRELATION_INVALID_SIGNATURE', reason: 'signature_error' };
|
|
105
|
+
}
|
|
106
|
+
return ok
|
|
107
|
+
? { valid: true, status: 'CORRELATION_VALID' }
|
|
108
|
+
: { valid: false, status: 'CORRELATION_INVALID_SIGNATURE', reason: 'signature_mismatch' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** cr.atomic.execution.attestation.v1 | <kid> | b64url(preimage) | <sig> */
|
|
112
|
+
function verifyAtomicAttestationToken(token, keyring) {
|
|
113
|
+
const seg = String(token).split('|');
|
|
114
|
+
if (seg.length !== 4 || seg[0] !== 'cr.atomic.execution.attestation.v1' || seg.some((s) => !s)) {
|
|
115
|
+
return { valid: false, status: 'ATTEST_MALFORMED', reason: 'malformed_structure' };
|
|
116
|
+
}
|
|
117
|
+
const publicKey = keyFor(keyring, seg[1]);
|
|
118
|
+
if (!publicKey) return { valid: false, status: 'ATTEST_UNKNOWN_KEY', reason: 'unknown_kid', kid: seg[1] };
|
|
119
|
+
let preimage;
|
|
120
|
+
try { preimage = Buffer.from(seg[2], 'base64url').toString('utf8'); } catch (_) {
|
|
121
|
+
return { valid: false, status: 'ATTEST_MALFORMED', reason: 'bad_preimage', kid: seg[1] };
|
|
122
|
+
}
|
|
123
|
+
let ok = false;
|
|
124
|
+
try {
|
|
125
|
+
ok = crypto.verify(null, Buffer.from(preimage, 'utf8'), publicKey, Buffer.from(seg[3], 'base64url'));
|
|
126
|
+
} catch (_) {
|
|
127
|
+
return { valid: false, status: 'ATTEST_INVALID_SIGNATURE', reason: 'signature_error', kid: seg[1] };
|
|
128
|
+
}
|
|
129
|
+
return ok
|
|
130
|
+
? { valid: true, status: 'ATTEST_VALID', kid: seg[1], preimage }
|
|
131
|
+
: { valid: false, status: 'ATTEST_INVALID_SIGNATURE', reason: 'signature_mismatch', kid: seg[1] };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Authenticate every signed token an artifact carries.
|
|
136
|
+
*
|
|
137
|
+
* @param {object} artifact a cr.prove.artifact.v1 document
|
|
138
|
+
* @param {object} o
|
|
139
|
+
* @param {object|Map} [o.issuerKeys] registry document (or Map) for the CodeRifts issuer
|
|
140
|
+
* @param {object|Map} [o.executorKeys] registry document (or Map) for the executor
|
|
141
|
+
* @param {string[]} [o.required] slots that MUST be present; absence is a failure
|
|
142
|
+
* @param {number} [o.now] clock injection; defaults to each token's own issuance
|
|
143
|
+
* instant, so a recorded artifact is authenticated as of
|
|
144
|
+
* when it was made rather than expiring in the vendor tree
|
|
145
|
+
* @returns {{ok: boolean, slots: object[], failures: string[]}}
|
|
146
|
+
*/
|
|
147
|
+
function verifyEvidenceEnvelope(artifact, o = {}) {
|
|
148
|
+
const issuer = toKeyring(o.issuerKeys);
|
|
149
|
+
const executor = toKeyring(o.executorKeys);
|
|
150
|
+
const required = new Set(Array.isArray(o.required) ? o.required : []);
|
|
151
|
+
const slots = [];
|
|
152
|
+
const failures = [];
|
|
153
|
+
|
|
154
|
+
const record = (name, present, result) => {
|
|
155
|
+
const entry = {
|
|
156
|
+
slot: name,
|
|
157
|
+
signer: SIGNER[name],
|
|
158
|
+
present,
|
|
159
|
+
verified: present ? result.valid === true : false,
|
|
160
|
+
status: present ? result.status : 'ABSENT',
|
|
161
|
+
reason: present ? (result.reason || null) : 'not_present_in_envelope',
|
|
162
|
+
kid: present ? (result.kid || null) : null,
|
|
163
|
+
};
|
|
164
|
+
slots.push(entry);
|
|
165
|
+
if (!present && required.has(name)) {
|
|
166
|
+
failures.push(`the ${name} is absent from the envelope, and this profile requires it`);
|
|
167
|
+
} else if (present && !entry.verified) {
|
|
168
|
+
failures.push(`the ${name} signature does not verify (${entry.status}${entry.reason ? `: ${entry.reason}` : ''})`);
|
|
169
|
+
}
|
|
170
|
+
return entry;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const iss = artifact && artifact.issuance ? artifact.issuance : null;
|
|
174
|
+
|
|
175
|
+
// ── issuer-signed ────────────────────────────────────────────────────────────────────────
|
|
176
|
+
const grantTok = iss && iss.execution_grant;
|
|
177
|
+
if (grantTok) {
|
|
178
|
+
// now = the grant's own not_before/iat. A RECORDED grant is short-lived by design; judging it
|
|
179
|
+
// against today's clock would report every vendored fixture as expired, which says something
|
|
180
|
+
// about the calendar and nothing about the signature.
|
|
181
|
+
const g = iss.grant || {};
|
|
182
|
+
const at = Number.isFinite(o.now) ? o.now : Date.parse(g.not_before || g.iat || artifact.started_at);
|
|
183
|
+
record(SLOT.EXECUTION_GRANT, true, verifyExecutionGrant(grantTok, {
|
|
184
|
+
ctx: { keyring: issuer, expectedKid: null },
|
|
185
|
+
now: Number.isFinite(at) ? at + 1000 : undefined,
|
|
186
|
+
}));
|
|
187
|
+
} else {
|
|
188
|
+
record(SLOT.EXECUTION_GRANT, false, {});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const receiptTok = iss && iss.chain_receipt;
|
|
192
|
+
if (receiptTok) {
|
|
193
|
+
const at = Number.isFinite(o.now) ? o.now : Date.parse(artifact.started_at);
|
|
194
|
+
record(SLOT.CHAIN_RECEIPT, true, verifyReceipt(receiptTok, {
|
|
195
|
+
ctx: { keyring: issuer, expectedKid: null },
|
|
196
|
+
now: Number.isFinite(at) ? at + 1000 : undefined,
|
|
197
|
+
}));
|
|
198
|
+
} else {
|
|
199
|
+
record(SLOT.CHAIN_RECEIPT, false, {});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── executor-signed ──────────────────────────────────────────────────────────────────────
|
|
203
|
+
if (artifact && artifact.transcript_token) {
|
|
204
|
+
record(SLOT.TRANSCRIPT_TOKEN, true,
|
|
205
|
+
verifyProveTranscript(artifact.transcript_token, { keyring: executor }));
|
|
206
|
+
} else {
|
|
207
|
+
record(SLOT.TRANSCRIPT_TOKEN, false, {});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (artifact && artifact.correlation) {
|
|
211
|
+
const c = artifact.correlation;
|
|
212
|
+
const kid = executor && executor instanceof Map ? [...executor.keys()][0] : null;
|
|
213
|
+
record(SLOT.CORRELATION, true, verifyCorrelation(c, keyFor(executor, kid)));
|
|
214
|
+
} else {
|
|
215
|
+
record(SLOT.CORRELATION, false, {});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const att = artifact && artifact.atomic_execution_attestation
|
|
219
|
+
? artifact.atomic_execution_attestation
|
|
220
|
+
: (artifact && artifact.attestation) || null;
|
|
221
|
+
if (typeof att === 'string' && att.length > 0) {
|
|
222
|
+
record(SLOT.ATTESTATION, true, verifyAtomicAttestationToken(att, executor));
|
|
223
|
+
} else {
|
|
224
|
+
record(SLOT.ATTESTATION, false, {});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { ok: failures.length === 0, slots, failures };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = {
|
|
231
|
+
verifyEvidenceEnvelope,
|
|
232
|
+
verifyCorrelation,
|
|
233
|
+
verifyAtomicAttestationToken,
|
|
234
|
+
correlationPreimage,
|
|
235
|
+
SLOT,
|
|
236
|
+
SIGNER,
|
|
237
|
+
CORRELATION_V,
|
|
238
|
+
};
|