@coderifts/agent-guard 17.2.0 → 17.3.1

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.
@@ -15,22 +15,42 @@
15
15
  # against an unauthenticated document is not a binding.
16
16
  #
17
17
  # Do not edit these files here. Recopy from receipt-verifier and re-pin.
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.
18
+ # PER-FILE PROVENANCE, RETIRED. It said verify.js was held at 6048195 because "recopying HEAD
19
+ # would change a verifier nobody measured again" true when written. MEASURED before retiring it:
20
+ # the 6048195 -> v1.0.0 delta on verify.js is ZERO non-comment lines (CLI usage docs), so the
21
+ # reason did not apply to this delta. verify-evidence.js was older still and did not export
22
+ # verifyEvidenceRootBinding at all, which is why the shared core needed a fail-closed guard against
23
+ # its own missing dependency. The core is now ONE released tag.
23
24
  #
24
25
  # verify.js 6048195c0bcf7e2594a2d78e83cfc082ea615790
25
26
  # arity.js 724aa9ae8171f1acf629c3c6b4879dc056575f20
26
- # verify-grant.js 724aa9ae8171f1acf629c3c6b4879dc056575f20
27
+ # verify-grant.js a472bf9073781cba25d83c7d1406b1a7c4af0b94
27
28
  # verify-prove-transcript.js 724aa9ae8171f1acf629c3c6b4879dc056575f20
28
29
  # verify-evidence.js 724aa9ae8171f1acf629c3c6b4879dc056575f20
29
30
  # keys/coderifts-keys.json 724aa9ae8171f1acf629c3c6b4879dc056575f20
30
31
 
31
- verify.js 363e52c72ec4b913e1f3edc8e2029848feaeacb1efa6c07960fe5e5f195c23b3
32
+ # ── THE RECEIPT-VERIFIER CORE IS PINNED TO A RELEASED TAG ───────────────────────────────────
33
+ #
34
+ # The shared verification core here comes from receipt-verifier **v1.0.0**, peeled commit
35
+ # 51a8224439959a5b46c0b09e9a2cd67117f05d56.
36
+ #
37
+ # It was pinned to a WORKING TREE and to per-file commits. Both are honest about where the bytes
38
+ # came from and neither is a provenance a stranger can resolve: a working tree is not fetchable,
39
+ # so "the vendored bytes match upstream" reduced to "they match whatever is on this machine right
40
+ # now". A released tag is a name anyone can check out — that is the difference between a pin and
41
+ # a note.
42
+ #
43
+ # The hash lines below are UNCHANGED by the re-pin. Where a file already matched the tag this is
44
+ # prose only; where it did not, it was re-copied from the tag and that is recorded below.
45
+ #
46
+ # source_commit: 51a8224439959a5b46c0b09e9a2cd67117f05d56 (receipt-verifier v1.0.0)
47
+ #
48
+
49
+ verify.js 348d3f851cd79bf63298b51da422d11be980820576ab0bf0a2990b9f007d6249
32
50
  arity.js 721fcac58b960b5e2aa1af91edb6cfc87ebe828a6da73879967ef79a1ca474e8
33
- verify-grant.js a285b021b32cc9c70c963de042154dc6be7175cdeaf722a88cc7699651a7c1ea
51
+ verify-grant.js 4a0ad5bd7bb8282e086bea99dcaf8ca278370d80019c5681d2942bf11bc87504
34
52
  verify-prove-transcript.js 3bb17bd4f16d5ee3cf3fbb754b4df9aa570a615ca94ff2a97b751c4fabd3bd0d
35
- verify-evidence.js 7ca498fad4b23e4ae8f28c42c30058bc3526a00d5cc6d6f36380335bf25d3288
53
+ verify-evidence.js 98cd9b989a25095e0442f91f68a1adecd6e6a2a5b05902bf2580b8cba973c5e2
54
+ verified-execution-binding.js 9d0259e916f2bcd32029744c3e9ec81448bcefa46e6ad3f054c8a650dc0482b7
36
55
  keys/coderifts-keys.json bfe0c898cda3c3a4c8141726d0adb4cdb7eb9762de6264e430eff6469b90422e
56
+ evidence-root.js 4d5476fc44bbfc2a847feeb313a1c6c08ef7fd832a2348ef3c244bd8d3f03fcd
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * cr.evidence.root.v1 — ONE signed manifest that binds a set of tokens to ONE run.
5
+ *
6
+ * ── THE ATTACK THIS EXISTS FOR (1432) ───────────────────────────────────────────────────────
7
+ *
8
+ * 1423 made every token in a prove artifact authenticate against its issuer's key. That closed
9
+ * forgery and left something open, which the second auditor found and which was reproduced three
10
+ * ways before this file existed: take a REAL second run of the same producer, at the same commit,
11
+ * and move one of ITS tokens into the first run's artifact. Recompute the pin. Everything still
12
+ * verifies — because nothing was forged. Each token really was issued, really is signed, and
13
+ * really says what it says.
14
+ *
15
+ * transcript_token from run B → graded COVERED
16
+ * execution_grant from run B → graded COVERED
17
+ * chain_receipt from run B → graded COVERED
18
+ *
19
+ * Authenticity is a property of a TOKEN. "These tokens are one run" is a property of a SET, and no
20
+ * per-token signature can carry it. So a set needs its own signature.
21
+ *
22
+ * ── WHAT THE ROOT IS, AND WHAT IT IS NOT ────────────────────────────────────────────────────
23
+ *
24
+ * It is the producer saying, under its own key: "this run emitted exactly these bytes". The
25
+ * binding is the sha256 of each token's EXACT bytes, not a claim copied out of it — a substituted
26
+ * token has different bytes, so its digest cannot match, whatever it says inside.
27
+ *
28
+ * It is NOT a second opinion on any token's validity. A root cannot make an expired grant current
29
+ * or a bad signature good; every per-token check still runs and still decides. The root adds one
30
+ * sentence the others could not say.
31
+ *
32
+ * ── WHY IT IS SIGNED BY THE EXECUTOR ────────────────────────────────────────────────────────
33
+ *
34
+ * The executor is the only party present for the whole run: it mints the challenge, consumes the
35
+ * grant, seals the attestation and signs the transcript. The issuer sees one authorize; the
36
+ * provider sees one merge. A manifest of the run has to be signed by whoever witnessed the run,
37
+ * and that is the executor — the same key the correlation already uses, deliberately, so a reader
38
+ * verifying one is verifying the other's signer too.
39
+ *
40
+ * The residual is stated rather than hidden: this is the EXECUTOR's account of its own run. It
41
+ * makes splicing detectable, not impossible for the executor itself. An executor that lies about
42
+ * its own run was never constrained by its own signature — what the root removes is the ability of
43
+ * a THIRD party (anyone who can edit a vendored artifact) to assemble two honest runs into one.
44
+ */
45
+
46
+ const crypto = require('node:crypto');
47
+
48
+ const ROOT_V = 'cr.evidence.root.v1';
49
+ const ROOT_SIGNING_PREFIX = 'crevidenceroot.v1';
50
+
51
+ /**
52
+ * The slots a root accounts for. MANDATORY ones must be present and non-null: a root that simply
53
+ * omits a token would let deletion — the strongest tamper there is — read as "not applicable".
54
+ */
55
+ const SLOTS = Object.freeze({
56
+ chain_receipt: { mandatory: true },
57
+ execution_grant: { mandatory: true },
58
+ transcript_token: { mandatory: true },
59
+ correlation: { mandatory: true },
60
+ atomic_attestation: { mandatory: false },
61
+ provider_readback: { mandatory: false },
62
+ });
63
+ const SLOT_NAMES = Object.freeze(Object.keys(SLOTS));
64
+
65
+ const sha256pref = (bytes) =>
66
+ `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`;
67
+
68
+ /** RFC 8785-shaped canonical JSON: sorted keys, no whitespace, no invented values. */
69
+ function canonicalJson(value) {
70
+ if (value === null) return 'null';
71
+ const t = typeof value;
72
+ if (t === 'boolean' || t === 'string') return JSON.stringify(value);
73
+ if (t === 'number') {
74
+ if (!Number.isFinite(value)) throw new TypeError('canonicalJson: non-finite number');
75
+ return JSON.stringify(value);
76
+ }
77
+ if (t === 'undefined') throw new TypeError('canonicalJson: undefined');
78
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
79
+ const keys = Object.keys(value).sort();
80
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
81
+ }
82
+
83
+ /**
84
+ * The exact bytes signed. The WHOLE body under canonical JSON, so every field is covered and a
85
+ * future field cannot be added without breaking the signature.
86
+ */
87
+ function rootSigningInput(body) {
88
+ return `${ROOT_SIGNING_PREFIX}|${canonicalJson(body)}`;
89
+ }
90
+
91
+ /**
92
+ * Digest a token exactly as it travels.
93
+ *
94
+ * A STRING is hashed as utf8 bytes. An OBJECT — the correlation is one — is hashed as canonical
95
+ * JSON, so key order in a re-serialised artifact cannot change the digest of a value nobody
96
+ * edited. Absent is null, never the empty-string hash: `sha256('')` is a real value and would
97
+ * make "no token" indistinguishable from "a token that happens to be empty".
98
+ */
99
+ function digestToken(token) {
100
+ if (token == null) return null;
101
+ if (typeof token === 'string') return token.length === 0 ? null : sha256pref(Buffer.from(token, 'utf8'));
102
+ return sha256pref(Buffer.from(canonicalJson(token), 'utf8'));
103
+ }
104
+
105
+ /**
106
+ * Build and sign a root.
107
+ *
108
+ * @param {object} o
109
+ * @param {string} o.run_id
110
+ * @param {{name: string, version: string, commit: string|null}} o.producer
111
+ * @param {string} o.operation
112
+ * @param {string} o.target_uri
113
+ * @param {string|null} o.contract_commit
114
+ * @param {object} o.tokens slot name → the token as it travels (string or object)
115
+ * @param {object} o.claims { grant_id, receipt_hash, scope_hash, policy_hash, state_token_hash }
116
+ * @param {import('crypto').KeyObject} o.privateKey
117
+ * @param {string} o.executor_kid
118
+ */
119
+ function buildEvidenceRoot(o) {
120
+ const artifact_digests = {};
121
+ for (const name of SLOT_NAMES) {
122
+ artifact_digests[name] = digestToken(o.tokens ? o.tokens[name] : null);
123
+ }
124
+ const c = o.claims || {};
125
+ const body = {
126
+ v: ROOT_V,
127
+ run_id: String(o.run_id),
128
+ executor_kid: String(o.executor_kid),
129
+ producer: {
130
+ name: String(o.producer.name),
131
+ version: String(o.producer.version),
132
+ commit: o.producer.commit == null ? null : String(o.producer.commit),
133
+ },
134
+ operation: o.operation == null ? null : String(o.operation),
135
+ target_uri: o.target_uri == null ? null : String(o.target_uri),
136
+ contract_commit: o.contract_commit == null ? null : String(o.contract_commit),
137
+ artifact_digests,
138
+ // The CLAIMS the run asserts. They are ALSO inside the tokens; carrying them here is what
139
+ // lets a verifier compare the two and refuse a manifest that agrees with itself but not with
140
+ // its own evidence.
141
+ grant_id: c.grant_id == null ? null : String(c.grant_id),
142
+ receipt_hash: c.receipt_hash == null ? null : String(c.receipt_hash),
143
+ scope_hash: c.scope_hash == null ? null : String(c.scope_hash),
144
+ policy_hash: c.policy_hash == null ? null : String(c.policy_hash),
145
+ state_token_hash: c.state_token_hash == null ? null : String(c.state_token_hash),
146
+ };
147
+ const signature = crypto.sign(null, Buffer.from(rootSigningInput(body), 'utf8'), o.privateKey);
148
+ return { ...body, signature: signature.toString('base64url') };
149
+ }
150
+
151
+ /** Verify a root's own signature. Says nothing about the tokens — that is verifyEvidenceEnvelope. */
152
+ function verifyEvidenceRoot(root, publicKey) {
153
+ if (!root || root.v !== ROOT_V) {
154
+ return { valid: false, status: 'ROOT_MALFORMED', reason: 'not_an_evidence_root' };
155
+ }
156
+ if (typeof root.signature !== 'string' || root.signature.length === 0) {
157
+ return { valid: false, status: 'ROOT_MALFORMED', reason: 'no_signature' };
158
+ }
159
+ if (!publicKey) {
160
+ return { valid: false, status: 'ROOT_UNKNOWN_KEY', reason: 'unknown_kid' };
161
+ }
162
+ const { signature, ...body } = root;
163
+ let ok = false;
164
+ try {
165
+ ok = crypto.verify(
166
+ null, Buffer.from(rootSigningInput(body), 'utf8'), publicKey,
167
+ Buffer.from(signature, 'base64url'),
168
+ );
169
+ } catch (_) {
170
+ return { valid: false, status: 'ROOT_INVALID_SIGNATURE', reason: 'signature_error' };
171
+ }
172
+ return ok
173
+ ? { valid: true, status: 'ROOT_VALID' }
174
+ : { valid: false, status: 'ROOT_INVALID_SIGNATURE', reason: 'signature_mismatch' };
175
+ }
176
+
177
+ module.exports = {
178
+ ROOT_V,
179
+ ROOT_SIGNING_PREFIX,
180
+ SLOTS,
181
+ SLOT_NAMES,
182
+ canonicalJson,
183
+ rootSigningInput,
184
+ digestToken,
185
+ buildEvidenceRoot,
186
+ verifyEvidenceRoot,
187
+ sha256pref,
188
+ };