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