@coordination-network/revocation-registry 0.0.1
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/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/index.cjs +463 -0
- package/dist/index.js +454 -0
- package/package.json +51 -0
- package/src/constants.ts +39 -0
- package/src/hashes.ts +22 -0
- package/src/index.ts +5 -0
- package/src/revocation-registry.ts +396 -0
- package/src/types.ts +22 -0
- package/src/utils.ts +55 -0
- package/types/index.d.ts +351 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ethereum Foundation
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# @coordination-network/revocation-registry
|
|
2
|
+
|
|
3
|
+
A revocation registry in TypeScript: a [LeanIMT+](https://pse.dev/blog/lean-imt-plus-efficient-merkle-tree-for-membership-and-non-membership-proofs) whose leaves are the revocation commitments of revoked credentials.
|
|
4
|
+
|
|
5
|
+
Revoking a credential inserts its commitment. Proving a credential is still valid means proving its commitment is **absent** from the registry, which a single Merkle proof of the commitment's predecessor establishes. That proof is the witness a zero-knowledge circuit consumes to show the same thing without revealing which credential is being presented.
|
|
6
|
+
|
|
7
|
+
## Where the registry lives
|
|
8
|
+
|
|
9
|
+
`RevocationRegistry` is a complete implementation of the structure, not a client for a particular backend: it reads and writes nothing external. What changes between deployments is only which copy is authoritative.
|
|
10
|
+
|
|
11
|
+
### On chain, the intended deployment
|
|
12
|
+
|
|
13
|
+
[`contracts`](../contracts) holds the authoritative registry, and it is what the rest of this design assumes. It gives you three things that matter here:
|
|
14
|
+
|
|
15
|
+
- **Everyone sees the same root.** The chain settles it, so an issuer cannot show one root to one verifier and a different one to another, and a verifier checks any root it is handed with a single `isValidRoot` call.
|
|
16
|
+
- **The registry is readable without any service.** The full leaf table is on chain, so a holder can always rebuild the tree even if every indexer and snapshot host is down.
|
|
17
|
+
- **Every revocation is publicly auditable.** Each one is a transaction with a timestamp that anyone can inspect after the fact.
|
|
18
|
+
|
|
19
|
+
This package is the off-chain half of that deployment, in both directions. An issuer uses it to compute the predecessor index a revocation transaction needs. A holder uses it to rebuild the tree and generate a proof locally, which is what keeps presentation private: they read the whole public registry and never disclose which commitment they care about.
|
|
20
|
+
|
|
21
|
+
### In a server, which works the same way
|
|
22
|
+
|
|
23
|
+
Nothing here depends on the contracts, so a service can hold the authoritative registry itself: keep a `RevocationRegistry`, revoke into it, and publish the root and the leaf slots. Persist it with `export()` / `import()`, or rebuild it from your own records at startup. Holders then rebuild from what you publish and prove exactly as they would against a chain. The structure, the hashes and the proofs are identical: the same commitments applied in the same order produce the same root wherever the registry is held, which the cross-language tests in both packages pin against each other.
|
|
24
|
+
|
|
25
|
+
The one thing that changes is who a verifier trusts for the root. On chain that question is settled. With a server, a verifier accepting a root is taking the operator's word that it is the current one. Signing published roots helps, since it proves a root came from the operator, but it does not stop the operator handing one root to one verifier and a different one to another.
|
|
26
|
+
|
|
27
|
+
It also matters who runs the server. Every presentation needs someone to answer "is this root still valid?", and if the issuer runs the server, the issuer answers. It still cannot tell which credential is being presented, but it sees that one is, when, and who is asking, and presentations stop while the server is down. On chain the issuer is never involved.
|
|
28
|
+
|
|
29
|
+
### Either way
|
|
30
|
+
|
|
31
|
+
Only the authoritative copy is authoritative. Every other copy, including one you just rebuilt, is trusted for **availability only, never integrity**: compare `root` against the root the authority publishes before using a proof built from it.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
yarn add @coordination-network/revocation-registry
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
### Revoking
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { RevocationRegistry } from "@coordination-network/revocation-registry"
|
|
45
|
+
|
|
46
|
+
const registry = new RevocationRegistry(commitments)
|
|
47
|
+
|
|
48
|
+
registry.revoke(revocationCommitment)
|
|
49
|
+
|
|
50
|
+
// registry.root is the new revocation root to publish
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Order matters. The tree is position-addressable, so the same commitments inserted in a different order produce a different root. When rebuilding a registry that already exists, supply the commitments in the order that registry inserted them.
|
|
54
|
+
|
|
55
|
+
### Proving a credential is not revoked
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const registry = new RevocationRegistry(commitments)
|
|
59
|
+
|
|
60
|
+
// Only trusted for availability: pin it to the authority before using it.
|
|
61
|
+
if (registry.root !== publishedRoot) throw new Error("stale registry")
|
|
62
|
+
|
|
63
|
+
const proof = registry.generateProof(revocationCommitment)
|
|
64
|
+
|
|
65
|
+
// proof.proofType === 1 means the commitment is absent, so the credential is not revoked.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
This package stops at the proof. Shaping it for a particular proving system belongs to whichever circuit consumes it.
|
|
69
|
+
|
|
70
|
+
### Rebuilding a registry
|
|
71
|
+
|
|
72
|
+
There are three ways to reconstruct a registry, and which one you need depends on whether anything has ever been un-revoked.
|
|
73
|
+
|
|
74
|
+
| Input | Rebuilds | Use when |
|
|
75
|
+
| -------------------------------------- | ----------------------------- | ----------------------------------------------------------------------------- |
|
|
76
|
+
| `new RevocationRegistry(commitments)` | Registries with no tombstones | Nothing has ever been un-revoked |
|
|
77
|
+
| `RevocationRegistry.fromLeaves(slots)` | Any registry | Always correct; the authority publishes `leafSlots`, or the chain's `getLeaf` |
|
|
78
|
+
| `RevocationRegistry.import(snapshot)` | Any registry | You already hold a snapshot |
|
|
79
|
+
|
|
80
|
+
The constructor takes only the commitments that are still revoked, which is lossy. An un-revocation tombstones a slot, and a tombstoned slot still occupies a Merkle position, so replaying the surviving commitments produces a **different root** and every proof built from it fails. Nothing warns you except the root comparison.
|
|
81
|
+
|
|
82
|
+
`leafSlots` is the lossless view: every physical slot in index order, sentinel and tombstones included. It is exactly what the on-chain registry exposes as `getLeaf(registryId, i)` for `i` in `0..getRevocationTreeLeafCount(registryId) - 1`.
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// Authority publishes
|
|
86
|
+
const slots = registry.leafSlots
|
|
87
|
+
|
|
88
|
+
// Anyone rebuilds the identical tree
|
|
89
|
+
const rebuilt = RevocationRegistry.fromLeaves(slots)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`fromLeaves` recomputes every hash from the slots rather than taking node data on trust, so unlike a snapshot there is nothing in the input to tamper with beyond the slots themselves. Compare `root` against the authority either way: that is what catches missing or reordered slots.
|
|
93
|
+
|
|
94
|
+
### Snapshots
|
|
95
|
+
|
|
96
|
+
Replaying every revocation since genesis gets slow. `export()` serializes the whole tree, hashes included, and `import()` rebuilds it without rehashing, so a service can publish periodic snapshots and a holder can sync from the latest one plus the few revocations after it.
|
|
97
|
+
|
|
98
|
+
`import()` validates by default that the serialized leaves and hashes agree with each other, which rejects a tampered snapshot. That is an internal-consistency check, not proof of authenticity: still compare `root` against the authority.
|
|
99
|
+
|
|
100
|
+
A snapshot carries the node hashes, so it is larger than the leaf slots and lets a publisher hand you hashes you did not derive. `fromLeaves` is the leaner and more suspicious option; `import` is the faster one when you produced the snapshot yourself.
|
|
101
|
+
|
|
102
|
+
### Predecessor indices, for constrained backends
|
|
103
|
+
|
|
104
|
+
`revoke` finds a commitment's predecessor itself, so most callers never need these.
|
|
105
|
+
|
|
106
|
+
They exist because some authoritative implementations cannot afford to search. The on-chain registry is the example: it validates a caller-supplied predecessor index in `O(1)` rather than scanning, which is what bounds a write to `O(depth)` storage writes. Compute the index here and pass it in:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
// Revoking one commitment
|
|
110
|
+
const lowLeafIndex = registry.lowLeafIndexOf(revocationCommitment)
|
|
111
|
+
// revocation.revoke(registryId, revocationCommitment, lowLeafIndex)
|
|
112
|
+
|
|
113
|
+
// Revoking a batch: each index is computed against the state after the earlier ones
|
|
114
|
+
const lowLeafIndices = registry.lowLeafIndicesOf(revocationCommitments)
|
|
115
|
+
// revocation.revokeMany(registryId, revocationCommitments, lowLeafIndices)
|
|
116
|
+
|
|
117
|
+
// Un-revoking, or replacing a commitment
|
|
118
|
+
const predecessor = registry.predecessorIndexOf(revocationCommitment)
|
|
119
|
+
// revocation.unrevoke(registryId, revocationCommitment, predecessor)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
An index is only valid against the tree state the write applies to. If another revocation lands first, the on-chain call reverts with `InvalidLowLeaf` and the index has to be recomputed against the new state.
|
|
123
|
+
|
|
124
|
+
## API
|
|
125
|
+
|
|
126
|
+
| Member | Purpose |
|
|
127
|
+
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
128
|
+
| `new RevocationRegistry(commitments?)` | Create a registry, optionally pre-loaded. Order must match the authority. |
|
|
129
|
+
| `root`, `depth`, `size`, `commitments`, `leaves` | Registry state. `root` is `0` while empty. |
|
|
130
|
+
| `leafSlots` | Every physical slot, sentinel and tombstones included. Lossless. |
|
|
131
|
+
| `isRevoked(c)`, `indexOf(c)` | Membership lookups. |
|
|
132
|
+
| `revoke(c)`, `revokeMany(cs)` | Insert commitments. |
|
|
133
|
+
| `unrevoke(c)`, `updateCommitment(old, new)` | Undo or replace a revocation. |
|
|
134
|
+
| `generateProof(c)` | Membership (`proofType: 0`) or non-membership (`proofType: 1`) proof. |
|
|
135
|
+
| `verifyProof(p)`, `RevocationRegistry.verifyProof(p)` | Against this registry's root, or against the proof's own root. |
|
|
136
|
+
| `lowLeafIndexOf(c)`, `lowLeafIndicesOf(cs)`, `predecessorIndexOf(c)` | Predecessor indices for backends that validate rather than search. |
|
|
137
|
+
| `export()`, `RevocationRegistry.import(s, validate?)` | Snapshots. |
|
|
138
|
+
| `RevocationRegistry.fromLeaves(slots)` | Rebuild any registry from its leaf slots, tombstones included. |
|
|
139
|
+
| `toCommitment(v)`, `toFieldElement(v)` | Coerce and validate a commitment, or any field element, against the registry's rules. |
|
|
140
|
+
|
|
141
|
+
## Notes
|
|
142
|
+
|
|
143
|
+
- Commitments must be non-zero and below the BN254 scalar field order. Zero is reserved for the sentinel leaf and for tombstones. Above the field order, two distinct values can reduce to the same field element, which would let one revocation silently cover an unrelated credential and would break the sorted ordering a non-membership proof relies on.
|
|
144
|
+
- An empty registry cannot produce proofs. Nothing has been revoked, so a verifier should treat that as "no revocations", not as a failure.
|
|
145
|
+
- Revocation is normally permanent. `unrevoke` exists because the structure supports it, but an un-revoked credential becomes valid again for every verifier still accepting an older root, and it leaves a tombstone that rules out rebuilding from `commitments` alone.
|
|
146
|
+
- `indexOf` returns a physical slot, never a position in `commitments`. The two always differ by at least one, because slot 0 is the sentinel.
|
|
147
|
+
- `revocationHashes` is the Poseidon pair every implementation of a registry must agree on: 3-input for leaves, 2-input for internal nodes. `tests/cross-language.test.ts` pins the resulting roots against the Solidity implementation.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var poseidon2 = require('poseidon-lite/poseidon2');
|
|
4
|
+
var poseidon3 = require('poseidon-lite/poseidon3');
|
|
5
|
+
var leanImtPlus = require('@zk-kit/lean-imt-plus');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The BN254 (alt_bn128) scalar field order. Every commitment stored in a
|
|
9
|
+
* revocation registry, and every hash the tree produces, is an element of this
|
|
10
|
+
* field. It matches `SNARK_SCALAR_FIELD` in the LeanIMT+ Solidity library and
|
|
11
|
+
* the field a BN254 zero-knowledge circuit works over.
|
|
12
|
+
*/
|
|
13
|
+
const SNARK_SCALAR_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
|
|
14
|
+
/**
|
|
15
|
+
* Domain-separation tag mixed into every leaf commitment as the third input of
|
|
16
|
+
* the 3-input leaf hash. Kept here so callers can reproduce a leaf commitment
|
|
17
|
+
* without depending on LeanIMT+ internals.
|
|
18
|
+
*/
|
|
19
|
+
const TAG_LEAF = 1n;
|
|
20
|
+
/**
|
|
21
|
+
* Proof discriminator. `MEMBERSHIP` proves the commitment is in the registry
|
|
22
|
+
* (the credential is revoked); `NON_MEMBERSHIP` proves it is absent (the
|
|
23
|
+
* credential is not revoked). A holder proving a valid credential always
|
|
24
|
+
* produces a `NON_MEMBERSHIP` proof.
|
|
25
|
+
*/
|
|
26
|
+
const PROOF_TYPE = {
|
|
27
|
+
MEMBERSHIP: 0,
|
|
28
|
+
NON_MEMBERSHIP: 1
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Physical index of the sentinel leaf, which the first revocation creates. The
|
|
32
|
+
* sentinel holds `{ value: 0, nextValue: firstRealCommitment }` and is the
|
|
33
|
+
* predecessor of the smallest commitment in the registry.
|
|
34
|
+
*/
|
|
35
|
+
const SENTINEL_INDEX = 0;
|
|
36
|
+
/**
|
|
37
|
+
* Version tag of the snapshot format produced by `export` and accepted by
|
|
38
|
+
* `import`. It must match the version the underlying LeanIMT+ writes, which
|
|
39
|
+
* enforces it on import.
|
|
40
|
+
*/
|
|
41
|
+
const SNAPSHOT_VERSION = 1;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The hash pair every revocation registry uses, in every language.
|
|
45
|
+
*
|
|
46
|
+
* - `leaf` is a 3-input Poseidon over `(value, nextValue, TAG_LEAF)`. It matches
|
|
47
|
+
* `PoseidonT4` in the LeanIMT+ Solidity library and `poseidon::bn254::hash_3`
|
|
48
|
+
* in a BN254 Poseidon circuit.
|
|
49
|
+
* - `internal` is a 2-input Poseidon for inner nodes. It matches `PoseidonT3`
|
|
50
|
+
* and `poseidon::bn254::hash_2`.
|
|
51
|
+
*
|
|
52
|
+
* The differing arity is what domain-separates a leaf commitment from an
|
|
53
|
+
* internal-node hash, which closes the second-preimage attack in which an
|
|
54
|
+
* attacker repackages an internal node as a leaf. All three implementations
|
|
55
|
+
* must agree on this pair or their roots will not match.
|
|
56
|
+
*/
|
|
57
|
+
const revocationHashes = {
|
|
58
|
+
leaf: (a, b, c) => poseidon3.poseidon3([a, b, c]),
|
|
59
|
+
internal: (a, b) => poseidon2.poseidon2([a, b])
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Coerces a value to a BN254 field element, rejecting anything outside the
|
|
64
|
+
* field.
|
|
65
|
+
*
|
|
66
|
+
* Every leaf field and every hash the registry produces must be a field element:
|
|
67
|
+
* outside that range two distinct values reduce to the same element, which would
|
|
68
|
+
* let one revocation silently cover an unrelated credential and would break the
|
|
69
|
+
* sorted ordering a non-membership proof relies on.
|
|
70
|
+
*
|
|
71
|
+
* Zero is accepted, because the sentinel and tombstone leaf slots hold it. Use
|
|
72
|
+
* {@link toCommitment} for anything that must be a real commitment.
|
|
73
|
+
*
|
|
74
|
+
* @param value The value, as a bigint, number or decimal string.
|
|
75
|
+
* @returns The value as a bigint.
|
|
76
|
+
*/
|
|
77
|
+
function toFieldElement(value) {
|
|
78
|
+
const element = BigInt(value);
|
|
79
|
+
if (element < 0n) {
|
|
80
|
+
throw new Error("RevocationRegistry: a field element must not be negative");
|
|
81
|
+
}
|
|
82
|
+
if (element >= SNARK_SCALAR_FIELD) {
|
|
83
|
+
throw new Error("RevocationRegistry: a field element must be less than the SNARK scalar field order");
|
|
84
|
+
}
|
|
85
|
+
return element;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Coerces a value to a revocation commitment, rejecting anything the registry
|
|
89
|
+
* cannot hold as one.
|
|
90
|
+
*
|
|
91
|
+
* A commitment must be a field element, for the reason given on
|
|
92
|
+
* {@link toFieldElement}, and must additionally be non-zero, because zero is
|
|
93
|
+
* reserved for the sentinel leaf and for tombstones.
|
|
94
|
+
*
|
|
95
|
+
* These are the rules of the structure itself, so every implementation of a
|
|
96
|
+
* registry enforces them.
|
|
97
|
+
*
|
|
98
|
+
* @param value The commitment, as a bigint, number or decimal string.
|
|
99
|
+
* @returns The commitment as a bigint.
|
|
100
|
+
*/
|
|
101
|
+
function toCommitment(value) {
|
|
102
|
+
const commitment = BigInt(value);
|
|
103
|
+
if (commitment <= 0n) {
|
|
104
|
+
throw new Error("RevocationRegistry: a commitment must be greater than zero");
|
|
105
|
+
}
|
|
106
|
+
return toFieldElement(commitment);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A revocation registry: a
|
|
111
|
+
* {@link https://pse.dev/blog/lean-imt-plus-efficient-merkle-tree-for-membership-and-non-membership-proofs | LeanIMT+}
|
|
112
|
+
* whose leaves are the revocation commitments of revoked credentials.
|
|
113
|
+
*
|
|
114
|
+
* The class is a self-contained implementation of the structure, not a client
|
|
115
|
+
* for any particular backend. It can hold a registry outright, which is what a
|
|
116
|
+
* service that publishes revocations from its own database does, or mirror one
|
|
117
|
+
* whose authoritative copy lives elsewhere, which is what a holder does before
|
|
118
|
+
* building a proof. Nothing here reads or writes anything external.
|
|
119
|
+
*
|
|
120
|
+
* Two operations serve the two roles:
|
|
121
|
+
*
|
|
122
|
+
* - **Issuer.** {@link revoke} inserts a commitment. Where the authoritative
|
|
123
|
+
* registry is a separate system that validates a predecessor index rather than
|
|
124
|
+
* searching for one, {@link lowLeafIndexOf} computes that index.
|
|
125
|
+
* - **Holder.** {@link generateProof} produces a proof about a commitment. A
|
|
126
|
+
* non-membership proof (`proofType: 1`) is what shows a credential is not
|
|
127
|
+
* revoked, and is the witness a zero-knowledge circuit consumes to show it
|
|
128
|
+
* without revealing the commitment.
|
|
129
|
+
*
|
|
130
|
+
* When this instance is a mirror rather than the authority, it is trusted for
|
|
131
|
+
* availability only, never for integrity: compare {@link root} against the root
|
|
132
|
+
* the authority reports before using any proof built from it. Building the tree
|
|
133
|
+
* locally is also what keeps a holder private, since they read the whole public
|
|
134
|
+
* registry and never disclose which commitment they care about.
|
|
135
|
+
*
|
|
136
|
+
* An empty registry has a root of `0` and cannot produce proofs: nothing has
|
|
137
|
+
* been revoked yet, so there is nothing to prove against. Verifiers must handle
|
|
138
|
+
* that case as "no revocations", not as a failure.
|
|
139
|
+
*/
|
|
140
|
+
class RevocationRegistry {
|
|
141
|
+
/**
|
|
142
|
+
* Creates a registry, optionally pre-loaded with revoked commitments.
|
|
143
|
+
* Passing the full list is more efficient than revoking them one by one,
|
|
144
|
+
* because each affected internal node is rehashed at most once.
|
|
145
|
+
*
|
|
146
|
+
* The tree is position-addressable, so a different insertion order produces
|
|
147
|
+
* a different root even for the same set of commitments. When rebuilding a
|
|
148
|
+
* registry that already exists elsewhere, the commitments must be supplied
|
|
149
|
+
* in the order that registry inserted them.
|
|
150
|
+
*
|
|
151
|
+
* This reconstructs a registry only while nothing has ever been un-revoked.
|
|
152
|
+
* An un-revocation leaves a tombstoned slot behind, and that slot still
|
|
153
|
+
* occupies a Merkle position, so replaying the surviving commitments would
|
|
154
|
+
* produce a different root. Use {@link RevocationRegistry.fromLeaves} or
|
|
155
|
+
* {@link RevocationRegistry.import} for a registry that may have tombstones.
|
|
156
|
+
*
|
|
157
|
+
* @param commitments Revoked commitments, in insertion order.
|
|
158
|
+
*/
|
|
159
|
+
constructor(commitments = []) {
|
|
160
|
+
this.leanIMTPlus = new leanImtPlus.LeanIMTPlus(revocationHashes, commitments.map(toCommitment));
|
|
161
|
+
}
|
|
162
|
+
/** The current revocation root. `0` while the registry is empty. */
|
|
163
|
+
get root() {
|
|
164
|
+
return this.leanIMTPlus.root ?? 0n;
|
|
165
|
+
}
|
|
166
|
+
/** The current tree depth. */
|
|
167
|
+
get depth() {
|
|
168
|
+
return this.leanIMTPlus.depth;
|
|
169
|
+
}
|
|
170
|
+
/** The number of revoked commitments, excluding the sentinel and tombstones. */
|
|
171
|
+
get size() {
|
|
172
|
+
return this.leanIMTPlus.size;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The revoked commitments in insertion order. Reading `.value` off each leaf
|
|
176
|
+
* gives the commitments; `.nextValue` exposes the sorted linked list the
|
|
177
|
+
* non-membership proof relies on.
|
|
178
|
+
*/
|
|
179
|
+
get leaves() {
|
|
180
|
+
return this.leanIMTPlus.leaves;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The revoked commitments, in insertion order. Excludes the sentinel and any
|
|
184
|
+
* tombstones, so this is a lossy view of the tree: it round-trips through the
|
|
185
|
+
* constructor only while nothing has been un-revoked. See
|
|
186
|
+
* {@link leafSlots}.
|
|
187
|
+
*/
|
|
188
|
+
get commitments() {
|
|
189
|
+
return this.leanIMTPlus.leaves.map((leaf) => leaf.value);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Every physical leaf slot in index order, sentinel and tombstones included.
|
|
193
|
+
*
|
|
194
|
+
* This is the lossless view of the registry and the counterpart of
|
|
195
|
+
* {@link RevocationRegistry.fromLeaves}: an authority publishes these slots,
|
|
196
|
+
* and anyone can rebuild the exact same tree from them. It mirrors what the
|
|
197
|
+
* on-chain registry exposes through `getLeaf`.
|
|
198
|
+
*
|
|
199
|
+
* Prefer {@link commitments} to enumerate what is revoked. This walks the
|
|
200
|
+
* whole tree, so treat it as a publishing operation rather than a hot path.
|
|
201
|
+
*/
|
|
202
|
+
get leafSlots() {
|
|
203
|
+
const { leaves } = JSON.parse(this.export());
|
|
204
|
+
return leaves.map(({ value, nextValue }) => ({ value: BigInt(value), nextValue: BigInt(nextValue) }));
|
|
205
|
+
}
|
|
206
|
+
/** Returns true if the commitment has been revoked. */
|
|
207
|
+
isRevoked(commitment) {
|
|
208
|
+
return this.leanIMTPlus.has(toCommitment(commitment));
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Returns the physical leaf index of a revoked commitment, or `-1` if it is
|
|
212
|
+
* not revoked.
|
|
213
|
+
*
|
|
214
|
+
* This is a slot in the tree, never a position in {@link commitments}. The
|
|
215
|
+
* two always differ by at least one, because slot 0 is the sentinel, and
|
|
216
|
+
* they drift further apart with every tombstone.
|
|
217
|
+
*/
|
|
218
|
+
indexOf(commitment) {
|
|
219
|
+
return this.leanIMTPlus.indexOf(toCommitment(commitment));
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Revokes a credential by inserting its commitment.
|
|
223
|
+
*
|
|
224
|
+
* @param commitment The revocation commitment of the credential.
|
|
225
|
+
*/
|
|
226
|
+
revoke(commitment) {
|
|
227
|
+
this.leanIMTPlus.insert(toCommitment(commitment));
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Revokes several credentials in one pass. Cheaper than calling
|
|
231
|
+
* {@link revoke} once per commitment, because each affected internal node is
|
|
232
|
+
* rehashed at most once.
|
|
233
|
+
*
|
|
234
|
+
* Not transactional: if a commitment mid-batch is rejected, the earlier ones
|
|
235
|
+
* have already been applied. Validate with {@link isRevoked} first when the
|
|
236
|
+
* input is untrusted.
|
|
237
|
+
*
|
|
238
|
+
* @param commitments The revocation commitments, in insertion order.
|
|
239
|
+
*/
|
|
240
|
+
revokeMany(commitments) {
|
|
241
|
+
this.leanIMTPlus.insertMany(commitments.map(toCommitment));
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Un-revokes a credential, tombstoning its slot.
|
|
245
|
+
*
|
|
246
|
+
* Revocation is normally permanent, and a registry that never un-revokes is
|
|
247
|
+
* the simpler thing to reason about: an un-revoked credential becomes valid
|
|
248
|
+
* again for every verifier that has not yet seen the new root. The operation
|
|
249
|
+
* is exposed because the underlying tree supports it and some issuers need
|
|
250
|
+
* to undo a mistaken revocation, but reach for it deliberately.
|
|
251
|
+
*
|
|
252
|
+
* It also leaves a tombstoned slot behind, after which the registry can no
|
|
253
|
+
* longer be rebuilt from {@link commitments} alone. Publish the leaf slots
|
|
254
|
+
* (see {@link RevocationRegistry.fromLeaves}) or a snapshot instead.
|
|
255
|
+
*
|
|
256
|
+
* @param commitment The revocation commitment to un-revoke.
|
|
257
|
+
*/
|
|
258
|
+
unrevoke(commitment) {
|
|
259
|
+
this.leanIMTPlus.remove(toCommitment(commitment));
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Replaces a revoked commitment with another one, in place, reusing the old
|
|
263
|
+
* physical slot. Cheaper than an {@link unrevoke} followed by a
|
|
264
|
+
* {@link revoke}, which would tombstone a slot and append a new one.
|
|
265
|
+
*
|
|
266
|
+
* @param oldCommitment The revoked commitment to replace.
|
|
267
|
+
* @param newCommitment The commitment to put in its place.
|
|
268
|
+
*/
|
|
269
|
+
updateCommitment(oldCommitment, newCommitment) {
|
|
270
|
+
this.leanIMTPlus.update(toCommitment(oldCommitment), toCommitment(newCommitment));
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Generates a proof for a commitment: a non-membership proof
|
|
274
|
+
* (`proofType: 1`) if the credential is not revoked, a membership proof
|
|
275
|
+
* (`proofType: 0`) if it is.
|
|
276
|
+
*
|
|
277
|
+
* The proof carries the root it was built against. It proves nothing until
|
|
278
|
+
* the verifier independently accepts that root as one the authoritative
|
|
279
|
+
* registry currently publishes.
|
|
280
|
+
*
|
|
281
|
+
* @param commitment The revocation commitment of the credential.
|
|
282
|
+
* @returns The revocation proof.
|
|
283
|
+
*/
|
|
284
|
+
generateProof(commitment) {
|
|
285
|
+
if (this.root === 0n) {
|
|
286
|
+
throw new Error("RevocationRegistry: cannot generate a proof against an empty registry");
|
|
287
|
+
}
|
|
288
|
+
return this.leanIMTPlus.generateProof(toCommitment(commitment));
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Verifies a proof against this tree's current root. Use the static
|
|
292
|
+
* {@link RevocationRegistry.verifyProof} to check a proof without a local tree.
|
|
293
|
+
*/
|
|
294
|
+
verifyProof(proof) {
|
|
295
|
+
return proof.root === this.root && this.leanIMTPlus.verifyProof(proof);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Verifies that a proof is internally consistent with the root it carries.
|
|
299
|
+
*
|
|
300
|
+
* This does not bind the proof to any registry: the caller supplies
|
|
301
|
+
* `proof.root`, so on its own this returns true for a proof against an
|
|
302
|
+
* attacker-chosen root. Always compare `proof.root` against a root you
|
|
303
|
+
* independently accept.
|
|
304
|
+
*/
|
|
305
|
+
static verifyProof(proof) {
|
|
306
|
+
return leanImtPlus.LeanIMTPlus.verifyProof(proof, revocationHashes);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Returns the physical index of the low leaf of a commitment: the
|
|
310
|
+
* predecessor a constrained implementation needs supplied rather than
|
|
311
|
+
* searched for.
|
|
312
|
+
*
|
|
313
|
+
* {@link revoke} does not need this, since it finds the predecessor itself.
|
|
314
|
+
* It exists for registries whose authoritative implementation validates a
|
|
315
|
+
* caller-supplied index in O(1) instead of searching, which is how a version
|
|
316
|
+
* that has to bound its own cost per write is built. Such an index is only
|
|
317
|
+
* valid against the tree state the write applies to, so if another revocation
|
|
318
|
+
* lands first it must be recomputed against the new state.
|
|
319
|
+
*
|
|
320
|
+
* @param commitment The commitment about to be revoked.
|
|
321
|
+
* @returns The physical index of its predecessor, or `0` for the first
|
|
322
|
+
* revocation, where the argument is ignored and the sentinel is created.
|
|
323
|
+
*/
|
|
324
|
+
lowLeafIndexOf(commitment) {
|
|
325
|
+
const value = toCommitment(commitment);
|
|
326
|
+
if (this.root === 0n) {
|
|
327
|
+
return SENTINEL_INDEX;
|
|
328
|
+
}
|
|
329
|
+
const proof = this.leanIMTPlus.generateProof(value);
|
|
330
|
+
if (proof.proofType === PROOF_TYPE.MEMBERSHIP) {
|
|
331
|
+
throw new Error("RevocationRegistry: the commitment has already been revoked");
|
|
332
|
+
}
|
|
333
|
+
// Not `proof.leafIndex`: that is the compacted Merkle path, which skips the
|
|
334
|
+
// levels where the leaf's ancestor had no sibling, so it does not equal the
|
|
335
|
+
// physical slot. Resolve the slot from the low leaf's value instead. A low
|
|
336
|
+
// leaf holding zero is the sentinel, which always sits at index 0.
|
|
337
|
+
return proof.leaf.value === 0n ? SENTINEL_INDEX : this.leanIMTPlus.indexOf(proof.leaf.value);
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Returns the low leaf indices for a batch of commitments, as a batched
|
|
341
|
+
* write expects them: index `i` is computed against the tree state after
|
|
342
|
+
* commitments `0..i-1` have been inserted.
|
|
343
|
+
*
|
|
344
|
+
* Computed on a throwaway copy, so this tree is left untouched.
|
|
345
|
+
*
|
|
346
|
+
* @param commitments The commitments about to be revoked, in insertion order.
|
|
347
|
+
*/
|
|
348
|
+
lowLeafIndicesOf(commitments) {
|
|
349
|
+
const scratch = RevocationRegistry.import(this.export(), false);
|
|
350
|
+
return commitments.map((commitment) => {
|
|
351
|
+
const index = scratch.lowLeafIndexOf(commitment);
|
|
352
|
+
scratch.revoke(commitment);
|
|
353
|
+
return index;
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Returns the physical index of the leaf whose `nextValue` is this
|
|
358
|
+
* commitment. The counterpart of {@link lowLeafIndexOf} for un-revoking and
|
|
359
|
+
* for replacing a commitment.
|
|
360
|
+
*
|
|
361
|
+
* Returns `0` when the commitment is the smallest in the registry, because
|
|
362
|
+
* the sentinel at index 0 is then its predecessor.
|
|
363
|
+
*
|
|
364
|
+
* @param commitment A revoked commitment.
|
|
365
|
+
*/
|
|
366
|
+
predecessorIndexOf(commitment) {
|
|
367
|
+
const value = toCommitment(commitment);
|
|
368
|
+
if (!this.leanIMTPlus.has(value)) {
|
|
369
|
+
throw new Error("RevocationRegistry: the commitment has not been revoked");
|
|
370
|
+
}
|
|
371
|
+
const predecessor = this.leanIMTPlus.leaves.find((leaf) => leaf.nextValue === value);
|
|
372
|
+
return predecessor === undefined ? SENTINEL_INDEX : this.leanIMTPlus.indexOf(predecessor.value);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Serializes the whole tree, hashes included.
|
|
376
|
+
*
|
|
377
|
+
* This is the snapshot format: publishing a snapshot lets a holder rebuild a
|
|
378
|
+
* large registry without replaying every revocation since genesis. A snapshot
|
|
379
|
+
* is trusted for availability only, so an importing client must still compare
|
|
380
|
+
* {@link root} against the root the authoritative registry publishes.
|
|
381
|
+
*/
|
|
382
|
+
export() {
|
|
383
|
+
return this.leanIMTPlus.export();
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Rebuilds a tree from {@link export} output.
|
|
387
|
+
*
|
|
388
|
+
* @param snapshot The serialized tree.
|
|
389
|
+
* @param validate Recompute every commitment and check it against the
|
|
390
|
+
* serialized hashes, rejecting a tampered snapshot. Defaults to `true`; pass
|
|
391
|
+
* `false` only for data you produced yourself.
|
|
392
|
+
*/
|
|
393
|
+
static import(snapshot, validate = true) {
|
|
394
|
+
const tree = new RevocationRegistry();
|
|
395
|
+
tree.leanIMTPlus = leanImtPlus.LeanIMTPlus.import(revocationHashes, snapshot, { validate });
|
|
396
|
+
return tree;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Rebuilds a registry from its full physical leaf slots, sentinel and
|
|
400
|
+
* tombstones included.
|
|
401
|
+
*
|
|
402
|
+
* This is the reconstruction path that always works. The constructor takes
|
|
403
|
+
* only the active commitments, which is enough only while no commitment has
|
|
404
|
+
* ever been un-revoked: an un-revocation leaves a tombstoned slot behind, and
|
|
405
|
+
* a slot still occupies a Merkle position, so a registry that has one cannot
|
|
406
|
+
* be reproduced from its commitments alone.
|
|
407
|
+
*
|
|
408
|
+
* The leaf slots are exactly what an authoritative registry can publish
|
|
409
|
+
* verbatim: {@link leafSlots} here, or `getLeaf(registryId, i)` for `i` in
|
|
410
|
+
* `0..getRevocationTreeLeafCount(registryId) - 1` on chain.
|
|
411
|
+
*
|
|
412
|
+
* Hashes are recomputed here rather than taken on trust, so unlike a snapshot
|
|
413
|
+
* the input carries no node data to tamper with. The caller must still
|
|
414
|
+
* compare {@link root} against the root the authority publishes, which is
|
|
415
|
+
* what detects missing or reordered slots.
|
|
416
|
+
*
|
|
417
|
+
* @param leaves The physical leaf slots, in index order starting at the
|
|
418
|
+
* sentinel.
|
|
419
|
+
* @returns The reconstructed registry.
|
|
420
|
+
*/
|
|
421
|
+
static fromLeaves(leaves) {
|
|
422
|
+
if (leaves.length === 0) {
|
|
423
|
+
return new RevocationRegistry();
|
|
424
|
+
}
|
|
425
|
+
const records = leaves.map(({ value, nextValue }) => ({
|
|
426
|
+
value: toFieldElement(value),
|
|
427
|
+
nextValue: toFieldElement(nextValue)
|
|
428
|
+
}));
|
|
429
|
+
if (records[0].value !== 0n) {
|
|
430
|
+
throw new Error("RevocationRegistry: the leaf at index 0 must be the sentinel, whose value is zero");
|
|
431
|
+
}
|
|
432
|
+
// Rebuild every level of the LeanIMT, promoting an unpaired node unchanged.
|
|
433
|
+
const nodes = [records.map((leaf) => revocationHashes.leaf(leaf.value, leaf.nextValue, TAG_LEAF))];
|
|
434
|
+
while (nodes[nodes.length - 1].length > 1) {
|
|
435
|
+
const level = nodes[nodes.length - 1];
|
|
436
|
+
const parents = [];
|
|
437
|
+
for (let i = 0; i < level.length; i += 2) {
|
|
438
|
+
parents.push(i + 1 < level.length ? revocationHashes.internal(level[i], level[i + 1]) : level[i]);
|
|
439
|
+
}
|
|
440
|
+
nodes.push(parents);
|
|
441
|
+
}
|
|
442
|
+
const snapshot = JSON.stringify({
|
|
443
|
+
version: SNAPSHOT_VERSION,
|
|
444
|
+
nodes: nodes.map((level) => level.map(String)),
|
|
445
|
+
leaves: records.map(({ value, nextValue }) => ({
|
|
446
|
+
value: value.toString(),
|
|
447
|
+
nextValue: nextValue.toString()
|
|
448
|
+
}))
|
|
449
|
+
});
|
|
450
|
+
// The node hashes were just derived from the leaves, so re-validating them
|
|
451
|
+
// would be circular. Integrity comes from comparing the root to the authority.
|
|
452
|
+
return RevocationRegistry.import(snapshot, false);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
exports.PROOF_TYPE = PROOF_TYPE;
|
|
457
|
+
exports.RevocationRegistry = RevocationRegistry;
|
|
458
|
+
exports.SNAPSHOT_VERSION = SNAPSHOT_VERSION;
|
|
459
|
+
exports.SNARK_SCALAR_FIELD = SNARK_SCALAR_FIELD;
|
|
460
|
+
exports.TAG_LEAF = TAG_LEAF;
|
|
461
|
+
exports.revocationHashes = revocationHashes;
|
|
462
|
+
exports.toCommitment = toCommitment;
|
|
463
|
+
exports.toFieldElement = toFieldElement;
|