@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/src/types.ts CHANGED
@@ -1,6 +1,23 @@
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
- kind: 'bolyra.auth';
20
+ kind: 'bolyra.auth' | 'bolyra.commerce';
4
21
  /** Unix seconds when the decision was made. */
5
22
  issuedAt: number;
6
23
  /** Server identifier. */
@@ -39,6 +56,12 @@ export interface ReceiptPayload {
39
56
  /** SHA-256 of canonical JSON of delegationChain (if v=2). */
40
57
  delegationChainHash?: string;
41
58
  };
59
+
60
+ /** Present only when kind === 'bolyra.commerce'. */
61
+ commerce?: CommerceFields;
62
+
63
+ /** Present only on hash-chained receipts (written via ReceiptChain). */
64
+ chain?: ReceiptChainFields;
42
65
  }
43
66
 
44
67
  export interface SignedReceipt {
@@ -55,6 +78,13 @@ export interface SignedReceipt {
55
78
  /** r (32 bytes) + s (32 bytes) + v (1 byte) = 65 bytes (hex). */
56
79
  value: string;
57
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;
58
88
  }
59
89
 
60
90
  export interface ReceiptSignerConfig {
@@ -64,6 +94,14 @@ export interface ReceiptSignerConfig {
64
94
  privateKey: string;
65
95
  }
66
96
 
97
+ export interface CommerceFields {
98
+ rail: string;
99
+ amount: number;
100
+ currency: string;
101
+ merchant: string;
102
+ intentHash: string;
103
+ }
104
+
67
105
  export interface AuthReceiptInput {
68
106
  /** From BolyraAuthContext or equivalent. */
69
107
  rootDid: string;
@@ -84,3 +122,7 @@ export interface AuthReceiptInput {
84
122
  nonce: string;
85
123
  delegationChain?: unknown[];
86
124
  }
125
+
126
+ export interface CommerceReceiptInput extends AuthReceiptInput {
127
+ commerce: CommerceFields;
128
+ }
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env node
2
+
3
+ import * as fs from 'fs';
4
+ import * as process from 'process';
5
+ import { verifyReceipt, hashPayload } from './sign';
6
+ import type { SignedReceipt } from './types';
7
+
8
+ // --- Helpers ---
9
+
10
+ function usage(): string {
11
+ return `Usage: bolyra-receipt-verify <receipt.json> [options]
12
+
13
+ Options:
14
+ --stdin Read receipt JSON from stdin instead of a file
15
+ --signer <address> Expected signer address (optional)
16
+ --max-age <seconds> Maximum receipt age in seconds (default: 86400)
17
+ --help Show this help`;
18
+ }
19
+
20
+ function fail(msg: string): never {
21
+ process.stderr.write(msg + '\n');
22
+ process.exit(2);
23
+ }
24
+
25
+ function truncate(hex: string): string {
26
+ if (hex.length <= 14) return hex;
27
+ return hex.slice(0, 8) + '...' + hex.slice(-4);
28
+ }
29
+
30
+ // --- Arg parsing ---
31
+
32
+ const args = process.argv.slice(2);
33
+
34
+ if (args.includes('--help') || args.includes('-h')) {
35
+ console.log(usage());
36
+ process.exit(0);
37
+ }
38
+
39
+ let filePath: string | undefined;
40
+ let expectedSigner: string | undefined;
41
+ let maxAgeSeconds = 86400;
42
+ let useStdin = false;
43
+
44
+ for (let i = 0; i < args.length; i++) {
45
+ const arg = args[i];
46
+ if (arg === '--stdin') {
47
+ useStdin = true;
48
+ } else if (arg === '--signer') {
49
+ expectedSigner = args[++i];
50
+ if (!expectedSigner) fail('--signer requires an address argument');
51
+ } else if (arg === '--max-age') {
52
+ const val = args[++i];
53
+ if (!val) fail('--max-age requires a number argument');
54
+ maxAgeSeconds = parseInt(val, 10);
55
+ if (Number.isNaN(maxAgeSeconds) || maxAgeSeconds < 0) {
56
+ fail('--max-age must be a non-negative integer');
57
+ }
58
+ } else if (arg.startsWith('-')) {
59
+ fail(`Unknown option: ${arg}\n\n${usage()}`);
60
+ } else {
61
+ filePath = arg;
62
+ }
63
+ }
64
+
65
+ if (!filePath && !useStdin) {
66
+ fail(`No input specified. Provide a file path or use --stdin.\n\n${usage()}`);
67
+ }
68
+
69
+ // --- Read input ---
70
+
71
+ let rawJson: string;
72
+ try {
73
+ if (useStdin) {
74
+ rawJson = fs.readFileSync(0, 'utf-8');
75
+ } else {
76
+ rawJson = fs.readFileSync(filePath!, 'utf-8');
77
+ }
78
+ } catch (err: unknown) {
79
+ const msg = err instanceof Error ? err.message : String(err);
80
+ fail(`Cannot read input: ${msg}`);
81
+ }
82
+
83
+ let receipt: SignedReceipt;
84
+ try {
85
+ receipt = JSON.parse(rawJson);
86
+ } catch {
87
+ fail('Invalid JSON');
88
+ }
89
+
90
+ // --- Checks ---
91
+
92
+ const checks: string[] = [];
93
+ let failed = false;
94
+
95
+ function pass(msg: string): void {
96
+ checks.push(`\u2713 ${msg}`);
97
+ }
98
+
99
+ function failCheck(msg: string): void {
100
+ checks.push(`\u2717 ${msg}`);
101
+ failed = true;
102
+ }
103
+
104
+ // 1. Schema validation
105
+ function validateSchema(): boolean {
106
+ const p = receipt.payload;
107
+ const s = receipt.signature;
108
+ const errors: string[] = [];
109
+
110
+ if (!p) { errors.push('missing payload'); }
111
+ if (!s) { errors.push('missing signature'); }
112
+ if (errors.length) { failCheck(`Schema: ${errors.join(', ')}`); return false; }
113
+
114
+ if (p.v !== 1) errors.push('payload.v !== 1');
115
+ if (p.kind !== 'bolyra.auth' && p.kind !== 'bolyra.commerce') {
116
+ errors.push('payload.kind must be "bolyra.auth" or "bolyra.commerce"');
117
+ }
118
+ if (typeof p.issuedAt !== 'number') errors.push('payload.issuedAt not a number');
119
+ if (typeof p.issuer !== 'string' || !p.issuer) errors.push('payload.issuer missing');
120
+ if (typeof p.keyId !== 'string' || !p.keyId) errors.push('payload.keyId missing');
121
+
122
+ // subject
123
+ if (!p.subject) {
124
+ errors.push('payload.subject missing');
125
+ } else {
126
+ for (const k of ['rootDid', 'actingDid', 'credentialCommitment', 'effectiveCommitment'] as const) {
127
+ if (typeof (p.subject as any)[k] !== 'string' || !(p.subject as any)[k]) {
128
+ errors.push(`payload.subject.${k} missing or not a string`);
129
+ }
130
+ }
131
+ }
132
+
133
+ // decision
134
+ if (!p.decision) {
135
+ errors.push('payload.decision missing');
136
+ } else {
137
+ if (typeof p.decision.allowed !== 'boolean') errors.push('decision.allowed not boolean');
138
+ if (typeof p.decision.score !== 'number') errors.push('decision.score not a number');
139
+ if (typeof p.decision.permissionBitmask !== 'string' || !p.decision.permissionBitmask) errors.push('decision.permissionBitmask missing or not a string');
140
+ if (typeof p.decision.chainDepth !== 'number') errors.push('decision.chainDepth not a number');
141
+ }
142
+
143
+ // proof
144
+ if (!p.proof) {
145
+ errors.push('payload.proof missing');
146
+ } else {
147
+ for (const k of ['bundleVersion', 'nonce', 'humanProofHash', 'agentProofHash', 'publicSignalsHash'] as const) {
148
+ if (!(k in p.proof)) errors.push(`proof.${k} missing`);
149
+ }
150
+ }
151
+
152
+ // commerce fields: required for bolyra.commerce, forbidden for bolyra.auth
153
+ if (p.kind === 'bolyra.auth' && (p as any).commerce) {
154
+ errors.push('auth receipt must not contain commerce fields');
155
+ }
156
+ if (p.kind === 'bolyra.commerce') {
157
+ const c = (p as any).commerce;
158
+ if (!c) {
159
+ errors.push('commerce fields missing for bolyra.commerce receipt');
160
+ } else {
161
+ if (typeof c.rail !== 'string' || !c.rail) errors.push('commerce.rail missing or not a string');
162
+ if (typeof c.amount !== 'number') errors.push('commerce.amount not a number');
163
+ if (typeof c.currency !== 'string' || !c.currency) errors.push('commerce.currency missing or not a string');
164
+ if (typeof c.merchant !== 'string') errors.push('commerce.merchant not a string');
165
+ if (typeof c.intentHash !== 'string' || !/^[0-9a-fA-F]{64}$/.test(c.intentHash)) {
166
+ errors.push('commerce.intentHash must be a 64-char hex string');
167
+ }
168
+ }
169
+ }
170
+
171
+ // signature fields
172
+ if (s.alg !== 'ES256K') errors.push('signature.alg !== "ES256K"');
173
+ if (s.keyId !== p.keyId) errors.push('signature.keyId does not match payload.keyId');
174
+ if (!/^0x[0-9a-fA-F]{40}$/.test(s.signer ?? '')) errors.push('signature.signer invalid format');
175
+ if (!/^0x[0-9a-fA-F]{64}$/.test(s.payloadHash ?? '')) errors.push('signature.payloadHash invalid format');
176
+ if (!/^0x[0-9a-fA-F]{130}$/.test(s.value ?? '')) errors.push('signature.value invalid format');
177
+
178
+ if (errors.length) {
179
+ failCheck(`Schema: ${errors.join('; ')}`);
180
+ return false;
181
+ }
182
+ pass('Schema valid');
183
+ return true;
184
+ }
185
+
186
+ // 2. Timestamp check
187
+ function validateTimestamp(): void {
188
+ const nowSec = Math.floor(Date.now() / 1000);
189
+ const issuedAt = receipt.payload.issuedAt;
190
+ const allowFutureSeconds = 300;
191
+ const ageSec = nowSec - issuedAt;
192
+ const isoStr = new Date(issuedAt * 1000).toISOString();
193
+
194
+ if (issuedAt > nowSec + allowFutureSeconds) {
195
+ failCheck(`Timestamp: ${isoStr} is ${issuedAt - nowSec}s in the future (max skew: ${allowFutureSeconds}s)`);
196
+ return;
197
+ }
198
+ if (ageSec > maxAgeSeconds) {
199
+ failCheck(`Timestamp: ${isoStr} (${ageSec}s ago, max-age: ${maxAgeSeconds}s)`);
200
+ return;
201
+ }
202
+ pass(`Timestamp: ${isoStr} (${ageSec}s ago)`);
203
+ }
204
+
205
+ // 3. Hash check
206
+ function validateHash(): boolean {
207
+ const computed = hashPayload(receipt.payload);
208
+ if (computed !== receipt.signature.payloadHash) {
209
+ failCheck(`Payload hash mismatch (expected ${truncate(receipt.signature.payloadHash)}, got ${truncate(computed)})`);
210
+ return false;
211
+ }
212
+ pass('Payload hash matches');
213
+ return true;
214
+ }
215
+
216
+ // 4. ID check
217
+ function validateId(): void {
218
+ const expected = '0x' + receipt.signature.payloadHash.slice(2, 18);
219
+ if (receipt.id !== expected) {
220
+ failCheck(`Receipt ID mismatch (expected ${expected}, got ${receipt.id})`);
221
+ return;
222
+ }
223
+ pass('Receipt ID matches');
224
+ }
225
+
226
+ // 5. Signature check
227
+ function validateSignature(): void {
228
+ const ok = verifyReceipt(receipt, expectedSigner);
229
+ if (!ok) {
230
+ failCheck(`Signature invalid${expectedSigner ? ` (expected signer: ${truncate(expectedSigner)})` : ''}`);
231
+ return;
232
+ }
233
+ pass(`Signature valid (signer: ${truncate(receipt.signature.signer)})`);
234
+ }
235
+
236
+ // --- Run checks ---
237
+
238
+ if (validateSchema()) {
239
+ validateTimestamp();
240
+ if (validateHash()) {
241
+ validateId();
242
+ validateSignature();
243
+ }
244
+ }
245
+
246
+ // --- Output ---
247
+
248
+ for (const line of checks) {
249
+ console.log(line);
250
+ }
251
+
252
+ if (failed) {
253
+ console.log('FAIL \u2014 receipt is invalid');
254
+ process.exit(1);
255
+ } else {
256
+ console.log('PASS \u2014 receipt is valid');
257
+ process.exit(0);
258
+ }