@optimystic/quereus-plugin-crypto 0.21.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sd.ts CHANGED
@@ -1,250 +1,250 @@
1
- /**
2
- * Salted-leaf SET COMMITMENT for per-attribute selective disclosure.
3
- *
4
- * An authority commits to a whole set of attributes as a single root value (which
5
- * it signs / persists), then later reveals only a chosen *subset* to a recipient —
6
- * with a proof that the revealed values are genuinely the committed ones — without
7
- * leaking the values of the withheld attributes. A flat `digest(whole set)` cannot
8
- * do this (verifying one field needs the whole pre-image, so it is all-or-nothing);
9
- * this construction supports *partial opening*.
10
- *
11
- * ## Construction (flat salted-leaf set commitment, NOT a Merkle tree)
12
- *
13
- * Each disclosable attribute is a salted leaf, and the commitment (root) is the
14
- * digest of all leaf digests in canonical order:
15
- *
16
- * ```
17
- * leafDigest = digest([SD_LEAF_DOMAIN_V1, name, value, salt]) // raw digest bytes
18
- * root = digest([SD_SET_DOMAIN_V1, sortedLeaf_0, sortedLeaf_1, ...])
19
- * ```
20
- *
21
- * Both layers compose on the existing canonical {@link encodeFields} framing
22
- * (injective, type-tagged, length-prefixed, replicable) — the same layering the CID
23
- * work uses — so a *generic* salted-set primitive is simultaneously reusable and
24
- * fully DB-enforceable. This is the same shape the IETF SD-JWT standard settled on
25
- * (flat salted hashes, not a tree); we are NOT wire-compatible with SD-JWT (we reuse
26
- * Optimystic's own `encodeFields` framing for cross-peer replicability) — SD-JWT is
27
- * cited only as conceptual precedent that the smaller construction is the right one.
28
- *
29
- * Voter selective-disclosure field sets are small (a handful to a few dozen fields),
30
- * so a tree's only advantage — O(log n) proof size — is marginal, while a tree drags
31
- * in real footguns we would have to hand-roll and pin (arity, odd-node handling /
32
- * the CVE-2012-2459 duplicate-leaf forgery class, leaf-vs-internal domain separation,
33
- * and a separate audit-path proof format). A flat construction avoids all of them.
34
- *
35
- * ## Why these specific choices
36
- *
37
- * - **`name` is hashed into the leaf** so a disclosed `(value, salt)` proof cannot be
38
- * replayed against a different attribute slot (e.g. presenting an `over18=true`
39
- * proof as the `citizen` field). The binding is free given `encodeFields` framing.
40
- * - **`salt` is per-leaf and mandatory** — low-entropy attributes (DOB, booleans, ZIP)
41
- * are brute-forceable from a bare hash, and independent salts also defeat cross-
42
- * registrant equality correlation. Salts come from `random_bytes` (≥128 bits).
43
- * - **Canonical order is by raw leaf-digest bytes (lexicographic), and this is FORCED,
44
- * not a preference.** In a disclosure the verifier learns the *names* of only the
45
- * disclosed leaves; the withheld leaves arrive as opaque digests with no name. So the
46
- * verifier can re-derive the root only if the ordering key is something it holds for
47
- * *every* leaf — the leaf digest itself. Sorting by name would be unverifiable for
48
- * hidden leaves. Do NOT "tidy" this into a name sort.
49
- * - Sort is over **raw digest bytes**, never over encoded strings — an encoding-
50
- * dependent ordering would break cross-peer agreement. Output encoding applies only
51
- * to the final root.
52
- *
53
- * Because leaf and root reuse `encodeFields`, a future `DIGEST_FORMAT_V1` bump changes
54
- * `setCommit` output too; this coupling is intentional (one canonical framing).
55
- */
56
-
57
- import { fromString as uint8ArrayFromString, toString as uint8ArrayToString } from 'uint8arrays';
58
- import {
59
- encodeFields,
60
- resolveHasher,
61
- resolveOutputEncoder,
62
- type DigestField,
63
- type DigestHasher,
64
- type OutputEncoder,
65
- } from './crypto.js';
66
-
67
- /**
68
- * Fixed domain-separation constants — the leading string field of each layer's
69
- * {@link encodeFields} tuple. They are pinned EXACTLY like `DIGEST_FORMAT_V1`:
70
- *
71
- * - the two strings MUST be distinct, so a leaf hash can never equal a root hash;
72
- * - neither may change without a deliberate, breaking version bump — which would
73
- * change every committed root and every signature taken over it.
74
- *
75
- * Do not "tidy" or shorten these.
76
- */
77
- const SD_LEAF_DOMAIN_V1 = 'optimystic/sd-leaf/v1';
78
- const SD_SET_DOMAIN_V1 = 'optimystic/sd-set/v1';
79
-
80
- /** Hidden leaf digests travel as base64url text — the plugin's canonical text encoding. */
81
- const HIDDEN_ENCODING = 'base64url';
82
-
83
- /** One disclosable attribute. `value` spans the SQL value space ({@link DigestField}). */
84
- export interface SaltedLeaf {
85
- readonly name: string;
86
- readonly value: DigestField;
87
- /** base64url text (e.g. from `random_bytes`) or raw bytes. Mandatory, non-empty. */
88
- readonly salt: string | Uint8Array;
89
- }
90
-
91
- /** A disclosure payload sent to a recipient. */
92
- export interface SetDisclosure {
93
- /** The opened `(name, value, salt)` triples. */
94
- readonly disclosed: readonly SaltedLeaf[];
95
- /** Opaque leaf digests (base64url) of the withheld leaves — no name, no value, no salt. */
96
- readonly hidden: readonly string[];
97
- }
98
-
99
- // --- internal helpers --- //
100
-
101
- /** Lexicographic compare of two byte arrays (the canonical leaf ordering key). */
102
- function compareBytes(a: Uint8Array, b: Uint8Array): number {
103
- const len = Math.min(a.length, b.length);
104
- for (let i = 0; i < len; i++) {
105
- const d = a[i]! - b[i]!;
106
- if (d !== 0) return d;
107
- }
108
- return a.length - b.length;
109
- }
110
-
111
- /** Constant-shape byte equality (length first, then content). */
112
- function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
113
- if (a.length !== b.length) return false;
114
- for (let i = 0; i < a.length; i++) {
115
- if (a[i] !== b[i]) return false;
116
- }
117
- return true;
118
- }
119
-
120
- /**
121
- * Normalize a leaf's salt to raw bytes — a base64url string (the form `random_bytes`
122
- * returns) decodes to bytes, raw bytes pass through — so the two representations of the
123
- * same salt commit identically. THROWS on a missing or empty salt (unsalted leaves are
124
- * brute-forceable, an invalid state we make impossible).
125
- */
126
- function requireSaltBytes(leaf: SaltedLeaf): Uint8Array {
127
- const { salt } = leaf;
128
- if (salt == null) {
129
- throw new Error(`set commitment: leaf '${leaf.name}' is missing a salt (an unsalted leaf is brute-forceable)`);
130
- }
131
- const bytes = salt instanceof Uint8Array ? salt : uint8ArrayFromString(salt, HIDDEN_ENCODING);
132
- if (bytes.length === 0) {
133
- throw new Error(`set commitment: leaf '${leaf.name}' has an empty salt (an unsalted leaf is brute-forceable)`);
134
- }
135
- return bytes;
136
- }
137
-
138
- /**
139
- * THROW on a duplicate `name`. Two leaves with the same name would let a holder
140
- * selectively present whichever value suits them; the authority side (which holds all
141
- * names) is the only place uniqueness can be enforced — the verifier never sees the
142
- * hidden names — so the primitive must fail-fast.
143
- */
144
- function assertUniqueNames(leaves: readonly SaltedLeaf[]): void {
145
- const seen = new Set<string>();
146
- for (const leaf of leaves) {
147
- if (seen.has(leaf.name)) {
148
- throw new Error(`set commitment: duplicate leaf name '${leaf.name}'`);
149
- }
150
- seen.add(leaf.name);
151
- }
152
- }
153
-
154
- // --- public API --- //
155
-
156
- /**
157
- * Raw leaf digest bytes for one salted leaf: `digest([SD_LEAF_DOMAIN_V1, name,
158
- * value, salt])`. Domain-separated (can never equal a root) and name-bound (a
159
- * `(value, salt)` proof cannot be replayed under another attribute name). THROWS on
160
- * a missing/empty salt.
161
- */
162
- export function leafDigest(leaf: SaltedLeaf, hasher: DigestHasher): Uint8Array {
163
- const saltBytes = requireSaltBytes(leaf);
164
- return hasher(encodeFields([SD_LEAF_DOMAIN_V1, leaf.name, leaf.value, saltBytes]));
165
- }
166
-
167
- /**
168
- * Commit to a SET of salted leaves → a single root (the signed/persisted value).
169
- * Sorts leaves by raw leaf-digest bytes, then digests them under `SD_SET_DOMAIN_V1`.
170
- * Like `digest`, this emits a BARE digest — apply `cid()` on top for the self-
171
- * describing column representation (`cid(set_commit(...))`).
172
- *
173
- * The empty set is well-defined (the digest of `[SD_SET_DOMAIN_V1]`), not an error.
174
- * THROWS on a duplicate `name` or a missing/empty `salt` (invalid states made
175
- * impossible). Resolve `hasher`/`encode` once and reuse — no per-call branching.
176
- */
177
- export function setCommit(
178
- leaves: readonly SaltedLeaf[],
179
- hasher: DigestHasher = resolveHasher('sha256'),
180
- encode: OutputEncoder = resolveOutputEncoder('base64url'),
181
- ): string | Uint8Array {
182
- assertUniqueNames(leaves);
183
- const leafDigests = leaves.map((leaf) => leafDigest(leaf, hasher));
184
- leafDigests.sort(compareBytes);
185
- return encode(hasher(encodeFields([SD_SET_DOMAIN_V1, ...leafDigests])));
186
- }
187
-
188
- /**
189
- * Split a leaf set into the revealed `(name, value, salt)` triples plus the opaque
190
- * leaf digests (base64url) of the rest. Withheld `value`/`salt` never appear in the
191
- * output. Names in `revealNames` that match no leaf are simply not disclosed.
192
- * THROWS on a duplicate `name` or a missing/empty salt of a withheld leaf.
193
- */
194
- export function setDisclose(
195
- leaves: readonly SaltedLeaf[],
196
- revealNames: readonly string[],
197
- hasher: DigestHasher = resolveHasher('sha256'),
198
- ): SetDisclosure {
199
- assertUniqueNames(leaves);
200
- const reveal = new Set(revealNames);
201
- const disclosed: SaltedLeaf[] = [];
202
- const hidden: string[] = [];
203
- for (const leaf of leaves) {
204
- if (reveal.has(leaf.name)) {
205
- disclosed.push(leaf);
206
- } else {
207
- hidden.push(uint8ArrayToString(leafDigest(leaf, hasher), HIDDEN_ENCODING));
208
- }
209
- }
210
- return { disclosed, hidden };
211
- }
212
-
213
- /**
214
- * Verify a disclosure against a signed root. Recomputes the disclosed leaves'
215
- * digests, unions them with the supplied hidden digests, sorts by bytes, recomputes
216
- * the root, and compares to `root`. This reconstructs the ENTIRE root, so it proves
217
- * the disclosed leaves belong to *exactly* this committed set — the holder cannot
218
- * add, drop, or swap a leaf (the leaf count is bound too).
219
- *
220
- * `encode` is how the signed `root` is rendered (so the recomputed root is encoded
221
- * the same way before comparison); for a `Uint8Array` root the raw bytes are compared
222
- * directly. Returns `false` on mismatch or malformed input — mirroring `verify`'s
223
- * forgiving contract rather than throwing.
224
- */
225
- export function setVerify(
226
- root: string | Uint8Array,
227
- disclosure: SetDisclosure,
228
- hasher: DigestHasher = resolveHasher('sha256'),
229
- encode: OutputEncoder = resolveOutputEncoder('base64url'),
230
- ): boolean {
231
- try {
232
- const { disclosed, hidden } = disclosure;
233
- const digests: Uint8Array[] = [];
234
- for (const leaf of disclosed) {
235
- digests.push(leafDigest(leaf, hasher));
236
- }
237
- for (const h of hidden) {
238
- digests.push(uint8ArrayFromString(h, HIDDEN_ENCODING));
239
- }
240
- digests.sort(compareBytes);
241
- const recomputed = hasher(encodeFields([SD_SET_DOMAIN_V1, ...digests]));
242
- if (root instanceof Uint8Array) {
243
- return bytesEqual(recomputed, root);
244
- }
245
- const encoded = encode(recomputed);
246
- return typeof encoded === 'string' && encoded === root;
247
- } catch {
248
- return false;
249
- }
250
- }
1
+ /**
2
+ * Salted-leaf SET COMMITMENT for per-attribute selective disclosure.
3
+ *
4
+ * An authority commits to a whole set of attributes as a single root value (which
5
+ * it signs / persists), then later reveals only a chosen *subset* to a recipient —
6
+ * with a proof that the revealed values are genuinely the committed ones — without
7
+ * leaking the values of the withheld attributes. A flat `digest(whole set)` cannot
8
+ * do this (verifying one field needs the whole pre-image, so it is all-or-nothing);
9
+ * this construction supports *partial opening*.
10
+ *
11
+ * ## Construction (flat salted-leaf set commitment, NOT a Merkle tree)
12
+ *
13
+ * Each disclosable attribute is a salted leaf, and the commitment (root) is the
14
+ * digest of all leaf digests in canonical order:
15
+ *
16
+ * ```
17
+ * leafDigest = digest([SD_LEAF_DOMAIN_V1, name, value, salt]) // raw digest bytes
18
+ * root = digest([SD_SET_DOMAIN_V1, sortedLeaf_0, sortedLeaf_1, ...])
19
+ * ```
20
+ *
21
+ * Both layers compose on the existing canonical {@link encodeFields} framing
22
+ * (injective, type-tagged, length-prefixed, replicable) — the same layering the CID
23
+ * work uses — so a *generic* salted-set primitive is simultaneously reusable and
24
+ * fully DB-enforceable. This is the same shape the IETF SD-JWT standard settled on
25
+ * (flat salted hashes, not a tree); we are NOT wire-compatible with SD-JWT (we reuse
26
+ * Optimystic's own `encodeFields` framing for cross-peer replicability) — SD-JWT is
27
+ * cited only as conceptual precedent that the smaller construction is the right one.
28
+ *
29
+ * Voter selective-disclosure field sets are small (a handful to a few dozen fields),
30
+ * so a tree's only advantage — O(log n) proof size — is marginal, while a tree drags
31
+ * in real footguns we would have to hand-roll and pin (arity, odd-node handling /
32
+ * the CVE-2012-2459 duplicate-leaf forgery class, leaf-vs-internal domain separation,
33
+ * and a separate audit-path proof format). A flat construction avoids all of them.
34
+ *
35
+ * ## Why these specific choices
36
+ *
37
+ * - **`name` is hashed into the leaf** so a disclosed `(value, salt)` proof cannot be
38
+ * replayed against a different attribute slot (e.g. presenting an `over18=true`
39
+ * proof as the `citizen` field). The binding is free given `encodeFields` framing.
40
+ * - **`salt` is per-leaf and mandatory** — low-entropy attributes (DOB, booleans, ZIP)
41
+ * are brute-forceable from a bare hash, and independent salts also defeat cross-
42
+ * registrant equality correlation. Salts come from `random_bytes` (≥128 bits).
43
+ * - **Canonical order is by raw leaf-digest bytes (lexicographic), and this is FORCED,
44
+ * not a preference.** In a disclosure the verifier learns the *names* of only the
45
+ * disclosed leaves; the withheld leaves arrive as opaque digests with no name. So the
46
+ * verifier can re-derive the root only if the ordering key is something it holds for
47
+ * *every* leaf — the leaf digest itself. Sorting by name would be unverifiable for
48
+ * hidden leaves. Do NOT "tidy" this into a name sort.
49
+ * - Sort is over **raw digest bytes**, never over encoded strings — an encoding-
50
+ * dependent ordering would break cross-peer agreement. Output encoding applies only
51
+ * to the final root.
52
+ *
53
+ * Because leaf and root reuse `encodeFields`, a future `DIGEST_FORMAT_V1` bump changes
54
+ * `setCommit` output too; this coupling is intentional (one canonical framing).
55
+ */
56
+
57
+ import { fromString as uint8ArrayFromString, toString as uint8ArrayToString } from 'uint8arrays';
58
+ import {
59
+ encodeFields,
60
+ resolveHasher,
61
+ resolveOutputEncoder,
62
+ type DigestField,
63
+ type DigestHasher,
64
+ type OutputEncoder,
65
+ } from './crypto.js';
66
+
67
+ /**
68
+ * Fixed domain-separation constants — the leading string field of each layer's
69
+ * {@link encodeFields} tuple. They are pinned EXACTLY like `DIGEST_FORMAT_V1`:
70
+ *
71
+ * - the two strings MUST be distinct, so a leaf hash can never equal a root hash;
72
+ * - neither may change without a deliberate, breaking version bump — which would
73
+ * change every committed root and every signature taken over it.
74
+ *
75
+ * Do not "tidy" or shorten these.
76
+ */
77
+ const SD_LEAF_DOMAIN_V1 = 'optimystic/sd-leaf/v1';
78
+ const SD_SET_DOMAIN_V1 = 'optimystic/sd-set/v1';
79
+
80
+ /** Hidden leaf digests travel as base64url text — the plugin's canonical text encoding. */
81
+ const HIDDEN_ENCODING = 'base64url';
82
+
83
+ /** One disclosable attribute. `value` spans the SQL value space ({@link DigestField}). */
84
+ export interface SaltedLeaf {
85
+ readonly name: string;
86
+ readonly value: DigestField;
87
+ /** base64url text (e.g. from `random_bytes`) or raw bytes. Mandatory, non-empty. */
88
+ readonly salt: string | Uint8Array;
89
+ }
90
+
91
+ /** A disclosure payload sent to a recipient. */
92
+ export interface SetDisclosure {
93
+ /** The opened `(name, value, salt)` triples. */
94
+ readonly disclosed: readonly SaltedLeaf[];
95
+ /** Opaque leaf digests (base64url) of the withheld leaves — no name, no value, no salt. */
96
+ readonly hidden: readonly string[];
97
+ }
98
+
99
+ // --- internal helpers --- //
100
+
101
+ /** Lexicographic compare of two byte arrays (the canonical leaf ordering key). */
102
+ function compareBytes(a: Uint8Array, b: Uint8Array): number {
103
+ const len = Math.min(a.length, b.length);
104
+ for (let i = 0; i < len; i++) {
105
+ const d = a[i]! - b[i]!;
106
+ if (d !== 0) return d;
107
+ }
108
+ return a.length - b.length;
109
+ }
110
+
111
+ /** Constant-shape byte equality (length first, then content). */
112
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
113
+ if (a.length !== b.length) return false;
114
+ for (let i = 0; i < a.length; i++) {
115
+ if (a[i] !== b[i]) return false;
116
+ }
117
+ return true;
118
+ }
119
+
120
+ /**
121
+ * Normalize a leaf's salt to raw bytes — a base64url string (the form `random_bytes`
122
+ * returns) decodes to bytes, raw bytes pass through — so the two representations of the
123
+ * same salt commit identically. THROWS on a missing or empty salt (unsalted leaves are
124
+ * brute-forceable, an invalid state we make impossible).
125
+ */
126
+ function requireSaltBytes(leaf: SaltedLeaf): Uint8Array {
127
+ const { salt } = leaf;
128
+ if (salt == null) {
129
+ throw new Error(`set commitment: leaf '${leaf.name}' is missing a salt (an unsalted leaf is brute-forceable)`);
130
+ }
131
+ const bytes = salt instanceof Uint8Array ? salt : uint8ArrayFromString(salt, HIDDEN_ENCODING);
132
+ if (bytes.length === 0) {
133
+ throw new Error(`set commitment: leaf '${leaf.name}' has an empty salt (an unsalted leaf is brute-forceable)`);
134
+ }
135
+ return bytes;
136
+ }
137
+
138
+ /**
139
+ * THROW on a duplicate `name`. Two leaves with the same name would let a holder
140
+ * selectively present whichever value suits them; the authority side (which holds all
141
+ * names) is the only place uniqueness can be enforced — the verifier never sees the
142
+ * hidden names — so the primitive must fail-fast.
143
+ */
144
+ function assertUniqueNames(leaves: readonly SaltedLeaf[]): void {
145
+ const seen = new Set<string>();
146
+ for (const leaf of leaves) {
147
+ if (seen.has(leaf.name)) {
148
+ throw new Error(`set commitment: duplicate leaf name '${leaf.name}'`);
149
+ }
150
+ seen.add(leaf.name);
151
+ }
152
+ }
153
+
154
+ // --- public API --- //
155
+
156
+ /**
157
+ * Raw leaf digest bytes for one salted leaf: `digest([SD_LEAF_DOMAIN_V1, name,
158
+ * value, salt])`. Domain-separated (can never equal a root) and name-bound (a
159
+ * `(value, salt)` proof cannot be replayed under another attribute name). THROWS on
160
+ * a missing/empty salt.
161
+ */
162
+ export function leafDigest(leaf: SaltedLeaf, hasher: DigestHasher): Uint8Array {
163
+ const saltBytes = requireSaltBytes(leaf);
164
+ return hasher(encodeFields([SD_LEAF_DOMAIN_V1, leaf.name, leaf.value, saltBytes]));
165
+ }
166
+
167
+ /**
168
+ * Commit to a SET of salted leaves → a single root (the signed/persisted value).
169
+ * Sorts leaves by raw leaf-digest bytes, then digests them under `SD_SET_DOMAIN_V1`.
170
+ * Like `digest`, this emits a BARE digest — apply `cid()` on top for the self-
171
+ * describing column representation (`cid(set_commit(...))`).
172
+ *
173
+ * The empty set is well-defined (the digest of `[SD_SET_DOMAIN_V1]`), not an error.
174
+ * THROWS on a duplicate `name` or a missing/empty `salt` (invalid states made
175
+ * impossible). Resolve `hasher`/`encode` once and reuse — no per-call branching.
176
+ */
177
+ export function setCommit(
178
+ leaves: readonly SaltedLeaf[],
179
+ hasher: DigestHasher = resolveHasher('sha256'),
180
+ encode: OutputEncoder = resolveOutputEncoder('base64url'),
181
+ ): string | Uint8Array {
182
+ assertUniqueNames(leaves);
183
+ const leafDigests = leaves.map((leaf) => leafDigest(leaf, hasher));
184
+ leafDigests.sort(compareBytes);
185
+ return encode(hasher(encodeFields([SD_SET_DOMAIN_V1, ...leafDigests])));
186
+ }
187
+
188
+ /**
189
+ * Split a leaf set into the revealed `(name, value, salt)` triples plus the opaque
190
+ * leaf digests (base64url) of the rest. Withheld `value`/`salt` never appear in the
191
+ * output. Names in `revealNames` that match no leaf are simply not disclosed.
192
+ * THROWS on a duplicate `name` or a missing/empty salt of a withheld leaf.
193
+ */
194
+ export function setDisclose(
195
+ leaves: readonly SaltedLeaf[],
196
+ revealNames: readonly string[],
197
+ hasher: DigestHasher = resolveHasher('sha256'),
198
+ ): SetDisclosure {
199
+ assertUniqueNames(leaves);
200
+ const reveal = new Set(revealNames);
201
+ const disclosed: SaltedLeaf[] = [];
202
+ const hidden: string[] = [];
203
+ for (const leaf of leaves) {
204
+ if (reveal.has(leaf.name)) {
205
+ disclosed.push(leaf);
206
+ } else {
207
+ hidden.push(uint8ArrayToString(leafDigest(leaf, hasher), HIDDEN_ENCODING));
208
+ }
209
+ }
210
+ return { disclosed, hidden };
211
+ }
212
+
213
+ /**
214
+ * Verify a disclosure against a signed root. Recomputes the disclosed leaves'
215
+ * digests, unions them with the supplied hidden digests, sorts by bytes, recomputes
216
+ * the root, and compares to `root`. This reconstructs the ENTIRE root, so it proves
217
+ * the disclosed leaves belong to *exactly* this committed set — the holder cannot
218
+ * add, drop, or swap a leaf (the leaf count is bound too).
219
+ *
220
+ * `encode` is how the signed `root` is rendered (so the recomputed root is encoded
221
+ * the same way before comparison); for a `Uint8Array` root the raw bytes are compared
222
+ * directly. Returns `false` on mismatch or malformed input — mirroring `verify`'s
223
+ * forgiving contract rather than throwing.
224
+ */
225
+ export function setVerify(
226
+ root: string | Uint8Array,
227
+ disclosure: SetDisclosure,
228
+ hasher: DigestHasher = resolveHasher('sha256'),
229
+ encode: OutputEncoder = resolveOutputEncoder('base64url'),
230
+ ): boolean {
231
+ try {
232
+ const { disclosed, hidden } = disclosure;
233
+ const digests: Uint8Array[] = [];
234
+ for (const leaf of disclosed) {
235
+ digests.push(leafDigest(leaf, hasher));
236
+ }
237
+ for (const h of hidden) {
238
+ digests.push(uint8ArrayFromString(h, HIDDEN_ENCODING));
239
+ }
240
+ digests.sort(compareBytes);
241
+ const recomputed = hasher(encodeFields([SD_SET_DOMAIN_V1, ...digests]));
242
+ if (root instanceof Uint8Array) {
243
+ return bytesEqual(recomputed, root);
244
+ }
245
+ const encoded = encode(recomputed);
246
+ return typeof encoded === 'string' && encoded === root;
247
+ } catch {
248
+ return false;
249
+ }
250
+ }