@bolyra/receipts 0.7.0 → 0.9.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 ADDED
@@ -0,0 +1,105 @@
1
+ # @bolyra/receipts
2
+
3
+ Tamper-evident signed receipts for Bolyra ZKP verification decisions — secp256k1 / ES256K signatures, canonical JSON, EVM-compatible r‖s‖v encoding.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @bolyra/receipts
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```typescript
14
+ import { createAuthReceipt, signReceipt, verifyReceipt } from '@bolyra/receipts';
15
+
16
+ // 1. Build a receipt from a verification result
17
+ const payload = createAuthReceipt(
18
+ {
19
+ rootDid: 'did:bolyra:0xabc…',
20
+ actingDid: 'did:bolyra:0xdef…',
21
+ credentialCommitment: '0x1234…',
22
+ effectiveCommitment: '0x1234…',
23
+ humanProof: verifiedBundle.humanProof,
24
+ agentProof: verifiedBundle.agentProof,
25
+ humanPublicSignals: verifiedBundle.humanPublicSignals,
26
+ agentPublicSignals: verifiedBundle.agentPublicSignals,
27
+ allowed: true,
28
+ score: 95,
29
+ permissionBitmask: 3n,
30
+ chainDepth: 0,
31
+ bundleVersion: 1,
32
+ nonce: '0xdeadbeef',
33
+ },
34
+ { issuer: 'https://gateway.example.com', keyId: 'k1' },
35
+ );
36
+
37
+ // 2. Sign it with your secp256k1 private key
38
+ const signed = signReceipt(payload, {
39
+ privateKey: process.env.RECEIPT_SIGNING_KEY!,
40
+ keyId: 'k1',
41
+ });
42
+
43
+ // 3. Verify later (or on another service)
44
+ const ok = verifyReceipt(signed, '0xYourExpectedSignerAddress');
45
+ console.log(ok); // true
46
+ ```
47
+
48
+ The `signed` object is JSON-serializable and can be stored in a database, forwarded to an audit log, or returned to the caller as proof of the verification decision.
49
+
50
+ ## Hash-chained logs (v0.8.0+)
51
+
52
+ A signature makes each *receipt* tamper-evident; it does not make a *log* of
53
+ receipts tamper-evident — deleting or reordering whole entries leaves every
54
+ remaining signature valid. `ReceiptChain` closes that gap:
55
+
56
+ ```typescript
57
+ import { ReceiptChain, verifyReceiptChain, GENESIS_PREV_RECEIPT_HASH } from '@bolyra/receipts';
58
+
59
+ // Writer side: one chain per log. Each signed payload gains
60
+ // chain: { seq, prevReceiptHash } — the fields are INSIDE the signed payload,
61
+ // so they cannot be rewritten without breaking the signature.
62
+ const chain = new ReceiptChain();
63
+ const first = chain.sign(payload1, signerConfig); // seq 0, prevReceiptHash = genesis sentinel
64
+ const second = chain.sign(payload2, signerConfig); // seq 1, prevReceiptHash = first.receiptHash
65
+
66
+ // Verifier side: every signature AND the chain links.
67
+ const result = verifyReceiptChain([first, second], { expectedSigner: '0x…' });
68
+ result.ok; // true
69
+ result.headHash; // pin this externally to detect tail truncation later
70
+ ```
71
+
72
+ Details:
73
+
74
+ - **Genesis sentinel:** the first receipt in a log has `seq: 0` and
75
+ `prevReceiptHash: GENESIS_PREV_RECEIPT_HASH` (`0x` + 64 zeros).
76
+ - **`receiptHash`** (envelope field) is `computeReceiptHash(receipt)`:
77
+ keccak256 over the canonical `{ payload, signature }` — it commits to the
78
+ exact signature bytes and excludes `id` and itself. Verifiers recompute it;
79
+ the stored copy is a convenience for linking and anchoring.
80
+ - **Backward compatible:** all fields are additive. Chain-less receipts keep
81
+ verifying, chained receipts still pass the plain `verifyReceipt()`, and
82
+ chain verification is a separate step. Logs that START with pre-chaining
83
+ receipts verify with `{ allowUnchained: true }` (deletions among that
84
+ unchained prefix are, unavoidably, not detectable). Only a prefix is
85
+ tolerated: a chain-less receipt after any chained receipt always fails
86
+ (`unchained-after-chained`) — otherwise a validly signed chain-less receipt
87
+ could be spliced in undetected.
88
+ - **What chain verification detects from the log alone:** edited receipts,
89
+ deleted lines, reordered lines, inserted lines, head truncation (missing
90
+ genesis), and a second chain spliced into the file.
91
+ - **What it provably cannot detect from the log alone:** truncation from the
92
+ **tail** — a chain cut after any receipt is still internally consistent.
93
+ Detecting it requires an external expectation: pass `expectedCount` and/or
94
+ `expectedHeadHash` (e.g. from a periodically anchored checkpoint). The
95
+ anchoring mechanism and checkpoint cadence are deployment policy —
96
+ enterprise-configurable, not fixed by this library.
97
+
98
+ CLI: `bolyra receipt verify-chain audit-log.jsonl` (from
99
+ [`@bolyra/cli`](../cli/README.md)) runs the same verification over a JSONL
100
+ file, with `--signer`, `--expect-count`, `--expect-head`, and
101
+ `--allow-unchained`.
102
+
103
+ ## License
104
+
105
+ Apache-2.0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Receipt hash-chaining — whole-log integrity on top of per-receipt signatures.
3
+ *
4
+ * Individual receipts are ES256K-signed and tamper-evident on their own; a log
5
+ * of receipts is not — deleting or reordering whole lines leaves every
6
+ * remaining signature valid. Chaining closes that gap:
7
+ *
8
+ * - Each chained receipt carries `payload.chain = { seq, prevReceiptHash }`.
9
+ * These fields live INSIDE the signed payload, so they cannot be rewritten
10
+ * after the fact without breaking the signature.
11
+ * - `seq` is 0-based and monotonic per log (per writer process).
12
+ * - `prevReceiptHash` is the previous receipt's canonical hash; the first
13
+ * receipt in a log uses the documented sentinel GENESIS_PREV_RECEIPT_HASH
14
+ * (32 zero bytes, hex).
15
+ * - `receiptHash` is attached to the signed-receipt ENVELOPE as a convenience:
16
+ * keccak256 over canonicalize({ payload, signature }) — it commits to both
17
+ * the payload and the exact signature bytes, and excludes `id` and the
18
+ * `receiptHash` field itself. Verifiers never trust it: verifyReceiptChain
19
+ * recomputes it and only flags a stored value that disagrees.
20
+ *
21
+ * Backward compatibility: all fields are additive. Chain-less receipts keep
22
+ * signing and verifying exactly as before, chained receipts verify with the
23
+ * existing per-receipt verifyReceipt(), and chain verification is a separate
24
+ * step (verifyReceiptChain).
25
+ *
26
+ * What chain verification can and cannot detect (be precise with auditors):
27
+ * - DETECTABLE from the log alone: edits to any receipt (signature), deleted
28
+ * lines (prev-hash/seq break), reordered lines, inserted lines, head
29
+ * truncation (log no longer starts at genesis), a restarted chain spliced
30
+ * into the same file.
31
+ * - NOT detectable from the log alone: truncation from the TAIL — a chain cut
32
+ * after any receipt is still internally consistent. Detecting it requires
33
+ * external knowledge: the expected receipt count (expectedCount) or the
34
+ * expected head hash (expectedHeadHash), e.g. from a periodically anchored
35
+ * checkpoint. Anchoring/checkpoint cadence is deployment policy
36
+ * (enterprise-configurable), not part of this library.
37
+ */
38
+ import type { ReceiptPayload, ReceiptSignerConfig, SignedReceipt } from './types';
39
+ /** Sentinel prevReceiptHash for the first receipt in a log: 32 zero bytes. */
40
+ export declare const GENESIS_PREV_RECEIPT_HASH: string;
41
+ /**
42
+ * Canonical hash of a signed receipt: keccak256 over
43
+ * canonicalize({ payload, signature }). Excludes `id` (derivable from
44
+ * signature.payloadHash) and `receiptHash` itself (to avoid self-reference).
45
+ */
46
+ export declare function computeReceiptHash(receipt: SignedReceipt): string;
47
+ /**
48
+ * Stateful writer-side chain: assigns { seq, prevReceiptHash } to each payload
49
+ * before signing and attaches the resulting receiptHash to the envelope.
50
+ *
51
+ * One ReceiptChain per log. State lives with the writer process; a restart
52
+ * starts a new chain (seq 0, genesis sentinel) — write it to a new log file
53
+ * if the log must verify as a single chain.
54
+ *
55
+ * State advances when a receipt is SIGNED, not when it is durably written.
56
+ * This is deliberate: if a signed receipt is subsequently lost (a dropped
57
+ * write), the next receipt links across the gap and chain verification FAILS
58
+ * — which is the truthful outcome, because the log really is incomplete. A
59
+ * writer-acknowledged design (advance only after persistence) would instead
60
+ * make the surviving log verify clean and silently hide the loss.
61
+ */
62
+ export declare class ReceiptChain {
63
+ private seq;
64
+ private prevReceiptHash;
65
+ /** Sign one payload as the next link. Advances state only on success. */
66
+ sign(payload: ReceiptPayload, config: ReceiptSignerConfig): SignedReceipt;
67
+ }
68
+ export type ReceiptChainIssueCode = 'malformed-receipt' | 'signature-invalid' | 'missing-chain-fields' | 'unchained-after-chained' | 'genesis-mismatch' | 'chain-restart' | 'seq-mismatch' | 'prev-hash-mismatch' | 'receipt-hash-mismatch' | 'count-mismatch' | 'head-hash-mismatch';
69
+ export interface ReceiptChainIssue {
70
+ /** 0-based position in the log; -1 for log-level issues (count/head checks). */
71
+ index: number;
72
+ receiptId?: string;
73
+ code: ReceiptChainIssueCode;
74
+ message: string;
75
+ }
76
+ export interface ChainVerifyOptions {
77
+ /** Require every signature to recover to this address. */
78
+ expectedSigner?: string;
79
+ /**
80
+ * Externally known receipt count. Without it, truncation from the TAIL of
81
+ * the log is NOT detectable — a cut chain is still internally consistent.
82
+ */
83
+ expectedCount?: number;
84
+ /** Externally known (anchored) hash of the last receipt — same purpose. */
85
+ expectedHeadHash?: string;
86
+ /**
87
+ * Tolerate a PREFIX of receipts without chain fields (logs that predate
88
+ * chaining). Their signatures are still verified, but deletion/reordering
89
+ * among them is NOT detectable. Only the prefix is tolerated: a chain-less
90
+ * receipt appearing AFTER any chained receipt is always an issue
91
+ * ('unchained-after-chained') — otherwise any validly signed chain-less
92
+ * receipt could be spliced into or appended to a chained log undetected.
93
+ */
94
+ allowUnchained?: boolean;
95
+ }
96
+ export interface ChainVerifyResult {
97
+ ok: boolean;
98
+ total: number;
99
+ chained: number;
100
+ unchained: number;
101
+ issues: ReceiptChainIssue[];
102
+ /**
103
+ * Recomputed hash of the last chained receipt — pin/anchor this externally
104
+ * to make tail truncation detectable on the next verification.
105
+ */
106
+ headHash?: string;
107
+ }
108
+ /**
109
+ * Verify a receipt log as a hash chain: every signature AND the chain links.
110
+ * Receipts must be passed in log order.
111
+ */
112
+ export declare function verifyReceiptChain(receipts: SignedReceipt[], options?: ChainVerifyOptions): ChainVerifyResult;
package/dist/chain.js ADDED
@@ -0,0 +1,228 @@
1
+ "use strict";
2
+ /**
3
+ * Receipt hash-chaining — whole-log integrity on top of per-receipt signatures.
4
+ *
5
+ * Individual receipts are ES256K-signed and tamper-evident on their own; a log
6
+ * of receipts is not — deleting or reordering whole lines leaves every
7
+ * remaining signature valid. Chaining closes that gap:
8
+ *
9
+ * - Each chained receipt carries `payload.chain = { seq, prevReceiptHash }`.
10
+ * These fields live INSIDE the signed payload, so they cannot be rewritten
11
+ * after the fact without breaking the signature.
12
+ * - `seq` is 0-based and monotonic per log (per writer process).
13
+ * - `prevReceiptHash` is the previous receipt's canonical hash; the first
14
+ * receipt in a log uses the documented sentinel GENESIS_PREV_RECEIPT_HASH
15
+ * (32 zero bytes, hex).
16
+ * - `receiptHash` is attached to the signed-receipt ENVELOPE as a convenience:
17
+ * keccak256 over canonicalize({ payload, signature }) — it commits to both
18
+ * the payload and the exact signature bytes, and excludes `id` and the
19
+ * `receiptHash` field itself. Verifiers never trust it: verifyReceiptChain
20
+ * recomputes it and only flags a stored value that disagrees.
21
+ *
22
+ * Backward compatibility: all fields are additive. Chain-less receipts keep
23
+ * signing and verifying exactly as before, chained receipts verify with the
24
+ * existing per-receipt verifyReceipt(), and chain verification is a separate
25
+ * step (verifyReceiptChain).
26
+ *
27
+ * What chain verification can and cannot detect (be precise with auditors):
28
+ * - DETECTABLE from the log alone: edits to any receipt (signature), deleted
29
+ * lines (prev-hash/seq break), reordered lines, inserted lines, head
30
+ * truncation (log no longer starts at genesis), a restarted chain spliced
31
+ * into the same file.
32
+ * - NOT detectable from the log alone: truncation from the TAIL — a chain cut
33
+ * after any receipt is still internally consistent. Detecting it requires
34
+ * external knowledge: the expected receipt count (expectedCount) or the
35
+ * expected head hash (expectedHeadHash), e.g. from a periodically anchored
36
+ * checkpoint. Anchoring/checkpoint cadence is deployment policy
37
+ * (enterprise-configurable), not part of this library.
38
+ */
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.ReceiptChain = exports.GENESIS_PREV_RECEIPT_HASH = void 0;
41
+ exports.computeReceiptHash = computeReceiptHash;
42
+ exports.verifyReceiptChain = verifyReceiptChain;
43
+ const sha3_1 = require("@noble/hashes/sha3");
44
+ const utils_1 = require("@noble/hashes/utils");
45
+ const canonical_1 = require("./canonical");
46
+ const sign_1 = require("./sign");
47
+ /** Sentinel prevReceiptHash for the first receipt in a log: 32 zero bytes. */
48
+ exports.GENESIS_PREV_RECEIPT_HASH = '0x' + '0'.repeat(64);
49
+ /**
50
+ * Canonical hash of a signed receipt: keccak256 over
51
+ * canonicalize({ payload, signature }). Excludes `id` (derivable from
52
+ * signature.payloadHash) and `receiptHash` itself (to avoid self-reference).
53
+ */
54
+ function computeReceiptHash(receipt) {
55
+ const canonical = (0, canonical_1.canonicalize)({ payload: receipt.payload, signature: receipt.signature });
56
+ return '0x' + (0, utils_1.bytesToHex)((0, sha3_1.keccak_256)(new TextEncoder().encode(canonical)));
57
+ }
58
+ /**
59
+ * Stateful writer-side chain: assigns { seq, prevReceiptHash } to each payload
60
+ * before signing and attaches the resulting receiptHash to the envelope.
61
+ *
62
+ * One ReceiptChain per log. State lives with the writer process; a restart
63
+ * starts a new chain (seq 0, genesis sentinel) — write it to a new log file
64
+ * if the log must verify as a single chain.
65
+ *
66
+ * State advances when a receipt is SIGNED, not when it is durably written.
67
+ * This is deliberate: if a signed receipt is subsequently lost (a dropped
68
+ * write), the next receipt links across the gap and chain verification FAILS
69
+ * — which is the truthful outcome, because the log really is incomplete. A
70
+ * writer-acknowledged design (advance only after persistence) would instead
71
+ * make the surviving log verify clean and silently hide the loss.
72
+ */
73
+ class ReceiptChain {
74
+ constructor() {
75
+ this.seq = 0;
76
+ this.prevReceiptHash = exports.GENESIS_PREV_RECEIPT_HASH;
77
+ }
78
+ /** Sign one payload as the next link. Advances state only on success. */
79
+ sign(payload, config) {
80
+ const chained = {
81
+ ...payload,
82
+ chain: { seq: this.seq, prevReceiptHash: this.prevReceiptHash },
83
+ };
84
+ const receipt = (0, sign_1.signReceipt)(chained, config);
85
+ const receiptHash = computeReceiptHash(receipt);
86
+ this.seq += 1;
87
+ this.prevReceiptHash = receiptHash;
88
+ return { ...receipt, receiptHash };
89
+ }
90
+ }
91
+ exports.ReceiptChain = ReceiptChain;
92
+ /**
93
+ * Verify a receipt log as a hash chain: every signature AND the chain links.
94
+ * Receipts must be passed in log order.
95
+ */
96
+ function verifyReceiptChain(receipts, options = {}) {
97
+ const issues = [];
98
+ let chained = 0;
99
+ let unchained = 0;
100
+ let prev;
101
+ let headHash;
102
+ receipts.forEach((receipt, index) => {
103
+ // 0. Shape guard: collected logs can contain non-receipt entries (e.g.
104
+ // the gateway's tagged `unsigned: true` raw fallback records). Flag them
105
+ // as malformed instead of throwing on property access below.
106
+ if (receipt === null ||
107
+ typeof receipt !== 'object' ||
108
+ typeof receipt.payload !== 'object' ||
109
+ receipt.payload === null ||
110
+ typeof receipt.signature !== 'object' ||
111
+ receipt.signature === null) {
112
+ issues.push({
113
+ index,
114
+ code: 'malformed-receipt',
115
+ message: `entry #${index} is not a signed receipt (missing payload/signature) — a foreign or corrupted log line`,
116
+ });
117
+ return;
118
+ }
119
+ const id = receipt.id;
120
+ // 1. Per-receipt signature (chain fields, when present, are signed).
121
+ if (!(0, sign_1.verifyReceipt)(receipt, options.expectedSigner)) {
122
+ issues.push({
123
+ index,
124
+ receiptId: id,
125
+ code: 'signature-invalid',
126
+ message: `receipt ${id ?? `#${index}`} failed ES256K signature verification${options.expectedSigner ? ` against signer ${options.expectedSigner}` : ''}`,
127
+ });
128
+ }
129
+ // 2. Chain fields present?
130
+ const chain = receipt.payload.chain;
131
+ if (!chain) {
132
+ unchained += 1;
133
+ if (prev !== undefined) {
134
+ // Never tolerated, even with allowUnchained: after the first chained
135
+ // receipt every line must chain, or any validly signed chain-less
136
+ // receipt could be inserted/appended without detection.
137
+ issues.push({
138
+ index,
139
+ receiptId: id,
140
+ code: 'unchained-after-chained',
141
+ message: `receipt ${id ?? `#${index}`} has no chain fields but follows chained receipts — an inserted or appended line (pre-chaining receipts can only form a prefix)`,
142
+ });
143
+ }
144
+ else if (!options.allowUnchained) {
145
+ issues.push({
146
+ index,
147
+ receiptId: id,
148
+ code: 'missing-chain-fields',
149
+ message: `receipt ${id ?? `#${index}`} has no chain fields — deletion or reordering around it is undetectable (re-run with allowUnchained to tolerate a pre-chaining prefix)`,
150
+ });
151
+ }
152
+ return;
153
+ }
154
+ chained += 1;
155
+ // 3. Link check against the previous CHAINED receipt.
156
+ const actualHash = computeReceiptHash(receipt);
157
+ if (prev === undefined) {
158
+ if (chain.seq !== 0 || chain.prevReceiptHash !== exports.GENESIS_PREV_RECEIPT_HASH) {
159
+ issues.push({
160
+ index,
161
+ receiptId: id,
162
+ code: 'genesis-mismatch',
163
+ message: `first chained receipt has seq ${chain.seq} and prevReceiptHash ${chain.prevReceiptHash} — expected seq 0 with the genesis sentinel; receipts before it were likely deleted (head truncation)`,
164
+ });
165
+ }
166
+ }
167
+ else if (chain.seq === 0 && chain.prevReceiptHash === exports.GENESIS_PREV_RECEIPT_HASH) {
168
+ issues.push({
169
+ index,
170
+ receiptId: id,
171
+ code: 'chain-restart',
172
+ message: `receipt ${id ?? `#${index}`} starts a new chain (seq 0, genesis sentinel) mid-log — writer restart or spliced logs; verify each chain from its own log file`,
173
+ });
174
+ }
175
+ else {
176
+ if (chain.seq !== prev.seq + 1) {
177
+ issues.push({
178
+ index,
179
+ receiptId: id,
180
+ code: 'seq-mismatch',
181
+ message: `receipt ${id ?? `#${index}`} has seq ${chain.seq}, expected ${prev.seq + 1} — receipts were deleted, reordered, or skipped`,
182
+ });
183
+ }
184
+ if (chain.prevReceiptHash !== prev.hash) {
185
+ issues.push({
186
+ index,
187
+ receiptId: id,
188
+ code: 'prev-hash-mismatch',
189
+ message: `receipt ${id ?? `#${index}`} prevReceiptHash does not match the preceding receipt's hash — the log was altered between them (deleted, reordered, or inserted lines)`,
190
+ });
191
+ }
192
+ }
193
+ // 4. Stored convenience hash, if present, must agree with the recomputed one.
194
+ if (receipt.receiptHash !== undefined && receipt.receiptHash !== actualHash) {
195
+ issues.push({
196
+ index,
197
+ receiptId: id,
198
+ code: 'receipt-hash-mismatch',
199
+ message: `receipt ${id ?? `#${index}`} carries receiptHash ${receipt.receiptHash}, but its content hashes to ${actualHash}`,
200
+ });
201
+ }
202
+ prev = { seq: chain.seq, hash: actualHash };
203
+ headHash = actualHash;
204
+ });
205
+ // 5. External expectations — the only way to detect tail truncation.
206
+ if (options.expectedCount !== undefined && receipts.length !== options.expectedCount) {
207
+ issues.push({
208
+ index: -1,
209
+ code: 'count-mismatch',
210
+ message: `log holds ${receipts.length} receipts, expected ${options.expectedCount}${receipts.length < options.expectedCount ? ' — consistent with tail truncation' : ''}`,
211
+ });
212
+ }
213
+ if (options.expectedHeadHash !== undefined && headHash !== options.expectedHeadHash) {
214
+ issues.push({
215
+ index: -1,
216
+ code: 'head-hash-mismatch',
217
+ message: `last chained receipt hashes to ${headHash ?? '(none)'}, expected head ${options.expectedHeadHash} — consistent with tail truncation or a diverged log`,
218
+ });
219
+ }
220
+ return {
221
+ ok: issues.length === 0,
222
+ total: receipts.length,
223
+ chained,
224
+ unchained,
225
+ issues,
226
+ headHash,
227
+ };
228
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export { canonicalize } from './canonical';
2
2
  export { createAuthReceipt, createCommerceReceipt } from './receipt';
3
3
  export { signReceipt, verifyReceipt, hashPayload } from './sign';
4
- export type { ReceiptPayload, SignedReceipt, ReceiptSignerConfig, AuthReceiptInput, CommerceReceiptInput, CommerceFields, } from './types';
4
+ export { GENESIS_PREV_RECEIPT_HASH, ReceiptChain, computeReceiptHash, verifyReceiptChain, } from './chain';
5
+ export type { ReceiptChainIssue, ReceiptChainIssueCode, ChainVerifyOptions, ChainVerifyResult, } from './chain';
6
+ export type { ReceiptPayload, ReceiptChainFields, SignedReceipt, ReceiptSignerConfig, AuthReceiptInput, CommerceReceiptInput, CommerceFields, } from './types';
7
+ export { parseSignerDiscovery, acceptedSigners, SignerDiscoveryError, type SignerDiscoveryDocument, type DiscoveredSigner, } from './signer-discovery';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.hashPayload = exports.verifyReceipt = exports.signReceipt = exports.createCommerceReceipt = exports.createAuthReceipt = exports.canonicalize = void 0;
3
+ exports.SignerDiscoveryError = exports.acceptedSigners = exports.parseSignerDiscovery = exports.verifyReceiptChain = exports.computeReceiptHash = exports.ReceiptChain = exports.GENESIS_PREV_RECEIPT_HASH = exports.hashPayload = exports.verifyReceipt = exports.signReceipt = exports.createCommerceReceipt = exports.createAuthReceipt = exports.canonicalize = void 0;
4
4
  var canonical_1 = require("./canonical");
5
5
  Object.defineProperty(exports, "canonicalize", { enumerable: true, get: function () { return canonical_1.canonicalize; } });
6
6
  var receipt_1 = require("./receipt");
@@ -10,3 +10,12 @@ var sign_1 = require("./sign");
10
10
  Object.defineProperty(exports, "signReceipt", { enumerable: true, get: function () { return sign_1.signReceipt; } });
11
11
  Object.defineProperty(exports, "verifyReceipt", { enumerable: true, get: function () { return sign_1.verifyReceipt; } });
12
12
  Object.defineProperty(exports, "hashPayload", { enumerable: true, get: function () { return sign_1.hashPayload; } });
13
+ var chain_1 = require("./chain");
14
+ Object.defineProperty(exports, "GENESIS_PREV_RECEIPT_HASH", { enumerable: true, get: function () { return chain_1.GENESIS_PREV_RECEIPT_HASH; } });
15
+ Object.defineProperty(exports, "ReceiptChain", { enumerable: true, get: function () { return chain_1.ReceiptChain; } });
16
+ Object.defineProperty(exports, "computeReceiptHash", { enumerable: true, get: function () { return chain_1.computeReceiptHash; } });
17
+ Object.defineProperty(exports, "verifyReceiptChain", { enumerable: true, get: function () { return chain_1.verifyReceiptChain; } });
18
+ var signer_discovery_1 = require("./signer-discovery");
19
+ Object.defineProperty(exports, "parseSignerDiscovery", { enumerable: true, get: function () { return signer_discovery_1.parseSignerDiscovery; } });
20
+ Object.defineProperty(exports, "acceptedSigners", { enumerable: true, get: function () { return signer_discovery_1.acceptedSigners; } });
21
+ Object.defineProperty(exports, "SignerDiscoveryError", { enumerable: true, get: function () { return signer_discovery_1.SignerDiscoveryError; } });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Receipt Signer Discovery v1 — canonical parser/validator.
3
+ * Spec: spec/receipt-signer-discovery-v1.md. Discovery is not endorsement:
4
+ * this module validates document SHAPE; deciding to trust the origin that
5
+ * served it stays with the consumer.
6
+ */
7
+ export declare class SignerDiscoveryError extends Error {
8
+ constructor(message: string);
9
+ }
10
+ export interface DiscoveredSigner {
11
+ keyId: string;
12
+ alg: 'ES256K';
13
+ signer: string;
14
+ label?: string;
15
+ }
16
+ export interface SignerDiscoveryDocument {
17
+ v: 1;
18
+ issuer: string;
19
+ updatedAt: number;
20
+ signers: DiscoveredSigner[];
21
+ }
22
+ /**
23
+ * Validate an untrusted JSON value as a v1 signer discovery document.
24
+ * Throws SignerDiscoveryError on any spec violation (consumers MUST treat
25
+ * that as verification failure — fail closed). Unknown fields are ignored.
26
+ */
27
+ export declare function parseSignerDiscovery(input: unknown): SignerDiscoveryDocument;
28
+ /** Lowercased accepted signer addresses from a validated document. */
29
+ export declare function acceptedSigners(doc: SignerDiscoveryDocument): Set<string>;
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ /**
3
+ * Receipt Signer Discovery v1 — canonical parser/validator.
4
+ * Spec: spec/receipt-signer-discovery-v1.md. Discovery is not endorsement:
5
+ * this module validates document SHAPE; deciding to trust the origin that
6
+ * served it stays with the consumer.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.SignerDiscoveryError = void 0;
10
+ exports.parseSignerDiscovery = parseSignerDiscovery;
11
+ exports.acceptedSigners = acceptedSigners;
12
+ const SIGNER_RE = /^0x[0-9a-fA-F]{40}$/;
13
+ class SignerDiscoveryError extends Error {
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = 'SignerDiscoveryError';
17
+ }
18
+ }
19
+ exports.SignerDiscoveryError = SignerDiscoveryError;
20
+ function bad(field, why) {
21
+ throw new SignerDiscoveryError(`signer discovery document invalid: ${field} ${why}`);
22
+ }
23
+ /**
24
+ * Validate an untrusted JSON value as a v1 signer discovery document.
25
+ * Throws SignerDiscoveryError on any spec violation (consumers MUST treat
26
+ * that as verification failure — fail closed). Unknown fields are ignored.
27
+ */
28
+ function parseSignerDiscovery(input) {
29
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
30
+ bad('document', 'must be a JSON object');
31
+ }
32
+ const doc = input;
33
+ if (doc.v !== 1)
34
+ bad('v', 'must be the number 1');
35
+ if (typeof doc.issuer !== 'string' || doc.issuer.length === 0) {
36
+ bad('issuer', 'must be a non-empty string');
37
+ }
38
+ if (typeof doc.updatedAt !== 'number' || !Number.isFinite(doc.updatedAt)) {
39
+ bad('updatedAt', 'must be a number (unix seconds)');
40
+ }
41
+ if (!Array.isArray(doc.signers) || doc.signers.length === 0) {
42
+ bad('signers', 'must be a non-empty array');
43
+ }
44
+ const byKeyId = new Map();
45
+ const signers = doc.signers.map((raw, i) => {
46
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
47
+ bad(`signers[${i}]`, 'must be an object');
48
+ }
49
+ const e = raw;
50
+ if (typeof e.keyId !== 'string' || e.keyId.length === 0) {
51
+ bad(`signers[${i}].keyId`, 'must be a non-empty string');
52
+ }
53
+ if (e.alg !== 'ES256K') {
54
+ bad(`signers[${i}].alg`, 'must be "ES256K" in v1 (closed set)');
55
+ }
56
+ if (typeof e.signer !== 'string' || !SIGNER_RE.test(e.signer)) {
57
+ bad(`signers[${i}].signer`, 'must match ^0x[0-9a-fA-F]{40}$');
58
+ }
59
+ if (e.label !== undefined && typeof e.label !== 'string') {
60
+ bad(`signers[${i}].label`, 'must be a string when present');
61
+ }
62
+ const keyId = e.keyId;
63
+ const signer = e.signer.toLowerCase();
64
+ const seen = byKeyId.get(keyId);
65
+ if (seen !== undefined && seen !== signer) {
66
+ bad(`signers[${i}].keyId`, `"${keyId}" appears twice with conflicting signer values`);
67
+ }
68
+ byKeyId.set(keyId, signer);
69
+ return {
70
+ keyId,
71
+ alg: 'ES256K',
72
+ signer: e.signer,
73
+ ...(e.label !== undefined ? { label: e.label } : {}),
74
+ };
75
+ });
76
+ return { v: 1, issuer: doc.issuer, updatedAt: doc.updatedAt, signers };
77
+ }
78
+ /** Lowercased accepted signer addresses from a validated document. */
79
+ function acceptedSigners(doc) {
80
+ return new Set(doc.signers.map((s) => s.signer.toLowerCase()));
81
+ }
package/dist/types.d.ts CHANGED
@@ -1,3 +1,19 @@
1
+ /**
2
+ * Optional hash-chain fields linking a receipt to its predecessor in a log.
3
+ * Additive and backward-compatible: chain-less receipts remain valid, and
4
+ * chained receipts still verify with the plain per-receipt verifyReceipt().
5
+ * Living inside the signed payload, these fields cannot be rewritten without
6
+ * breaking the ES256K signature.
7
+ */
8
+ export interface ReceiptChainFields {
9
+ /** 0-based, monotonic position in the log (per writer process). */
10
+ seq: number;
11
+ /**
12
+ * computeReceiptHash() of the previous receipt in the log;
13
+ * GENESIS_PREV_RECEIPT_HASH (32 zero bytes) for the first receipt.
14
+ */
15
+ prevReceiptHash: string;
16
+ }
1
17
  export interface ReceiptPayload {
2
18
  v: 1;
3
19
  kind: 'bolyra.auth' | 'bolyra.commerce';
@@ -38,6 +54,8 @@ export interface ReceiptPayload {
38
54
  };
39
55
  /** Present only when kind === 'bolyra.commerce'. */
40
56
  commerce?: CommerceFields;
57
+ /** Present only on hash-chained receipts (written via ReceiptChain). */
58
+ chain?: ReceiptChainFields;
41
59
  }
42
60
  export interface SignedReceipt {
43
61
  /** First 16 hex chars of payloadHash. */
@@ -53,6 +71,13 @@ export interface SignedReceipt {
53
71
  /** r (32 bytes) + s (32 bytes) + v (1 byte) = 65 bytes (hex). */
54
72
  value: string;
55
73
  };
74
+ /**
75
+ * Convenience copy of computeReceiptHash(this) — keccak256 over the
76
+ * canonical { payload, signature }. Present on hash-chained receipts; the
77
+ * next receipt's payload.chain.prevReceiptHash equals it. Verifiers must
78
+ * recompute rather than trust it (verifyReceiptChain does).
79
+ */
80
+ receiptHash?: string;
56
81
  }
57
82
  export interface ReceiptSignerConfig {
58
83
  issuer: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolyra/receipts",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Signed auth receipts for Bolyra ZKP verification decisions — canonical JSON, secp256k1 sign/verify, EVM-compatible r||s||v signatures.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/chain.ts ADDED
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Receipt hash-chaining — whole-log integrity on top of per-receipt signatures.
3
+ *
4
+ * Individual receipts are ES256K-signed and tamper-evident on their own; a log
5
+ * of receipts is not — deleting or reordering whole lines leaves every
6
+ * remaining signature valid. Chaining closes that gap:
7
+ *
8
+ * - Each chained receipt carries `payload.chain = { seq, prevReceiptHash }`.
9
+ * These fields live INSIDE the signed payload, so they cannot be rewritten
10
+ * after the fact without breaking the signature.
11
+ * - `seq` is 0-based and monotonic per log (per writer process).
12
+ * - `prevReceiptHash` is the previous receipt's canonical hash; the first
13
+ * receipt in a log uses the documented sentinel GENESIS_PREV_RECEIPT_HASH
14
+ * (32 zero bytes, hex).
15
+ * - `receiptHash` is attached to the signed-receipt ENVELOPE as a convenience:
16
+ * keccak256 over canonicalize({ payload, signature }) — it commits to both
17
+ * the payload and the exact signature bytes, and excludes `id` and the
18
+ * `receiptHash` field itself. Verifiers never trust it: verifyReceiptChain
19
+ * recomputes it and only flags a stored value that disagrees.
20
+ *
21
+ * Backward compatibility: all fields are additive. Chain-less receipts keep
22
+ * signing and verifying exactly as before, chained receipts verify with the
23
+ * existing per-receipt verifyReceipt(), and chain verification is a separate
24
+ * step (verifyReceiptChain).
25
+ *
26
+ * What chain verification can and cannot detect (be precise with auditors):
27
+ * - DETECTABLE from the log alone: edits to any receipt (signature), deleted
28
+ * lines (prev-hash/seq break), reordered lines, inserted lines, head
29
+ * truncation (log no longer starts at genesis), a restarted chain spliced
30
+ * into the same file.
31
+ * - NOT detectable from the log alone: truncation from the TAIL — a chain cut
32
+ * after any receipt is still internally consistent. Detecting it requires
33
+ * external knowledge: the expected receipt count (expectedCount) or the
34
+ * expected head hash (expectedHeadHash), e.g. from a periodically anchored
35
+ * checkpoint. Anchoring/checkpoint cadence is deployment policy
36
+ * (enterprise-configurable), not part of this library.
37
+ */
38
+
39
+ import { keccak_256 } from '@noble/hashes/sha3';
40
+ import { bytesToHex } from '@noble/hashes/utils';
41
+ import { canonicalize } from './canonical';
42
+ import { signReceipt, verifyReceipt } from './sign';
43
+ import type { ReceiptPayload, ReceiptSignerConfig, SignedReceipt } from './types';
44
+
45
+ /** Sentinel prevReceiptHash for the first receipt in a log: 32 zero bytes. */
46
+ export const GENESIS_PREV_RECEIPT_HASH = '0x' + '0'.repeat(64);
47
+
48
+ /**
49
+ * Canonical hash of a signed receipt: keccak256 over
50
+ * canonicalize({ payload, signature }). Excludes `id` (derivable from
51
+ * signature.payloadHash) and `receiptHash` itself (to avoid self-reference).
52
+ */
53
+ export function computeReceiptHash(receipt: SignedReceipt): string {
54
+ const canonical = canonicalize({ payload: receipt.payload, signature: receipt.signature });
55
+ return '0x' + bytesToHex(keccak_256(new TextEncoder().encode(canonical)));
56
+ }
57
+
58
+ /**
59
+ * Stateful writer-side chain: assigns { seq, prevReceiptHash } to each payload
60
+ * before signing and attaches the resulting receiptHash to the envelope.
61
+ *
62
+ * One ReceiptChain per log. State lives with the writer process; a restart
63
+ * starts a new chain (seq 0, genesis sentinel) — write it to a new log file
64
+ * if the log must verify as a single chain.
65
+ *
66
+ * State advances when a receipt is SIGNED, not when it is durably written.
67
+ * This is deliberate: if a signed receipt is subsequently lost (a dropped
68
+ * write), the next receipt links across the gap and chain verification FAILS
69
+ * — which is the truthful outcome, because the log really is incomplete. A
70
+ * writer-acknowledged design (advance only after persistence) would instead
71
+ * make the surviving log verify clean and silently hide the loss.
72
+ */
73
+ export class ReceiptChain {
74
+ private seq = 0;
75
+ private prevReceiptHash = GENESIS_PREV_RECEIPT_HASH;
76
+
77
+ /** Sign one payload as the next link. Advances state only on success. */
78
+ sign(payload: ReceiptPayload, config: ReceiptSignerConfig): SignedReceipt {
79
+ const chained: ReceiptPayload = {
80
+ ...payload,
81
+ chain: { seq: this.seq, prevReceiptHash: this.prevReceiptHash },
82
+ };
83
+ const receipt = signReceipt(chained, config);
84
+ const receiptHash = computeReceiptHash(receipt);
85
+ this.seq += 1;
86
+ this.prevReceiptHash = receiptHash;
87
+ return { ...receipt, receiptHash };
88
+ }
89
+ }
90
+
91
+ export type ReceiptChainIssueCode =
92
+ | 'malformed-receipt'
93
+ | 'signature-invalid'
94
+ | 'missing-chain-fields'
95
+ | 'unchained-after-chained'
96
+ | 'genesis-mismatch'
97
+ | 'chain-restart'
98
+ | 'seq-mismatch'
99
+ | 'prev-hash-mismatch'
100
+ | 'receipt-hash-mismatch'
101
+ | 'count-mismatch'
102
+ | 'head-hash-mismatch';
103
+
104
+ export interface ReceiptChainIssue {
105
+ /** 0-based position in the log; -1 for log-level issues (count/head checks). */
106
+ index: number;
107
+ receiptId?: string;
108
+ code: ReceiptChainIssueCode;
109
+ message: string;
110
+ }
111
+
112
+ export interface ChainVerifyOptions {
113
+ /** Require every signature to recover to this address. */
114
+ expectedSigner?: string;
115
+ /**
116
+ * Externally known receipt count. Without it, truncation from the TAIL of
117
+ * the log is NOT detectable — a cut chain is still internally consistent.
118
+ */
119
+ expectedCount?: number;
120
+ /** Externally known (anchored) hash of the last receipt — same purpose. */
121
+ expectedHeadHash?: string;
122
+ /**
123
+ * Tolerate a PREFIX of receipts without chain fields (logs that predate
124
+ * chaining). Their signatures are still verified, but deletion/reordering
125
+ * among them is NOT detectable. Only the prefix is tolerated: a chain-less
126
+ * receipt appearing AFTER any chained receipt is always an issue
127
+ * ('unchained-after-chained') — otherwise any validly signed chain-less
128
+ * receipt could be spliced into or appended to a chained log undetected.
129
+ */
130
+ allowUnchained?: boolean;
131
+ }
132
+
133
+ export interface ChainVerifyResult {
134
+ ok: boolean;
135
+ total: number;
136
+ chained: number;
137
+ unchained: number;
138
+ issues: ReceiptChainIssue[];
139
+ /**
140
+ * Recomputed hash of the last chained receipt — pin/anchor this externally
141
+ * to make tail truncation detectable on the next verification.
142
+ */
143
+ headHash?: string;
144
+ }
145
+
146
+ /**
147
+ * Verify a receipt log as a hash chain: every signature AND the chain links.
148
+ * Receipts must be passed in log order.
149
+ */
150
+ export function verifyReceiptChain(
151
+ receipts: SignedReceipt[],
152
+ options: ChainVerifyOptions = {},
153
+ ): ChainVerifyResult {
154
+ const issues: ReceiptChainIssue[] = [];
155
+ let chained = 0;
156
+ let unchained = 0;
157
+ let prev: { seq: number; hash: string } | undefined;
158
+ let headHash: string | undefined;
159
+
160
+ receipts.forEach((receipt, index) => {
161
+ // 0. Shape guard: collected logs can contain non-receipt entries (e.g.
162
+ // the gateway's tagged `unsigned: true` raw fallback records). Flag them
163
+ // as malformed instead of throwing on property access below.
164
+ if (
165
+ receipt === null ||
166
+ typeof receipt !== 'object' ||
167
+ typeof receipt.payload !== 'object' ||
168
+ receipt.payload === null ||
169
+ typeof receipt.signature !== 'object' ||
170
+ receipt.signature === null
171
+ ) {
172
+ issues.push({
173
+ index,
174
+ code: 'malformed-receipt',
175
+ message: `entry #${index} is not a signed receipt (missing payload/signature) — a foreign or corrupted log line`,
176
+ });
177
+ return;
178
+ }
179
+
180
+ const id = receipt.id;
181
+
182
+ // 1. Per-receipt signature (chain fields, when present, are signed).
183
+ if (!verifyReceipt(receipt, options.expectedSigner)) {
184
+ issues.push({
185
+ index,
186
+ receiptId: id,
187
+ code: 'signature-invalid',
188
+ message: `receipt ${id ?? `#${index}`} failed ES256K signature verification${options.expectedSigner ? ` against signer ${options.expectedSigner}` : ''}`,
189
+ });
190
+ }
191
+
192
+ // 2. Chain fields present?
193
+ const chain = receipt.payload.chain;
194
+ if (!chain) {
195
+ unchained += 1;
196
+ if (prev !== undefined) {
197
+ // Never tolerated, even with allowUnchained: after the first chained
198
+ // receipt every line must chain, or any validly signed chain-less
199
+ // receipt could be inserted/appended without detection.
200
+ issues.push({
201
+ index,
202
+ receiptId: id,
203
+ code: 'unchained-after-chained',
204
+ message: `receipt ${id ?? `#${index}`} has no chain fields but follows chained receipts — an inserted or appended line (pre-chaining receipts can only form a prefix)`,
205
+ });
206
+ } else if (!options.allowUnchained) {
207
+ issues.push({
208
+ index,
209
+ receiptId: id,
210
+ code: 'missing-chain-fields',
211
+ message: `receipt ${id ?? `#${index}`} has no chain fields — deletion or reordering around it is undetectable (re-run with allowUnchained to tolerate a pre-chaining prefix)`,
212
+ });
213
+ }
214
+ return;
215
+ }
216
+ chained += 1;
217
+
218
+ // 3. Link check against the previous CHAINED receipt.
219
+ const actualHash = computeReceiptHash(receipt);
220
+ if (prev === undefined) {
221
+ if (chain.seq !== 0 || chain.prevReceiptHash !== GENESIS_PREV_RECEIPT_HASH) {
222
+ issues.push({
223
+ index,
224
+ receiptId: id,
225
+ code: 'genesis-mismatch',
226
+ message: `first chained receipt has seq ${chain.seq} and prevReceiptHash ${chain.prevReceiptHash} — expected seq 0 with the genesis sentinel; receipts before it were likely deleted (head truncation)`,
227
+ });
228
+ }
229
+ } else if (chain.seq === 0 && chain.prevReceiptHash === GENESIS_PREV_RECEIPT_HASH) {
230
+ issues.push({
231
+ index,
232
+ receiptId: id,
233
+ code: 'chain-restart',
234
+ message: `receipt ${id ?? `#${index}`} starts a new chain (seq 0, genesis sentinel) mid-log — writer restart or spliced logs; verify each chain from its own log file`,
235
+ });
236
+ } else {
237
+ if (chain.seq !== prev.seq + 1) {
238
+ issues.push({
239
+ index,
240
+ receiptId: id,
241
+ code: 'seq-mismatch',
242
+ message: `receipt ${id ?? `#${index}`} has seq ${chain.seq}, expected ${prev.seq + 1} — receipts were deleted, reordered, or skipped`,
243
+ });
244
+ }
245
+ if (chain.prevReceiptHash !== prev.hash) {
246
+ issues.push({
247
+ index,
248
+ receiptId: id,
249
+ code: 'prev-hash-mismatch',
250
+ message: `receipt ${id ?? `#${index}`} prevReceiptHash does not match the preceding receipt's hash — the log was altered between them (deleted, reordered, or inserted lines)`,
251
+ });
252
+ }
253
+ }
254
+
255
+ // 4. Stored convenience hash, if present, must agree with the recomputed one.
256
+ if (receipt.receiptHash !== undefined && receipt.receiptHash !== actualHash) {
257
+ issues.push({
258
+ index,
259
+ receiptId: id,
260
+ code: 'receipt-hash-mismatch',
261
+ message: `receipt ${id ?? `#${index}`} carries receiptHash ${receipt.receiptHash}, but its content hashes to ${actualHash}`,
262
+ });
263
+ }
264
+
265
+ prev = { seq: chain.seq, hash: actualHash };
266
+ headHash = actualHash;
267
+ });
268
+
269
+ // 5. External expectations — the only way to detect tail truncation.
270
+ if (options.expectedCount !== undefined && receipts.length !== options.expectedCount) {
271
+ issues.push({
272
+ index: -1,
273
+ code: 'count-mismatch',
274
+ message: `log holds ${receipts.length} receipts, expected ${options.expectedCount}${receipts.length < options.expectedCount ? ' — consistent with tail truncation' : ''}`,
275
+ });
276
+ }
277
+ if (options.expectedHeadHash !== undefined && headHash !== options.expectedHeadHash) {
278
+ issues.push({
279
+ index: -1,
280
+ code: 'head-hash-mismatch',
281
+ message: `last chained receipt hashes to ${headHash ?? '(none)'}, expected head ${options.expectedHeadHash} — consistent with tail truncation or a diverged log`,
282
+ });
283
+ }
284
+
285
+ return {
286
+ ok: issues.length === 0,
287
+ total: receipts.length,
288
+ chained,
289
+ unchained,
290
+ issues,
291
+ headHash,
292
+ };
293
+ }
package/src/index.ts CHANGED
@@ -1,11 +1,31 @@
1
1
  export { canonicalize } from './canonical';
2
2
  export { createAuthReceipt, createCommerceReceipt } from './receipt';
3
3
  export { signReceipt, verifyReceipt, hashPayload } from './sign';
4
+ export {
5
+ GENESIS_PREV_RECEIPT_HASH,
6
+ ReceiptChain,
7
+ computeReceiptHash,
8
+ verifyReceiptChain,
9
+ } from './chain';
10
+ export type {
11
+ ReceiptChainIssue,
12
+ ReceiptChainIssueCode,
13
+ ChainVerifyOptions,
14
+ ChainVerifyResult,
15
+ } from './chain';
4
16
  export type {
5
17
  ReceiptPayload,
18
+ ReceiptChainFields,
6
19
  SignedReceipt,
7
20
  ReceiptSignerConfig,
8
21
  AuthReceiptInput,
9
22
  CommerceReceiptInput,
10
23
  CommerceFields,
11
24
  } from './types';
25
+ export {
26
+ parseSignerDiscovery,
27
+ acceptedSigners,
28
+ SignerDiscoveryError,
29
+ type SignerDiscoveryDocument,
30
+ type DiscoveredSigner,
31
+ } from './signer-discovery';
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Receipt Signer Discovery v1 — canonical parser/validator.
3
+ * Spec: spec/receipt-signer-discovery-v1.md. Discovery is not endorsement:
4
+ * this module validates document SHAPE; deciding to trust the origin that
5
+ * served it stays with the consumer.
6
+ */
7
+
8
+ const SIGNER_RE = /^0x[0-9a-fA-F]{40}$/;
9
+
10
+ export class SignerDiscoveryError extends Error {
11
+ constructor(message: string) {
12
+ super(message);
13
+ this.name = 'SignerDiscoveryError';
14
+ }
15
+ }
16
+
17
+ export interface DiscoveredSigner {
18
+ keyId: string;
19
+ alg: 'ES256K';
20
+ signer: string;
21
+ label?: string;
22
+ }
23
+
24
+ export interface SignerDiscoveryDocument {
25
+ v: 1;
26
+ issuer: string;
27
+ updatedAt: number;
28
+ signers: DiscoveredSigner[];
29
+ }
30
+
31
+ function bad(field: string, why: string): never {
32
+ throw new SignerDiscoveryError(`signer discovery document invalid: ${field} ${why}`);
33
+ }
34
+
35
+ /**
36
+ * Validate an untrusted JSON value as a v1 signer discovery document.
37
+ * Throws SignerDiscoveryError on any spec violation (consumers MUST treat
38
+ * that as verification failure — fail closed). Unknown fields are ignored.
39
+ */
40
+ export function parseSignerDiscovery(input: unknown): SignerDiscoveryDocument {
41
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
42
+ bad('document', 'must be a JSON object');
43
+ }
44
+ const doc = input as Record<string, unknown>;
45
+
46
+ if (doc.v !== 1) bad('v', 'must be the number 1');
47
+ if (typeof doc.issuer !== 'string' || doc.issuer.length === 0) {
48
+ bad('issuer', 'must be a non-empty string');
49
+ }
50
+ if (typeof doc.updatedAt !== 'number' || !Number.isFinite(doc.updatedAt)) {
51
+ bad('updatedAt', 'must be a number (unix seconds)');
52
+ }
53
+ if (!Array.isArray(doc.signers) || doc.signers.length === 0) {
54
+ bad('signers', 'must be a non-empty array');
55
+ }
56
+
57
+ const byKeyId = new Map<string, string>();
58
+ const signers: DiscoveredSigner[] = doc.signers.map((raw, i) => {
59
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
60
+ bad(`signers[${i}]`, 'must be an object');
61
+ }
62
+ const e = raw as Record<string, unknown>;
63
+ if (typeof e.keyId !== 'string' || e.keyId.length === 0) {
64
+ bad(`signers[${i}].keyId`, 'must be a non-empty string');
65
+ }
66
+ if (e.alg !== 'ES256K') {
67
+ bad(`signers[${i}].alg`, 'must be "ES256K" in v1 (closed set)');
68
+ }
69
+ if (typeof e.signer !== 'string' || !SIGNER_RE.test(e.signer)) {
70
+ bad(`signers[${i}].signer`, 'must match ^0x[0-9a-fA-F]{40}$');
71
+ }
72
+ if (e.label !== undefined && typeof e.label !== 'string') {
73
+ bad(`signers[${i}].label`, 'must be a string when present');
74
+ }
75
+ const keyId = e.keyId as string;
76
+ const signer = (e.signer as string).toLowerCase();
77
+ const seen = byKeyId.get(keyId);
78
+ if (seen !== undefined && seen !== signer) {
79
+ bad(`signers[${i}].keyId`, `"${keyId}" appears twice with conflicting signer values`);
80
+ }
81
+ byKeyId.set(keyId, signer);
82
+ return {
83
+ keyId,
84
+ alg: 'ES256K' as const,
85
+ signer: e.signer as string,
86
+ ...(e.label !== undefined ? { label: e.label as string } : {}),
87
+ };
88
+ });
89
+
90
+ return { v: 1, issuer: doc.issuer as string, updatedAt: doc.updatedAt as number, signers };
91
+ }
92
+
93
+ /** Lowercased accepted signer addresses from a validated document. */
94
+ export function acceptedSigners(doc: SignerDiscoveryDocument): Set<string> {
95
+ return new Set(doc.signers.map((s) => s.signer.toLowerCase()));
96
+ }
package/src/types.ts CHANGED
@@ -1,3 +1,20 @@
1
+ /**
2
+ * Optional hash-chain fields linking a receipt to its predecessor in a log.
3
+ * Additive and backward-compatible: chain-less receipts remain valid, and
4
+ * chained receipts still verify with the plain per-receipt verifyReceipt().
5
+ * Living inside the signed payload, these fields cannot be rewritten without
6
+ * breaking the ES256K signature.
7
+ */
8
+ export interface ReceiptChainFields {
9
+ /** 0-based, monotonic position in the log (per writer process). */
10
+ seq: number;
11
+ /**
12
+ * computeReceiptHash() of the previous receipt in the log;
13
+ * GENESIS_PREV_RECEIPT_HASH (32 zero bytes) for the first receipt.
14
+ */
15
+ prevReceiptHash: string;
16
+ }
17
+
1
18
  export interface ReceiptPayload {
2
19
  v: 1;
3
20
  kind: 'bolyra.auth' | 'bolyra.commerce';
@@ -42,6 +59,9 @@ export interface ReceiptPayload {
42
59
 
43
60
  /** Present only when kind === 'bolyra.commerce'. */
44
61
  commerce?: CommerceFields;
62
+
63
+ /** Present only on hash-chained receipts (written via ReceiptChain). */
64
+ chain?: ReceiptChainFields;
45
65
  }
46
66
 
47
67
  export interface SignedReceipt {
@@ -58,6 +78,13 @@ export interface SignedReceipt {
58
78
  /** r (32 bytes) + s (32 bytes) + v (1 byte) = 65 bytes (hex). */
59
79
  value: string;
60
80
  };
81
+ /**
82
+ * Convenience copy of computeReceiptHash(this) — keccak256 over the
83
+ * canonical { payload, signature }. Present on hash-chained receipts; the
84
+ * next receipt's payload.chain.prevReceiptHash equals it. Verifiers must
85
+ * recompute rather than trust it (verifyReceiptChain does).
86
+ */
87
+ receiptHash?: string;
61
88
  }
62
89
 
63
90
  export interface ReceiptSignerConfig {