@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 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
- if (command === "export") {
61
- return runExport(rest, io);
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 export <source-bundle.json> --attestations <f> --key <f> [-o <out>]`
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 runExport(rest, io) {
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 export <source-bundle.json> --attestations <file> --key <file> [-o <out.json>]");
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/export");
394
- io.out(" export <source.json> --attestations <f> --key <f> [-o] Compose an attested export");
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");
@@ -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
- let inBody;
346
- if (typeof ev.content === "string") {
347
- try {
348
- inBody = hexToBytes(ev.content);
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
- export { ANCHOR_TRACE_BODY_TYPE, EXPORT_BODY_TYPE, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
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.4.1",
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
- if (command === "export") {
76
- return runExport(rest, io);
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 export <source-bundle.json> --attestations <f> --key <f> [-o <out>]`
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 runExport(rest: string[], io: Cli): Promise<number> {
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 export <source-bundle.json> --attestations <file> --key <file> [-o <out.json>]",
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/export");
403
- io.out(" export <source.json> --attestations <f> --key <f> [-o] Compose an attested export");
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
- let inBody: Uint8Array | undefined;
704
- if (typeof ev.content === "string") {
705
- try {
706
- inBody = hexToBytes(ev.content);
707
- } catch (e) {
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,