@bolyra/receipts 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/receipt.d.ts +6 -1
- package/dist/receipt.js +10 -1
- package/dist/types.d.ts +13 -1
- package/dist/verify-cli.d.ts +2 -0
- package/dist/verify-cli.js +295 -0
- package/package.json +5 -1
- package/src/index.ts +3 -1
- package/src/receipt.ts +15 -3
- package/src/types.ts +16 -1
- package/src/verify-cli.ts +258 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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 type { ReceiptPayload, SignedReceipt, ReceiptSignerConfig, AuthReceiptInput, } from './types';
|
|
4
|
+
export type { ReceiptPayload, SignedReceipt, ReceiptSignerConfig, AuthReceiptInput, CommerceReceiptInput, CommerceFields, } from './types';
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.hashPayload = exports.verifyReceipt = exports.signReceipt = exports.createAuthReceipt = exports.canonicalize = void 0;
|
|
3
|
+
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");
|
|
7
7
|
Object.defineProperty(exports, "createAuthReceipt", { enumerable: true, get: function () { return receipt_1.createAuthReceipt; } });
|
|
8
|
+
Object.defineProperty(exports, "createCommerceReceipt", { enumerable: true, get: function () { return receipt_1.createCommerceReceipt; } });
|
|
8
9
|
var sign_1 = require("./sign");
|
|
9
10
|
Object.defineProperty(exports, "signReceipt", { enumerable: true, get: function () { return sign_1.signReceipt; } });
|
|
10
11
|
Object.defineProperty(exports, "verifyReceipt", { enumerable: true, get: function () { return sign_1.verifyReceipt; } });
|
package/dist/receipt.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import type { AuthReceiptInput, ReceiptPayload } from './types';
|
|
1
|
+
import type { AuthReceiptInput, CommerceReceiptInput, ReceiptPayload } from './types';
|
|
2
2
|
export declare function createAuthReceipt(input: AuthReceiptInput, config: {
|
|
3
3
|
issuer: string;
|
|
4
4
|
keyId: string;
|
|
5
|
+
issuedAt?: number;
|
|
6
|
+
}): ReceiptPayload;
|
|
7
|
+
export declare function createCommerceReceipt(input: CommerceReceiptInput, config: {
|
|
8
|
+
issuer: string;
|
|
9
|
+
keyId: string;
|
|
5
10
|
}): ReceiptPayload;
|
package/dist/receipt.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createAuthReceipt = createAuthReceipt;
|
|
4
|
+
exports.createCommerceReceipt = createCommerceReceipt;
|
|
4
5
|
const sha256_1 = require("@noble/hashes/sha256");
|
|
5
6
|
const utils_1 = require("@noble/hashes/utils");
|
|
6
7
|
const canonical_1 = require("./canonical");
|
|
@@ -18,7 +19,7 @@ function createAuthReceipt(input, config) {
|
|
|
18
19
|
return {
|
|
19
20
|
v: 1,
|
|
20
21
|
kind: 'bolyra.auth',
|
|
21
|
-
issuedAt: Math.floor(Date.now() / 1000),
|
|
22
|
+
issuedAt: config.issuedAt ?? Math.floor(Date.now() / 1000),
|
|
22
23
|
issuer: config.issuer,
|
|
23
24
|
keyId: config.keyId,
|
|
24
25
|
subject: {
|
|
@@ -44,3 +45,11 @@ function createAuthReceipt(input, config) {
|
|
|
44
45
|
},
|
|
45
46
|
};
|
|
46
47
|
}
|
|
48
|
+
function createCommerceReceipt(input, config) {
|
|
49
|
+
const base = createAuthReceipt(input, config);
|
|
50
|
+
return {
|
|
51
|
+
...base,
|
|
52
|
+
kind: 'bolyra.commerce',
|
|
53
|
+
commerce: input.commerce,
|
|
54
|
+
};
|
|
55
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export interface ReceiptPayload {
|
|
2
2
|
v: 1;
|
|
3
|
-
kind: 'bolyra.auth';
|
|
3
|
+
kind: 'bolyra.auth' | 'bolyra.commerce';
|
|
4
4
|
/** Unix seconds when the decision was made. */
|
|
5
5
|
issuedAt: number;
|
|
6
6
|
/** Server identifier. */
|
|
@@ -36,6 +36,8 @@ export interface ReceiptPayload {
|
|
|
36
36
|
/** SHA-256 of canonical JSON of delegationChain (if v=2). */
|
|
37
37
|
delegationChainHash?: string;
|
|
38
38
|
};
|
|
39
|
+
/** Present only when kind === 'bolyra.commerce'. */
|
|
40
|
+
commerce?: CommerceFields;
|
|
39
41
|
}
|
|
40
42
|
export interface SignedReceipt {
|
|
41
43
|
/** First 16 hex chars of payloadHash. */
|
|
@@ -58,6 +60,13 @@ export interface ReceiptSignerConfig {
|
|
|
58
60
|
/** secp256k1 private key, 32 bytes, hex-encoded, 0x-prefixed. */
|
|
59
61
|
privateKey: string;
|
|
60
62
|
}
|
|
63
|
+
export interface CommerceFields {
|
|
64
|
+
rail: string;
|
|
65
|
+
amount: number;
|
|
66
|
+
currency: string;
|
|
67
|
+
merchant: string;
|
|
68
|
+
intentHash: string;
|
|
69
|
+
}
|
|
61
70
|
export interface AuthReceiptInput {
|
|
62
71
|
/** From BolyraAuthContext or equivalent. */
|
|
63
72
|
rootDid: string;
|
|
@@ -82,3 +91,6 @@ export interface AuthReceiptInput {
|
|
|
82
91
|
nonce: string;
|
|
83
92
|
delegationChain?: unknown[];
|
|
84
93
|
}
|
|
94
|
+
export interface CommerceReceiptInput extends AuthReceiptInput {
|
|
95
|
+
commerce: CommerceFields;
|
|
96
|
+
}
|
|
@@ -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.7.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/index.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
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
4
|
export type {
|
|
5
5
|
ReceiptPayload,
|
|
6
6
|
SignedReceipt,
|
|
7
7
|
ReceiptSignerConfig,
|
|
8
8
|
AuthReceiptInput,
|
|
9
|
+
CommerceReceiptInput,
|
|
10
|
+
CommerceFields,
|
|
9
11
|
} 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
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export interface ReceiptPayload {
|
|
2
2
|
v: 1;
|
|
3
|
-
kind: 'bolyra.auth';
|
|
3
|
+
kind: 'bolyra.auth' | 'bolyra.commerce';
|
|
4
4
|
/** Unix seconds when the decision was made. */
|
|
5
5
|
issuedAt: number;
|
|
6
6
|
/** Server identifier. */
|
|
@@ -39,6 +39,9 @@ export interface ReceiptPayload {
|
|
|
39
39
|
/** SHA-256 of canonical JSON of delegationChain (if v=2). */
|
|
40
40
|
delegationChainHash?: string;
|
|
41
41
|
};
|
|
42
|
+
|
|
43
|
+
/** Present only when kind === 'bolyra.commerce'. */
|
|
44
|
+
commerce?: CommerceFields;
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
export interface SignedReceipt {
|
|
@@ -64,6 +67,14 @@ export interface ReceiptSignerConfig {
|
|
|
64
67
|
privateKey: string;
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
export interface CommerceFields {
|
|
71
|
+
rail: string;
|
|
72
|
+
amount: number;
|
|
73
|
+
currency: string;
|
|
74
|
+
merchant: string;
|
|
75
|
+
intentHash: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
67
78
|
export interface AuthReceiptInput {
|
|
68
79
|
/** From BolyraAuthContext or equivalent. */
|
|
69
80
|
rootDid: string;
|
|
@@ -84,3 +95,7 @@ export interface AuthReceiptInput {
|
|
|
84
95
|
nonce: string;
|
|
85
96
|
delegationChain?: unknown[];
|
|
86
97
|
}
|
|
98
|
+
|
|
99
|
+
export interface CommerceReceiptInput extends AuthReceiptInput {
|
|
100
|
+
commerce: CommerceFields;
|
|
101
|
+
}
|
|
@@ -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
|
+
}
|