@mikeargento/bitgraph-verify 1.1.1 → 1.2.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/dist/index.d.ts CHANGED
@@ -8,6 +8,6 @@
8
8
  export type { BitGraphProof, BitGraphPolicy, VerificationPolicy, SignedBody, EnforcementTier, Attribution, PolicyBinding, SlotAllocation, ActorIdentity, AuthorizationPayload, WebAuthnAuthorization, AgencyEnvelope, } from "./types.js";
9
9
  export { verify, verifyProofIntegrity, resetEpochLinkState } from "./verifier.js";
10
10
  export type { VerifyResult, ProofIntegrityResult } from "./verifier.js";
11
- export { computeProofHash, computeChainHash } from "./proof-hash.js";
11
+ export { computeProofHash, computeChainHash, buildSignedBody, computeSignedBodyHash } from "./proof-hash.js";
12
12
  export { canonicalize, canonicalizeToString, constantTimeEqual } from "./canonical.js";
13
13
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AAEH,YAAY,EACV,aAAa,EACb,cAAc,EACd,kBAAkB,EAClB,UAAU,EACV,eAAe,EACf,WAAW,EACX,aAAa,EACb,cAAc,EACd,aAAa,EACb,oBAAoB,EACpB,qBAAqB,EACrB,cAAc,GACf,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,MAAM,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAClF,YAAY,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAExE,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAErE,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AAEH,YAAY,EACV,aAAa,EACb,cAAc,EACd,kBAAkB,EAClB,UAAU,EACV,eAAe,EACf,WAAW,EACX,aAAa,EACb,cAAc,EACd,aAAa,EACb,oBAAoB,EACpB,qBAAqB,EACrB,cAAc,GACf,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,MAAM,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAClF,YAAY,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAExE,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,eAAe,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAE7G,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC"}
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
2
  export { verify, verifyProofIntegrity, resetEpochLinkState } from "./verifier.js";
3
- export { computeProofHash, computeChainHash } from "./proof-hash.js";
3
+ export { computeProofHash, computeChainHash, buildSignedBody, computeSignedBodyHash } from "./proof-hash.js";
4
4
  export { canonicalize, canonicalizeToString, constantTimeEqual } from "./canonical.js";
5
5
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,qFAAqF;AAyBrF,OAAO,EAAE,MAAM,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAGlF,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAErE,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,qFAAqF;AAyBrF,OAAO,EAAE,MAAM,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAGlF,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,eAAe,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAE7G,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC"}
@@ -1,6 +1,10 @@
1
1
  import type { BitGraphProof } from "./types.js";
2
2
  /**
3
- * Compute the canonical hash of a BitGraph proof's signed body.
3
+ * Compute the ledger identity hash of a BitGraph proof.
4
+ *
5
+ * Hashes the frozen signed-body subset described at the top of this file.
6
+ * This is NOT the value the Ed25519 signature covers and NOT the attestation
7
+ * user_data; for that, reconstruct the full signed body (verifier.ts step 3).
4
8
  *
5
9
  * @param proof - The full BitGraphProof object (or equivalent Record)
6
10
  * @returns Base64-standard encoded SHA-256 hash
@@ -8,22 +12,48 @@ import type { BitGraphProof } from "./types.js";
8
12
  export declare function computeProofHash(proof: BitGraphProof | Record<string, unknown>): string;
9
13
  /**
10
14
  * Compute the CHAIN hash of a BitGraph proof: SHA-256 over the canonicalized
11
- * whole proof object, with the ledger-added `proofHash` field removed.
15
+ * whole proof object, with the ledger/service-added fields removed.
12
16
  *
13
17
  * This is the value the enclave writes into the NEXT proof's
14
18
  * `commit.prevB64` (and into `epochLink.prevProofHashB64` at an epoch
15
19
  * boundary): the enclave hashes the entire assembled proof, before the ledger
16
- * appends its convenience `proofHash` field. To link a chain, match a proof's
17
- * `commit.prevB64` against `computeChainHash(predecessor)`.
20
+ * or anchor service appends its convenience fields. To link a chain, match a
21
+ * proof's `commit.prevB64` against `computeChainHash(predecessor)`.
18
22
  *
19
23
  * This differs from computeProofHash, which hashes only the signed-body
20
- * subset. The chain hash covers every field (signer signature, attestation
21
- * report, slot allocation, attribution, and so on), so any change anywhere in
22
- * a proof breaks the link from its successor.
24
+ * subset. The chain hash covers every enclave-produced field (signer
25
+ * signature, attestation report, slot allocation, attribution, and so on), so
26
+ * any change anywhere in a proof breaks the link from its successor.
23
27
  *
24
- * @param proof - The full BitGraphProof object (or equivalent Record). A
25
- * top-level `proofHash` field, if present, is excluded before hashing.
28
+ * @param proof - The full BitGraphProof object (or equivalent Record). Any
29
+ * ledger/service-added field (see LEDGER_ADDED_FIELDS) is excluded before
30
+ * hashing.
26
31
  * @returns Base64-standard encoded SHA-256 hash
27
32
  */
28
33
  export declare function computeChainHash(proof: BitGraphProof | Record<string, unknown>): string;
34
+ /**
35
+ * Reconstruct the FULL canonical signed body: the bytes the Ed25519 signature
36
+ * actually covers, and the bytes whose SHA-256 the enclave places in the Nitro
37
+ * attestation's `user_data`.
38
+ *
39
+ * ⚠️ THIS IS HASH (2) IN THE LIST AT THE TOP OF THIS FILE, AND IT IS NOT
40
+ * computeProofHash. For a proof carrying neither `actor` nor `policy` the two
41
+ * coincide, which is why substituting one for the other survives every
42
+ * ordinary proof and fails only on agency or policy proofs — a bug that hides
43
+ * until the day the feature ships. That is exactly what happened: the offline
44
+ * auditor compared the attestation's user_data against computeProofHash and
45
+ * reported every declared recording as belonging to "some other proof",
46
+ * turning a valid proof into a FALSE (2026-08-18, found on the first real
47
+ * declaration, ledger position #12,010).
48
+ *
49
+ * Signature verification and attestation binding MUST both use this function
50
+ * so they cannot drift apart again.
51
+ */
52
+ export declare function buildSignedBody(proof: BitGraphProof | Record<string, unknown>): Record<string, unknown>;
53
+ /**
54
+ * base64(SHA-256(canonical full signed body)). The value the enclave writes
55
+ * into the attestation's user_data, and the digest the Ed25519 signature
56
+ * covers. See buildSignedBody for why this is never computeProofHash.
57
+ */
58
+ export declare function computeSignedBodyHash(proof: BitGraphProof | Record<string, unknown>): string;
29
59
  //# sourceMappingURL=proof-hash.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"proof-hash.d.ts","sourceRoot":"","sources":["../src/proof-hash.ts"],"names":[],"mappings":"AA2CA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CA2BvF;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAavF"}
1
+ {"version":3,"file":"proof-hash.d.ts","sourceRoot":"","sources":["../src/proof-hash.ts"],"names":[],"mappings":"AAoEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CA2BvF;AAsBD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAavF;AAcD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAqBvG;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAE5F"}
@@ -4,28 +4,53 @@
4
4
  *
5
5
  * Canonical, deterministic proof hash computation.
6
6
  *
7
- * The proof hash covers the SIGNED BODY the fields that are
8
- * cryptographically signed by the enclave. This matches what the
9
- * Ed25519 signature covers, making the hash verifiable.
10
- *
11
- * Signed body fields:
7
+ * This is the LEDGER IDENTITY of a proof. It hashes a fixed SUBSET of the
8
+ * signed body:
12
9
  * - version, artifact, commit
13
10
  * - publicKeyB64 (from signer)
14
11
  * - enforcement, measurement (from environment)
15
12
  * - attribution (if present)
16
13
  * - attestationFormat (if attestation present)
17
14
  *
18
- * This hash (the signed-body hash) is used for:
19
- * - S3 ledger key generation
15
+ * Used for:
16
+ * - S3 ledger key generation — proofs/{epoch}/{counter}-{proofHash}.json
20
17
  * - Ethereum anchor binding
21
18
  * - Proof deduplication and canonical identity
22
19
  *
23
- * It is NOT the value referenced by commit.prevB64 or by
24
- * epochLink.prevProofHashB64. Those reference the CHAIN hash: SHA-256 over
25
- * the canonicalized WHOLE proof (every field, minus the ledger-added
26
- * proofHash), computed by computeChainHash below. Matching prevB64 against
27
- * computeProofHash instead of computeChainHash silently fails to link real
28
- * chains.
20
+ * ── THE FIELD LIST ABOVE IS FROZEN. DO NOT ADD TO IT. ──
21
+ *
22
+ * It is tempting to "fix" this function by adding `actor` and `policy`, which
23
+ * ARE in the signed body the enclave signs. Doing so is a silent, permanent
24
+ * break: this value is baked into every S3 object key already written, and
25
+ * ledger verify() compares a recomputed value against the stored `proofHash`
26
+ * field (packages/ledger/src/verify.ts). Widening the subset changes the hash
27
+ * of every existing agency-bearing or policy-bearing proof, orphaning its key
28
+ * and failing its hash check forever. The subset is a compatibility contract,
29
+ * not an approximation of the signature.
30
+ *
31
+ * BitGraph has THREE distinct proof hashes. Confusing them is the single
32
+ * easiest way to break the system, so they are spelled out here:
33
+ *
34
+ * 1. computeProofHash (this function) — signed-body SUBSET.
35
+ * Ledger identity and S3 key. Frozen. Not a signature-equivalent value.
36
+ *
37
+ * 2. SHA-256 of the FULL canonical signed body — what the Ed25519 signature
38
+ * actually covers, and what the enclave puts in the Nitro attestation's
39
+ * user_data. Reconstructed by verifier.ts (step 3) and, in the browser,
40
+ * by buildSignedBody()/proofHashB64() in website/src/lib/bitgraph.ts.
41
+ * For a proof with no `actor` and no `policy` this coincides with (1);
42
+ * for agency or policy proofs it does NOT. Use this for signature and
43
+ * attestation checks. Never use (1).
44
+ *
45
+ * 3. computeChainHash (below) — the WHOLE proof, minus ledger-added fields.
46
+ * This is what the enclave writes into the next proof's commit.prevB64
47
+ * and into epochLink.prevProofHashB64. Matching prevB64 against (1)
48
+ * silently fails to link real chains.
49
+ *
50
+ * packages/hosted/src/bitcoin-anchor.ts carries a deliberate inline copy of
51
+ * (1) because Railway cannot resolve the monorepo package. It mirrors this
52
+ * field list exactly and must continue to; if this list ever does change,
53
+ * that copy changes with it in the same commit.
29
54
  *
30
55
  * Algorithm:
31
56
  * 1. Extract signed body fields
@@ -40,7 +65,11 @@
40
65
  import { canonicalize } from "./canonical.js";
41
66
  import { sha256 } from "@noble/hashes/sha256";
42
67
  /**
43
- * Compute the canonical hash of a BitGraph proof's signed body.
68
+ * Compute the ledger identity hash of a BitGraph proof.
69
+ *
70
+ * Hashes the frozen signed-body subset described at the top of this file.
71
+ * This is NOT the value the Ed25519 signature covers and NOT the attestation
72
+ * user_data; for that, reconstruct the full signed body (verifier.ts step 3).
44
73
  *
45
74
  * @param proof - The full BitGraphProof object (or equivalent Record)
46
75
  * @returns Base64-standard encoded SHA-256 hash
@@ -69,34 +98,54 @@ export function computeProofHash(proof) {
69
98
  const hash = sha256(bytes);
70
99
  return base64(hash);
71
100
  }
101
+ /**
102
+ * Fields that are NOT produced by the enclave. They are appended to a proof
103
+ * AFTER it is signed, by the ledger or the anchor service, purely as read-time
104
+ * convenience. The enclave computed its chain hash before any of these
105
+ * existed, so they MUST be excluded from computeChainHash — otherwise a
106
+ * successor's `commit.prevB64` (computed by the enclave over the clean proof)
107
+ * can never match.
108
+ *
109
+ * proofHash — SHA-256 of the signed-body subset; added by the ledger at
110
+ * write time (see computeProofHash above).
111
+ * ethereum — { blockNumber, blockHash }; added by the anchor service to
112
+ * anchor records after the transaction is mined. Purely
113
+ * redundant: the block hash is already in the signed
114
+ * `attribution.message` and the block number in
115
+ * `attribution.title`. Present only on Ethereum-anchor proofs.
116
+ *
117
+ * Any future ledger/service-added field must be added here as well.
118
+ */
119
+ const LEDGER_ADDED_FIELDS = ["proofHash", "ethereum"];
72
120
  /**
73
121
  * Compute the CHAIN hash of a BitGraph proof: SHA-256 over the canonicalized
74
- * whole proof object, with the ledger-added `proofHash` field removed.
122
+ * whole proof object, with the ledger/service-added fields removed.
75
123
  *
76
124
  * This is the value the enclave writes into the NEXT proof's
77
125
  * `commit.prevB64` (and into `epochLink.prevProofHashB64` at an epoch
78
126
  * boundary): the enclave hashes the entire assembled proof, before the ledger
79
- * appends its convenience `proofHash` field. To link a chain, match a proof's
80
- * `commit.prevB64` against `computeChainHash(predecessor)`.
127
+ * or anchor service appends its convenience fields. To link a chain, match a
128
+ * proof's `commit.prevB64` against `computeChainHash(predecessor)`.
81
129
  *
82
130
  * This differs from computeProofHash, which hashes only the signed-body
83
- * subset. The chain hash covers every field (signer signature, attestation
84
- * report, slot allocation, attribution, and so on), so any change anywhere in
85
- * a proof breaks the link from its successor.
131
+ * subset. The chain hash covers every enclave-produced field (signer
132
+ * signature, attestation report, slot allocation, attribution, and so on), so
133
+ * any change anywhere in a proof breaks the link from its successor.
86
134
  *
87
- * @param proof - The full BitGraphProof object (or equivalent Record). A
88
- * top-level `proofHash` field, if present, is excluded before hashing.
135
+ * @param proof - The full BitGraphProof object (or equivalent Record). Any
136
+ * ledger/service-added field (see LEDGER_ADDED_FIELDS) is excluded before
137
+ * hashing.
89
138
  * @returns Base64-standard encoded SHA-256 hash
90
139
  */
91
140
  export function computeChainHash(proof) {
92
141
  const p = proof;
93
142
  let hashable = p;
94
- if (Object.prototype.hasOwnProperty.call(p, "proofHash")) {
95
- // Shallow copy without the ledger-added field; the enclave hashed the
96
- // proof before this field existed.
143
+ if (LEDGER_ADDED_FIELDS.some((k) => Object.prototype.hasOwnProperty.call(p, k))) {
144
+ // Shallow copy without the ledger/service-added fields; the enclave hashed
145
+ // the proof before these existed.
97
146
  hashable = {};
98
147
  for (const key of Object.keys(p)) {
99
- if (key !== "proofHash")
148
+ if (!LEDGER_ADDED_FIELDS.includes(key))
100
149
  hashable[key] = p[key];
101
150
  }
102
151
  }
@@ -114,4 +163,53 @@ function base64(hash) {
114
163
  }
115
164
  return btoa(binary);
116
165
  }
166
+ /**
167
+ * Reconstruct the FULL canonical signed body: the bytes the Ed25519 signature
168
+ * actually covers, and the bytes whose SHA-256 the enclave places in the Nitro
169
+ * attestation's `user_data`.
170
+ *
171
+ * ⚠️ THIS IS HASH (2) IN THE LIST AT THE TOP OF THIS FILE, AND IT IS NOT
172
+ * computeProofHash. For a proof carrying neither `actor` nor `policy` the two
173
+ * coincide, which is why substituting one for the other survives every
174
+ * ordinary proof and fails only on agency or policy proofs — a bug that hides
175
+ * until the day the feature ships. That is exactly what happened: the offline
176
+ * auditor compared the attestation's user_data against computeProofHash and
177
+ * reported every declared recording as belonging to "some other proof",
178
+ * turning a valid proof into a FALSE (2026-08-18, found on the first real
179
+ * declaration, ledger position #12,010).
180
+ *
181
+ * Signature verification and attestation binding MUST both use this function
182
+ * so they cannot drift apart again.
183
+ */
184
+ export function buildSignedBody(proof) {
185
+ const p = proof;
186
+ const signer = p.signer;
187
+ const env = p.environment;
188
+ const body = {
189
+ version: p.version,
190
+ artifact: p.artifact,
191
+ commit: p.commit,
192
+ publicKeyB64: signer?.publicKeyB64,
193
+ enforcement: env?.enforcement,
194
+ measurement: env?.measurement,
195
+ };
196
+ if (env?.attestation !== undefined)
197
+ body.attestationFormat = env.attestation.format;
198
+ const agency = p.agency;
199
+ if (agency !== undefined)
200
+ body.actor = agency.actor;
201
+ if (p.policy !== undefined)
202
+ body.policy = p.policy;
203
+ if (p.attribution !== undefined)
204
+ body.attribution = p.attribution;
205
+ return body;
206
+ }
207
+ /**
208
+ * base64(SHA-256(canonical full signed body)). The value the enclave writes
209
+ * into the attestation's user_data, and the digest the Ed25519 signature
210
+ * covers. See buildSignedBody for why this is never computeProofHash.
211
+ */
212
+ export function computeSignedBodyHash(proof) {
213
+ return base64(sha256(canonicalize(buildSignedBody(proof))));
214
+ }
117
215
  //# sourceMappingURL=proof-hash.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"proof-hash.js","sourceRoot":"","sources":["../src/proof-hash.ts"],"names":[],"mappings":"AAAA,qFAAqF;AAErF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAG9C;;;;;GAKG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAA8C;IAC7E,MAAM,CAAC,GAAG,KAAgC,CAAC;IAC3C,MAAM,MAAM,GAAG,CAAC,CAAC,MAA8C,CAAC;IAChE,MAAM,GAAG,GAAG,CAAC,CAAC,WAAyG,CAAC;IAExH,MAAM,UAAU,GAA4B;QAC1C,OAAO,EAAE,CAAC,CAAC,OAAO;QAClB,QAAQ,EAAE,CAAC,CAAC,QAAQ;QACpB,MAAM,EAAE,CAAC,CAAC,MAAM;QAChB,YAAY,EAAE,MAAM,EAAE,YAAY;QAClC,WAAW,EAAE,GAAG,EAAE,WAAW;QAC7B,WAAW,EAAE,GAAG,EAAE,WAAW;KAC9B,CAAC;IAEF,iCAAiC;IACjC,IAAI,CAAC,CAAC,WAAW,EAAE,CAAC;QAClB,UAAU,CAAC,WAAW,GAAG,CAAC,CAAC,WAAW,CAAC;IACzC,CAAC;IAED,wCAAwC;IACxC,IAAI,GAAG,EAAE,WAAW,EAAE,CAAC;QACrB,UAAU,CAAC,iBAAiB,GAAG,GAAG,CAAC,WAAW,CAAC,MAAM,CAAC;IACxD,CAAC;IAED,MAAM,KAAK,GAAG,YAAY,CAAC,UAAsC,CAAC,CAAC;IACnE,MAAM,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IAC3B,OAAO,MAAM,CAAC,IAAI,CAAC,CAAC;AACtB,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAA8C;IAC7E,MAAM,CAAC,GAAG,KAAgC,CAAC;IAC3C,IAAI,QAAQ,GAA4B,CAAC,CAAC;IAC1C,IAAI,MAAM,CAAC,SAAS,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC,EAAE,WAAW,CAAC,EAAE,CAAC;QACzD,sEAAsE;QACtE,mCAAmC;QACnC,QAAQ,GAAG,EAAE,CAAC;QACd,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;YACjC,IAAI,GAAG,KAAK,WAAW;gBAAE,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC;QAClD,CAAC;IACH,CAAC;IACD,MAAM,KAAK,GAAG,YAAY,CAAC,QAAoC,CAAC,CAAC;IACjE,OAAO,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;AAC/B,CAAC;AAED,8DAA8D;AAC9D,SAAS,MAAM,CAAC,IAAgB;IAC9B,IAAI,OAAO,MAAM,KAAK,WAAW,EAAE,CAAC;QAClC,OAAO,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAC9C,CAAC;IACD,IAAI,MAAM,GAAG,EAAE,CAAC;IAChB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACrC,MAAM,IAAI,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAE,CAAC,CAAC;IAC1C,CAAC;IACD,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC;AACtB,CAAC"}
1
+ {"version":3,"file":"proof-hash.js","sourceRoot":"","sources":["../src/proof-hash.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikeargento/bitgraph-verify",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Offline verification of BitGraph proofs. Permissionless by design.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -26,6 +26,6 @@ export type {
26
26
  export { verify, verifyProofIntegrity, resetEpochLinkState } from "./verifier.js";
27
27
  export type { VerifyResult, ProofIntegrityResult } from "./verifier.js";
28
28
 
29
- export { computeProofHash, computeChainHash } from "./proof-hash.js";
29
+ export { computeProofHash, computeChainHash, buildSignedBody, computeSignedBodyHash } from "./proof-hash.js";
30
30
 
31
31
  export { canonicalize, canonicalizeToString, constantTimeEqual } from "./canonical.js";
package/src/proof-hash.ts CHANGED
@@ -5,28 +5,53 @@
5
5
  *
6
6
  * Canonical, deterministic proof hash computation.
7
7
  *
8
- * The proof hash covers the SIGNED BODY the fields that are
9
- * cryptographically signed by the enclave. This matches what the
10
- * Ed25519 signature covers, making the hash verifiable.
11
- *
12
- * Signed body fields:
8
+ * This is the LEDGER IDENTITY of a proof. It hashes a fixed SUBSET of the
9
+ * signed body:
13
10
  * - version, artifact, commit
14
11
  * - publicKeyB64 (from signer)
15
12
  * - enforcement, measurement (from environment)
16
13
  * - attribution (if present)
17
14
  * - attestationFormat (if attestation present)
18
15
  *
19
- * This hash (the signed-body hash) is used for:
20
- * - S3 ledger key generation
16
+ * Used for:
17
+ * - S3 ledger key generation — proofs/{epoch}/{counter}-{proofHash}.json
21
18
  * - Ethereum anchor binding
22
19
  * - Proof deduplication and canonical identity
23
20
  *
24
- * It is NOT the value referenced by commit.prevB64 or by
25
- * epochLink.prevProofHashB64. Those reference the CHAIN hash: SHA-256 over
26
- * the canonicalized WHOLE proof (every field, minus the ledger-added
27
- * proofHash), computed by computeChainHash below. Matching prevB64 against
28
- * computeProofHash instead of computeChainHash silently fails to link real
29
- * chains.
21
+ * ── THE FIELD LIST ABOVE IS FROZEN. DO NOT ADD TO IT. ──
22
+ *
23
+ * It is tempting to "fix" this function by adding `actor` and `policy`, which
24
+ * ARE in the signed body the enclave signs. Doing so is a silent, permanent
25
+ * break: this value is baked into every S3 object key already written, and
26
+ * ledger verify() compares a recomputed value against the stored `proofHash`
27
+ * field (packages/ledger/src/verify.ts). Widening the subset changes the hash
28
+ * of every existing agency-bearing or policy-bearing proof, orphaning its key
29
+ * and failing its hash check forever. The subset is a compatibility contract,
30
+ * not an approximation of the signature.
31
+ *
32
+ * BitGraph has THREE distinct proof hashes. Confusing them is the single
33
+ * easiest way to break the system, so they are spelled out here:
34
+ *
35
+ * 1. computeProofHash (this function) — signed-body SUBSET.
36
+ * Ledger identity and S3 key. Frozen. Not a signature-equivalent value.
37
+ *
38
+ * 2. SHA-256 of the FULL canonical signed body — what the Ed25519 signature
39
+ * actually covers, and what the enclave puts in the Nitro attestation's
40
+ * user_data. Reconstructed by verifier.ts (step 3) and, in the browser,
41
+ * by buildSignedBody()/proofHashB64() in website/src/lib/bitgraph.ts.
42
+ * For a proof with no `actor` and no `policy` this coincides with (1);
43
+ * for agency or policy proofs it does NOT. Use this for signature and
44
+ * attestation checks. Never use (1).
45
+ *
46
+ * 3. computeChainHash (below) — the WHOLE proof, minus ledger-added fields.
47
+ * This is what the enclave writes into the next proof's commit.prevB64
48
+ * and into epochLink.prevProofHashB64. Matching prevB64 against (1)
49
+ * silently fails to link real chains.
50
+ *
51
+ * packages/hosted/src/bitcoin-anchor.ts carries a deliberate inline copy of
52
+ * (1) because Railway cannot resolve the monorepo package. It mirrors this
53
+ * field list exactly and must continue to; if this list ever does change,
54
+ * that copy changes with it in the same commit.
30
55
  *
31
56
  * Algorithm:
32
57
  * 1. Extract signed body fields
@@ -44,7 +69,11 @@ import { sha256 } from "@noble/hashes/sha256";
44
69
  import type { BitGraphProof } from "./types.js";
45
70
 
46
71
  /**
47
- * Compute the canonical hash of a BitGraph proof's signed body.
72
+ * Compute the ledger identity hash of a BitGraph proof.
73
+ *
74
+ * Hashes the frozen signed-body subset described at the top of this file.
75
+ * This is NOT the value the Ed25519 signature covers and NOT the attestation
76
+ * user_data; for that, reconstruct the full signed body (verifier.ts step 3).
48
77
  *
49
78
  * @param proof - The full BitGraphProof object (or equivalent Record)
50
79
  * @returns Base64-standard encoded SHA-256 hash
@@ -78,34 +107,55 @@ export function computeProofHash(proof: BitGraphProof | Record<string, unknown>)
78
107
  return base64(hash);
79
108
  }
80
109
 
110
+ /**
111
+ * Fields that are NOT produced by the enclave. They are appended to a proof
112
+ * AFTER it is signed, by the ledger or the anchor service, purely as read-time
113
+ * convenience. The enclave computed its chain hash before any of these
114
+ * existed, so they MUST be excluded from computeChainHash — otherwise a
115
+ * successor's `commit.prevB64` (computed by the enclave over the clean proof)
116
+ * can never match.
117
+ *
118
+ * proofHash — SHA-256 of the signed-body subset; added by the ledger at
119
+ * write time (see computeProofHash above).
120
+ * ethereum — { blockNumber, blockHash }; added by the anchor service to
121
+ * anchor records after the transaction is mined. Purely
122
+ * redundant: the block hash is already in the signed
123
+ * `attribution.message` and the block number in
124
+ * `attribution.title`. Present only on Ethereum-anchor proofs.
125
+ *
126
+ * Any future ledger/service-added field must be added here as well.
127
+ */
128
+ const LEDGER_ADDED_FIELDS: readonly string[] = ["proofHash", "ethereum"];
129
+
81
130
  /**
82
131
  * Compute the CHAIN hash of a BitGraph proof: SHA-256 over the canonicalized
83
- * whole proof object, with the ledger-added `proofHash` field removed.
132
+ * whole proof object, with the ledger/service-added fields removed.
84
133
  *
85
134
  * This is the value the enclave writes into the NEXT proof's
86
135
  * `commit.prevB64` (and into `epochLink.prevProofHashB64` at an epoch
87
136
  * boundary): the enclave hashes the entire assembled proof, before the ledger
88
- * appends its convenience `proofHash` field. To link a chain, match a proof's
89
- * `commit.prevB64` against `computeChainHash(predecessor)`.
137
+ * or anchor service appends its convenience fields. To link a chain, match a
138
+ * proof's `commit.prevB64` against `computeChainHash(predecessor)`.
90
139
  *
91
140
  * This differs from computeProofHash, which hashes only the signed-body
92
- * subset. The chain hash covers every field (signer signature, attestation
93
- * report, slot allocation, attribution, and so on), so any change anywhere in
94
- * a proof breaks the link from its successor.
141
+ * subset. The chain hash covers every enclave-produced field (signer
142
+ * signature, attestation report, slot allocation, attribution, and so on), so
143
+ * any change anywhere in a proof breaks the link from its successor.
95
144
  *
96
- * @param proof - The full BitGraphProof object (or equivalent Record). A
97
- * top-level `proofHash` field, if present, is excluded before hashing.
145
+ * @param proof - The full BitGraphProof object (or equivalent Record). Any
146
+ * ledger/service-added field (see LEDGER_ADDED_FIELDS) is excluded before
147
+ * hashing.
98
148
  * @returns Base64-standard encoded SHA-256 hash
99
149
  */
100
150
  export function computeChainHash(proof: BitGraphProof | Record<string, unknown>): string {
101
151
  const p = proof as Record<string, unknown>;
102
152
  let hashable: Record<string, unknown> = p;
103
- if (Object.prototype.hasOwnProperty.call(p, "proofHash")) {
104
- // Shallow copy without the ledger-added field; the enclave hashed the
105
- // proof before this field existed.
153
+ if (LEDGER_ADDED_FIELDS.some((k) => Object.prototype.hasOwnProperty.call(p, k))) {
154
+ // Shallow copy without the ledger/service-added fields; the enclave hashed
155
+ // the proof before these existed.
106
156
  hashable = {};
107
157
  for (const key of Object.keys(p)) {
108
- if (key !== "proofHash") hashable[key] = p[key];
158
+ if (!LEDGER_ADDED_FIELDS.includes(key)) hashable[key] = p[key];
109
159
  }
110
160
  }
111
161
  const bytes = canonicalize(hashable as unknown as BitGraphProof);
@@ -123,3 +173,53 @@ function base64(hash: Uint8Array): string {
123
173
  }
124
174
  return btoa(binary);
125
175
  }
176
+
177
+ /**
178
+ * Reconstruct the FULL canonical signed body: the bytes the Ed25519 signature
179
+ * actually covers, and the bytes whose SHA-256 the enclave places in the Nitro
180
+ * attestation's `user_data`.
181
+ *
182
+ * ⚠️ THIS IS HASH (2) IN THE LIST AT THE TOP OF THIS FILE, AND IT IS NOT
183
+ * computeProofHash. For a proof carrying neither `actor` nor `policy` the two
184
+ * coincide, which is why substituting one for the other survives every
185
+ * ordinary proof and fails only on agency or policy proofs — a bug that hides
186
+ * until the day the feature ships. That is exactly what happened: the offline
187
+ * auditor compared the attestation's user_data against computeProofHash and
188
+ * reported every declared recording as belonging to "some other proof",
189
+ * turning a valid proof into a FALSE (2026-08-18, found on the first real
190
+ * declaration, ledger position #12,010).
191
+ *
192
+ * Signature verification and attestation binding MUST both use this function
193
+ * so they cannot drift apart again.
194
+ */
195
+ export function buildSignedBody(proof: BitGraphProof | Record<string, unknown>): Record<string, unknown> {
196
+ const p = proof as Record<string, unknown>;
197
+ const signer = p.signer as { publicKeyB64?: string } | undefined;
198
+ const env = p.environment as
199
+ | { enforcement?: string; measurement?: string; attestation?: { format?: string } }
200
+ | undefined;
201
+
202
+ const body: Record<string, unknown> = {
203
+ version: p.version,
204
+ artifact: p.artifact,
205
+ commit: p.commit,
206
+ publicKeyB64: signer?.publicKeyB64,
207
+ enforcement: env?.enforcement,
208
+ measurement: env?.measurement,
209
+ };
210
+ if (env?.attestation !== undefined) body.attestationFormat = env.attestation.format;
211
+ const agency = p.agency as { actor?: unknown } | undefined;
212
+ if (agency !== undefined) body.actor = agency.actor;
213
+ if (p.policy !== undefined) body.policy = p.policy;
214
+ if (p.attribution !== undefined) body.attribution = p.attribution;
215
+ return body;
216
+ }
217
+
218
+ /**
219
+ * base64(SHA-256(canonical full signed body)). The value the enclave writes
220
+ * into the attestation's user_data, and the digest the Ed25519 signature
221
+ * covers. See buildSignedBody for why this is never computeProofHash.
222
+ */
223
+ export function computeSignedBodyHash(proof: BitGraphProof | Record<string, unknown>): string {
224
+ return base64(sha256(canonicalize(buildSignedBody(proof) as unknown as BitGraphProof)));
225
+ }