@blockula/sdk 0.3.0 → 1.0.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.
@@ -0,0 +1,170 @@
1
+ /** Record namespaces (node/src/state/mod.rs `ns`): the first byte of every record's tree key. */
2
+ export declare const NAMESPACES: {
3
+ readonly META: 0;
4
+ readonly ACCOUNTS: 1;
5
+ readonly NAMES: 2;
6
+ readonly VALIDATORS: 3;
7
+ readonly ACTIVE_SETS: 4;
8
+ readonly HUNTS: 5;
9
+ readonly GITHUB: 6;
10
+ readonly GITHUB_BY_ACCOUNT: 7;
11
+ readonly FAMILIARS: 8;
12
+ readonly REPOS: 16;
13
+ readonly SOULS: 17;
14
+ readonly VOUCHES: 18;
15
+ readonly SYBILS: 19;
16
+ readonly CLAIMS: 20;
17
+ readonly CLAIM_BY_PR: 21;
18
+ readonly HANDLES: 22;
19
+ readonly JURORS: 23;
20
+ readonly SEANCES: 24;
21
+ readonly GRANTS: 25;
22
+ readonly RESERVED_NAMES: 43;
23
+ readonly FEATURES: 47;
24
+ /** A program's record, keyed by its address as a string (as accounts are). */
25
+ readonly PROGRAMS: 66;
26
+ readonly PROGRAM_CODE: 67;
27
+ readonly PROGRAM_INTERFACES: 68;
28
+ readonly PROGRAM_STORAGE: 69;
29
+ readonly PROGRAM_BLOB_REFS: 72;
30
+ };
31
+ /** Hash of an empty subtree. */
32
+ export declare const EMPTY_HASH: Uint8Array<ArrayBuffer>;
33
+ /** A record key as bytes: strings are UTF-8 (addresses, names), as the node's `RecordKey`. */
34
+ export declare function recordKey(key: string | Uint8Array): Uint8Array;
35
+ /** Tree key of a record: BLAKE3(namespace ‖ key). */
36
+ export declare function keyHash(namespace: number, key: Uint8Array): Uint8Array;
37
+ /** Hash of a record's encoded value. */
38
+ export declare function valueHash(value: Uint8Array): Uint8Array;
39
+ /** A lone leaf: BLAKE3(0x00 ‖ k ‖ v). */
40
+ export declare function leafHash(key: Uint8Array, value: Uint8Array): Uint8Array;
41
+ /** An internal node: BLAKE3(0x01 ‖ left ‖ right). */
42
+ export declare function nodeHash(left: Uint8Array, right: Uint8Array): Uint8Array;
43
+ /**
44
+ * The root of a set of leaves `[key hash, value hash]`, straight from the definition (tree.rs
45
+ * `root_of`). Leaves are sorted here; duplicate keys are an error.
46
+ */
47
+ export declare function rootOf(leaves: [Uint8Array, Uint8Array][]): Uint8Array;
48
+ /** The leaf a proof's path ends at (hex), as the node serves it. */
49
+ export interface ProofLeaf {
50
+ key: string;
51
+ value: string;
52
+ }
53
+ /** A Merkle proof (node/src/state/tree.rs `Proof`, JSON): sibling hashes from the root down, and where the path ends. */
54
+ export interface Proof {
55
+ siblings: string[];
56
+ /** `null`: an empty subtree (the key is absent). Otherwise a lone leaf: the key's own, or another key with the same prefix (absent). */
57
+ leaf: ProofLeaf | null;
58
+ }
59
+ /**
60
+ * Checks that `(namespace, key)` holds `value` (`null`: is absent) under `root` (tree.rs
61
+ * `verify`). Never throws: malformed input is `false`.
62
+ */
63
+ export declare function verifyProof(root: Uint8Array | string, namespace: number, key: string | Uint8Array, value: Uint8Array | null, proof: Proof): boolean;
64
+ /** `/api/proof/{namespace}/{key}`: a record's value and its proof against the state root after block `height`. */
65
+ export interface ProofResponse {
66
+ namespace: number;
67
+ /** The record key, hex. */
68
+ key: string;
69
+ /** The encoded value, hex; `null` if the record doesn't exist. */
70
+ value: string | null;
71
+ proof: Proof;
72
+ root: string;
73
+ height: number;
74
+ block_hash?: string;
75
+ }
76
+ /** A record checked against a state root. */
77
+ export interface ProvenRecord {
78
+ /** The record's encoded value, or `null` if it provably doesn't exist. */
79
+ value: Uint8Array | null;
80
+ /** The state root it was proven against, and the block it's the root after. */
81
+ root: string;
82
+ height: number;
83
+ }
84
+ /**
85
+ * Checks a `/api/proof` response for the record you asked for: the node must answer for exactly
86
+ * `(namespace, key)` and its proof must verify against the root it reports. Throws otherwise.
87
+ * This proves the record is in the state with that root; whether the root is the chain's is a
88
+ * separate question (see `Client.provenRecord`'s `witnesses`).
89
+ */
90
+ export declare function checkProofResponse(r: ProofResponse, namespace: number, key: string | Uint8Array): ProvenRecord;
91
+ /** One key on an account, as proven. */
92
+ export interface ProvenKey {
93
+ key_id: string;
94
+ label: string;
95
+ role: {
96
+ type: "owner";
97
+ } | {
98
+ type: "session";
99
+ limit: string;
100
+ period_blocks: number;
101
+ expires_at: number;
102
+ };
103
+ added_at: number;
104
+ }
105
+ /** The parts of an account record (chain.rs `Account`) a wallet needs: balance, nonce and keys. Later fields (locks) aren't read. */
106
+ export interface AccountRecord {
107
+ balance: bigint;
108
+ nonce: number;
109
+ /** Authorized keys. Empty means only the key the address was derived from. */
110
+ keys: ProvenKey[];
111
+ }
112
+ export declare function decodeAccount(value: Uint8Array): AccountRecord;
113
+ /**
114
+ * A name record (chain.rs `NameRecord`): the bare name, its owner, when it was registered, and
115
+ * the last height it resolves at (DEC-11; after it, a grace period in which only the owner can
116
+ * renew it, then anyone can register it). Later fields, if a version adds them, aren't read.
117
+ */
118
+ export interface NameRecord {
119
+ name: string;
120
+ owner: string;
121
+ registered_at: number;
122
+ expires_at: number;
123
+ /** The account the owner offered the name to (`TransferName`); it's still the owner's until they accept it. */
124
+ offered_to?: string;
125
+ /**
126
+ * The grace (`name_grace_blocks`) it was registered or last renewed with. Its grace runs until
127
+ * `expires_at` plus the longer of this and the chain's `name_grace_blocks` now (`/api/name`'s
128
+ * `grace_until`), so lowering the parameter doesn't shorten it.
129
+ */
130
+ grace_blocks?: number;
131
+ }
132
+ export declare function decodeName(value: Uint8Array): NameRecord;
133
+ /**
134
+ * A program's record as the state holds it (node/src/programs/state.rs `ProgramRecord`, format 1),
135
+ * decoded from the value `/api/proof/66/{address}` proves. `version_digest` isn't in the record:
136
+ * `Client.provenProgram` computes it from the record's own fields (`versionDigest`).
137
+ */
138
+ export interface ProgramRecord {
139
+ /** The account whose transaction created it. */
140
+ creator: string;
141
+ created_at: number;
142
+ /** Lowercase hex, 64 characters. */
143
+ wasm_hash: string;
144
+ interface_hash: string;
145
+ version: number;
146
+ /** Stored as created, with no power until program upgrades exist. */
147
+ authority: string | null;
148
+ upgrade_delay_blocks: number;
149
+ /** 0 open, 1 additive. */
150
+ policy: "open" | "additive";
151
+ accepts_transfers: boolean;
152
+ /** Its storage entries: how many, Σ their accounted bytes, Σ their deposits (drops, as decimal strings). */
153
+ storage: {
154
+ items: number;
155
+ bytes: number;
156
+ deposit: string;
157
+ };
158
+ /** The part of those deposits the program paid itself. */
159
+ sponsored: string;
160
+ /** What its creator bonded for the record itself, and who. */
161
+ record_deposit: string;
162
+ record_payer: string;
163
+ }
164
+ /**
165
+ * Decodes a program record: the format byte (1: the only format this SDK reads, as the node's
166
+ * `Versioned` does) and the borsh body, field by field in the order the node writes them. Refuses
167
+ * a format it doesn't know, a bool or an option tag that isn't 0 or 1, text that isn't UTF-8 and
168
+ * bytes left over, so what it returns is what the state holds.
169
+ */
170
+ export declare function decodeProgram(value: Uint8Array): ProgramRecord;
package/dist/proof.js ADDED
@@ -0,0 +1,317 @@
1
+ // Light-client checks of state proofs. A port of node/src/state/tree.rs (the tree's definition is
2
+ // a consensus rule): a compact binary sparse Merkle tree over BLAKE3, where a record
3
+ // `(namespace, key) → value` is the leaf `k = BLAKE3(namespace ‖ key)`, `v = BLAKE3(value)`.
4
+ // Shared vectors: sdk-ts/test/proof-vectors.json (written and checked by the node's
5
+ // `state::proof_vectors` tests).
6
+ import { blake3 } from "@noble/hashes/blake3.js";
7
+ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
8
+ /** Record namespaces (node/src/state/mod.rs `ns`): the first byte of every record's tree key. */
9
+ export const NAMESPACES = {
10
+ META: 0,
11
+ ACCOUNTS: 1,
12
+ NAMES: 2,
13
+ VALIDATORS: 3,
14
+ ACTIVE_SETS: 4,
15
+ HUNTS: 5,
16
+ GITHUB: 6,
17
+ GITHUB_BY_ACCOUNT: 7,
18
+ FAMILIARS: 8,
19
+ REPOS: 16,
20
+ SOULS: 17,
21
+ VOUCHES: 18,
22
+ SYBILS: 19,
23
+ CLAIMS: 20,
24
+ CLAIM_BY_PR: 21,
25
+ HANDLES: 22,
26
+ JURORS: 23,
27
+ SEANCES: 24,
28
+ GRANTS: 25,
29
+ // 32–47 belong to the protocol v1 modules (staking 32–36, governance 37–42, econ 43, beacon
30
+ // 44–46, upgrade 47); see node/src/state/mod.rs for what each holds.
31
+ RESERVED_NAMES: 43,
32
+ FEATURES: 47,
33
+ // Programs (protocol v2, docs/PROGRAMS-V0.md §4.1): empty until programs first switch on.
34
+ /** A program's record, keyed by its address as a string (as accounts are). */
35
+ PROGRAMS: 66,
36
+ PROGRAM_CODE: 67,
37
+ PROGRAM_INTERFACES: 68,
38
+ PROGRAM_STORAGE: 69,
39
+ PROGRAM_BLOB_REFS: 72,
40
+ };
41
+ /** Hash of an empty subtree. */
42
+ export const EMPTY_HASH = new Uint8Array(32);
43
+ const utf8 = new TextEncoder();
44
+ /** A record key as bytes: strings are UTF-8 (addresses, names), as the node's `RecordKey`. */
45
+ export function recordKey(key) {
46
+ return typeof key === "string" ? utf8.encode(key) : key;
47
+ }
48
+ /** Tree key of a record: BLAKE3(namespace ‖ key). */
49
+ export function keyHash(namespace, key) {
50
+ const b = new Uint8Array(1 + key.length);
51
+ b[0] = namespace;
52
+ b.set(key, 1);
53
+ return blake3(b);
54
+ }
55
+ /** Hash of a record's encoded value. */
56
+ export function valueHash(value) {
57
+ return blake3(value);
58
+ }
59
+ const cat3 = (tag, a, b) => {
60
+ const out = new Uint8Array(65);
61
+ out[0] = tag;
62
+ out.set(a, 1);
63
+ out.set(b, 33);
64
+ return out;
65
+ };
66
+ /** A lone leaf: BLAKE3(0x00 ‖ k ‖ v). */
67
+ export function leafHash(key, value) {
68
+ return blake3(cat3(0, key, value));
69
+ }
70
+ /** An internal node: BLAKE3(0x01 ‖ left ‖ right). */
71
+ export function nodeHash(left, right) {
72
+ return blake3(cat3(1, left, right));
73
+ }
74
+ /** Bit `i` of a key hash, most significant first. */
75
+ const bit = (h, i) => (h[i >> 3] >> (7 - (i & 7))) & 1;
76
+ /** Whether two key hashes agree on their first `bits` bits. */
77
+ function samePrefix(a, b, bits) {
78
+ const whole = bits >> 3;
79
+ const rest = bits & 7;
80
+ for (let i = 0; i < whole; i++)
81
+ if (a[i] !== b[i])
82
+ return false;
83
+ return rest === 0 || ((a[whole] ^ b[whole]) >> (8 - rest)) === 0;
84
+ }
85
+ const equal = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
86
+ const compare = (a, b) => {
87
+ for (let i = 0; i < 32; i++)
88
+ if (a[i] !== b[i])
89
+ return a[i] - b[i];
90
+ return 0;
91
+ };
92
+ /**
93
+ * The root of a set of leaves `[key hash, value hash]`, straight from the definition (tree.rs
94
+ * `root_of`). Leaves are sorted here; duplicate keys are an error.
95
+ */
96
+ export function rootOf(leaves) {
97
+ const sorted = [...leaves].sort((x, y) => compare(x[0], y[0]));
98
+ for (let i = 1; i < sorted.length; i++)
99
+ if (compare(sorted[i - 1][0], sorted[i][0]) === 0)
100
+ throw new Error("duplicate leaf key");
101
+ const sub = (from, to, depth) => {
102
+ if (to === from)
103
+ return EMPTY_HASH;
104
+ if (to - from === 1)
105
+ return leafHash(sorted[from][0], sorted[from][1]);
106
+ let split = from;
107
+ while (split < to && bit(sorted[split][0], depth) === 0)
108
+ split++;
109
+ return nodeHash(sub(from, split, depth + 1), sub(split, to, depth + 1));
110
+ };
111
+ return sub(0, sorted.length, 0);
112
+ }
113
+ const hex32 = (s) => (typeof s === "string" && /^[0-9a-f]{64}$/.test(s) ? hexToBytes(s) : null);
114
+ /**
115
+ * Checks that `(namespace, key)` holds `value` (`null`: is absent) under `root` (tree.rs
116
+ * `verify`). Never throws: malformed input is `false`.
117
+ */
118
+ export function verifyProof(root, namespace, key, value, proof) {
119
+ const r = typeof root === "string" ? hex32(root) : root;
120
+ if (!r || r.length !== 32 || !Number.isInteger(namespace) || namespace < 0 || namespace > 255)
121
+ return false;
122
+ if (!proof || !Array.isArray(proof.siblings) || proof.siblings.length > 256)
123
+ return false;
124
+ const siblings = proof.siblings.map(hex32);
125
+ if (siblings.some((s) => s === null))
126
+ return false;
127
+ const k = keyHash(namespace, recordKey(key));
128
+ const depth = siblings.length;
129
+ let h;
130
+ if (proof.leaf) {
131
+ const lk = hex32(proof.leaf.key);
132
+ const lv = hex32(proof.leaf.value);
133
+ if (!lk || !lv)
134
+ return false;
135
+ if (value !== null && equal(lk, k) && equal(lv, valueHash(value)))
136
+ h = leafHash(lk, lv);
137
+ else if (value === null && !equal(lk, k) && samePrefix(lk, k, depth))
138
+ h = leafHash(lk, lv);
139
+ else
140
+ return false;
141
+ }
142
+ else if (value === null) {
143
+ h = EMPTY_HASH;
144
+ }
145
+ else {
146
+ return false;
147
+ }
148
+ for (let i = depth - 1; i >= 0; i--) {
149
+ const s = siblings[i];
150
+ h = bit(k, i) === 0 ? nodeHash(h, s) : nodeHash(s, h);
151
+ }
152
+ return equal(h, r);
153
+ }
154
+ /**
155
+ * Checks a `/api/proof` response for the record you asked for: the node must answer for exactly
156
+ * `(namespace, key)` and its proof must verify against the root it reports. Throws otherwise.
157
+ * This proves the record is in the state with that root; whether the root is the chain's is a
158
+ * separate question (see `Client.provenRecord`'s `witnesses`).
159
+ */
160
+ export function checkProofResponse(r, namespace, key) {
161
+ const want = bytesToHex(recordKey(key));
162
+ if (!r || r.namespace !== namespace || typeof r.key !== "string" || r.key.toLowerCase() !== want)
163
+ throw new Error("the node answered for a different record than the one asked for");
164
+ if (!Number.isSafeInteger(r.height) || r.height < 0)
165
+ throw new Error("the proof has no valid height");
166
+ let value = null;
167
+ if (r.value !== null && r.value !== undefined) {
168
+ if (typeof r.value !== "string" || !/^([0-9a-f]{2})*$/.test(r.value))
169
+ throw new Error("the proof's value isn't hex");
170
+ value = hexToBytes(r.value);
171
+ }
172
+ if (!verifyProof(r.root, namespace, key, value, r.proof))
173
+ throw new Error(`the node's proof for this record doesn't verify against state root ${String(r.root).slice(0, 16)}…`);
174
+ return { value, root: r.root, height: r.height };
175
+ }
176
+ // ---- Decoding records (node/src/state/codec.rs: borsh, little-endian, u32 lengths) ----
177
+ /** A strict little-endian reader over a record's bytes. */
178
+ class Reader {
179
+ b;
180
+ at = 0;
181
+ view;
182
+ constructor(b) {
183
+ this.b = b;
184
+ this.view = new DataView(b.buffer, b.byteOffset, b.byteLength);
185
+ }
186
+ need(n) {
187
+ if (this.at + n > this.b.length)
188
+ throw new Error("record ends early");
189
+ }
190
+ u8() {
191
+ this.need(1);
192
+ return this.b[this.at++];
193
+ }
194
+ u32() {
195
+ this.need(4);
196
+ const v = this.view.getUint32(this.at, true);
197
+ this.at += 4;
198
+ return v;
199
+ }
200
+ u64() {
201
+ this.need(8);
202
+ const v = this.view.getBigUint64(this.at, true);
203
+ this.at += 8;
204
+ return v;
205
+ }
206
+ /** `n` raw bytes. */
207
+ fixed(n) {
208
+ this.need(n);
209
+ const v = this.b.subarray(this.at, this.at + n);
210
+ this.at += n;
211
+ return v;
212
+ }
213
+ /** A borsh bool: one byte, 0 or 1 and nothing else. */
214
+ bool() {
215
+ const v = this.u8();
216
+ if (v > 1)
217
+ throw new Error(`record holds ${v} where a bool should be`);
218
+ return v === 1;
219
+ }
220
+ str() {
221
+ const n = this.u32();
222
+ this.need(n);
223
+ const s = new TextDecoder("utf-8", { fatal: true }).decode(this.b.subarray(this.at, this.at + n));
224
+ this.at += n;
225
+ return s;
226
+ }
227
+ }
228
+ export function decodeAccount(value) {
229
+ const r = new Reader(value);
230
+ const balance = r.u64();
231
+ const nonce = Number(r.u64());
232
+ const n = r.u32();
233
+ if (n > 64)
234
+ throw new Error("account record lists too many keys");
235
+ const keys = [];
236
+ for (let i = 0; i < n; i++) {
237
+ const key_id = r.str();
238
+ const label = r.str();
239
+ const tag = r.u8();
240
+ let role;
241
+ if (tag === 0)
242
+ role = { type: "owner" };
243
+ else if (tag === 1)
244
+ role = { type: "session", limit: r.u64().toString(), period_blocks: Number(r.u64()), expires_at: Number(r.u64()) };
245
+ else
246
+ throw new Error(`bad key role tag ${tag}`);
247
+ const added_at = Number(r.u64());
248
+ r.u64(); // spent in the current window
249
+ r.u64(); // window start
250
+ keys.push({ key_id, label, role, added_at });
251
+ }
252
+ return { balance, nonce, keys };
253
+ }
254
+ export function decodeName(value) {
255
+ const r = new Reader(value);
256
+ const name = r.str();
257
+ const owner = r.str();
258
+ const registered_at = Number(r.u64());
259
+ // Saturated, so a never-expiring record (u64::MAX) compares as "later than any height".
260
+ const sat = (v) => (v > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(v));
261
+ const expires_at = sat(r.u64());
262
+ const rec = { name, owner, registered_at, expires_at };
263
+ // State schema 6 appended `offered_to` (an Option: 0, or 1 and the address), schema 7
264
+ // `grace_blocks`; older records end earlier.
265
+ if (r.at === value.length)
266
+ return rec;
267
+ const tag = r.u8();
268
+ if (tag > 1)
269
+ throw new Error(`name record: bad offered_to tag ${tag}`);
270
+ if (tag === 1)
271
+ rec.offered_to = r.str();
272
+ if (r.at === value.length)
273
+ return rec;
274
+ rec.grace_blocks = sat(r.u64());
275
+ return rec;
276
+ }
277
+ /** A number the record holds as a u64, which must be one JSON and JS carry exactly. */
278
+ const exactCount = (v, what) => {
279
+ if (v > BigInt(Number.MAX_SAFE_INTEGER))
280
+ throw new Error(`a program record's ${what} (${v}) is past 2^53 − 1, which this SDK can't carry exactly`);
281
+ return Number(v);
282
+ };
283
+ /**
284
+ * Decodes a program record: the format byte (1: the only format this SDK reads, as the node's
285
+ * `Versioned` does) and the borsh body, field by field in the order the node writes them. Refuses
286
+ * a format it doesn't know, a bool or an option tag that isn't 0 or 1, text that isn't UTF-8 and
287
+ * bytes left over, so what it returns is what the state holds.
288
+ */
289
+ export function decodeProgram(value) {
290
+ const r = new Reader(value);
291
+ const format = r.u8();
292
+ if (format !== 1)
293
+ throw new Error(`program record format ${format}: this SDK reads format 1`);
294
+ const creator = r.str();
295
+ const created_at = exactCount(r.u64(), "created_at");
296
+ const wasm_hash = bytesToHex(r.fixed(32));
297
+ const interface_hash = bytesToHex(r.fixed(32));
298
+ const version = r.u32();
299
+ const hasAuthority = r.u8();
300
+ if (hasAuthority > 1)
301
+ throw new Error(`a program record's authority has option tag ${hasAuthority}`);
302
+ const authority = hasAuthority === 1 ? r.str() : null;
303
+ const upgrade_delay_blocks = exactCount(r.u64(), "upgrade_delay_blocks");
304
+ const policyByte = r.u8();
305
+ if (policyByte > 1)
306
+ throw new Error(`a program record's policy is ${policyByte}: this SDK knows open (0) and additive (1)`);
307
+ const accepts_transfers = r.bool();
308
+ const items = exactCount(r.u64(), "items");
309
+ const bytes = exactCount(r.u64(), "bytes");
310
+ const deposit = r.u64().toString();
311
+ const sponsored = r.u64().toString();
312
+ const record_deposit = r.u64().toString();
313
+ const record_payer = r.str();
314
+ if (r.at !== value.length)
315
+ throw new Error("a program record has bytes after its last field");
316
+ return { creator, created_at, wasm_hash, interface_hash, version, authority, upgrade_delay_blocks, policy: policyByte === 0 ? "open" : "additive", accepts_transfers, storage: { items, bytes, deposit }, sponsored, record_deposit, record_payer };
317
+ }
@@ -0,0 +1,15 @@
1
+ export declare const RELEASE_SIGNATURE_FORMAT = "blockula-release-signature-v1";
2
+ /** The contents of a `checksums.txt.sig` file. */
3
+ export interface ReleaseSignature {
4
+ format: string;
5
+ key_id: string;
6
+ public_key: string;
7
+ signature: string;
8
+ }
9
+ /** What the release key signs: the domain, then the file's exact bytes. */
10
+ export declare function releaseMessage(file: Uint8Array): Uint8Array;
11
+ /**
12
+ * Throws unless `sig` is a valid release signature on `file` by the key whose id is
13
+ * `trustedKeyId` (the published release key id, never one taken from the signature itself).
14
+ */
15
+ export declare function verifyReleaseSignature(sig: ReleaseSignature, file: Uint8Array, trustedKeyId: string): void;
@@ -0,0 +1,41 @@
1
+ // Release signatures (docs/OPERATIONS.md "Releases"): the offline ML-DSA-65 key signs each
2
+ // release's checksums.txt. Same format and checks as scripts/release-sig.mjs, so tools that
3
+ // install a release (the MCP's install_node) can verify it with the SDK alone:
4
+ // message = "BLOCKULA_RELEASE_V1" ‖ file bytes
5
+ // signature = ML-DSA-65 (FIPS 204, empty context)
6
+ // key id = hex BLAKE3(public key)
7
+ // .sig file = one JSON line: {"format","key_id","public_key","signature"} (hex)
8
+ import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
9
+ import { blake3 } from "@noble/hashes/blake3.js";
10
+ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
11
+ import { PUBLIC_KEY_LEN, SIGNATURE_LEN } from "./tx.js";
12
+ export const RELEASE_SIGNATURE_FORMAT = "blockula-release-signature-v1";
13
+ const DOMAIN = new TextEncoder().encode("BLOCKULA_RELEASE_V1");
14
+ /** What the release key signs: the domain, then the file's exact bytes. */
15
+ export function releaseMessage(file) {
16
+ const m = new Uint8Array(DOMAIN.length + file.length);
17
+ m.set(DOMAIN);
18
+ m.set(file, DOMAIN.length);
19
+ return m;
20
+ }
21
+ const isHex = (s, bytes) => typeof s === "string" && s.length === bytes * 2 && /^[0-9a-f]*$/.test(s);
22
+ /**
23
+ * Throws unless `sig` is a valid release signature on `file` by the key whose id is
24
+ * `trustedKeyId` (the published release key id, never one taken from the signature itself).
25
+ */
26
+ export function verifyReleaseSignature(sig, file, trustedKeyId) {
27
+ if (!sig || sig.format !== RELEASE_SIGNATURE_FORMAT)
28
+ throw new Error(`unknown release signature format ${String(sig?.format)}`);
29
+ if (!isHex(trustedKeyId, 32))
30
+ throw new Error("the trusted release key id must be 64 lowercase hex characters");
31
+ if (!isHex(sig.public_key, PUBLIC_KEY_LEN) || !isHex(sig.signature, SIGNATURE_LEN))
32
+ throw new Error("malformed ML-DSA-65 key or signature");
33
+ const publicKey = hexToBytes(sig.public_key);
34
+ const id = bytesToHex(blake3(publicKey));
35
+ if (sig.key_id !== id)
36
+ throw new Error("the signature's key_id doesn't match its public key");
37
+ if (id !== trustedKeyId)
38
+ throw new Error(`signed by key ${id}, not the release key ${trustedKeyId}`);
39
+ if (!ml_dsa65.verify(hexToBytes(sig.signature), releaseMessage(file), publicKey))
40
+ throw new Error("INVALID release signature");
41
+ }