@optimystic/quereus-plugin-crypto 0.22.0 → 0.24.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/CHANGELOG.md +91 -91
- package/dist/index.js.map +1 -1
- package/dist/plugin.js.map +1 -1
- package/package.json +2 -2
- package/src/cid.ts +200 -200
- package/src/crypto.ts +547 -547
- package/src/sd.ts +250 -250
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
|
+
}
|