@sanning/proof 0.6.0 → 0.7.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 +1 -1
- package/dist/chain.d.ts +99 -0
- package/dist/chain.js +351 -0
- package/dist/cli.js +42 -2
- package/dist/evidence.d.ts +127 -0
- package/dist/evidence.js +337 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +22 -1
- package/dist/verifier.js +46 -7
- package/package.json +1 -1
- package/src/chain.ts +460 -0
- package/src/cli.ts +50 -1
- package/src/evidence.ts +481 -1
- package/src/index.ts +42 -0
- package/src/verifier.ts +49 -7
- package/vectors/chain-reading.json +293 -0
package/dist/evidence.d.ts
CHANGED
|
@@ -12,6 +12,31 @@ export declare const ANCHOR_TRACE_BODY_TYPE_V1 = "sanning.anchor.trace/v1";
|
|
|
12
12
|
/** Every anchor-trace body this kernel verifies. Order is newest-first. */
|
|
13
13
|
export declare const ANCHOR_TRACE_BODY_TYPES: readonly ["sanning.anchor.trace/v2", "sanning.anchor.trace/v1"];
|
|
14
14
|
export declare const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
|
|
15
|
+
/**
|
|
16
|
+
* The Sanning-signed interval stamp body (evidence-bundle.md §5.2, DL-58).
|
|
17
|
+
*
|
|
18
|
+
* Its own `body_type` rather than a widened anchor-trace body, and the reason
|
|
19
|
+
* is the whole decision: an anchor-trace checkpoint carries a PRODUCER-signed
|
|
20
|
+
* `sanning.events/v1` envelope, and a stamp has no producer signature at all —
|
|
21
|
+
* Sanning places it, under DL-52, for a customer who holds no chain identity.
|
|
22
|
+
* The two rejected options were making `envelope` optional and synthesising an
|
|
23
|
+
* envelope that Sanning signs; the second would have made a Sanning
|
|
24
|
+
* attestation indistinguishable ON THE WIRE from a producer's, which is
|
|
25
|
+
* unrecoverable once witnessed.
|
|
26
|
+
*/
|
|
27
|
+
export declare const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
|
|
28
|
+
/**
|
|
29
|
+
* The version string INSIDE a witnessed stamp body — a different string from
|
|
30
|
+
* the `body_type` above, and the two are easy to confuse.
|
|
31
|
+
*
|
|
32
|
+
* `sanning.stamp.trace/v1` discriminates the BUNDLE BODY: it is the reader's
|
|
33
|
+
* dispatch key. `sanning.stamp/v1` is the control plane's own version of the
|
|
34
|
+
* bytes it hands a witness (`control-plane/src/stamp-tree.ts`,
|
|
35
|
+
* `STAMP_BODY_VERSION`). A `sanning.stamp.trace/v1` body pins the inner
|
|
36
|
+
* version to exactly this value: two answers to one question inside one
|
|
37
|
+
* signature is the §5.1.2 mixed-vocabulary reject, one layer down.
|
|
38
|
+
*/
|
|
39
|
+
export declare const STAMP_BODY_VERSION = "sanning.stamp/v1";
|
|
15
40
|
export interface EvidenceIssuer {
|
|
16
41
|
kind: string;
|
|
17
42
|
tenant_id?: string;
|
|
@@ -85,6 +110,58 @@ export interface AnchorTraceBody {
|
|
|
85
110
|
checkpoints: TraceCheckpoint[];
|
|
86
111
|
events: TraceEvent[];
|
|
87
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* One sub-tree of a stamp, as the WITNESSED bytes carry it.
|
|
115
|
+
*
|
|
116
|
+
* Keyed in the body by a blinded reference (an HMAC under a per-org secret we
|
|
117
|
+
* never publish), so a reader learns that the org runs N agents and their
|
|
118
|
+
* relative volumes and learns no name. The RANGE is deliberately in the clear:
|
|
119
|
+
* it is what makes a later per-agent slice claim checkable by a third party
|
|
120
|
+
* without Sanning, and hiding it would put us back in the trust path.
|
|
121
|
+
*/
|
|
122
|
+
export interface StampSubtree {
|
|
123
|
+
first_leaf: number;
|
|
124
|
+
leaf_count: number;
|
|
125
|
+
/** Hex RFC-9162 root over this sub-tree's leaves alone. */
|
|
126
|
+
root: string;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The stamp body as the control plane witnesses it
|
|
130
|
+
* (`control-plane/src/stamp-tree.ts`, `StampBody`).
|
|
131
|
+
*
|
|
132
|
+
* Never read from the bundle directly — it is parsed out of `stamp_bytes`,
|
|
133
|
+
* which is the only authority. Present as a type so the parse has a shape.
|
|
134
|
+
*/
|
|
135
|
+
export interface StampBody {
|
|
136
|
+
stamp_version: string;
|
|
137
|
+
/** The org, BLINDED. Not an org id, and not reversible without the secret. */
|
|
138
|
+
org_ref: string;
|
|
139
|
+
interval_start: string;
|
|
140
|
+
interval_end: string;
|
|
141
|
+
merkle_root: string;
|
|
142
|
+
leaf_count: number;
|
|
143
|
+
subtrees: Record<string, StampSubtree>;
|
|
144
|
+
}
|
|
145
|
+
/** One entry of a `sanning.stamp.trace/v1` body's `stamps[]` (§5.2). */
|
|
146
|
+
export interface StampEntry {
|
|
147
|
+
/**
|
|
148
|
+
* The stamp's identity within this bundle, DERIVED: hex SHA-256 of the
|
|
149
|
+
* DECODED `stamp_bytes`, not of the hex text. A verifier MUST recompute it.
|
|
150
|
+
*/
|
|
151
|
+
stamp_ref: string;
|
|
152
|
+
/** Lowercase hex of the exact JCS bytes that were handed to the witness. */
|
|
153
|
+
stamp_bytes: string;
|
|
154
|
+
/**
|
|
155
|
+
* Where the stamp was witnessed, IF IT HAS BEEN (§5.1.1). Absent is honest:
|
|
156
|
+
* a sealed-but-unconfirmed stamp is witnessed by nobody, and an emitter that
|
|
157
|
+
* filled this in from a submission receipt would be claiming a chain
|
|
158
|
+
* position that does not exist yet.
|
|
159
|
+
*/
|
|
160
|
+
anchor_ref?: AnchorRef;
|
|
161
|
+
}
|
|
162
|
+
export interface StampTraceBody {
|
|
163
|
+
stamps: StampEntry[];
|
|
164
|
+
}
|
|
88
165
|
export interface EvidenceBundle {
|
|
89
166
|
spec_version: string;
|
|
90
167
|
body_type: string;
|
|
@@ -149,6 +226,54 @@ export interface EventResult {
|
|
|
149
226
|
ok: boolean;
|
|
150
227
|
errors: string[];
|
|
151
228
|
}
|
|
229
|
+
/** One sub-tree's recomputed result (§5.2). */
|
|
230
|
+
export interface StampSubtreeResult {
|
|
231
|
+
/** The blinded key the body spells it under. Meaningless without the org's secret. */
|
|
232
|
+
ref: string;
|
|
233
|
+
firstLeaf: number;
|
|
234
|
+
leafCount: number;
|
|
235
|
+
root: string;
|
|
236
|
+
}
|
|
237
|
+
/** One `stamps[]` entry's result (§5.2). */
|
|
238
|
+
export interface StampResult {
|
|
239
|
+
/** The stamp's derived identity, as the bundle spells it. "" when unusable. */
|
|
240
|
+
stampRef: string;
|
|
241
|
+
/** The asserted `stamp_ref` equals SHA-256 of the decoded `stamp_bytes`. */
|
|
242
|
+
stampRefOk: boolean;
|
|
243
|
+
/**
|
|
244
|
+
* The witnessed bytes decoded, parsed, and re-canonicalized to themselves.
|
|
245
|
+
*
|
|
246
|
+
* The last clause is not pedantry. The plane stores these bytes as TEXT and
|
|
247
|
+
* replays them verbatim precisely because a round-trip through a normalizing
|
|
248
|
+
* serializer is not guaranteed to reproduce what was signed; a body that is
|
|
249
|
+
* not JCS-canonical is one that every re-deriving reader hashes differently.
|
|
250
|
+
*/
|
|
251
|
+
bodyOk: boolean;
|
|
252
|
+
stampVersion: string | null;
|
|
253
|
+
intervalStart: string | null;
|
|
254
|
+
intervalEnd: string | null;
|
|
255
|
+
merkleRoot: string | null;
|
|
256
|
+
leafCount: number | null;
|
|
257
|
+
/**
|
|
258
|
+
* Every sub-tree range accounts for exactly one leaf of the stamp: the
|
|
259
|
+
* ranges sorted by `first_leaf` tile `[0, leaf_count)` with no gap and no
|
|
260
|
+
* overlap, and their counts sum to `leaf_count`.
|
|
261
|
+
*/
|
|
262
|
+
subtreesOk: boolean;
|
|
263
|
+
subtrees: StampSubtreeResult[];
|
|
264
|
+
/**
|
|
265
|
+
* Where it was witnessed, or `null` for a stamp nothing has witnessed yet.
|
|
266
|
+
*
|
|
267
|
+
* `null` is NOT a failure and never an accusation — it is the honest state
|
|
268
|
+
* of every stamp for the first minutes of its life and the permanent state
|
|
269
|
+
* of every stamp when no witness is configured. It does hold the bundle at
|
|
270
|
+
* `partial`: a stamp is Sanning's own assertion until something independent
|
|
271
|
+
* carries it.
|
|
272
|
+
*/
|
|
273
|
+
anchorRef: AnchorRef | null;
|
|
274
|
+
ok: boolean;
|
|
275
|
+
errors: string[];
|
|
276
|
+
}
|
|
152
277
|
export interface EvidenceBundleResult {
|
|
153
278
|
status: EvidenceStatus;
|
|
154
279
|
specVersionOk: boolean;
|
|
@@ -160,6 +285,7 @@ export interface EvidenceBundleResult {
|
|
|
160
285
|
events: EventResult[];
|
|
161
286
|
assertedStatus: string | null;
|
|
162
287
|
export?: ExportResult;
|
|
288
|
+
stamps?: StampResult[];
|
|
163
289
|
errors: string[];
|
|
164
290
|
}
|
|
165
291
|
export interface AttestationRecord {
|
|
@@ -304,6 +430,7 @@ export interface DisclosedContentResult {
|
|
|
304
430
|
* know their payload is text decode it themselves and own that assumption.
|
|
305
431
|
*/
|
|
306
432
|
export declare function disclosedContent(bundle: unknown, options?: VerifyEvidenceOptions): Promise<DisclosedContentResult>;
|
|
433
|
+
export declare function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined;
|
|
307
434
|
export interface ComposeVerdictResult {
|
|
308
435
|
verdict: VerdictObject;
|
|
309
436
|
sourceStatus: EvidenceStatus;
|
package/dist/evidence.js
CHANGED
|
@@ -48,6 +48,31 @@ export const ANCHOR_TRACE_BODY_TYPES = [
|
|
|
48
48
|
// operator attestations. verifyEvidenceBundle dispatches on it to
|
|
49
49
|
// verifyExportBody (§5).
|
|
50
50
|
export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
|
|
51
|
+
/**
|
|
52
|
+
* The Sanning-signed interval stamp body (evidence-bundle.md §5.2, DL-58).
|
|
53
|
+
*
|
|
54
|
+
* Its own `body_type` rather than a widened anchor-trace body, and the reason
|
|
55
|
+
* is the whole decision: an anchor-trace checkpoint carries a PRODUCER-signed
|
|
56
|
+
* `sanning.events/v1` envelope, and a stamp has no producer signature at all —
|
|
57
|
+
* Sanning places it, under DL-52, for a customer who holds no chain identity.
|
|
58
|
+
* The two rejected options were making `envelope` optional and synthesising an
|
|
59
|
+
* envelope that Sanning signs; the second would have made a Sanning
|
|
60
|
+
* attestation indistinguishable ON THE WIRE from a producer's, which is
|
|
61
|
+
* unrecoverable once witnessed.
|
|
62
|
+
*/
|
|
63
|
+
export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
|
|
64
|
+
/**
|
|
65
|
+
* The version string INSIDE a witnessed stamp body — a different string from
|
|
66
|
+
* the `body_type` above, and the two are easy to confuse.
|
|
67
|
+
*
|
|
68
|
+
* `sanning.stamp.trace/v1` discriminates the BUNDLE BODY: it is the reader's
|
|
69
|
+
* dispatch key. `sanning.stamp/v1` is the control plane's own version of the
|
|
70
|
+
* bytes it hands a witness (`control-plane/src/stamp-tree.ts`,
|
|
71
|
+
* `STAMP_BODY_VERSION`). A `sanning.stamp.trace/v1` body pins the inner
|
|
72
|
+
* version to exactly this value: two answers to one question inside one
|
|
73
|
+
* signature is the §5.1.2 mixed-vocabulary reject, one layer down.
|
|
74
|
+
*/
|
|
75
|
+
export const STAMP_BODY_VERSION = "sanning.stamp/v1";
|
|
51
76
|
function isEvidenceMajorAccepted(specVersion) {
|
|
52
77
|
if (typeof specVersion !== "string" || specVersion === "")
|
|
53
78
|
return false;
|
|
@@ -164,6 +189,12 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
|
|
|
164
189
|
if (bodyType === EXPORT_BODY_TYPE) {
|
|
165
190
|
return verifyExportBody(b, specVersionOk, assertedStatus, errors, options);
|
|
166
191
|
}
|
|
192
|
+
// The Sanning-signed interval stamp body (evidence-bundle.md §5.2). The
|
|
193
|
+
// wrapper is authentic — and for this body that IS "Sanning signed it", since
|
|
194
|
+
// a stamp carries no second signature of its own inside the bundle.
|
|
195
|
+
if (bodyType === STAMP_TRACE_BODY_TYPE) {
|
|
196
|
+
return verifyStampTrace(b, specVersionOk, assertedStatus, errors);
|
|
197
|
+
}
|
|
167
198
|
if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
|
|
168
199
|
// The wrapper is authentic but we don't know this body's structure. Per
|
|
169
200
|
// wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
|
|
@@ -663,6 +694,305 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
663
694
|
errors,
|
|
664
695
|
};
|
|
665
696
|
}
|
|
697
|
+
// ===========================================================================
|
|
698
|
+
// `sanning.stamp.trace/v1` — the Sanning-signed interval stamp (§5.2, DL-58)
|
|
699
|
+
//
|
|
700
|
+
// ── What this can prove, and what it deliberately does not claim ──
|
|
701
|
+
//
|
|
702
|
+
// A stamp is not a producer artifact. Under DL-52 the customer signs an
|
|
703
|
+
// envelope and POSTs it; the commitment is witnessed later, on an interval, by
|
|
704
|
+
// a stamp the customer never sees and holds no key for. So there is no
|
|
705
|
+
// producer signature inside this body to check, and — this is the part worth
|
|
706
|
+
// being precise about — there is no SECOND Sanning signature either. The
|
|
707
|
+
// control plane stores the stamp's canonical bytes and hands them to a witness
|
|
708
|
+
// as a signed chain data item; the signature lives on the chain, not in the
|
|
709
|
+
// `stamps` table and not in this body.
|
|
710
|
+
//
|
|
711
|
+
// What "Sanning signed this" means for a stamp bundle is therefore exactly the
|
|
712
|
+
// WRAPPER signature, verified generically before this function is reached. And
|
|
713
|
+
// what makes the stamp evidence rather than an assertion is the witness, which
|
|
714
|
+
// is on a chain and not checkable offline. Hence the rollup below: a stamp we
|
|
715
|
+
// can read but nobody has witnessed holds the bundle at `partial`.
|
|
716
|
+
//
|
|
717
|
+
// So the checkable surface here is the stamp's INTERNAL consistency against
|
|
718
|
+
// the one authority in the bundle — the witnessed bytes:
|
|
719
|
+
//
|
|
720
|
+
// 1. `stamp_ref` is SHA-256 of the DECODED bytes (derived, not asserted).
|
|
721
|
+
// 2. Those bytes parse and re-canonicalize to themselves under JCS.
|
|
722
|
+
// 3. Their `stamp_version` is the one this body_type pins.
|
|
723
|
+
// 4. Every sub-tree range accounts for exactly one leaf of the stamp.
|
|
724
|
+
//
|
|
725
|
+
// (4) is the one that carries customer weight. R-8's whole per-agent story is
|
|
726
|
+
// "prove this agent was under evidence over window W", answered by a
|
|
727
|
+
// contiguous `first_leaf`/`leaf_count` slice of the org tree. Ranges that do
|
|
728
|
+
// not tile `[0, leaf_count)` describe a partition that does not exist, so a
|
|
729
|
+
// slice claim resting on them is unfounded — and unlike a bad signature,
|
|
730
|
+
// nothing else in the stack would ever notice.
|
|
731
|
+
// ===========================================================================
|
|
732
|
+
/** Lowercase hex, even length, non-empty. The family's serialization rule. */
|
|
733
|
+
function isLowerHex(s, nibbles) {
|
|
734
|
+
if (typeof s !== "string" || s.length === 0 || s.length % 2 !== 0)
|
|
735
|
+
return false;
|
|
736
|
+
if (nibbles !== undefined && s.length !== nibbles)
|
|
737
|
+
return false;
|
|
738
|
+
return /^[0-9a-f]+$/.test(s);
|
|
739
|
+
}
|
|
740
|
+
function isNonNegInt(n) {
|
|
741
|
+
return typeof n === "number" && Number.isInteger(n) && n >= 0;
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Parse and check one stamp's witnessed bytes.
|
|
745
|
+
*
|
|
746
|
+
* Errors accumulate rather than short-circuit wherever a later check is still
|
|
747
|
+
* meaningful, so a bundle with two independent defects reports two — a
|
|
748
|
+
* verifier that stops at the first one turns a second fix into a second
|
|
749
|
+
* round-trip.
|
|
750
|
+
*/
|
|
751
|
+
async function verifyStampEntry(raw, seen) {
|
|
752
|
+
const errors = [];
|
|
753
|
+
const base = {
|
|
754
|
+
stampRef: "",
|
|
755
|
+
stampRefOk: false,
|
|
756
|
+
bodyOk: false,
|
|
757
|
+
stampVersion: null,
|
|
758
|
+
intervalStart: null,
|
|
759
|
+
intervalEnd: null,
|
|
760
|
+
merkleRoot: null,
|
|
761
|
+
leafCount: null,
|
|
762
|
+
subtreesOk: false,
|
|
763
|
+
subtrees: [],
|
|
764
|
+
anchorRef: null,
|
|
765
|
+
ok: false,
|
|
766
|
+
errors,
|
|
767
|
+
};
|
|
768
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
769
|
+
errors.push("stamp entry is not an object");
|
|
770
|
+
return base;
|
|
771
|
+
}
|
|
772
|
+
const e = raw;
|
|
773
|
+
// --- the witnessed bytes, which are the only authority in this body -------
|
|
774
|
+
if (!isLowerHex(e.stamp_bytes)) {
|
|
775
|
+
errors.push("stamp_bytes is missing or not lowercase hex");
|
|
776
|
+
return base;
|
|
777
|
+
}
|
|
778
|
+
let bytes;
|
|
779
|
+
try {
|
|
780
|
+
bytes = hexToBytes(e.stamp_bytes);
|
|
781
|
+
}
|
|
782
|
+
catch (err) {
|
|
783
|
+
errors.push(`stamp_bytes is not hex: ${stringifyErr(err)}`);
|
|
784
|
+
return base;
|
|
785
|
+
}
|
|
786
|
+
// --- the derived identity -------------------------------------------------
|
|
787
|
+
const derived = await sha256Hex(bytes);
|
|
788
|
+
if (typeof e.stamp_ref !== "string" || e.stamp_ref === "") {
|
|
789
|
+
errors.push("stamp entry missing stamp_ref");
|
|
790
|
+
}
|
|
791
|
+
else if (e.stamp_ref.toLowerCase() !== derived) {
|
|
792
|
+
// The bytes were edited and the label was not — or the label was edited
|
|
793
|
+
// and the bytes were not. Either way the bundle names a stamp it is not
|
|
794
|
+
// carrying, and quoting that name to a witness would resolve to something
|
|
795
|
+
// else entirely.
|
|
796
|
+
errors.push(`stamp_ref does not match SHA-256(stamp_bytes): claimed=${e.stamp_ref} derived=${derived}`);
|
|
797
|
+
}
|
|
798
|
+
else {
|
|
799
|
+
base.stampRefOk = true;
|
|
800
|
+
base.stampRef = derived;
|
|
801
|
+
if (seen.has(derived)) {
|
|
802
|
+
errors.push(`duplicate stamp_ref ${derived}`);
|
|
803
|
+
base.stampRefOk = false;
|
|
804
|
+
}
|
|
805
|
+
else {
|
|
806
|
+
seen.add(derived);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
// --- the body itself ------------------------------------------------------
|
|
810
|
+
let body;
|
|
811
|
+
try {
|
|
812
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
813
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
814
|
+
errors.push("stamp body is not a JSON object");
|
|
815
|
+
return base;
|
|
816
|
+
}
|
|
817
|
+
body = parsed;
|
|
818
|
+
// Re-canonicalize and compare. The plane replays these bytes verbatim on
|
|
819
|
+
// every submit attempt for exactly this reason: a non-canonical body makes
|
|
820
|
+
// a retry a DIFFERENT submission, and makes every independent re-derivation
|
|
821
|
+
// of the stamp disagree with the one that was witnessed.
|
|
822
|
+
if (jcs(body) !== new TextDecoder().decode(bytes)) {
|
|
823
|
+
errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
|
|
824
|
+
return base;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
catch (err) {
|
|
828
|
+
errors.push(`stamp body is not JSON: ${stringifyErr(err)}`);
|
|
829
|
+
return base;
|
|
830
|
+
}
|
|
831
|
+
if (body.stamp_version !== STAMP_BODY_VERSION) {
|
|
832
|
+
// A `sanning.stamp.trace/v1` body whose inner version is something else is
|
|
833
|
+
// two answers to one question inside one signature. The body_type is the
|
|
834
|
+
// reader's dispatch key and it said v1; honouring the other spelling is
|
|
835
|
+
// how a reader half-parses a shape it does not know.
|
|
836
|
+
errors.push(`unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
|
|
837
|
+
`${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`);
|
|
838
|
+
return base;
|
|
839
|
+
}
|
|
840
|
+
base.stampVersion = body.stamp_version;
|
|
841
|
+
base.bodyOk = true;
|
|
842
|
+
for (const f of ["org_ref", "interval_start", "interval_end"]) {
|
|
843
|
+
if (typeof body[f] !== "string" || body[f] === "") {
|
|
844
|
+
errors.push(`stamp body ${f} is missing or not a non-empty string`);
|
|
845
|
+
base.bodyOk = false;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
base.intervalStart = typeof body.interval_start === "string" ? body.interval_start : null;
|
|
849
|
+
base.intervalEnd = typeof body.interval_end === "string" ? body.interval_end : null;
|
|
850
|
+
if (!isLowerHex(body.merkle_root, 64)) {
|
|
851
|
+
errors.push("stamp body merkle_root is not 64 lowercase hex characters");
|
|
852
|
+
base.bodyOk = false;
|
|
853
|
+
}
|
|
854
|
+
else {
|
|
855
|
+
base.merkleRoot = body.merkle_root;
|
|
856
|
+
}
|
|
857
|
+
if (!isNonNegInt(body.leaf_count)) {
|
|
858
|
+
errors.push("stamp body leaf_count is not a non-negative integer");
|
|
859
|
+
base.bodyOk = false;
|
|
860
|
+
}
|
|
861
|
+
else {
|
|
862
|
+
base.leafCount = body.leaf_count;
|
|
863
|
+
}
|
|
864
|
+
// --- the sub-tree ranges --------------------------------------------------
|
|
865
|
+
base.subtreesOk = checkSubtrees(body, base, errors);
|
|
866
|
+
base.anchorRef = parseAnchorRef(e.anchor_ref, errors);
|
|
867
|
+
base.ok =
|
|
868
|
+
base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
|
|
869
|
+
return base;
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* The range arithmetic (§5.2), and the reason it is a hard check.
|
|
873
|
+
*
|
|
874
|
+
* `buildStampTree` orders a stamp's leaves agent-major, which is the only
|
|
875
|
+
* reason `first_leaf` + `leaf_count` can describe an agent's slice at all: the
|
|
876
|
+
* ranges partition `[0, leaf_count)` exactly, by construction. A body whose
|
|
877
|
+
* ranges do not is either not a stamp this stack built or one built wrong, and
|
|
878
|
+
* in both cases the per-agent claim the ranges exist to support is unfounded.
|
|
879
|
+
*
|
|
880
|
+
* The sum is checked BEFORE the tiling and reports its own message, so a body
|
|
881
|
+
* that is merely short does not get filed under a gap it does not have.
|
|
882
|
+
*/
|
|
883
|
+
function checkSubtrees(body, out, errors) {
|
|
884
|
+
const raw = body.subtrees;
|
|
885
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
886
|
+
errors.push("stamp body subtrees is not an object");
|
|
887
|
+
return false;
|
|
888
|
+
}
|
|
889
|
+
let shapeOk = true;
|
|
890
|
+
const subtrees = [];
|
|
891
|
+
for (const [ref, v] of Object.entries(raw)) {
|
|
892
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) {
|
|
893
|
+
errors.push(`sub-tree ${ref} is not an object`);
|
|
894
|
+
shapeOk = false;
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
const s = v;
|
|
898
|
+
if (!isNonNegInt(s.first_leaf)) {
|
|
899
|
+
errors.push(`sub-tree ${ref} first_leaf is not a non-negative integer`);
|
|
900
|
+
shapeOk = false;
|
|
901
|
+
}
|
|
902
|
+
// A zero-leaf sub-tree is not a slice of anything, and admitting one lets
|
|
903
|
+
// an arbitrary number of empty ranges sit inside a tiling that still adds
|
|
904
|
+
// up.
|
|
905
|
+
if (!isNonNegInt(s.leaf_count) || s.leaf_count === 0) {
|
|
906
|
+
errors.push(`sub-tree ${ref} leaf_count is not a positive integer`);
|
|
907
|
+
shapeOk = false;
|
|
908
|
+
}
|
|
909
|
+
if (!isLowerHex(s.root, 64)) {
|
|
910
|
+
errors.push(`sub-tree ${ref} root is not 64 lowercase hex characters`);
|
|
911
|
+
shapeOk = false;
|
|
912
|
+
}
|
|
913
|
+
if (!shapeOk)
|
|
914
|
+
continue;
|
|
915
|
+
subtrees.push({
|
|
916
|
+
ref,
|
|
917
|
+
firstLeaf: s.first_leaf,
|
|
918
|
+
leafCount: s.leaf_count,
|
|
919
|
+
root: s.root,
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
subtrees.sort((a, b) => a.firstLeaf - b.firstLeaf);
|
|
923
|
+
out.subtrees = subtrees;
|
|
924
|
+
if (!shapeOk)
|
|
925
|
+
return false;
|
|
926
|
+
const total = body.leaf_count;
|
|
927
|
+
if (!isNonNegInt(total))
|
|
928
|
+
return false; // already reported by the caller
|
|
929
|
+
const sum = subtrees.reduce((n, s) => n + s.leafCount, 0);
|
|
930
|
+
if (sum !== total) {
|
|
931
|
+
errors.push(`sub-tree leaf_count sum ${sum} does not equal the stamp's leaf_count ${total}`);
|
|
932
|
+
return false;
|
|
933
|
+
}
|
|
934
|
+
let next = 0;
|
|
935
|
+
for (const s of subtrees) {
|
|
936
|
+
if (s.firstLeaf !== next) {
|
|
937
|
+
errors.push(`sub-tree ranges do not tile [0, ${total}): expected a range at leaf ` +
|
|
938
|
+
`${next}, found one at ${s.firstLeaf}`);
|
|
939
|
+
return false;
|
|
940
|
+
}
|
|
941
|
+
next += s.leafCount;
|
|
942
|
+
}
|
|
943
|
+
return true;
|
|
944
|
+
}
|
|
945
|
+
async function verifyStampTrace(b, specVersionOk, assertedStatus, errors) {
|
|
946
|
+
const body = b.body;
|
|
947
|
+
if (!Array.isArray(body?.stamps)) {
|
|
948
|
+
return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true);
|
|
949
|
+
}
|
|
950
|
+
const seen = new Set();
|
|
951
|
+
const stamps = [];
|
|
952
|
+
for (const entry of body.stamps) {
|
|
953
|
+
stamps.push(await verifyStampEntry(entry, seen));
|
|
954
|
+
}
|
|
955
|
+
// --- Rollup ----------------------------------------------------------------
|
|
956
|
+
const anyHardFailure = stamps.some((s) => !s.ok);
|
|
957
|
+
const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
|
|
958
|
+
let status;
|
|
959
|
+
if (anyHardFailure) {
|
|
960
|
+
status = "failed";
|
|
961
|
+
}
|
|
962
|
+
else if (stamps.length === 0) {
|
|
963
|
+
// A bundle asserting nothing. Not a failure — nothing here is wrong — but
|
|
964
|
+
// it must never read `verified`, which a caller would take as a witness it
|
|
965
|
+
// does not have.
|
|
966
|
+
status = "partial";
|
|
967
|
+
errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
|
|
968
|
+
}
|
|
969
|
+
else if (anyUnwitnessed) {
|
|
970
|
+
// The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
|
|
971
|
+
// sealed and unwitnessed: `retained-by-sanning`, which is a statement about
|
|
972
|
+
// our coverage and never an accusation about their evidence. It is also not
|
|
973
|
+
// `verified`, because for a stamp the witness is the entire independent
|
|
974
|
+
// part — unlike an anchor-trace checkpoint, which still carries a producer
|
|
975
|
+
// signature and an inclusion proof that stand on their own offline.
|
|
976
|
+
status = "partial";
|
|
977
|
+
errors.push("one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody");
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
status = "verified";
|
|
981
|
+
}
|
|
982
|
+
return {
|
|
983
|
+
status,
|
|
984
|
+
specVersionOk,
|
|
985
|
+
bodyHashOk: true,
|
|
986
|
+
signatureOk: true,
|
|
987
|
+
bodyType: STAMP_TRACE_BODY_TYPE,
|
|
988
|
+
onChainChecked: false,
|
|
989
|
+
checkpoints: [],
|
|
990
|
+
events: [],
|
|
991
|
+
assertedStatus,
|
|
992
|
+
stamps,
|
|
993
|
+
errors,
|
|
994
|
+
};
|
|
995
|
+
}
|
|
666
996
|
// How many `sanning.evidence.export/v1` wrappers deep the source bundle may be
|
|
667
997
|
// nested before this path gives up. verifyEvidenceBundle's canonicalization
|
|
668
998
|
// depth bound already rejects a pathological bundle, but the unwrap must be
|
|
@@ -779,7 +1109,13 @@ export async function disclosedContent(bundle, options = {}) {
|
|
|
779
1109
|
// wrapper(s) to the inline source bundle — mirroring how verifyEvidenceBundle
|
|
780
1110
|
// recurses into `source_bundle`, so the events line up with its results.
|
|
781
1111
|
// `undefined` when there is no anchor-trace body to materialize from.
|
|
782
|
-
|
|
1112
|
+
//
|
|
1113
|
+
// Exported for `chain.ts`, which needs the same descent and must not derive it
|
|
1114
|
+
// again: two answers to "which events is this bundle actually about" inside one
|
|
1115
|
+
// kernel is two implementations of one rule, and the one that drifted would be
|
|
1116
|
+
// the one nobody was looking at. Deliberately NOT re-exported from `index.ts` —
|
|
1117
|
+
// same posture as `side-content.ts`, internal and shared.
|
|
1118
|
+
export function resolveTraceEvents(bundle) {
|
|
783
1119
|
let cur = bundle;
|
|
784
1120
|
for (let depth = 0; depth < MAX_EXPORT_UNWRAP; depth++) {
|
|
785
1121
|
if (cur === null || typeof cur !== "object" || Array.isArray(cur))
|
package/dist/index.d.ts
CHANGED
|
@@ -2,10 +2,12 @@ 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, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TYPES, 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, AnchorRef, TraceCheckpoint, TraceCheckpointV1, TraceEvent, TraceInclusion, VerdictAttestation, VerdictCheckpoint, VerdictEvent, VerdictObject, VerdictOnChain, VerdictOnChainGateway, VerifyEvidenceOptions, } from "./evidence.js";
|
|
5
|
+
export { ANCHOR_TRACE_BODY_TYPE, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TYPES, EXPORT_BODY_TYPE, STAMP_BODY_VERSION, STAMP_TRACE_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, AnchorRef, StampBody, StampEntry, StampResult, StampSubtree, StampSubtreeResult, StampTraceBody, TraceCheckpoint, TraceCheckpointV1, TraceEvent, TraceInclusion, VerdictAttestation, VerdictCheckpoint, VerdictEvent, VerdictObject, VerdictOnChain, VerdictOnChainGateway, VerifyEvidenceOptions, } from "./evidence.js";
|
|
7
7
|
export { accountDisclosure, completenessNotes, gradeCauses, gradePack, renderCause, signedLeafCount, } from "./grading.js";
|
|
8
8
|
export type { CompletenessNote, DisclosureAccount, GradeInput, PackGrade, PackGradeCause, PackGradeCauseKind, PackVerdict, } from "./grading.js";
|
|
9
|
+
export { ADAPTER_CHAIN_NAMESPACES, CHAIN_READING_SCOPE, readChains, renderChainFinding, } from "./chain.js";
|
|
10
|
+
export type { ChainFinding, ChainFindingKind, ChainReading, ChainSource, ChainSummary, } from "./chain.js";
|
|
9
11
|
export { attestationSignatureToHex, composeExport } from "./compose.js";
|
|
10
12
|
export type { ComposeExportOptions, ComposeExportResult, ExporterKey } from "./compose.js";
|
|
11
13
|
export { AGENT_PROOF_SPEC_PREFIX, isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
|
package/dist/index.js
CHANGED
|
@@ -34,7 +34,7 @@ export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyI
|
|
|
34
34
|
// files that failed verification on genuine evidence. This hands back DECODED
|
|
35
35
|
// bytes, and only for events whose verification bound them to the committed
|
|
36
36
|
// content_hash. Additive; verifyEvidenceBundle's behavior is unchanged.
|
|
37
|
-
export { ANCHOR_TRACE_BODY_TYPE, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TYPES, EXPORT_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
|
|
37
|
+
export { ANCHOR_TRACE_BODY_TYPE, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TYPES, EXPORT_BODY_TYPE, STAMP_BODY_VERSION, STAMP_TRACE_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
|
|
38
38
|
// Pack grading (src/grading.ts): what a verification SURFACE may claim about a
|
|
39
39
|
// pack — the signed bundle PLUS whatever disclosed bytes travelled beside it.
|
|
40
40
|
//
|
|
@@ -51,6 +51,27 @@ export { ANCHOR_TRACE_BODY_TYPE, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TY
|
|
|
51
51
|
// derivation to it, and diff its copy against ours. Additive — no existing
|
|
52
52
|
// export changes.
|
|
53
53
|
export { accountDisclosure, completenessNotes, gradeCauses, gradePack, renderCause, signedLeafCount, } from "./grading.js";
|
|
54
|
+
// Chain reading (src/chain.ts): following the pointer every sanning.events/v1
|
|
55
|
+
// record carries to the record before it, over the records ONE bundle presents.
|
|
56
|
+
//
|
|
57
|
+
// Every anchored event has carried that pointer since the profile was ratified
|
|
58
|
+
// and nothing we shipped read it, so a bundle with an event removed — and a
|
|
59
|
+
// chain that restarted after a crash and now asserts two beginnings — passed
|
|
60
|
+
// every check we had (proof#43). The verifier was right not to do this: it
|
|
61
|
+
// verifies a single envelope and says so. Nothing above it did it either.
|
|
62
|
+
//
|
|
63
|
+
// Pure — data in, data out, no I/O — so the kernel's scope boundary
|
|
64
|
+
// (specs/architecture.md §3) is unchanged, and the rule ships as
|
|
65
|
+
// `vectors/chain-reading.json` beside the pack-grading table for the same
|
|
66
|
+
// reason: a rule consumers must agree with needs a statement they can fetch.
|
|
67
|
+
//
|
|
68
|
+
// It returns FINDINGS, never a verdict, and it feeds no status. Chain links are
|
|
69
|
+
// deletion/reorder-evidence for the events present and NOT a completeness proof
|
|
70
|
+
// (`anchor/docs/profile-sanning.events-v1.md` §5) — drop the last N events of a
|
|
71
|
+
// chain and nothing dangles. `CHAIN_READING_SCOPE` is that sentence, exported so
|
|
72
|
+
// every surface prints the same limit rather than paraphrasing it into a claim.
|
|
73
|
+
// Additive — no existing export changes.
|
|
74
|
+
export { ADAPTER_CHAIN_NAMESPACES, CHAIN_READING_SCOPE, readChains, renderChainFinding, } from "./chain.js";
|
|
54
75
|
// The `proof export` composer (specs/evidence-export.md §5, phase 2): produce a
|
|
55
76
|
// signed, offline-verifiable sanning.evidence.export/v1 artifact from a source
|
|
56
77
|
// anchor-trace bundle + operator attestations + an exporter Ed25519 key. The
|
package/dist/verifier.js
CHANGED
|
@@ -215,6 +215,18 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
|
|
|
215
215
|
const opts = typeof optionsOrContentHash === "string"
|
|
216
216
|
? { expectedContentHash: optionsOrContentHash }
|
|
217
217
|
: (optionsOrContentHash ?? {});
|
|
218
|
+
// REFUSE an unknown option key rather than ignore it. `expected_content_hash`
|
|
219
|
+
// — the Python kernel's spelling — previously fell through as `undefined`, so
|
|
220
|
+
// the content bind silently never ran and the caller got a clean pass with no
|
|
221
|
+
// errors at all. A check that quietly does not happen is worse than one that
|
|
222
|
+
// fails: the caller believes they verified an artifact they never checked.
|
|
223
|
+
const KNOWN_VERIFY_OPTIONS = new Set(["payloadBytes", "expectedContentHash"]);
|
|
224
|
+
const unknownOptions = Object.keys(opts).filter((k) => !KNOWN_VERIFY_OPTIONS.has(k));
|
|
225
|
+
if (unknownOptions.length > 0) {
|
|
226
|
+
throw new TypeError(`verifyEnvelope: unknown option(s) ${unknownOptions.join(", ")}. ` +
|
|
227
|
+
`Did you mean ${[...KNOWN_VERIFY_OPTIONS].join(" / ")}? ` +
|
|
228
|
+
"Option keys are camelCase in the TypeScript kernel; the Python kernel uses snake_case.");
|
|
229
|
+
}
|
|
218
230
|
const expectedContentHash = opts.expectedContentHash;
|
|
219
231
|
const errors = [];
|
|
220
232
|
// Guard a malformed input (null / non-object / array) up front — every field
|
|
@@ -296,18 +308,45 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
|
|
|
296
308
|
let contentHashOk = null;
|
|
297
309
|
let contentRole = null;
|
|
298
310
|
if (expectedContentHash !== undefined) {
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
311
|
+
const committed = contentHashes(env);
|
|
312
|
+
if (committed.length === 0) {
|
|
313
|
+
// UNDETERMINED, not failed. An external-commitment envelope
|
|
314
|
+
// (`sanning.events/v1`) has no inline payload, so it commits to no
|
|
315
|
+
// envelope-readable content hash and there is nothing here to compare
|
|
316
|
+
// against. Reporting that as a failure would render "we cannot check
|
|
317
|
+
// this" as "it did not match" — what P-10 and I-5 exist to prevent — and
|
|
318
|
+
// would fail every envelope on the DL-52 path.
|
|
319
|
+
//
|
|
320
|
+
// The caller is still told the check did not happen; silence would be
|
|
321
|
+
// worse than either verdict.
|
|
322
|
+
contentHashOk = null;
|
|
323
|
+
errors.push("this envelope commits to no readable content hash (external-commitment " +
|
|
324
|
+
"profile), so the provided content hash could not be checked — " +
|
|
325
|
+
"undetermined, not failed");
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
const want = expectedContentHash.toLowerCase();
|
|
329
|
+
const match = committed.find((c) => c.hash.toLowerCase() === want);
|
|
330
|
+
contentHashOk = match !== undefined;
|
|
331
|
+
contentRole = match ? match.role : null;
|
|
332
|
+
if (!contentHashOk) {
|
|
333
|
+
errors.push("provided content hash does not match any hash this envelope commits to");
|
|
334
|
+
}
|
|
305
335
|
}
|
|
306
336
|
}
|
|
307
337
|
return {
|
|
308
338
|
// null (binding not checkable) does not fail the verdict — Python-kernel
|
|
309
339
|
// parity: ok = spec && sig && payloadHashOk is not False.
|
|
310
|
-
|
|
340
|
+
// A supplied `expectedContentHash` that matches nothing this envelope
|
|
341
|
+
// commits to is a FAILING verdict, not a side note (proof#42). This is the
|
|
342
|
+
// check an external auditor runs — "does this file match what was
|
|
343
|
+
// anchored" — and reporting it only in `contentHashOk` meant the top-level
|
|
344
|
+
// verdict, the field a caller naturally reads, said pass over a file that
|
|
345
|
+
// does not match.
|
|
346
|
+
//
|
|
347
|
+
// Absence stays undetermined: `contentHashOk` is null when no hash was
|
|
348
|
+
// supplied, and null must not fail (P-10 — absence is not failure).
|
|
349
|
+
ok: specVersionOk && signatureOk && payloadHashOk !== false && contentHashOk !== false,
|
|
311
350
|
specVersionOk,
|
|
312
351
|
payloadHashOk,
|
|
313
352
|
signatureOk,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanning/proof",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|