@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.
- package/README.md +105 -0
- package/dist/chain.d.ts +112 -0
- package/dist/chain.js +228 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +7 -1
- package/dist/receipt.d.ts +6 -1
- package/dist/receipt.js +10 -1
- package/dist/types.d.ts +38 -1
- package/dist/verify-cli.d.ts +2 -0
- package/dist/verify-cli.js +295 -0
- package/package.json +5 -1
- package/src/chain.ts +293 -0
- package/src/index.ts +16 -1
- package/src/receipt.ts +15 -3
- package/src/types.ts +43 -1
- package/src/verify-cli.ts +258 -0
|
@@ -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.
|
|
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
|
+
}
|