@sanning/proof 0.11.1 → 0.13.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 +43 -8
- package/dist/agent-proof.d.ts +2 -2
- package/dist/agent-proof.js +4 -4
- package/dist/case-file.d.ts +126 -0
- package/dist/case-file.js +349 -0
- package/dist/case.d.ts +199 -43
- package/dist/case.js +522 -146
- package/dist/cli.d.ts +132 -1
- package/dist/cli.js +1140 -96
- package/dist/crypto.d.ts +1 -1
- package/dist/crypto.js +5 -1
- package/dist/declaration.d.ts +58 -52
- package/dist/declaration.js +256 -33
- package/dist/evidence.d.ts +2 -0
- package/dist/evidence.js +134 -19
- package/dist/exit-codes.d.ts +17 -0
- package/dist/exit-codes.js +50 -0
- package/dist/grading.d.ts +28 -13
- package/dist/grading.js +90 -31
- package/dist/handoff.d.ts +16 -1
- package/dist/handoff.js +91 -16
- package/dist/index.d.ts +10 -7
- package/dist/index.js +15 -7
- package/dist/message-parts.d.ts +11 -9
- package/dist/message-parts.js +11 -9
- package/dist/types.d.ts +1 -0
- package/dist/verifier.d.ts +9 -2
- package/dist/verifier.js +28 -12
- package/dist/zip-reader.d.ts +50 -0
- package/dist/zip-reader.js +220 -0
- package/package.json +2 -2
- package/src/agent-proof.ts +4 -4
- package/src/case-file.ts +419 -0
- package/src/case.ts +651 -156
- package/src/cli.ts +1325 -100
- package/src/crypto.ts +5 -1
- package/src/declaration.ts +248 -52
- package/src/evidence.ts +142 -21
- package/src/exit-codes.ts +54 -0
- package/src/grading.ts +109 -52
- package/src/handoff.ts +108 -17
- package/src/index.ts +60 -3
- package/src/message-parts.ts +11 -9
- package/src/types.ts +5 -0
- package/src/verifier.ts +35 -13
- package/src/zip-reader.ts +229 -0
- package/vectors/README.md +56 -3
- package/vectors/case-file-index.json +668 -0
- package/vectors/case-split.json +1102 -0
- package/vectors/pack-grading.json +68 -4
- package/vectors/stamp-on-chain.json +3 -3
package/src/handoff.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The hand-off pairing rule of specs/envelope-spec.md §4.1 (v1.
|
|
1
|
+
// The hand-off pairing rule of specs/envelope-spec.md §4.1 (v1.17).
|
|
2
2
|
//
|
|
3
3
|
// Two agents exchange a message and each side records what it held. This module
|
|
4
4
|
// is the TypeScript half of the reference reading of those two records: which
|
|
@@ -119,14 +119,23 @@
|
|
|
119
119
|
// `not_a_handoff_record` are findings reported beside a verdict rather than
|
|
120
120
|
// verdicts.
|
|
121
121
|
//
|
|
122
|
-
// THE COST OF GRADING EVERY CANDIDATE,
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
// than one message
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
122
|
+
// THE COST OF GRADING EVERY CANDIDATE, AND THE ACCOUNTED-FOR RULE THAT PAYS
|
|
123
|
+
// MOST OF IT (envelope-spec.md 4.1 v1.17, amendment A20). Grading every
|
|
124
|
+
// candidate means the records of a party's OTHER exchanges are candidates too,
|
|
125
|
+
// and they fail. Up to v1.16 that put `verified` out of reach for any two agents
|
|
126
|
+
// that exchanged more than one message in one direction, and the declaration
|
|
127
|
+
// named as the belt cannot be one: evidence-bundle.md 5.5 forbids the reading
|
|
128
|
+
// to consult `handoffs[]` to pair anything. So a candidate that grades `failed`
|
|
129
|
+
// is SET ASIDE from the subject's verdict, failed checks and ambiguity when,
|
|
130
|
+
// and only when, the reader holds another record `o` that accounts for it
|
|
131
|
+
// (`accountedFor` below): `o` is signed by the subject's own key and is the
|
|
132
|
+
// subject's type; `o` names the candidate's signer and the candidate names the
|
|
133
|
+
// subject's key back; `o`'s sequence differs from the subject's; and the
|
|
134
|
+
// candidate grades `verified` against `o`. The candidate stays in `candidates`
|
|
135
|
+
// and `candidateReadings`, with `accountedFor` set. Setting one aside needs a
|
|
136
|
+
// verified pair with a record the OTHER side signed, so a tampered record
|
|
137
|
+
// stays live. Fan-in, fan-out, and one pair in two cases at one sequence still
|
|
138
|
+
// read `failed`: the Python half's module docstring says why.
|
|
130
139
|
|
|
131
140
|
export const CANDIDATE_NAMESPACE = "handoff.";
|
|
132
141
|
export const DEFINED_EVENT_TYPES = ["handoff.sent", "handoff.received"] as const;
|
|
@@ -315,6 +324,16 @@ export interface CandidateReading {
|
|
|
315
324
|
ref: string;
|
|
316
325
|
verdict: HandoffVerdict;
|
|
317
326
|
failedChecks: string[];
|
|
327
|
+
/**
|
|
328
|
+
* The ref of the record that ACCOUNTS FOR this candidate, or `null` (4.1
|
|
329
|
+
* v1.17, A20). Set only on a candidate that graded `failed` and that the
|
|
330
|
+
* reader holds a verified pair for elsewhere; such a candidate is still
|
|
331
|
+
* reported here and takes no part in the subject's verdict, failed checks or
|
|
332
|
+
* ambiguity. Every reading this kernel returns carries it. It is optional in
|
|
333
|
+
* the type only so that code which builds a `CandidateReading` itself keeps
|
|
334
|
+
* compiling: the field is an addition.
|
|
335
|
+
*/
|
|
336
|
+
accountedFor?: string | null;
|
|
318
337
|
}
|
|
319
338
|
|
|
320
339
|
export interface HandoffReading {
|
|
@@ -325,7 +344,12 @@ export interface HandoffReading {
|
|
|
325
344
|
findings: string[];
|
|
326
345
|
/** Ordered, never filtered. */
|
|
327
346
|
candidates: string[];
|
|
328
|
-
/**
|
|
347
|
+
/**
|
|
348
|
+
* One entry per candidate, in the same order, and nothing left out of it. A
|
|
349
|
+
* candidate another record accounts for is here with `accountedFor` set, and
|
|
350
|
+
* where every candidate is accounted for the reading is `counterpart_absent`
|
|
351
|
+
* with the candidates still listed.
|
|
352
|
+
*/
|
|
329
353
|
candidateReadings: CandidateReading[];
|
|
330
354
|
}
|
|
331
355
|
|
|
@@ -620,23 +644,42 @@ export function readHandoff(subject: HeldRecord, held: readonly HeldRecord[]): H
|
|
|
620
644
|
// and took none of them out of this list.
|
|
621
645
|
const graded: CandidateReading[] = candidates.map((candidate) => {
|
|
622
646
|
const { verdict, checks } = gradePair(subject, candidate);
|
|
623
|
-
|
|
647
|
+
const by = verdict === "failed" ? accountedFor(subject, candidate, held) : null;
|
|
648
|
+
return { ref: candidate.ref, verdict, failedChecks: checks, accountedFor: by };
|
|
624
649
|
});
|
|
625
|
-
|
|
650
|
+
|
|
651
|
+
// The verdict, the failed checks and the ambiguity are taken over the
|
|
652
|
+
// candidates nothing accounts for. The others stay in the report.
|
|
653
|
+
const live = graded.filter((g) => g.accountedFor === null);
|
|
654
|
+
if (live.length === 0) {
|
|
655
|
+
// Every candidate is another exchange's record, each proven so by a
|
|
656
|
+
// verified pair with a record the subject's own key signed: an absence,
|
|
657
|
+
// reported with the candidates it looked at.
|
|
658
|
+
return {
|
|
659
|
+
ref: subject.ref,
|
|
660
|
+
verdict: null,
|
|
661
|
+
failedChecks: [],
|
|
662
|
+
findings: ["counterpart_absent"],
|
|
663
|
+
candidates: candidates.map((c) => c.ref),
|
|
664
|
+
candidateReadings: graded,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
const worst = Math.max(...live.map((g) => VERDICT_RANK[g.verdict]));
|
|
626
668
|
const verdict = (Object.keys(VERDICT_RANK).find(
|
|
627
669
|
(name) => VERDICT_RANK[name] === worst,
|
|
628
670
|
) as HandoffVerdict)!;
|
|
629
671
|
|
|
630
|
-
// Over every candidate, not only the ones at the winning verdict:
|
|
631
|
-
// outranks `failed`, so a single junk record would otherwise
|
|
632
|
-
// `message_hash` failure out of the report (proof#133
|
|
672
|
+
// Over every live candidate, not only the ones at the winning verdict:
|
|
673
|
+
// `malformed` outranks `failed`, so a single junk record would otherwise
|
|
674
|
+
// strip the named `message_hash` failure out of the report (proof#133
|
|
675
|
+
// review, ruling 6).
|
|
633
676
|
const failed = new Set<string>();
|
|
634
|
-
for (const g of
|
|
677
|
+
for (const g of live) for (const check of g.failedChecks) failed.add(check);
|
|
635
678
|
|
|
636
679
|
const findings: string[] = [];
|
|
637
680
|
// Never a free choice: a reader permitted to take one match can be handed an
|
|
638
681
|
// honest decoy beside a tampered record.
|
|
639
|
-
if (
|
|
682
|
+
if (live.length > 1) findings.push("counterpart_ambiguous");
|
|
640
683
|
if (verdict === "malformed") findings.push("malformed_record");
|
|
641
684
|
|
|
642
685
|
return {
|
|
@@ -649,6 +692,54 @@ export function readHandoff(subject: HeldRecord, held: readonly HeldRecord[]): H
|
|
|
649
692
|
};
|
|
650
693
|
}
|
|
651
694
|
|
|
695
|
+
/**
|
|
696
|
+
* The ref of the record that accounts for a FAILED candidate, or `null` (4.1
|
|
697
|
+
* v1.17, A20). The candidate is another exchange's record, and not a deviation
|
|
698
|
+
* on this one, only where the reader holds a record `o`, other than the
|
|
699
|
+
* subject, for which all four hold:
|
|
700
|
+
*
|
|
701
|
+
* 1. `o` is signed by the SUBJECT's own key, and is the subject's type;
|
|
702
|
+
* 2. `o` names the candidate's signer, and the candidate names the subject's
|
|
703
|
+
* key back;
|
|
704
|
+
* 3. `o`'s `sequence` differs from the subject's;
|
|
705
|
+
* 4. the candidate grades `verified` against `o`.
|
|
706
|
+
*
|
|
707
|
+
* Condition 1 is what the audited party cannot supply: a verified pair needs
|
|
708
|
+
* the OTHER side's signature over the digest and the number. Condition 3 is
|
|
709
|
+
* the position guard (proof#129): a second record at the subject's own number
|
|
710
|
+
* is a second claim about the subject's message. The first `o` in the order
|
|
711
|
+
* the records are held is reported, so two kernels name one record. The
|
|
712
|
+
* Python half, `_accounted_for`, carries the full reasoning.
|
|
713
|
+
*/
|
|
714
|
+
function accountedFor(
|
|
715
|
+
subject: HeldRecord,
|
|
716
|
+
candidate: HeldRecord,
|
|
717
|
+
held: readonly HeldRecord[],
|
|
718
|
+
): string | null {
|
|
719
|
+
const own = normalizedSigningKey(subject.publicKey);
|
|
720
|
+
const theirs = normalizedSigningKey(candidate.publicKey);
|
|
721
|
+
// Condition 2: the candidate names the subject's key back.
|
|
722
|
+
if (normalizedSigningKey(section(candidate.record, "event").counterparty_public_key) !== own) {
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
const subjectSequence = section(subject.record, "event").sequence;
|
|
726
|
+
for (const other of held) {
|
|
727
|
+
if (other === subject) continue;
|
|
728
|
+
// Condition 1: signed by the subject's own key, of the subject's own type.
|
|
729
|
+
if (normalizedSigningKey(other.publicKey) !== own) continue;
|
|
730
|
+
if (eventType(other) !== eventType(subject)) continue;
|
|
731
|
+
// Condition 2: o names the candidate's signer.
|
|
732
|
+
if (normalizedSigningKey(section(other.record, "event").counterparty_public_key) !== theirs) {
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
// Condition 3: another position than the subject's.
|
|
736
|
+
if (sameNumber(section(other.record, "event").sequence, subjectSequence)) continue;
|
|
737
|
+
// Condition 4: a verified pair.
|
|
738
|
+
if (gradePair(other, candidate).verdict === "verified") return other.ref;
|
|
739
|
+
}
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
|
|
652
743
|
/**
|
|
653
744
|
* EVERY record the reader holds gets a reading, in the order given.
|
|
654
745
|
*
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// gateway/transport concerns are consumer-layer logic composed above it.
|
|
13
13
|
|
|
14
14
|
// ACCEPTED_SPEC_MAJORS is exported for parity with the sibling kernels'
|
|
15
|
-
// registries (Python
|
|
15
|
+
// registries (Python ACCEPTED_SPEC_MAJORS, Go SupportedSpecMajor): a consumer
|
|
16
16
|
// can see which profiles this build admits without inferring it from probe
|
|
17
17
|
// calls. It is frozen at its definition (`Object.freeze`, typed
|
|
18
18
|
// `readonly string[]`) because the verifier reads that same array — an exported
|
|
@@ -60,6 +60,7 @@ export type { ContentRole, Envelope, Subject, VerificationResult, VerifyOptions
|
|
|
60
60
|
// bytes, and only for events whose verification bound them to the committed
|
|
61
61
|
// content_hash. Additive; verifyEvidenceBundle's behavior is unchanged.
|
|
62
62
|
export {
|
|
63
|
+
ACCEPTED_EVIDENCE_MAJORS,
|
|
63
64
|
ANCHOR_TRACE_BODY_TYPE,
|
|
64
65
|
EXPORT_BODY_TYPE,
|
|
65
66
|
STAMP_BODY_VERSION,
|
|
@@ -128,6 +129,8 @@ export type {
|
|
|
128
129
|
// derivation to it, and diff its copy against ours. Additive — no existing
|
|
129
130
|
// export changes.
|
|
130
131
|
export {
|
|
132
|
+
PACK_GRADE_CAUSE_KINDS,
|
|
133
|
+
PACK_VERDICTS,
|
|
131
134
|
accountDisclosure,
|
|
132
135
|
completenessNotes,
|
|
133
136
|
gradeCauses,
|
|
@@ -162,7 +165,14 @@ export type {
|
|
|
162
165
|
// optional fourth argument, so a surface gets one verdict; omitting it is the
|
|
163
166
|
// pre-§5.4 behavior, unchanged. Additive: no existing export changes.
|
|
164
167
|
export {
|
|
168
|
+
DECLARATION_CONTENT_KINDS,
|
|
169
|
+
DECLARATION_DEVIATION_KINDS,
|
|
170
|
+
DECLARATION_NOTE_KINDS,
|
|
171
|
+
DECLARATION_ROLES,
|
|
172
|
+
DECLARATION_VERDICTS,
|
|
165
173
|
DECLARATION_VERSION,
|
|
174
|
+
DECLARATION_VERSION_V2,
|
|
175
|
+
DECLARATION_VERSIONS,
|
|
166
176
|
checkDeclaration,
|
|
167
177
|
declarationClaims,
|
|
168
178
|
packRecords,
|
|
@@ -180,6 +190,7 @@ export type {
|
|
|
180
190
|
DeclarationDeviationKind,
|
|
181
191
|
DeclarationNote,
|
|
182
192
|
DeclarationNoteKind,
|
|
193
|
+
DeclarationRole,
|
|
183
194
|
DeclarationVerdict,
|
|
184
195
|
PackRecord,
|
|
185
196
|
} from "./declaration.js";
|
|
@@ -193,7 +204,8 @@ export type {
|
|
|
193
204
|
// because the omission happens before any reader runs. Reading several parties'
|
|
194
205
|
// packs at once is what closes it: a `handoff.sent` with no counterpart is
|
|
195
206
|
// always named, and where a pack signed by the key it names is in the case, the
|
|
196
|
-
// reading says that pack holds no counterpart
|
|
207
|
+
// reading says that pack holds no counterpart (unless every candidate was
|
|
208
|
+
// accounted for under A20, which reads as no counterpart and names no pack). Whether a party that presented
|
|
197
209
|
// no such pack recorded anything is not established (proof#143). `readCase`
|
|
198
210
|
// verifies every pack itself, offline, so it is async. Matching is on keys and hashes alone and NEVER on tenancy, so a pair from two
|
|
199
211
|
// organizations verifies (DL-60).
|
|
@@ -205,20 +217,53 @@ export {
|
|
|
205
217
|
CASE_EXCEPTION_KINDS,
|
|
206
218
|
CASE_QUARANTINED_PROFILES,
|
|
207
219
|
CASE_RECORD_PROFILES,
|
|
220
|
+
CASE_NOTE_KINDS,
|
|
221
|
+
CASE_RELIANCE,
|
|
208
222
|
CASE_VERDICTS,
|
|
223
|
+
CONTENT_WITHHELD_BY_DECLARATION,
|
|
224
|
+
VERDICT_DISPLAY_TITLES,
|
|
225
|
+
WITNESS_NOT_LOOKED_UP,
|
|
226
|
+
WITNESS_STATES,
|
|
209
227
|
readCase,
|
|
228
|
+
renderCaseNote,
|
|
210
229
|
renderCaseSummary,
|
|
211
230
|
renderException,
|
|
231
|
+
renderWitnessLine,
|
|
212
232
|
} from "./case.js";
|
|
213
233
|
export type {
|
|
214
234
|
CaseException,
|
|
215
235
|
CaseExceptionKind,
|
|
236
|
+
CaseNote,
|
|
237
|
+
CaseNoteKind,
|
|
216
238
|
CasePack,
|
|
239
|
+
CasePackReading,
|
|
217
240
|
CaseReading,
|
|
218
241
|
CaseRecordReading,
|
|
219
242
|
CaseVerdict,
|
|
243
|
+
WitnessLine,
|
|
244
|
+
WitnessState,
|
|
220
245
|
} from "./case.js";
|
|
221
246
|
|
|
247
|
+
// The case file's index (src/case-file.ts, evidence-bundle.md §5.6, A25):
|
|
248
|
+
// compared with the drop it describes, with NO verdict contribution. The index
|
|
249
|
+
// is never an input to `readCase`. Pure: bytes and a file list in.
|
|
250
|
+
export {
|
|
251
|
+
CASE_FILE_FINDING_KINDS,
|
|
252
|
+
CASE_FILE_INDEX_NAME,
|
|
253
|
+
CASE_FILE_VERSION,
|
|
254
|
+
HOW_TO_VERIFY_NAME,
|
|
255
|
+
REPORT_README_FIRST_LINE,
|
|
256
|
+
howToVerifyText,
|
|
257
|
+
isPackRef,
|
|
258
|
+
readCaseFileIndex,
|
|
259
|
+
renderCaseFileFinding,
|
|
260
|
+
} from "./case-file.js";
|
|
261
|
+
export type {
|
|
262
|
+
CaseFileFinding,
|
|
263
|
+
CaseFileFindingKind,
|
|
264
|
+
CaseFileIndexReading,
|
|
265
|
+
} from "./case-file.js";
|
|
266
|
+
|
|
222
267
|
// Chain reading (src/chain.ts): following the pointer every sanning.events/v2
|
|
223
268
|
// record carries to the record before it, over the records ONE bundle presents.
|
|
224
269
|
//
|
|
@@ -257,13 +302,25 @@ export type {
|
|
|
257
302
|
// signed, offline-verifiable sanning.evidence.export/v2 artifact from a source
|
|
258
303
|
// anchor-trace bundle + operator attestations + an exporter Ed25519 key. The
|
|
259
304
|
// inverse of verifyEvidenceBundle's verifyExportBody dispatch.
|
|
305
|
+
// One exit-code table for every verb (0.13.0): the verdict word to the exit
|
|
306
|
+
// code, and the help text every verb prints. Additive.
|
|
307
|
+
export {
|
|
308
|
+
EXIT_CODES,
|
|
309
|
+
EXIT_CODES_HELP,
|
|
310
|
+
EXIT_FAILED,
|
|
311
|
+
EXIT_MALFORMED,
|
|
312
|
+
EXIT_MIXED,
|
|
313
|
+
EXIT_UNDETERMINED,
|
|
314
|
+
EXIT_VERIFIED,
|
|
315
|
+
exitCodeFor,
|
|
316
|
+
} from "./exit-codes.js";
|
|
260
317
|
export { attestationSignatureToHex, composeExport } from "./compose.js";
|
|
261
318
|
export type { ComposeExportOptions, ComposeExportResult, ExporterKey } from "./compose.js";
|
|
262
319
|
|
|
263
320
|
// Agent inclusion-proof bundle verification: the
|
|
264
321
|
// sanning.agent.proof/v1 bundle. The CLI sniffs spec_version and routes here so
|
|
265
322
|
// one verifier covers both agent and anchor bundles.
|
|
266
|
-
export {
|
|
323
|
+
export { AGENT_PROOF_SPEC_VERSION, isAgentProofSpec, verifyAgentProof } from "./agent-proof.js";
|
|
267
324
|
export type {
|
|
268
325
|
AgentProofBundle,
|
|
269
326
|
AgentProofResult,
|
package/src/message-parts.ts
CHANGED
|
@@ -278,17 +278,19 @@ const HELD_UNCHECKED_RESULTS: readonly string[] = [
|
|
|
278
278
|
|
|
279
279
|
/**
|
|
280
280
|
* The keys that make a part carry its content INLINE, in the order they are
|
|
281
|
-
* read. Named rather than inferred: `bytes`
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
281
|
+
* read. Named rather than inferred: `bytes`, `data` and `content` are the
|
|
282
|
+
* spellings in the wild. None is A2A 1.0.0's: its `Part` has no `kind` and
|
|
283
|
+
* carries a file as `raw` or `url`, so a literal A2A part reads
|
|
284
|
+
* `part_not_a_file` (envelope-spec §4.1, A19). The FIRST of them present with
|
|
285
|
+
* a value that is not `null` is the inline content, so a part spelling it
|
|
286
|
+
* twice reads one way in both kernels.
|
|
285
287
|
*
|
|
286
288
|
* A key present with `null` is an ABSENT inline value, on A14's ruling that an
|
|
287
|
-
* absent value and one written as `null` name one thing. That matters:
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
* (proof#156 gate).
|
|
291
|
-
*
|
|
289
|
+
* absent value and one written as `null` name one thing. That matters: a
|
|
290
|
+
* proto3 JSON encoder can write an unset field as `"bytes": null`, and reading
|
|
291
|
+
* that as inline made a conformant DESCRIPTOR part uncheckable
|
|
292
|
+
* (proof#156 gate). Such a part falls through to the descriptor rule, which is
|
|
293
|
+
* what it is.
|
|
292
294
|
*/
|
|
293
295
|
// A conformant DESCRIPTOR part that also carries a `content` or `data` key
|
|
294
296
|
// meaning something else (a filename, a metadata blob) is reclassified by this
|
package/src/types.ts
CHANGED
|
@@ -48,6 +48,11 @@ export interface VerifyOptions {
|
|
|
48
48
|
// Content bind: the caller's own hash of an artifact (e.g. the in-browser
|
|
49
49
|
// hash of a user's file) to match against the hashes the envelope commits to.
|
|
50
50
|
expectedContentHash?: string;
|
|
51
|
+
// NARROWS the accept-set for this call and never widens it: the set used is
|
|
52
|
+
// `ACCEPTED_SPEC_MAJORS` intersected with this list, so a caller cannot
|
|
53
|
+
// admit a profile this kernel does not. The Python twin is
|
|
54
|
+
// `accepted_spec_majors` (P-10, 0.13.0).
|
|
55
|
+
acceptedSpecMajors?: readonly string[];
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
export interface VerificationResult {
|
package/src/verifier.ts
CHANGED
|
@@ -23,7 +23,7 @@ import type { ContentRole, Envelope, VerificationResult, VerifyOptions } from ".
|
|
|
23
23
|
// profile-specific dialect (no underscore strip, that is mlflow-only).
|
|
24
24
|
//
|
|
25
25
|
// Exported for parity with the sibling kernels' registries — Python
|
|
26
|
-
// `
|
|
26
|
+
// `ACCEPTED_SPEC_MAJORS`, Go `SupportedSpecMajor` — so the accept-set is
|
|
27
27
|
// assertable rather than inferable: test/accept-set.test.ts holds it to the
|
|
28
28
|
// ratified §4/§5 set transcribed in cross-kernel/accept-set.json, and the
|
|
29
29
|
// cross-kernel gate probes one signed envelope per identifier. This entry list
|
|
@@ -231,6 +231,16 @@ const JCS_BOM = "";
|
|
|
231
231
|
* Throws for anything that is not conformant; every call site treats a throw as
|
|
232
232
|
* "this reader cannot read these bytes" and names the check that failed.
|
|
233
233
|
*/
|
|
234
|
+
/**
|
|
235
|
+
* Bytes as strict UTF-8, a leading byte-order mark KEPT as a character.
|
|
236
|
+
* `utf8_strict` in the Python twin's `canonicalize.py` is the same rule, and
|
|
237
|
+
* `jcsParse` below performs the identical decode. Throws on bytes that are not
|
|
238
|
+
* UTF-8.
|
|
239
|
+
*/
|
|
240
|
+
export function utf8Strict(bytes: Uint8Array): string {
|
|
241
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
242
|
+
}
|
|
243
|
+
|
|
234
244
|
export function jcsParse(bytes: Uint8Array): unknown {
|
|
235
245
|
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
236
246
|
if (text.startsWith(JCS_BOM)) throw new Error("jcsParse: leading byte-order mark");
|
|
@@ -311,9 +321,17 @@ function hasLoneSurrogate(s: string): boolean {
|
|
|
311
321
|
// `<major>.<minor>` where minor is `[0-9]+`. A non-numeric minor suffix is
|
|
312
322
|
// malformed, not a tolerated future version — matching the Python kernel's
|
|
313
323
|
// 0.1.1 semantics (the TS/Go fail-open lenience was closed deliberately).
|
|
314
|
-
|
|
324
|
+
//
|
|
325
|
+
// `accepted` NARROWS the registry and never widens it, as the Python twin's
|
|
326
|
+
// `spec_version_supported(spec_version, accepted)` does (P-10, 0.13.0).
|
|
327
|
+
export function specVersionSupported(
|
|
328
|
+
specVersion: string,
|
|
329
|
+
accepted?: readonly string[],
|
|
330
|
+
): boolean {
|
|
315
331
|
if (typeof specVersion !== "string" || specVersion === "") return false;
|
|
316
|
-
|
|
332
|
+
const registry =
|
|
333
|
+
accepted === undefined ? ACCEPTED_SPEC_MAJORS : ACCEPTED_SPEC_MAJORS.filter((m) => accepted.includes(m));
|
|
334
|
+
return registry.some((m) => {
|
|
317
335
|
if (specVersion === m) return true;
|
|
318
336
|
if (!specVersion.startsWith(`${m}.`)) return false;
|
|
319
337
|
const minor = specVersion.slice(m.length + 1);
|
|
@@ -456,22 +474,26 @@ export function contentHashes(env: Envelope): { role: ContentRole; hash: string
|
|
|
456
474
|
// signature, so stripping it to fake "external" — or injecting one to fake
|
|
457
475
|
// "inline" — breaks the signature).
|
|
458
476
|
//
|
|
459
|
-
// The second argument
|
|
460
|
-
//
|
|
477
|
+
// The second argument is a VerifyOptions object ({ payloadBytes,
|
|
478
|
+
// expectedContentHash, acceptedSpecMajors }). Until 0.13.0 it could also be a
|
|
479
|
+
// bare `expectedContentHash` string; that overload is gone (P-10), so the two
|
|
480
|
+
// kernels take one options shape: write `{ expectedContentHash: hash }`.
|
|
461
481
|
export async function verifyEnvelope(
|
|
462
482
|
env: Envelope,
|
|
463
|
-
|
|
483
|
+
options?: VerifyOptions,
|
|
464
484
|
): Promise<VerificationResult> {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
485
|
+
if (typeof options === "string") {
|
|
486
|
+
throw new TypeError(
|
|
487
|
+
"verifyEnvelope: pass { expectedContentHash } rather than a bare string (0.13.0)",
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
const opts: VerifyOptions = options ?? {};
|
|
469
491
|
// REFUSE an unknown option key rather than ignore it. `expected_content_hash`
|
|
470
492
|
// — the Python kernel's spelling — previously fell through as `undefined`, so
|
|
471
493
|
// the content bind silently never ran and the caller got a clean pass with no
|
|
472
494
|
// errors at all. A check that quietly does not happen is worse than one that
|
|
473
495
|
// fails: the caller believes they verified an artifact they never checked.
|
|
474
|
-
const KNOWN_VERIFY_OPTIONS = new Set(["payloadBytes", "expectedContentHash"]);
|
|
496
|
+
const KNOWN_VERIFY_OPTIONS = new Set(["payloadBytes", "expectedContentHash", "acceptedSpecMajors"]);
|
|
475
497
|
const unknownOptions = Object.keys(opts).filter((k) => !KNOWN_VERIFY_OPTIONS.has(k));
|
|
476
498
|
if (unknownOptions.length > 0) {
|
|
477
499
|
throw new TypeError(
|
|
@@ -551,7 +573,7 @@ async function verifyEnvelopeChecked(
|
|
|
551
573
|
};
|
|
552
574
|
}
|
|
553
575
|
|
|
554
|
-
const specVersionOk = specVersionSupported(env.spec_version);
|
|
576
|
+
const specVersionOk = specVersionSupported(env.spec_version, opts.acceptedSpecMajors);
|
|
555
577
|
if (!specVersionOk) errors.push(`unsupported spec_version: ${JSON.stringify(env.spec_version)}`);
|
|
556
578
|
|
|
557
579
|
// Skeleton field format (envelope-spec §2, proof#95): a present `signed_at`
|
|
@@ -625,7 +647,7 @@ async function verifyEnvelopeChecked(
|
|
|
625
647
|
let signatureOk = false;
|
|
626
648
|
try {
|
|
627
649
|
const { signature: _signature, co_signatures: _coSignatures, ...envelopeForSig } = env;
|
|
628
|
-
signatureOk = await ed25519Verify(
|
|
650
|
+
signatureOk = await ed25519Verify(utf8(jcs(envelopeForSig)), env.signature, env.public_key);
|
|
629
651
|
if (!signatureOk) errors.push("Ed25519 signature verification failed");
|
|
630
652
|
} catch (e) {
|
|
631
653
|
errors.push(`signature verification error: ${stringifyErr(e)}`);
|