@sanning/proof 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/dist/cli.js +11 -7
- package/dist/evidence.d.ts +29 -0
- package/dist/evidence.js +170 -23
- package/dist/index.d.ts +2 -2
- package/dist/index.js +9 -1
- package/package.json +1 -1
- package/src/cli.ts +11 -7
- package/src/evidence.ts +261 -20
- package/src/index.ts +12 -0
package/README.md
CHANGED
|
@@ -111,6 +111,33 @@ result.events[0].contentOk; // true | false | null
|
|
|
111
111
|
|
|
112
112
|
In-body `events[].content` (signed) takes precedence over the side input; if both are present for an event and disagree, the event fails.
|
|
113
113
|
|
|
114
|
+
### …and get those raw logs back out (ingest)
|
|
115
|
+
|
|
116
|
+
`events[].content` rides as **lowercase hex** — JSON cannot carry arbitrary bytes. To *materialize* a received bundle's logs (serve them, write pack files, hand them on), use `disclosedContent` rather than decoding that field yourself:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { disclosedContent } from "@sanning/proof";
|
|
120
|
+
|
|
121
|
+
const result = await disclosedContent(bundle); // verifies, then decodes
|
|
122
|
+
|
|
123
|
+
for (const [eventId, bytes] of result.content) {
|
|
124
|
+
await writeFile(`logs/${eventId}.json`, bytes); // Uint8Array — write it as-is
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Verify-then-return:** `disclosedContent` runs the full `verifyEvidenceBundle` (same options) and hands back bytes **only** for events whose chain verified *and* whose disclosed bytes hash to the committed `content_hash`. There is no path through it that yields bytes that are not the committed ones — and `result.verification` carries the whole `EvidenceBundleResult`, so you verify once and render the verdict from the same call.
|
|
129
|
+
|
|
130
|
+
Every event also appears in `result.events`, in body order, with a status that never lets "nothing was disclosed" pass for "empty":
|
|
131
|
+
|
|
132
|
+
| `status` | `bytes` | Meaning |
|
|
133
|
+
|---|---|---|
|
|
134
|
+
| `verified` | the bytes | Decoded **and** bound to the committed `content_hash` — safe to write as-is. May be zero-length: an empty payload is real, verifiable content. |
|
|
135
|
+
| `not_disclosed` | `null` | Minimal disclosure — the hash was committed, the bytes were not shared. Nothing to write, and not an error. |
|
|
136
|
+
| `undetermined` | `null` | Bytes were disclosed but there is nothing to bind them to (withheld record, or no committed `content_hash`). Withheld rather than returned. |
|
|
137
|
+
| `failed` | `null` | The event's own proof chain failed, or the disclosure did not bind or did not decode. `errors` says which. |
|
|
138
|
+
|
|
139
|
+
Bytes come back as `Uint8Array`, never a string: the spec commits to **raw bytes**, and a UTF-8 decode is lossy for any payload that is not valid UTF-8 — re-encoding such a string would change the bytes and break the hash. Decode to text yourself if you know your payload is text.
|
|
140
|
+
|
|
114
141
|
## Verify a Merkle inclusion proof
|
|
115
142
|
|
|
116
143
|
Agents close daily checkpoints over per-cycle leaves (RFC 9162, §2.1 domain separation — not the Bitcoin duplicate-leaf variant). Verify a leaf's inclusion against an anchored root:
|
|
@@ -133,6 +160,7 @@ const ok = await verifyInclusion(
|
|
|
133
160
|
|---|---|
|
|
134
161
|
| `verifyEnvelope(env, optionsOrContentHash?)` | The three load-bearing checks (spec-version registry, payload-hash recompute, Ed25519 over the signed scope) + optional content bind. Pass `{ payloadBytes }` for external-commitment envelopes. Returns `VerificationResult`. |
|
|
135
162
|
| `verifyEvidenceBundle(bundle, { gateways?, fetchImpl? })` | Verify a signed `sanning.evidence/v1` / `sanning.anchor.trace/v1` bundle: wrapper signature + `body_hash` + every checkpoint + every event's inclusion, offline (optional on-chain re-fetch). Returns `EvidenceBundleResult`. Powers the `npx @sanning/proof verify` CLI. |
|
|
163
|
+
| `disclosedContent(bundle, { gateways?, content?, fetchImpl? })` | **Ingest:** verify a bundle, then hand back the DECODED raw bytes (`Uint8Array`) of every event whose disclosure bound to its committed `content_hash` — so materializing a received pack never means re-implementing the hex convention. Returns `DisclosedContentResult` (`content` map + per-event `verified` / `not_disclosed` / `undetermined` / `failed`, plus the full `verification`). |
|
|
136
164
|
| `verifyAgentProofBundle(bundle, { gateways?, fetchImpl? })` / `isAgentProofSpec(v)` | Verify the agent producer's `sanning.agent.proof/v1` inclusion bundle. |
|
|
137
165
|
| `contentHashes(env)` | The content hash(es) an envelope commits to, by event type — the reverse-provenance join keys. |
|
|
138
166
|
| `specVersionSupported(v)` | Fail-closed accept-check: `sanning.agent/v1` and additive minors (`sanning.agent/v1.<n>`). Unknown majors/profiles are rejected. |
|
package/dist/cli.js
CHANGED
|
@@ -57,8 +57,11 @@ export async function runCli(argv, io) {
|
|
|
57
57
|
io.out("@sanning/proof verify CLI");
|
|
58
58
|
return EXIT_VERIFIED;
|
|
59
59
|
}
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
// `bundle` is the customer-facing verb (proof#19: one artifact, one word).
|
|
61
|
+
// `export` is kept working as a QUIET alias — it shipped and is published, so
|
|
62
|
+
// it is not broken abruptly; it is simply no longer taught anywhere.
|
|
63
|
+
if (command === "bundle" || command === "export") {
|
|
64
|
+
return runBundle(rest, io);
|
|
62
65
|
}
|
|
63
66
|
if (command !== "verify") {
|
|
64
67
|
io.err(`unknown command: ${command}`);
|
|
@@ -164,7 +167,8 @@ export async function runCli(argv, io) {
|
|
|
164
167
|
});
|
|
165
168
|
return reportEvidence(result, bundlePath, gateways, io);
|
|
166
169
|
}
|
|
167
|
-
// `proof
|
|
170
|
+
// `proof bundle <source-bundle.json> --attestations <f> --key <f> [-o <out>]`
|
|
171
|
+
// (`proof export` still reaches this, undocumented — see the dispatch above.)
|
|
168
172
|
//
|
|
169
173
|
// Compose a signed, offline-verifiable sanning.evidence.export/v1 artifact from a
|
|
170
174
|
// source sanning.anchor.trace/v1 bundle + operator attestation records + an exporter
|
|
@@ -175,7 +179,7 @@ export async function runCli(argv, io) {
|
|
|
175
179
|
// forged/mis-bound attestation — the composer refuses to emit a green artifact
|
|
176
180
|
// over failing evidence) · 2 usage / malformed input (missing flag, unreadable
|
|
177
181
|
// or non-JSON input, bad exporter key, malformed attestation record).
|
|
178
|
-
async function
|
|
182
|
+
async function runBundle(rest, io) {
|
|
179
183
|
let attestationsPath;
|
|
180
184
|
let keyPath;
|
|
181
185
|
let outPath;
|
|
@@ -296,7 +300,7 @@ async function runExport(rest, io) {
|
|
|
296
300
|
return composed.status === "failed" ? EXIT_FAILED : EXIT_VERIFIED;
|
|
297
301
|
}
|
|
298
302
|
function usageExport(io) {
|
|
299
|
-
io.out("Usage: npx @sanning/proof
|
|
303
|
+
io.out("Usage: npx @sanning/proof bundle <source-bundle.json> --attestations <file> --key <file> [-o <out.json>]");
|
|
300
304
|
io.out("");
|
|
301
305
|
io.out("Compose a signed, offline-verifiable sanning.evidence.export/v1 artifact from an");
|
|
302
306
|
io.out("sanning.anchor.trace/v1 source bundle + operator attestation records + an exporter");
|
|
@@ -390,8 +394,8 @@ function printUsage(io) {
|
|
|
390
394
|
io.out("Usage: npx @sanning/proof <command> [args]");
|
|
391
395
|
io.out("");
|
|
392
396
|
io.out("Commands:");
|
|
393
|
-
io.out(" verify <bundle.json> [gateways] [--logs <logs.json>] Verify a bundle
|
|
394
|
-
io.out("
|
|
397
|
+
io.out(" verify <bundle.json> [gateways] [--logs <logs.json>] Verify a bundle");
|
|
398
|
+
io.out(" bundle <source.json> --attestations <f> --key <f> [-o] Create an attested bundle");
|
|
395
399
|
io.out("");
|
|
396
400
|
io.out("Verify a sanning.evidence/v1 (sanning.anchor.trace/v1) bundle, an");
|
|
397
401
|
io.out("sanning.evidence.export/v1 attested export, or a sanning.agent.proof/v1");
|
package/dist/evidence.d.ts
CHANGED
|
@@ -205,6 +205,35 @@ export interface VerifyEvidenceOptions {
|
|
|
205
205
|
fetchImpl?: typeof fetch;
|
|
206
206
|
}
|
|
207
207
|
export declare function verifyEvidenceBundle(bundle: unknown, options?: VerifyEvidenceOptions): Promise<EvidenceBundleResult>;
|
|
208
|
+
export type DisclosureStatus = "verified" | "not_disclosed" | "undetermined" | "failed";
|
|
209
|
+
export interface DisclosedEventContent {
|
|
210
|
+
eventId: string;
|
|
211
|
+
status: DisclosureStatus;
|
|
212
|
+
bytes: Uint8Array | null;
|
|
213
|
+
errors: string[];
|
|
214
|
+
}
|
|
215
|
+
export interface DisclosedContentResult {
|
|
216
|
+
status: EvidenceStatus;
|
|
217
|
+
verification: EvidenceBundleResult;
|
|
218
|
+
events: DisclosedEventContent[];
|
|
219
|
+
content: Map<string, Uint8Array>;
|
|
220
|
+
errors: string[];
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Materialize the disclosed raw bytes of a received bundle (evidence-bundle §5.1).
|
|
224
|
+
*
|
|
225
|
+
* Verify-then-return: the bundle is verified with {@link verifyEvidenceBundle}
|
|
226
|
+
* (same options — gateways, out-of-band `content`, `fetchImpl`), and an event's
|
|
227
|
+
* bytes are returned ONLY when that verification bound them to the committed
|
|
228
|
+
* `content_hash`. There is no path through this function that yields bytes
|
|
229
|
+
* whose SHA-256 is not the commitment.
|
|
230
|
+
*
|
|
231
|
+
* Returns `Uint8Array`, never a string: the spec commits to raw bytes, and a
|
|
232
|
+
* UTF-8 decode is lossy for any payload that is not valid UTF-8 — re-encoding
|
|
233
|
+
* such a string would produce different bytes and a different hash. Callers who
|
|
234
|
+
* know their payload is text decode it themselves and own that assumption.
|
|
235
|
+
*/
|
|
236
|
+
export declare function disclosedContent(bundle: unknown, options?: VerifyEvidenceOptions): Promise<DisclosedContentResult>;
|
|
208
237
|
export interface ComposeVerdictResult {
|
|
209
238
|
verdict: VerdictObject;
|
|
210
239
|
sourceStatus: EvidenceStatus;
|
package/dist/evidence.js
CHANGED
|
@@ -341,29 +341,12 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
341
341
|
committed.push(c.hash.toLowerCase());
|
|
342
342
|
}
|
|
343
343
|
// Disclosed bytes, precedence: in-body (signed) → side input. A string
|
|
344
|
-
// side input is hex; a Uint8Array is used as-is.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
catch (e) {
|
|
351
|
-
evErrors.push(`event content is not hex: ${stringifyErr(e)}`);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
let side;
|
|
355
|
-
const sideRaw = options.content?.[eventId];
|
|
356
|
-
if (sideRaw instanceof Uint8Array) {
|
|
357
|
-
side = sideRaw;
|
|
358
|
-
}
|
|
359
|
-
else if (typeof sideRaw === "string") {
|
|
360
|
-
try {
|
|
361
|
-
side = hexToBytes(sideRaw);
|
|
362
|
-
}
|
|
363
|
-
catch (e) {
|
|
364
|
-
evErrors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
|
|
365
|
-
}
|
|
366
|
-
}
|
|
344
|
+
// side input is hex; a Uint8Array is used as-is. Resolved by the SHARED
|
|
345
|
+
// selector so `disclosedContent` hands a consumer the very bytes bound
|
|
346
|
+
// here — one decode, not two that can drift apart.
|
|
347
|
+
const selection = selectDisclosedBytes(ev, eventId, options.content);
|
|
348
|
+
const { inBody, side } = selection;
|
|
349
|
+
evErrors.push(...selection.errors);
|
|
367
350
|
if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
|
|
368
351
|
// Two disclosures that disagree: a genuine conflict, not undetermined.
|
|
369
352
|
contentOk = false;
|
|
@@ -473,6 +456,144 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
473
456
|
errors,
|
|
474
457
|
};
|
|
475
458
|
}
|
|
459
|
+
// How many `sanning.evidence.export/v1` wrappers deep the source bundle may be
|
|
460
|
+
// nested before this path gives up. verifyEvidenceBundle's canonicalization
|
|
461
|
+
// depth bound already rejects a pathological bundle, but the unwrap must be
|
|
462
|
+
// independently bounded rather than trusting that it ran first.
|
|
463
|
+
const MAX_EXPORT_UNWRAP = 4;
|
|
464
|
+
/**
|
|
465
|
+
* Materialize the disclosed raw bytes of a received bundle (evidence-bundle §5.1).
|
|
466
|
+
*
|
|
467
|
+
* Verify-then-return: the bundle is verified with {@link verifyEvidenceBundle}
|
|
468
|
+
* (same options — gateways, out-of-band `content`, `fetchImpl`), and an event's
|
|
469
|
+
* bytes are returned ONLY when that verification bound them to the committed
|
|
470
|
+
* `content_hash`. There is no path through this function that yields bytes
|
|
471
|
+
* whose SHA-256 is not the commitment.
|
|
472
|
+
*
|
|
473
|
+
* Returns `Uint8Array`, never a string: the spec commits to raw bytes, and a
|
|
474
|
+
* UTF-8 decode is lossy for any payload that is not valid UTF-8 — re-encoding
|
|
475
|
+
* such a string would produce different bytes and a different hash. Callers who
|
|
476
|
+
* know their payload is text decode it themselves and own that assumption.
|
|
477
|
+
*/
|
|
478
|
+
export async function disclosedContent(bundle, options = {}) {
|
|
479
|
+
const verification = await verifyEvidenceBundle(bundle, options);
|
|
480
|
+
const events = [];
|
|
481
|
+
const content = new Map();
|
|
482
|
+
const errors = [];
|
|
483
|
+
const empty = () => ({
|
|
484
|
+
status: verification.status,
|
|
485
|
+
verification,
|
|
486
|
+
events: [],
|
|
487
|
+
content: new Map(),
|
|
488
|
+
errors: [...errors, ...verification.errors],
|
|
489
|
+
});
|
|
490
|
+
const traceEvents = resolveTraceEvents(bundle);
|
|
491
|
+
if (traceEvents === undefined) {
|
|
492
|
+
errors.push(`no ${ANCHOR_TRACE_BODY_TYPE} body to materialize content from ` +
|
|
493
|
+
`(body_type ${JSON.stringify(verification.bodyType)})`);
|
|
494
|
+
return empty();
|
|
495
|
+
}
|
|
496
|
+
// Fail closed on any drift between the body we are decoding and the results
|
|
497
|
+
// we are trusting. Both must be the same events in the same order, or the
|
|
498
|
+
// "these bytes are the bytes that verified" guarantee is void — which is the
|
|
499
|
+
// only guarantee this function makes. A broken wrapper lands here too:
|
|
500
|
+
// verifyEvidenceBundle returns zero event results, so nothing is emitted.
|
|
501
|
+
if (traceEvents.length !== verification.events.length) {
|
|
502
|
+
errors.push("the bundle did not verify far enough to materialize content: " +
|
|
503
|
+
`${verification.events.length} verified event result(s) for ${traceEvents.length} body event(s)`);
|
|
504
|
+
return empty();
|
|
505
|
+
}
|
|
506
|
+
for (let i = 0; i < traceEvents.length; i++) {
|
|
507
|
+
const te = traceEvents[i];
|
|
508
|
+
const envelope = te !== null && typeof te === "object" ? te.envelope : undefined;
|
|
509
|
+
if (getEventId(envelope) !== verification.events[i].eventId) {
|
|
510
|
+
errors.push(`event alignment drift at index ${i}: the body event and its verification result ` +
|
|
511
|
+
"are not the same event; refusing to hand back any content");
|
|
512
|
+
return empty();
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
for (let i = 0; i < traceEvents.length; i++) {
|
|
516
|
+
const ev = traceEvents[i];
|
|
517
|
+
const res = verification.events[i];
|
|
518
|
+
const selection = selectDisclosedBytes(ev, res.eventId, options.content);
|
|
519
|
+
const bytes = selection.inBody ?? selection.side;
|
|
520
|
+
const evErrors = [];
|
|
521
|
+
let status;
|
|
522
|
+
if (!res.ok || res.contentOk === false) {
|
|
523
|
+
// The event itself is not trustworthy (bad signature, broken inclusion,
|
|
524
|
+
// unbound checkpoint) or its disclosure contradicts the commitment.
|
|
525
|
+
status = "failed";
|
|
526
|
+
evErrors.push(res.contentOk === false
|
|
527
|
+
? "disclosed content does not bind to the committed content_hash — withheld"
|
|
528
|
+
: "the event's own proof chain did not verify — nothing here is safe to materialize");
|
|
529
|
+
evErrors.push(...res.errors);
|
|
530
|
+
}
|
|
531
|
+
else if (!selection.offered) {
|
|
532
|
+
// Minimal disclosure: the committed hash is there, the bytes are not.
|
|
533
|
+
// Distinct from empty bytes, and never an error.
|
|
534
|
+
status = "not_disclosed";
|
|
535
|
+
}
|
|
536
|
+
else if (bytes === undefined) {
|
|
537
|
+
status = "failed";
|
|
538
|
+
evErrors.push("content was disclosed for this event but is not a decodable lowercase-hex string");
|
|
539
|
+
evErrors.push(...selection.errors);
|
|
540
|
+
}
|
|
541
|
+
else if (res.contentOk !== true) {
|
|
542
|
+
// Disclosed and decodable, but there is no commitment to check it against
|
|
543
|
+
// (withheld record, or a record committing no content_hash). Unbound
|
|
544
|
+
// bytes are exactly what a consumer must not silently write out.
|
|
545
|
+
status = "undetermined";
|
|
546
|
+
evErrors.push("disclosed bytes are not bound to any committed content_hash — withheld rather than returned");
|
|
547
|
+
evErrors.push(...res.errors);
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
status = "verified";
|
|
551
|
+
}
|
|
552
|
+
const verified = status === "verified" && bytes !== undefined;
|
|
553
|
+
if (verified) {
|
|
554
|
+
if (content.has(res.eventId)) {
|
|
555
|
+
errors.push(`duplicate event_id ${JSON.stringify(res.eventId)}: the content map keeps the first ` +
|
|
556
|
+
"occurrence — read events[] for every event");
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
content.set(res.eventId, bytes);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
events.push({
|
|
563
|
+
eventId: res.eventId,
|
|
564
|
+
status,
|
|
565
|
+
bytes: verified ? bytes : null,
|
|
566
|
+
errors: evErrors,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
return { status: verification.status, verification, events, content, errors };
|
|
570
|
+
}
|
|
571
|
+
// The anchor-trace events a bundle carries, unwrapping any evidence-export
|
|
572
|
+
// wrapper(s) to the inline source bundle — mirroring how verifyEvidenceBundle
|
|
573
|
+
// recurses into `source_bundle`, so the events line up with its results.
|
|
574
|
+
// `undefined` when there is no anchor-trace body to materialize from.
|
|
575
|
+
function resolveTraceEvents(bundle) {
|
|
576
|
+
let cur = bundle;
|
|
577
|
+
for (let depth = 0; depth < MAX_EXPORT_UNWRAP; depth++) {
|
|
578
|
+
if (cur === null || typeof cur !== "object" || Array.isArray(cur))
|
|
579
|
+
return undefined;
|
|
580
|
+
const b = cur;
|
|
581
|
+
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
582
|
+
if (bodyType === ANCHOR_TRACE_BODY_TYPE) {
|
|
583
|
+
const body = b.body;
|
|
584
|
+
if (body === null || typeof body !== "object" || !Array.isArray(body.events))
|
|
585
|
+
return undefined;
|
|
586
|
+
return body.events;
|
|
587
|
+
}
|
|
588
|
+
if (bodyType !== EXPORT_BODY_TYPE)
|
|
589
|
+
return undefined;
|
|
590
|
+
const body = b.body;
|
|
591
|
+
if (body === null || typeof body !== "object")
|
|
592
|
+
return undefined;
|
|
593
|
+
cur = body.source_bundle;
|
|
594
|
+
}
|
|
595
|
+
return undefined;
|
|
596
|
+
}
|
|
476
597
|
// ===========================================================================
|
|
477
598
|
// Attested-evidence-export verification (evidence-export.md §5, steps 3–9).
|
|
478
599
|
// Reached from verifyEvidenceBundle AFTER the wrapper (Ed25519 issuer signature
|
|
@@ -1033,6 +1154,32 @@ function getEventId(env) {
|
|
|
1033
1154
|
}
|
|
1034
1155
|
return "<unknown>";
|
|
1035
1156
|
}
|
|
1157
|
+
function selectDisclosedBytes(ev, eventId, sideContent) {
|
|
1158
|
+
const errors = [];
|
|
1159
|
+
let inBody;
|
|
1160
|
+
if (typeof ev.content === "string") {
|
|
1161
|
+
try {
|
|
1162
|
+
inBody = hexToBytes(ev.content);
|
|
1163
|
+
}
|
|
1164
|
+
catch (e) {
|
|
1165
|
+
errors.push(`event content is not hex: ${stringifyErr(e)}`);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
let side;
|
|
1169
|
+
const sideRaw = sideContent?.[eventId];
|
|
1170
|
+
if (sideRaw instanceof Uint8Array) {
|
|
1171
|
+
side = sideRaw;
|
|
1172
|
+
}
|
|
1173
|
+
else if (typeof sideRaw === "string") {
|
|
1174
|
+
try {
|
|
1175
|
+
side = hexToBytes(sideRaw);
|
|
1176
|
+
}
|
|
1177
|
+
catch (e) {
|
|
1178
|
+
errors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return { inBody, side, offered: ev.content !== undefined || sideRaw !== undefined, errors };
|
|
1182
|
+
}
|
|
1036
1183
|
function toHex(bytes) {
|
|
1037
1184
|
let out = "";
|
|
1038
1185
|
for (const x of bytes)
|
package/dist/index.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ export { ACCEPTED_SPEC_MAJORS, contentHashes, jcs, specVersionSupported, verifyE
|
|
|
2
2
|
export { bytesToHex, deriveOperatorAddress, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
|
|
3
3
|
export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyInclusion, } from "./merkle.js";
|
|
4
4
|
export type { ContentRole, Envelope, Subject, VerificationResult, VerifyOptions } from "./types.js";
|
|
5
|
-
export { ANCHOR_TRACE_BODY_TYPE, EXPORT_BODY_TYPE, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
|
|
6
|
-
export type { AnchorTraceBody, AttestationPayload, AttestationRecord, AttestationResult, CheckpointResult, ComposeVerdictResult, EvidenceBundle, EvidenceBundleResult, EvidenceIssuer, EvidenceStatus, EvidenceVerdict, EventResult, ExportBody, ExportResult, OnChainOutcome, OnChainResult, PerGatewayOutcome, TraceCheckpoint, TraceEvent, TraceInclusion, VerdictAttestation, VerdictCheckpoint, VerdictEvent, VerdictObject, VerdictOnChain, VerdictOnChainGateway, VerifyEvidenceOptions, } from "./evidence.js";
|
|
5
|
+
export { ANCHOR_TRACE_BODY_TYPE, EXPORT_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
|
|
6
|
+
export type { AnchorTraceBody, AttestationPayload, AttestationRecord, AttestationResult, CheckpointResult, ComposeVerdictResult, DisclosedContentResult, DisclosedEventContent, DisclosureStatus, EvidenceBundle, EvidenceBundleResult, EvidenceIssuer, EvidenceStatus, EvidenceVerdict, EventResult, ExportBody, ExportResult, OnChainOutcome, OnChainResult, PerGatewayOutcome, TraceCheckpoint, TraceEvent, TraceInclusion, VerdictAttestation, VerdictCheckpoint, VerdictEvent, VerdictObject, VerdictOnChain, VerdictOnChainGateway, VerifyEvidenceOptions, } from "./evidence.js";
|
|
7
7
|
export { attestationSignatureToHex, composeExport } from "./compose.js";
|
|
8
8
|
export type { ComposeExportOptions, ComposeExportResult, ExporterKey } from "./compose.js";
|
|
9
9
|
export { AGENT_PROOF_SPEC_PREFIX, isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
|
package/dist/index.js
CHANGED
|
@@ -26,7 +26,15 @@ export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyI
|
|
|
26
26
|
// issuer-composed sanning.evidence.export/v1 body (specs/evidence-export.md) to
|
|
27
27
|
// verifyExportBody (embedded RSA-PSS operator attestations + a recomputed §4
|
|
28
28
|
// verdict object with per-gateway on-chain outcomes).
|
|
29
|
-
|
|
29
|
+
//
|
|
30
|
+
// `disclosedContent` is the INGEST half of the same path (evidence-bundle
|
|
31
|
+
// §5.1). A received bundle carries its disclosed raw bytes as lowercase hex, so
|
|
32
|
+
// materializing them was, until now, every consumer re-implementing the
|
|
33
|
+
// encoding convention — and a consumer that copied the field VERBATIM wrote
|
|
34
|
+
// files that failed verification on genuine evidence. This hands back DECODED
|
|
35
|
+
// bytes, and only for events whose verification bound them to the committed
|
|
36
|
+
// content_hash. Additive; verifyEvidenceBundle's behavior is unchanged.
|
|
37
|
+
export { ANCHOR_TRACE_BODY_TYPE, EXPORT_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
|
|
30
38
|
// The `proof export` composer (specs/evidence-export.md §5, phase 2): produce a
|
|
31
39
|
// signed, offline-verifiable sanning.evidence.export/v1 artifact from a source
|
|
32
40
|
// anchor-trace bundle + operator attestations + an exporter Ed25519 key. The
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanning/proof",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "TypeScript kernel of the Sanning verification stack: RFC 8785 (JCS) canonicalization, SHA-256, and Ed25519 verification for Verifiable Event Envelopes. Full-family verifier (inline + external commitment); accepts sanning.agent/v1, sanning.mlflow/v1, sanning.events/v1. Conformance-gated byte-for-byte against the test-vectors corpus.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/cli.ts
CHANGED
|
@@ -72,8 +72,11 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
72
72
|
io.out("@sanning/proof verify CLI");
|
|
73
73
|
return EXIT_VERIFIED;
|
|
74
74
|
}
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
// `bundle` is the customer-facing verb (proof#19: one artifact, one word).
|
|
76
|
+
// `export` is kept working as a QUIET alias — it shipped and is published, so
|
|
77
|
+
// it is not broken abruptly; it is simply no longer taught anywhere.
|
|
78
|
+
if (command === "bundle" || command === "export") {
|
|
79
|
+
return runBundle(rest, io);
|
|
77
80
|
}
|
|
78
81
|
if (command !== "verify") {
|
|
79
82
|
io.err(`unknown command: ${command}`);
|
|
@@ -184,7 +187,8 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
184
187
|
return reportEvidence(result, bundlePath, gateways, io);
|
|
185
188
|
}
|
|
186
189
|
|
|
187
|
-
// `proof
|
|
190
|
+
// `proof bundle <source-bundle.json> --attestations <f> --key <f> [-o <out>]`
|
|
191
|
+
// (`proof export` still reaches this, undocumented — see the dispatch above.)
|
|
188
192
|
//
|
|
189
193
|
// Compose a signed, offline-verifiable sanning.evidence.export/v1 artifact from a
|
|
190
194
|
// source sanning.anchor.trace/v1 bundle + operator attestation records + an exporter
|
|
@@ -195,7 +199,7 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
195
199
|
// forged/mis-bound attestation — the composer refuses to emit a green artifact
|
|
196
200
|
// over failing evidence) · 2 usage / malformed input (missing flag, unreadable
|
|
197
201
|
// or non-JSON input, bad exporter key, malformed attestation record).
|
|
198
|
-
async function
|
|
202
|
+
async function runBundle(rest: string[], io: Cli): Promise<number> {
|
|
199
203
|
let attestationsPath: string | undefined;
|
|
200
204
|
let keyPath: string | undefined;
|
|
201
205
|
let outPath: string | undefined;
|
|
@@ -306,7 +310,7 @@ async function runExport(rest: string[], io: Cli): Promise<number> {
|
|
|
306
310
|
|
|
307
311
|
function usageExport(io: Cli): number {
|
|
308
312
|
io.out(
|
|
309
|
-
"Usage: npx @sanning/proof
|
|
313
|
+
"Usage: npx @sanning/proof bundle <source-bundle.json> --attestations <file> --key <file> [-o <out.json>]",
|
|
310
314
|
);
|
|
311
315
|
io.out("");
|
|
312
316
|
io.out("Compose a signed, offline-verifiable sanning.evidence.export/v1 artifact from an");
|
|
@@ -399,8 +403,8 @@ function printUsage(io: Cli): void {
|
|
|
399
403
|
io.out("Usage: npx @sanning/proof <command> [args]");
|
|
400
404
|
io.out("");
|
|
401
405
|
io.out("Commands:");
|
|
402
|
-
io.out(" verify <bundle.json> [gateways] [--logs <logs.json>] Verify a bundle
|
|
403
|
-
io.out("
|
|
406
|
+
io.out(" verify <bundle.json> [gateways] [--logs <logs.json>] Verify a bundle");
|
|
407
|
+
io.out(" bundle <source.json> --attestations <f> --key <f> [-o] Create an attested bundle");
|
|
404
408
|
io.out("");
|
|
405
409
|
io.out("Verify a sanning.evidence/v1 (sanning.anchor.trace/v1) bundle, an");
|
|
406
410
|
io.out("sanning.evidence.export/v1 attested export, or a sanning.agent.proof/v1");
|
package/src/evidence.ts
CHANGED
|
@@ -699,26 +699,12 @@ async function verifyAnchorTrace(
|
|
|
699
699
|
}
|
|
700
700
|
|
|
701
701
|
// Disclosed bytes, precedence: in-body (signed) → side input. A string
|
|
702
|
-
// side input is hex; a Uint8Array is used as-is.
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
evErrors.push(`event content is not hex: ${stringifyErr(e)}`);
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
let side: Uint8Array | undefined;
|
|
712
|
-
const sideRaw = options.content?.[eventId];
|
|
713
|
-
if (sideRaw instanceof Uint8Array) {
|
|
714
|
-
side = sideRaw;
|
|
715
|
-
} else if (typeof sideRaw === "string") {
|
|
716
|
-
try {
|
|
717
|
-
side = hexToBytes(sideRaw);
|
|
718
|
-
} catch (e) {
|
|
719
|
-
evErrors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
|
|
720
|
-
}
|
|
721
|
-
}
|
|
702
|
+
// side input is hex; a Uint8Array is used as-is. Resolved by the SHARED
|
|
703
|
+
// selector so `disclosedContent` hands a consumer the very bytes bound
|
|
704
|
+
// here — one decode, not two that can drift apart.
|
|
705
|
+
const selection = selectDisclosedBytes(ev, eventId, options.content);
|
|
706
|
+
const { inBody, side } = selection;
|
|
707
|
+
evErrors.push(...selection.errors);
|
|
722
708
|
|
|
723
709
|
if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
|
|
724
710
|
// Two disclosures that disagree: a genuine conflict, not undetermined.
|
|
@@ -832,6 +818,214 @@ async function verifyAnchorTrace(
|
|
|
832
818
|
};
|
|
833
819
|
}
|
|
834
820
|
|
|
821
|
+
// ===========================================================================
|
|
822
|
+
// Ingest: materializing the disclosed raw bytes (evidence-bundle.md §5.1).
|
|
823
|
+
//
|
|
824
|
+
// The receiving half of a handoff. `events[].content` rides as lowercase hex
|
|
825
|
+
// (JSON cannot carry arbitrary bytes), and until now every consumer that wanted
|
|
826
|
+
// the actual log bytes back had to know that and decode them itself —
|
|
827
|
+
// re-implementing an encoding convention the kernel already applies internally.
|
|
828
|
+
// A consumer that copied `content` VERBATIM into a file shipped packs that
|
|
829
|
+
// failed verification on genuine, untampered evidence, because the hash of the
|
|
830
|
+
// hex text is not the hash of the bytes it encodes.
|
|
831
|
+
//
|
|
832
|
+
// `disclosedContent` closes that gap with one rule: bytes come back only when
|
|
833
|
+
// they are BOUND. It verifies the bundle first and hands back a decoded
|
|
834
|
+
// Uint8Array only for events whose whole chain held AND whose disclosed bytes
|
|
835
|
+
// hash to the committed `content_hash`. Everything else comes back as a status
|
|
836
|
+
// with a reason and NO bytes — in particular a minimal-disclosure event reports
|
|
837
|
+
// `not_disclosed` rather than empty bytes, because a caller that cannot tell
|
|
838
|
+
// those apart writes an empty log file and believes it succeeded.
|
|
839
|
+
// ===========================================================================
|
|
840
|
+
|
|
841
|
+
// Per-event outcome of an ingest attempt. Only `verified` carries bytes.
|
|
842
|
+
export type DisclosureStatus =
|
|
843
|
+
| "verified" // bytes decoded AND bound to the committed content_hash
|
|
844
|
+
| "not_disclosed" // nothing disclosed for this event — minimal disclosure, nothing to write
|
|
845
|
+
| "undetermined" // bytes disclosed but nothing to bind them to (record withheld / no commitment)
|
|
846
|
+
| "failed"; // the event's chain did not verify, or the disclosure did not bind/decode
|
|
847
|
+
|
|
848
|
+
export interface DisclosedEventContent {
|
|
849
|
+
eventId: string;
|
|
850
|
+
status: DisclosureStatus;
|
|
851
|
+
// The decoded raw bytes. Non-null ONLY when status === "verified" — an
|
|
852
|
+
// unbound or unverified disclosure is withheld, never handed back.
|
|
853
|
+
bytes: Uint8Array | null;
|
|
854
|
+
// Why, whenever bytes are absent for a reason other than plain non-disclosure.
|
|
855
|
+
errors: string[];
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
export interface DisclosedContentResult {
|
|
859
|
+
// The bundle-level rollup, verbatim from verifyEvidenceBundle.
|
|
860
|
+
status: EvidenceStatus;
|
|
861
|
+
// The full verification result, so a caller verifies ONCE: render the verdict
|
|
862
|
+
// from this and materialize from `content` in the same pass.
|
|
863
|
+
verification: EvidenceBundleResult;
|
|
864
|
+
// One entry per event in the bundle body, in body order.
|
|
865
|
+
events: DisclosedEventContent[];
|
|
866
|
+
// event_id → verified raw bytes: the materialization map. Contains ONLY
|
|
867
|
+
// entries whose status is "verified", so `for (const [id, bytes] of
|
|
868
|
+
// result.content)` is always safe to write straight to disk.
|
|
869
|
+
content: Map<string, Uint8Array>;
|
|
870
|
+
// Bundle-level reasons no content could be materialized at all.
|
|
871
|
+
errors: string[];
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// How many `sanning.evidence.export/v1` wrappers deep the source bundle may be
|
|
875
|
+
// nested before this path gives up. verifyEvidenceBundle's canonicalization
|
|
876
|
+
// depth bound already rejects a pathological bundle, but the unwrap must be
|
|
877
|
+
// independently bounded rather than trusting that it ran first.
|
|
878
|
+
const MAX_EXPORT_UNWRAP = 4;
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Materialize the disclosed raw bytes of a received bundle (evidence-bundle §5.1).
|
|
882
|
+
*
|
|
883
|
+
* Verify-then-return: the bundle is verified with {@link verifyEvidenceBundle}
|
|
884
|
+
* (same options — gateways, out-of-band `content`, `fetchImpl`), and an event's
|
|
885
|
+
* bytes are returned ONLY when that verification bound them to the committed
|
|
886
|
+
* `content_hash`. There is no path through this function that yields bytes
|
|
887
|
+
* whose SHA-256 is not the commitment.
|
|
888
|
+
*
|
|
889
|
+
* Returns `Uint8Array`, never a string: the spec commits to raw bytes, and a
|
|
890
|
+
* UTF-8 decode is lossy for any payload that is not valid UTF-8 — re-encoding
|
|
891
|
+
* such a string would produce different bytes and a different hash. Callers who
|
|
892
|
+
* know their payload is text decode it themselves and own that assumption.
|
|
893
|
+
*/
|
|
894
|
+
export async function disclosedContent(
|
|
895
|
+
bundle: unknown,
|
|
896
|
+
options: VerifyEvidenceOptions = {},
|
|
897
|
+
): Promise<DisclosedContentResult> {
|
|
898
|
+
const verification = await verifyEvidenceBundle(bundle, options);
|
|
899
|
+
const events: DisclosedEventContent[] = [];
|
|
900
|
+
const content = new Map<string, Uint8Array>();
|
|
901
|
+
const errors: string[] = [];
|
|
902
|
+
const empty = (): DisclosedContentResult => ({
|
|
903
|
+
status: verification.status,
|
|
904
|
+
verification,
|
|
905
|
+
events: [],
|
|
906
|
+
content: new Map(),
|
|
907
|
+
errors: [...errors, ...verification.errors],
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
const traceEvents = resolveTraceEvents(bundle);
|
|
911
|
+
if (traceEvents === undefined) {
|
|
912
|
+
errors.push(
|
|
913
|
+
`no ${ANCHOR_TRACE_BODY_TYPE} body to materialize content from ` +
|
|
914
|
+
`(body_type ${JSON.stringify(verification.bodyType)})`,
|
|
915
|
+
);
|
|
916
|
+
return empty();
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Fail closed on any drift between the body we are decoding and the results
|
|
920
|
+
// we are trusting. Both must be the same events in the same order, or the
|
|
921
|
+
// "these bytes are the bytes that verified" guarantee is void — which is the
|
|
922
|
+
// only guarantee this function makes. A broken wrapper lands here too:
|
|
923
|
+
// verifyEvidenceBundle returns zero event results, so nothing is emitted.
|
|
924
|
+
if (traceEvents.length !== verification.events.length) {
|
|
925
|
+
errors.push(
|
|
926
|
+
"the bundle did not verify far enough to materialize content: " +
|
|
927
|
+
`${verification.events.length} verified event result(s) for ${traceEvents.length} body event(s)`,
|
|
928
|
+
);
|
|
929
|
+
return empty();
|
|
930
|
+
}
|
|
931
|
+
for (let i = 0; i < traceEvents.length; i++) {
|
|
932
|
+
const te = traceEvents[i];
|
|
933
|
+
const envelope = te !== null && typeof te === "object" ? te.envelope : undefined;
|
|
934
|
+
if (getEventId(envelope) !== verification.events[i]!.eventId) {
|
|
935
|
+
errors.push(
|
|
936
|
+
`event alignment drift at index ${i}: the body event and its verification result ` +
|
|
937
|
+
"are not the same event; refusing to hand back any content",
|
|
938
|
+
);
|
|
939
|
+
return empty();
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
for (let i = 0; i < traceEvents.length; i++) {
|
|
944
|
+
const ev = traceEvents[i] as TraceEvent;
|
|
945
|
+
const res = verification.events[i]!;
|
|
946
|
+
const selection = selectDisclosedBytes(ev, res.eventId, options.content);
|
|
947
|
+
const bytes = selection.inBody ?? selection.side;
|
|
948
|
+
const evErrors: string[] = [];
|
|
949
|
+
let status: DisclosureStatus;
|
|
950
|
+
|
|
951
|
+
if (!res.ok || res.contentOk === false) {
|
|
952
|
+
// The event itself is not trustworthy (bad signature, broken inclusion,
|
|
953
|
+
// unbound checkpoint) or its disclosure contradicts the commitment.
|
|
954
|
+
status = "failed";
|
|
955
|
+
evErrors.push(
|
|
956
|
+
res.contentOk === false
|
|
957
|
+
? "disclosed content does not bind to the committed content_hash — withheld"
|
|
958
|
+
: "the event's own proof chain did not verify — nothing here is safe to materialize",
|
|
959
|
+
);
|
|
960
|
+
evErrors.push(...res.errors);
|
|
961
|
+
} else if (!selection.offered) {
|
|
962
|
+
// Minimal disclosure: the committed hash is there, the bytes are not.
|
|
963
|
+
// Distinct from empty bytes, and never an error.
|
|
964
|
+
status = "not_disclosed";
|
|
965
|
+
} else if (bytes === undefined) {
|
|
966
|
+
status = "failed";
|
|
967
|
+
evErrors.push(
|
|
968
|
+
"content was disclosed for this event but is not a decodable lowercase-hex string",
|
|
969
|
+
);
|
|
970
|
+
evErrors.push(...selection.errors);
|
|
971
|
+
} else if (res.contentOk !== true) {
|
|
972
|
+
// Disclosed and decodable, but there is no commitment to check it against
|
|
973
|
+
// (withheld record, or a record committing no content_hash). Unbound
|
|
974
|
+
// bytes are exactly what a consumer must not silently write out.
|
|
975
|
+
status = "undetermined";
|
|
976
|
+
evErrors.push(
|
|
977
|
+
"disclosed bytes are not bound to any committed content_hash — withheld rather than returned",
|
|
978
|
+
);
|
|
979
|
+
evErrors.push(...res.errors);
|
|
980
|
+
} else {
|
|
981
|
+
status = "verified";
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const verified = status === "verified" && bytes !== undefined;
|
|
985
|
+
if (verified) {
|
|
986
|
+
if (content.has(res.eventId)) {
|
|
987
|
+
errors.push(
|
|
988
|
+
`duplicate event_id ${JSON.stringify(res.eventId)}: the content map keeps the first ` +
|
|
989
|
+
"occurrence — read events[] for every event",
|
|
990
|
+
);
|
|
991
|
+
} else {
|
|
992
|
+
content.set(res.eventId, bytes);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
events.push({
|
|
996
|
+
eventId: res.eventId,
|
|
997
|
+
status,
|
|
998
|
+
bytes: verified ? bytes : null,
|
|
999
|
+
errors: evErrors,
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
return { status: verification.status, verification, events, content, errors };
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// The anchor-trace events a bundle carries, unwrapping any evidence-export
|
|
1007
|
+
// wrapper(s) to the inline source bundle — mirroring how verifyEvidenceBundle
|
|
1008
|
+
// recurses into `source_bundle`, so the events line up with its results.
|
|
1009
|
+
// `undefined` when there is no anchor-trace body to materialize from.
|
|
1010
|
+
function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
|
|
1011
|
+
let cur: unknown = bundle;
|
|
1012
|
+
for (let depth = 0; depth < MAX_EXPORT_UNWRAP; depth++) {
|
|
1013
|
+
if (cur === null || typeof cur !== "object" || Array.isArray(cur)) return undefined;
|
|
1014
|
+
const b = cur as EvidenceBundle;
|
|
1015
|
+
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
1016
|
+
if (bodyType === ANCHOR_TRACE_BODY_TYPE) {
|
|
1017
|
+
const body = b.body as AnchorTraceBody | null | undefined;
|
|
1018
|
+
if (body === null || typeof body !== "object" || !Array.isArray(body.events)) return undefined;
|
|
1019
|
+
return body.events;
|
|
1020
|
+
}
|
|
1021
|
+
if (bodyType !== EXPORT_BODY_TYPE) return undefined;
|
|
1022
|
+
const body = b.body as ExportBody | null | undefined;
|
|
1023
|
+
if (body === null || typeof body !== "object") return undefined;
|
|
1024
|
+
cur = body.source_bundle;
|
|
1025
|
+
}
|
|
1026
|
+
return undefined;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
835
1029
|
// ===========================================================================
|
|
836
1030
|
// Attested-evidence-export verification (evidence-export.md §5, steps 3–9).
|
|
837
1031
|
// Reached from verifyEvidenceBundle AFTER the wrapper (Ed25519 issuer signature
|
|
@@ -1478,6 +1672,53 @@ function getEventId(env: unknown): string {
|
|
|
1478
1672
|
return "<unknown>";
|
|
1479
1673
|
}
|
|
1480
1674
|
|
|
1675
|
+
// The disclosed bytes offered for ONE trace event (evidence-bundle §5.1),
|
|
1676
|
+
// decoded once. Shared by the verifier's content check and the `disclosedContent`
|
|
1677
|
+
// ingest API: the bytes a consumer receives are then, by construction, the bytes
|
|
1678
|
+
// the verifier hashed against the commitment rather than a second decode of the
|
|
1679
|
+
// same field that could drift from it.
|
|
1680
|
+
interface DisclosedSelection {
|
|
1681
|
+
// In-body `events[].content` (the signed disclosure), when it decoded.
|
|
1682
|
+
inBody?: Uint8Array;
|
|
1683
|
+
// Out-of-band `options.content[eventId]`, when it decoded.
|
|
1684
|
+
side?: Uint8Array;
|
|
1685
|
+
// A disclosure was OFFERED for this event — in-body or side input — whether or
|
|
1686
|
+
// not it decoded. This is what separates "minimal disclosure, nothing to
|
|
1687
|
+
// materialize" from "disclosed, but unusable"; conflating those is how a
|
|
1688
|
+
// consumer writes an empty log file and believes it succeeded.
|
|
1689
|
+
offered: boolean;
|
|
1690
|
+
// Decode failures, in the verifier's existing wording.
|
|
1691
|
+
errors: string[];
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
function selectDisclosedBytes(
|
|
1695
|
+
ev: TraceEvent,
|
|
1696
|
+
eventId: string,
|
|
1697
|
+
sideContent: VerifyEvidenceOptions["content"],
|
|
1698
|
+
): DisclosedSelection {
|
|
1699
|
+
const errors: string[] = [];
|
|
1700
|
+
let inBody: Uint8Array | undefined;
|
|
1701
|
+
if (typeof ev.content === "string") {
|
|
1702
|
+
try {
|
|
1703
|
+
inBody = hexToBytes(ev.content);
|
|
1704
|
+
} catch (e) {
|
|
1705
|
+
errors.push(`event content is not hex: ${stringifyErr(e)}`);
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
let side: Uint8Array | undefined;
|
|
1709
|
+
const sideRaw = sideContent?.[eventId];
|
|
1710
|
+
if (sideRaw instanceof Uint8Array) {
|
|
1711
|
+
side = sideRaw;
|
|
1712
|
+
} else if (typeof sideRaw === "string") {
|
|
1713
|
+
try {
|
|
1714
|
+
side = hexToBytes(sideRaw);
|
|
1715
|
+
} catch (e) {
|
|
1716
|
+
errors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
return { inBody, side, offered: ev.content !== undefined || sideRaw !== undefined, errors };
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1481
1722
|
function toHex(bytes: Uint8Array): string {
|
|
1482
1723
|
let out = "";
|
|
1483
1724
|
for (const x of bytes) out += x.toString(16).padStart(2, "0");
|
package/src/index.ts
CHANGED
|
@@ -51,9 +51,18 @@ export type { ContentRole, Envelope, Subject, VerificationResult, VerifyOptions
|
|
|
51
51
|
// issuer-composed sanning.evidence.export/v1 body (specs/evidence-export.md) to
|
|
52
52
|
// verifyExportBody (embedded RSA-PSS operator attestations + a recomputed §4
|
|
53
53
|
// verdict object with per-gateway on-chain outcomes).
|
|
54
|
+
//
|
|
55
|
+
// `disclosedContent` is the INGEST half of the same path (evidence-bundle
|
|
56
|
+
// §5.1). A received bundle carries its disclosed raw bytes as lowercase hex, so
|
|
57
|
+
// materializing them was, until now, every consumer re-implementing the
|
|
58
|
+
// encoding convention — and a consumer that copied the field VERBATIM wrote
|
|
59
|
+
// files that failed verification on genuine evidence. This hands back DECODED
|
|
60
|
+
// bytes, and only for events whose verification bound them to the committed
|
|
61
|
+
// content_hash. Additive; verifyEvidenceBundle's behavior is unchanged.
|
|
54
62
|
export {
|
|
55
63
|
ANCHOR_TRACE_BODY_TYPE,
|
|
56
64
|
EXPORT_BODY_TYPE,
|
|
65
|
+
disclosedContent,
|
|
57
66
|
recomputeExportVerdict,
|
|
58
67
|
verifyEvidenceBundle,
|
|
59
68
|
} from "./evidence.js";
|
|
@@ -64,6 +73,9 @@ export type {
|
|
|
64
73
|
AttestationResult,
|
|
65
74
|
CheckpointResult,
|
|
66
75
|
ComposeVerdictResult,
|
|
76
|
+
DisclosedContentResult,
|
|
77
|
+
DisclosedEventContent,
|
|
78
|
+
DisclosureStatus,
|
|
67
79
|
EvidenceBundle,
|
|
68
80
|
EvidenceBundleResult,
|
|
69
81
|
EvidenceIssuer,
|