@bolyra/receipts 0.6.0 → 0.8.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.
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ const fs = __importStar(require("fs"));
38
+ const process = __importStar(require("process"));
39
+ const sign_1 = require("./sign");
40
+ // --- Helpers ---
41
+ function usage() {
42
+ return `Usage: bolyra-receipt-verify <receipt.json> [options]
43
+
44
+ Options:
45
+ --stdin Read receipt JSON from stdin instead of a file
46
+ --signer <address> Expected signer address (optional)
47
+ --max-age <seconds> Maximum receipt age in seconds (default: 86400)
48
+ --help Show this help`;
49
+ }
50
+ function fail(msg) {
51
+ process.stderr.write(msg + '\n');
52
+ process.exit(2);
53
+ }
54
+ function truncate(hex) {
55
+ if (hex.length <= 14)
56
+ return hex;
57
+ return hex.slice(0, 8) + '...' + hex.slice(-4);
58
+ }
59
+ // --- Arg parsing ---
60
+ const args = process.argv.slice(2);
61
+ if (args.includes('--help') || args.includes('-h')) {
62
+ console.log(usage());
63
+ process.exit(0);
64
+ }
65
+ let filePath;
66
+ let expectedSigner;
67
+ let maxAgeSeconds = 86400;
68
+ let useStdin = false;
69
+ for (let i = 0; i < args.length; i++) {
70
+ const arg = args[i];
71
+ if (arg === '--stdin') {
72
+ useStdin = true;
73
+ }
74
+ else if (arg === '--signer') {
75
+ expectedSigner = args[++i];
76
+ if (!expectedSigner)
77
+ fail('--signer requires an address argument');
78
+ }
79
+ else if (arg === '--max-age') {
80
+ const val = args[++i];
81
+ if (!val)
82
+ fail('--max-age requires a number argument');
83
+ maxAgeSeconds = parseInt(val, 10);
84
+ if (Number.isNaN(maxAgeSeconds) || maxAgeSeconds < 0) {
85
+ fail('--max-age must be a non-negative integer');
86
+ }
87
+ }
88
+ else if (arg.startsWith('-')) {
89
+ fail(`Unknown option: ${arg}\n\n${usage()}`);
90
+ }
91
+ else {
92
+ filePath = arg;
93
+ }
94
+ }
95
+ if (!filePath && !useStdin) {
96
+ fail(`No input specified. Provide a file path or use --stdin.\n\n${usage()}`);
97
+ }
98
+ // --- Read input ---
99
+ let rawJson;
100
+ try {
101
+ if (useStdin) {
102
+ rawJson = fs.readFileSync(0, 'utf-8');
103
+ }
104
+ else {
105
+ rawJson = fs.readFileSync(filePath, 'utf-8');
106
+ }
107
+ }
108
+ catch (err) {
109
+ const msg = err instanceof Error ? err.message : String(err);
110
+ fail(`Cannot read input: ${msg}`);
111
+ }
112
+ let receipt;
113
+ try {
114
+ receipt = JSON.parse(rawJson);
115
+ }
116
+ catch {
117
+ fail('Invalid JSON');
118
+ }
119
+ // --- Checks ---
120
+ const checks = [];
121
+ let failed = false;
122
+ function pass(msg) {
123
+ checks.push(`\u2713 ${msg}`);
124
+ }
125
+ function failCheck(msg) {
126
+ checks.push(`\u2717 ${msg}`);
127
+ failed = true;
128
+ }
129
+ // 1. Schema validation
130
+ function validateSchema() {
131
+ const p = receipt.payload;
132
+ const s = receipt.signature;
133
+ const errors = [];
134
+ if (!p) {
135
+ errors.push('missing payload');
136
+ }
137
+ if (!s) {
138
+ errors.push('missing signature');
139
+ }
140
+ if (errors.length) {
141
+ failCheck(`Schema: ${errors.join(', ')}`);
142
+ return false;
143
+ }
144
+ if (p.v !== 1)
145
+ errors.push('payload.v !== 1');
146
+ if (p.kind !== 'bolyra.auth' && p.kind !== 'bolyra.commerce') {
147
+ errors.push('payload.kind must be "bolyra.auth" or "bolyra.commerce"');
148
+ }
149
+ if (typeof p.issuedAt !== 'number')
150
+ errors.push('payload.issuedAt not a number');
151
+ if (typeof p.issuer !== 'string' || !p.issuer)
152
+ errors.push('payload.issuer missing');
153
+ if (typeof p.keyId !== 'string' || !p.keyId)
154
+ errors.push('payload.keyId missing');
155
+ // subject
156
+ if (!p.subject) {
157
+ errors.push('payload.subject missing');
158
+ }
159
+ else {
160
+ for (const k of ['rootDid', 'actingDid', 'credentialCommitment', 'effectiveCommitment']) {
161
+ if (typeof p.subject[k] !== 'string' || !p.subject[k]) {
162
+ errors.push(`payload.subject.${k} missing or not a string`);
163
+ }
164
+ }
165
+ }
166
+ // decision
167
+ if (!p.decision) {
168
+ errors.push('payload.decision missing');
169
+ }
170
+ else {
171
+ if (typeof p.decision.allowed !== 'boolean')
172
+ errors.push('decision.allowed not boolean');
173
+ if (typeof p.decision.score !== 'number')
174
+ errors.push('decision.score not a number');
175
+ if (typeof p.decision.permissionBitmask !== 'string' || !p.decision.permissionBitmask)
176
+ errors.push('decision.permissionBitmask missing or not a string');
177
+ if (typeof p.decision.chainDepth !== 'number')
178
+ errors.push('decision.chainDepth not a number');
179
+ }
180
+ // proof
181
+ if (!p.proof) {
182
+ errors.push('payload.proof missing');
183
+ }
184
+ else {
185
+ for (const k of ['bundleVersion', 'nonce', 'humanProofHash', 'agentProofHash', 'publicSignalsHash']) {
186
+ if (!(k in p.proof))
187
+ errors.push(`proof.${k} missing`);
188
+ }
189
+ }
190
+ // commerce fields: required for bolyra.commerce, forbidden for bolyra.auth
191
+ if (p.kind === 'bolyra.auth' && p.commerce) {
192
+ errors.push('auth receipt must not contain commerce fields');
193
+ }
194
+ if (p.kind === 'bolyra.commerce') {
195
+ const c = p.commerce;
196
+ if (!c) {
197
+ errors.push('commerce fields missing for bolyra.commerce receipt');
198
+ }
199
+ else {
200
+ if (typeof c.rail !== 'string' || !c.rail)
201
+ errors.push('commerce.rail missing or not a string');
202
+ if (typeof c.amount !== 'number')
203
+ errors.push('commerce.amount not a number');
204
+ if (typeof c.currency !== 'string' || !c.currency)
205
+ errors.push('commerce.currency missing or not a string');
206
+ if (typeof c.merchant !== 'string')
207
+ errors.push('commerce.merchant not a string');
208
+ if (typeof c.intentHash !== 'string' || !/^[0-9a-fA-F]{64}$/.test(c.intentHash)) {
209
+ errors.push('commerce.intentHash must be a 64-char hex string');
210
+ }
211
+ }
212
+ }
213
+ // signature fields
214
+ if (s.alg !== 'ES256K')
215
+ errors.push('signature.alg !== "ES256K"');
216
+ if (s.keyId !== p.keyId)
217
+ errors.push('signature.keyId does not match payload.keyId');
218
+ if (!/^0x[0-9a-fA-F]{40}$/.test(s.signer ?? ''))
219
+ errors.push('signature.signer invalid format');
220
+ if (!/^0x[0-9a-fA-F]{64}$/.test(s.payloadHash ?? ''))
221
+ errors.push('signature.payloadHash invalid format');
222
+ if (!/^0x[0-9a-fA-F]{130}$/.test(s.value ?? ''))
223
+ errors.push('signature.value invalid format');
224
+ if (errors.length) {
225
+ failCheck(`Schema: ${errors.join('; ')}`);
226
+ return false;
227
+ }
228
+ pass('Schema valid');
229
+ return true;
230
+ }
231
+ // 2. Timestamp check
232
+ function validateTimestamp() {
233
+ const nowSec = Math.floor(Date.now() / 1000);
234
+ const issuedAt = receipt.payload.issuedAt;
235
+ const allowFutureSeconds = 300;
236
+ const ageSec = nowSec - issuedAt;
237
+ const isoStr = new Date(issuedAt * 1000).toISOString();
238
+ if (issuedAt > nowSec + allowFutureSeconds) {
239
+ failCheck(`Timestamp: ${isoStr} is ${issuedAt - nowSec}s in the future (max skew: ${allowFutureSeconds}s)`);
240
+ return;
241
+ }
242
+ if (ageSec > maxAgeSeconds) {
243
+ failCheck(`Timestamp: ${isoStr} (${ageSec}s ago, max-age: ${maxAgeSeconds}s)`);
244
+ return;
245
+ }
246
+ pass(`Timestamp: ${isoStr} (${ageSec}s ago)`);
247
+ }
248
+ // 3. Hash check
249
+ function validateHash() {
250
+ const computed = (0, sign_1.hashPayload)(receipt.payload);
251
+ if (computed !== receipt.signature.payloadHash) {
252
+ failCheck(`Payload hash mismatch (expected ${truncate(receipt.signature.payloadHash)}, got ${truncate(computed)})`);
253
+ return false;
254
+ }
255
+ pass('Payload hash matches');
256
+ return true;
257
+ }
258
+ // 4. ID check
259
+ function validateId() {
260
+ const expected = '0x' + receipt.signature.payloadHash.slice(2, 18);
261
+ if (receipt.id !== expected) {
262
+ failCheck(`Receipt ID mismatch (expected ${expected}, got ${receipt.id})`);
263
+ return;
264
+ }
265
+ pass('Receipt ID matches');
266
+ }
267
+ // 5. Signature check
268
+ function validateSignature() {
269
+ const ok = (0, sign_1.verifyReceipt)(receipt, expectedSigner);
270
+ if (!ok) {
271
+ failCheck(`Signature invalid${expectedSigner ? ` (expected signer: ${truncate(expectedSigner)})` : ''}`);
272
+ return;
273
+ }
274
+ pass(`Signature valid (signer: ${truncate(receipt.signature.signer)})`);
275
+ }
276
+ // --- Run checks ---
277
+ if (validateSchema()) {
278
+ validateTimestamp();
279
+ if (validateHash()) {
280
+ validateId();
281
+ validateSignature();
282
+ }
283
+ }
284
+ // --- Output ---
285
+ for (const line of checks) {
286
+ console.log(line);
287
+ }
288
+ if (failed) {
289
+ console.log('FAIL \u2014 receipt is invalid');
290
+ process.exit(1);
291
+ }
292
+ else {
293
+ console.log('PASS \u2014 receipt is valid');
294
+ process.exit(0);
295
+ }
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "@bolyra/receipts",
3
- "version": "0.6.0",
3
+ "version": "0.8.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",
7
+ "bin": {
8
+ "bolyra-receipt-verify": "dist/verify-cli.js"
9
+ },
7
10
  "scripts": {
8
11
  "build": "tsc",
12
+ "prepare": "tsc",
9
13
  "test": "jest --passWithNoTests",
10
14
  "typecheck": "tsc --noEmit"
11
15
  },
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,9 +1,24 @@
1
1
  export { canonicalize } from './canonical';
2
- export { createAuthReceipt } from './receipt';
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,
22
+ CommerceReceiptInput,
23
+ CommerceFields,
9
24
  } from './types';
package/src/receipt.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { sha256 } from '@noble/hashes/sha256';
2
2
  import { bytesToHex } from '@noble/hashes/utils';
3
3
  import { canonicalize } from './canonical';
4
- import type { AuthReceiptInput, ReceiptPayload } from './types';
4
+ import type { AuthReceiptInput, CommerceReceiptInput, ReceiptPayload } from './types';
5
5
 
6
6
  function sha256Hex(data: string): string {
7
7
  const encoder = new TextEncoder();
@@ -10,7 +10,7 @@ function sha256Hex(data: string): string {
10
10
 
11
11
  export function createAuthReceipt(
12
12
  input: AuthReceiptInput,
13
- config: { issuer: string; keyId: string },
13
+ config: { issuer: string; keyId: string; issuedAt?: number },
14
14
  ): ReceiptPayload {
15
15
  const humanProofHash = sha256Hex(canonicalize(input.humanProof.proof));
16
16
  const agentProofHash = sha256Hex(canonicalize(input.agentProof.proof));
@@ -24,7 +24,7 @@ export function createAuthReceipt(
24
24
  return {
25
25
  v: 1,
26
26
  kind: 'bolyra.auth',
27
- issuedAt: Math.floor(Date.now() / 1000),
27
+ issuedAt: config.issuedAt ?? Math.floor(Date.now() / 1000),
28
28
  issuer: config.issuer,
29
29
  keyId: config.keyId,
30
30
  subject: {
@@ -50,3 +50,15 @@ export function createAuthReceipt(
50
50
  },
51
51
  };
52
52
  }
53
+
54
+ export function createCommerceReceipt(
55
+ input: CommerceReceiptInput,
56
+ config: { issuer: string; keyId: string },
57
+ ): ReceiptPayload {
58
+ const base = createAuthReceipt(input, config);
59
+ return {
60
+ ...base,
61
+ kind: 'bolyra.commerce',
62
+ commerce: input.commerce,
63
+ };
64
+ }