burnledger 0.4.0 → 0.6.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.
Files changed (104) hide show
  1. package/README.md +11 -4
  2. package/dist/cjs/anchor.d.ts +71 -0
  3. package/dist/cjs/anchor.d.ts.map +1 -0
  4. package/dist/cjs/anchor.js +279 -0
  5. package/dist/cjs/anchor.js.map +1 -0
  6. package/dist/cjs/client.d.ts +26 -2
  7. package/dist/cjs/client.d.ts.map +1 -1
  8. package/dist/cjs/client.js +27 -5
  9. package/dist/cjs/client.js.map +1 -1
  10. package/dist/cjs/enclave-seal.d.ts +101 -0
  11. package/dist/cjs/enclave-seal.d.ts.map +1 -0
  12. package/dist/cjs/enclave-seal.js +479 -0
  13. package/dist/cjs/enclave-seal.js.map +1 -0
  14. package/dist/cjs/errors.d.ts +42 -1
  15. package/dist/cjs/errors.d.ts.map +1 -1
  16. package/dist/cjs/errors.js +44 -2
  17. package/dist/cjs/errors.js.map +1 -1
  18. package/dist/cjs/http.d.ts +21 -1
  19. package/dist/cjs/http.d.ts.map +1 -1
  20. package/dist/cjs/http.js +76 -12
  21. package/dist/cjs/http.js.map +1 -1
  22. package/dist/cjs/index.browser.d.ts +2 -4
  23. package/dist/cjs/index.browser.d.ts.map +1 -1
  24. package/dist/cjs/index.browser.js.map +1 -1
  25. package/dist/cjs/index.d.ts +16 -11
  26. package/dist/cjs/index.d.ts.map +1 -1
  27. package/dist/cjs/index.js +20 -1
  28. package/dist/cjs/index.js.map +1 -1
  29. package/dist/cjs/keys.d.ts +66 -0
  30. package/dist/cjs/keys.d.ts.map +1 -1
  31. package/dist/cjs/keys.js +128 -3
  32. package/dist/cjs/keys.js.map +1 -1
  33. package/dist/cjs/models.d.ts +41 -3
  34. package/dist/cjs/models.d.ts.map +1 -1
  35. package/dist/cjs/models.js +25 -4
  36. package/dist/cjs/models.js.map +1 -1
  37. package/dist/cjs/verify.d.ts +72 -6
  38. package/dist/cjs/verify.d.ts.map +1 -1
  39. package/dist/cjs/verify.js +392 -62
  40. package/dist/cjs/verify.js.map +1 -1
  41. package/dist/cjs/web-verifier.d.ts +54 -5
  42. package/dist/cjs/web-verifier.d.ts.map +1 -1
  43. package/dist/cjs/web-verifier.js +36 -3
  44. package/dist/cjs/web-verifier.js.map +1 -1
  45. package/dist/esm/anchor.d.ts +71 -0
  46. package/dist/esm/anchor.d.ts.map +1 -0
  47. package/dist/esm/anchor.js +275 -0
  48. package/dist/esm/anchor.js.map +1 -0
  49. package/dist/esm/cli.d.ts +45 -14
  50. package/dist/esm/cli.d.ts.map +1 -1
  51. package/dist/esm/cli.js +357 -50
  52. package/dist/esm/cli.js.map +1 -1
  53. package/dist/esm/client.d.ts +26 -2
  54. package/dist/esm/client.d.ts.map +1 -1
  55. package/dist/esm/client.js +27 -5
  56. package/dist/esm/client.js.map +1 -1
  57. package/dist/esm/enclave-seal.d.ts +101 -0
  58. package/dist/esm/enclave-seal.d.ts.map +1 -0
  59. package/dist/esm/enclave-seal.js +472 -0
  60. package/dist/esm/enclave-seal.js.map +1 -0
  61. package/dist/esm/errors.d.ts +42 -1
  62. package/dist/esm/errors.d.ts.map +1 -1
  63. package/dist/esm/errors.js +43 -1
  64. package/dist/esm/errors.js.map +1 -1
  65. package/dist/esm/http.d.ts +21 -1
  66. package/dist/esm/http.d.ts.map +1 -1
  67. package/dist/esm/http.js +75 -12
  68. package/dist/esm/http.js.map +1 -1
  69. package/dist/esm/index.browser.d.ts +2 -4
  70. package/dist/esm/index.browser.d.ts.map +1 -1
  71. package/dist/esm/index.browser.js.map +1 -1
  72. package/dist/esm/index.d.ts +16 -11
  73. package/dist/esm/index.d.ts.map +1 -1
  74. package/dist/esm/index.js +11 -2
  75. package/dist/esm/index.js.map +1 -1
  76. package/dist/esm/keys.d.ts +66 -0
  77. package/dist/esm/keys.d.ts.map +1 -1
  78. package/dist/esm/keys.js +125 -3
  79. package/dist/esm/keys.js.map +1 -1
  80. package/dist/esm/models.d.ts +41 -3
  81. package/dist/esm/models.d.ts.map +1 -1
  82. package/dist/esm/models.js +24 -4
  83. package/dist/esm/models.js.map +1 -1
  84. package/dist/esm/verify.d.ts +72 -6
  85. package/dist/esm/verify.d.ts.map +1 -1
  86. package/dist/esm/verify.js +390 -63
  87. package/dist/esm/verify.js.map +1 -1
  88. package/dist/esm/web-verifier.d.ts +54 -5
  89. package/dist/esm/web-verifier.d.ts.map +1 -1
  90. package/dist/esm/web-verifier.js +40 -3
  91. package/dist/esm/web-verifier.js.map +1 -1
  92. package/package.json +1 -1
  93. package/src/anchor.ts +330 -0
  94. package/src/cli.ts +389 -54
  95. package/src/client.ts +46 -4
  96. package/src/enclave-seal.ts +582 -0
  97. package/src/errors.ts +47 -2
  98. package/src/http.ts +79 -12
  99. package/src/index.browser.ts +2 -2
  100. package/src/index.ts +28 -9
  101. package/src/keys.ts +176 -3
  102. package/src/models.ts +72 -7
  103. package/src/verify.ts +467 -72
  104. package/src/web-verifier.ts +58 -5
@@ -0,0 +1,582 @@
1
+ /**
2
+ * Seal a connection config to the enclave, so the BurnLedger host never reads it.
3
+ *
4
+ * WHY THIS MODULE EXISTS. Registering a system used to mean POSTing your
5
+ * datastore credentials to the API in the clear. The host then handed them to
6
+ * the enclave to be sealed, and everything after that point was ciphertext only
7
+ * the enclave could open — but the host held the plaintext for the length of
8
+ * that request, and nothing recorded that it had. This removes that step: you
9
+ * seal the config yourself, and the host relays bytes it cannot open.
10
+ *
11
+ * WHAT MAKES IT WORK, AND WHAT WOULD MAKE IT THEATRE. The enclave publishes an
12
+ * X25519 public key inside its AWS Nitro attestation document, beside the
13
+ * Ed25519 signing key it already published. `verifyEnclaveConfigSealKey` checks
14
+ * that document — the COSE signature, the certificate chain to the pinned AWS
15
+ * Nitro root, and the image's PCR0 — and returns the key. Sealing to a key the
16
+ * API simply handed over protects nothing: the host would supply its own key,
17
+ * read the config, and re-seal to the real enclave, and your registration would
18
+ * succeed exactly as it does today. So `sealConnectionConfig` takes the
19
+ * attestation document and the PCR0 you expect, never a bare key.
20
+ *
21
+ * WHERE `expectedPcr0` COMES FROM. Not from this API. A measurement served by
22
+ * the same host that serves the document pins nothing. Take BurnLedger's
23
+ * published measurement for the enclave image out of band and pin it in your own
24
+ * configuration. That is a trust-on-first-use anchor in the operator's
25
+ * publication — the same anchor every other claim in this product rests on.
26
+ *
27
+ * WHAT IT DOES NOT DO. It stops the host reading a config in transit. It does
28
+ * not stop the host composing a config of ITS own and having the enclave seal
29
+ * that, and it does nothing if the machine running this code is compromised.
30
+ *
31
+ * NODE ONLY. Verifying an attestation document needs X.509 chain building,
32
+ * which browsers do not expose, and sealing a datastore credential is not a
33
+ * thing a browser should be doing. This module is not part of the browser entry
34
+ * point.
35
+ */
36
+
37
+ import { X509Certificate, verify as nodeVerify, webcrypto } from "node:crypto";
38
+
39
+ /**
40
+ * Envelope wire format (v1). Must match enclave/configsealkey.go byte for byte:
41
+ *
42
+ * magic 8 "BLCFGSL1"
43
+ * recipient 32 the X25519 key this envelope is addressed to
44
+ * ephemeral 32 this sender's one-time X25519 key
45
+ * nonce 12 AES-GCM nonce
46
+ * sealed n AES-256-GCM(plaintext), tag appended
47
+ *
48
+ * The key is HKDF-SHA256 over the X25519 shared secret salted with the whole
49
+ * header; the same header is the GCM additional data.
50
+ */
51
+ export const ENVELOPE_MAGIC = new Uint8Array([0x42, 0x4c, 0x43, 0x46, 0x47, 0x53, 0x4c, 0x31]); // "BLCFGSL1"
52
+ const KEY_SIZE = 32;
53
+ const NONCE_SIZE = 12;
54
+ export const HEADER_SIZE = ENVELOPE_MAGIC.length + 2 * KEY_SIZE + NONCE_SIZE;
55
+ const KDF_INFO = new TextEncoder().encode("burnledger/config-seal/v1");
56
+
57
+ /**
58
+ * The AWS Nitro Enclaves root CA (G1), pinned. A root fetched at runtime is not
59
+ * a pin; this is the anchor the whole verification hangs from.
60
+ */
61
+ export const NITRO_ROOT_G1_PEM = `-----BEGIN CERTIFICATE-----
62
+ MIICETCCAZagAwIBAgIRAPkxdWgbkK/hHUbMtOTn+FYwCgYIKoZIzj0EAwMwSTEL
63
+ MAkGA1UEBhMCVVMxDzANBgNVBAoMBkFtYXpvbjEMMAoGA1UECwwDQVdTMRswGQYD
64
+ VQQDDBJhd3Mubml0cm8tZW5jbGF2ZXMwHhcNMTkxMDI4MTMyODA1WhcNNDkxMDI4
65
+ MTQyODA1WjBJMQswCQYDVQQGEwJVUzEPMA0GA1UECgwGQW1hem9uMQwwCgYDVQQL
66
+ DANBV1MxGzAZBgNVBAMMEmF3cy5uaXRyby1lbmNsYXZlczB2MBAGByqGSM49AgEG
67
+ BSuBBAAiA2IABPwCVOumCMHzaHDimtqQvkY4MpJzbolL//Zy2YlES1BR5TSksfbb
68
+ 48C8WBoyt7F2Bw7eEtaaP+ohG2bnUs990d0JX28TcPQXCEPZ3BABIeTPYwEoCWZE
69
+ h8l5YoQwTcU/9KNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUkCW1DdkF
70
+ R+eWw5b6cp3PmanfS5YwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2kAMGYC
71
+ MQCjfy+Rocm9Xue4YnwWmNJVA44fA0P5W2OpYow9OYCVRaEevL8uO1XYru5xtMPW
72
+ rfMCMQCi85sWBbJwKKXdS6BptQFuZbT73o/gBh1qUxl/nNr12UO8Yfwr6wPLb+6N
73
+ IwLz3/Y=
74
+ -----END CERTIFICATE-----
75
+ `;
76
+
77
+ /** Same bound as enclave.MaxAttestationClockSkew. */
78
+ const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;
79
+ const COSE_ALG_ES384 = -35;
80
+
81
+ /** Raised when an attestation document is not one this client will seal to. */
82
+ export class EnclaveAttestationError extends Error {
83
+ constructor(message: string) {
84
+ super(message);
85
+ this.name = "EnclaveAttestationError";
86
+ }
87
+ }
88
+
89
+ export interface SealOptions {
90
+ /** Hex PCR0 of the enclave image you accept, pinned out of band. Required. */
91
+ expectedPcr0: string;
92
+ /** Further PCRs to pin, hex-encoded, by index. */
93
+ expectedPcrs?: Record<number, string>;
94
+ /** The reference time the document must describe. Defaults to now. */
95
+ now?: Date;
96
+ /**
97
+ * Override the pinned AWS Nitro root. Leave unset in production — a root
98
+ * supplied per call is not a pin.
99
+ */
100
+ rootPem?: string;
101
+ }
102
+
103
+ /**
104
+ * Seal `config` to the enclave the attestation document describes. Pass the
105
+ * result as `sealed_connection_config` when registering a system.
106
+ *
107
+ * Throws {@link EnclaveAttestationError} if the document is not a genuine Nitro
108
+ * attestation from the pinned image. Nothing is sealed in that case.
109
+ */
110
+ export async function sealConnectionConfig(
111
+ config: Record<string, unknown> | string | Uint8Array,
112
+ attestationDocument: Uint8Array,
113
+ options: SealOptions,
114
+ ): Promise<Uint8Array> {
115
+ const key = await verifyEnclaveConfigSealKey(attestationDocument, options);
116
+ return sealToKey(key, configBytes(config));
117
+ }
118
+
119
+ /**
120
+ * Seal `plaintext` to an already-verified enclave config-seal key.
121
+ *
122
+ * Prefer {@link sealConnectionConfig}, which verifies the key for you. This is
123
+ * the lower half, for a caller that verified the document itself.
124
+ */
125
+ export async function sealToKey(
126
+ recipientPublicKey: Uint8Array,
127
+ plaintext: Uint8Array,
128
+ ): Promise<Uint8Array> {
129
+ if (recipientPublicKey.length !== KEY_SIZE) {
130
+ throw new EnclaveAttestationError(
131
+ `config-seal recipient key is ${recipientPublicKey.length} bytes, want ${KEY_SIZE}`,
132
+ );
133
+ }
134
+ let peer: webcrypto.CryptoKey;
135
+ try {
136
+ peer = await webcrypto.subtle.importKey(
137
+ "raw",
138
+ bufferSource(recipientPublicKey),
139
+ { name: "X25519" },
140
+ false,
141
+ [],
142
+ );
143
+ } catch (err) {
144
+ throw new EnclaveAttestationError(`config-seal recipient key: ${(err as Error).message}`);
145
+ }
146
+ const ephemeral = (await webcrypto.subtle.generateKey({ name: "X25519" }, true, [
147
+ "deriveBits",
148
+ ])) as unknown as webcrypto.CryptoKeyPair;
149
+ const shared = new Uint8Array(
150
+ await webcrypto.subtle.deriveBits({ name: "X25519", public: peer }, ephemeral.privateKey, 256),
151
+ );
152
+ const ephemeralPublic = new Uint8Array(
153
+ await webcrypto.subtle.exportKey("raw", ephemeral.publicKey),
154
+ );
155
+
156
+ const nonce = webcrypto.getRandomValues(new Uint8Array(NONCE_SIZE));
157
+ const header = concat(ENVELOPE_MAGIC, recipientPublicKey, ephemeralPublic, nonce);
158
+ const aesKey = await deriveEnvelopeKey(shared, header);
159
+ const sealed = new Uint8Array(
160
+ await webcrypto.subtle.encrypt(
161
+ { name: "AES-GCM", iv: nonce, additionalData: header },
162
+ aesKey,
163
+ bufferSource(plaintext),
164
+ ),
165
+ );
166
+ return concat(header, sealed);
167
+ }
168
+
169
+ /**
170
+ * Verify an enclave attestation document and return the X25519 config-seal key
171
+ * bound in it.
172
+ *
173
+ * Checks, in order: the COSE_Sign1 structure and its ES384 algorithm; the
174
+ * certificate chain from the document's leaf through its CA bundle to the
175
+ * pinned AWS Nitro root; the ES384 signature over the RFC 8152 Sig_structure;
176
+ * the document's own AWS-signed timestamp against `now`; the PCRs; and that the
177
+ * bound key is 32 bytes.
178
+ *
179
+ * PCR0 must be pinned. Without it any genuinely-signed Nitro document — including
180
+ * one from an enclave the host itself booted — would pass.
181
+ */
182
+ export async function verifyEnclaveConfigSealKey(
183
+ attestationDocument: Uint8Array,
184
+ options: SealOptions,
185
+ ): Promise<Uint8Array> {
186
+ if (!attestationDocument || attestationDocument.length === 0) {
187
+ throw new EnclaveAttestationError(
188
+ "no attestation document (nothing to verify — this is not a pass)",
189
+ );
190
+ }
191
+ const expected: Record<number, string> = { ...(options.expectedPcrs ?? {}) };
192
+ if (options.expectedPcr0) expected[0] = options.expectedPcr0;
193
+ if (expected[0] === undefined) {
194
+ throw new EnclaveAttestationError(
195
+ "no expected PCR0; refusing to accept an unpinned enclave image",
196
+ );
197
+ }
198
+ const now = options.now ?? new Date();
199
+
200
+ const { protected: protectedBytes, payload: payloadBytes, signature } =
201
+ decodeCoseSign1(attestationDocument);
202
+ checkAlgEs384(protectedBytes);
203
+
204
+ const payload = cborDecode(payloadBytes);
205
+ if (!(payload instanceof Map)) {
206
+ throw new EnclaveAttestationError("attestation payload is not a CBOR map");
207
+ }
208
+ const leafDer = payload.get("certificate");
209
+ if (!(leafDer instanceof Uint8Array) || leafDer.length === 0) {
210
+ throw new EnclaveAttestationError("attestation payload missing certificate");
211
+ }
212
+ const pcrs = payload.get("pcrs");
213
+ if (!(pcrs instanceof Map)) {
214
+ throw new EnclaveAttestationError("attestation payload missing pcrs");
215
+ }
216
+
217
+ const leaf = verifyChain(leafDer, payload.get("cabundle"), now, options.rootPem ?? NITRO_ROOT_G1_PEM);
218
+ verifyCoseSignature(leaf, protectedBytes, payloadBytes, signature);
219
+ checkClock(payload.get("timestamp"), now);
220
+ matchPcrs(pcrs, expected);
221
+
222
+ const publicKey = payload.get("public_key");
223
+ if (!(publicKey instanceof Uint8Array) || publicKey.length !== KEY_SIZE) {
224
+ const got = publicKey instanceof Uint8Array ? publicKey.length : 0;
225
+ throw new EnclaveAttestationError(
226
+ `the document binds a ${got}-byte public key, want a ${KEY_SIZE}-byte X25519 ` +
227
+ "config-seal key (this enclave image may not publish one)",
228
+ );
229
+ }
230
+ return publicKey;
231
+ }
232
+
233
+ // --- verification internals -------------------------------------------------
234
+
235
+ /**
236
+ * Build and verify leaf -> cabundle -> pinned root, valid at `now`.
237
+ *
238
+ * The path is not general: the NSM hands over an ORDERED bundle, root first, so
239
+ * the chain is walked in order and every hop must check out. Nothing is
240
+ * searched for an alternative path — an ambiguous chain is a refusal.
241
+ */
242
+ function verifyChain(
243
+ leafDer: Uint8Array,
244
+ cabundle: unknown,
245
+ now: Date,
246
+ rootPem: string,
247
+ ): X509Certificate {
248
+ let root: X509Certificate;
249
+ try {
250
+ root = new X509Certificate(rootPem);
251
+ } catch (err) {
252
+ throw new EnclaveAttestationError(`pinned Nitro root is unreadable: ${(err as Error).message}`);
253
+ }
254
+ if (!Array.isArray(cabundle) || cabundle.length === 0) {
255
+ throw new EnclaveAttestationError("attestation payload carries no CA bundle");
256
+ }
257
+
258
+ const chain: X509Certificate[] = [];
259
+ cabundle.forEach((der, i) => {
260
+ if (!(der instanceof Uint8Array)) {
261
+ throw new EnclaveAttestationError(`cabundle[${i}] is not a byte string`);
262
+ }
263
+ try {
264
+ chain.push(new X509Certificate(Buffer.from(der)));
265
+ } catch (err) {
266
+ throw new EnclaveAttestationError(`cabundle[${i}] is not a certificate: ${(err as Error).message}`);
267
+ }
268
+ });
269
+ let leaf: X509Certificate;
270
+ try {
271
+ leaf = new X509Certificate(Buffer.from(leafDer));
272
+ } catch (err) {
273
+ throw new EnclaveAttestationError(`leaf certificate is unreadable: ${(err as Error).message}`);
274
+ }
275
+
276
+ if (chain[0]!.fingerprint256 !== root.fingerprint256) {
277
+ throw new EnclaveAttestationError(
278
+ "the document's CA bundle does not start at the pinned AWS Nitro root",
279
+ );
280
+ }
281
+
282
+ const ordered = [...chain, leaf];
283
+ for (const cert of ordered) checkValidity(cert, now);
284
+ for (let i = 1; i < ordered.length; i++) {
285
+ const issuer = ordered[i - 1]!;
286
+ const subject = ordered[i]!;
287
+ if (!subject.checkIssued(issuer) || !subject.verify(issuer.publicKey)) {
288
+ throw new EnclaveAttestationError(
289
+ `certificate chain is broken at ${subject.subject.replace(/\n/g, ", ")}`,
290
+ );
291
+ }
292
+ }
293
+ return leaf;
294
+ }
295
+
296
+ function checkValidity(cert: X509Certificate, now: Date): void {
297
+ const from = Date.parse(cert.validFrom);
298
+ const to = Date.parse(cert.validTo);
299
+ if (Number.isNaN(from) || Number.isNaN(to)) {
300
+ throw new EnclaveAttestationError(
301
+ `certificate ${cert.subject.replace(/\n/g, ", ")} has an unreadable validity window`,
302
+ );
303
+ }
304
+ const t = now.getTime();
305
+ if (t < from || t > to) {
306
+ throw new EnclaveAttestationError(
307
+ `certificate ${cert.subject.replace(/\n/g, ", ")} is not valid at ${now.toISOString()} ` +
308
+ `(valid ${new Date(from).toISOString()} to ${new Date(to).toISOString()})`,
309
+ );
310
+ }
311
+ }
312
+
313
+ function verifyCoseSignature(
314
+ leaf: X509Certificate,
315
+ protectedBytes: Uint8Array,
316
+ payload: Uint8Array,
317
+ signature: Uint8Array,
318
+ ): void {
319
+ if (signature.length !== 96) {
320
+ throw new EnclaveAttestationError(
321
+ `signature is ${signature.length} bytes, want 96 (P-384 r||s)`,
322
+ );
323
+ }
324
+ const sigStructure = cborEncode(["Signature1", protectedBytes, new Uint8Array(0), payload]);
325
+ // ieee-p1363 is the raw r||s pair COSE carries; the default would expect DER.
326
+ const ok = nodeVerify(
327
+ "sha384",
328
+ sigStructure,
329
+ { key: leaf.publicKey, dsaEncoding: "ieee-p1363" },
330
+ signature,
331
+ );
332
+ if (!ok) throw new EnclaveAttestationError("the attestation COSE signature is invalid");
333
+ }
334
+
335
+ function checkClock(timestamp: unknown, now: Date): void {
336
+ if (typeof timestamp !== "number" || !Number.isFinite(timestamp) || timestamp <= 0) {
337
+ throw new EnclaveAttestationError(
338
+ "the document carries no timestamp (nothing to check the clock against)",
339
+ );
340
+ }
341
+ const skew = Math.abs(now.getTime() - timestamp);
342
+ if (skew > MAX_CLOCK_SKEW_MS) {
343
+ throw new EnclaveAttestationError(
344
+ `the NSM signed this document at ${new Date(timestamp).toISOString()} but it is being ` +
345
+ `verified against ${now.toISOString()} — ${Math.round(skew / 1000)}s of clock skew ` +
346
+ `exceeds the ${MAX_CLOCK_SKEW_MS / 1000}s bound`,
347
+ );
348
+ }
349
+ }
350
+
351
+ function matchPcrs(actual: Map<unknown, unknown>, expected: Record<number, string>): void {
352
+ for (const [key, wantHex] of Object.entries(expected)) {
353
+ const index = Number(key);
354
+ const got = actual.get(index);
355
+ if (!(got instanceof Uint8Array)) {
356
+ throw new EnclaveAttestationError(`the document carries no PCR${index}`);
357
+ }
358
+ if (toHex(got) !== wantHex.toLowerCase()) {
359
+ throw new EnclaveAttestationError(
360
+ `PCR${index} is ${toHex(got)}, expected ${wantHex} — this is not the enclave image you pinned`,
361
+ );
362
+ }
363
+ }
364
+ }
365
+
366
+ async function deriveEnvelopeKey(
367
+ shared: Uint8Array,
368
+ header: Uint8Array<ArrayBuffer>,
369
+ ): Promise<webcrypto.CryptoKey> {
370
+ const ikm = await webcrypto.subtle.importKey("raw", bufferSource(shared), "HKDF", false, [
371
+ "deriveKey",
372
+ ]);
373
+ return webcrypto.subtle.deriveKey(
374
+ { name: "HKDF", hash: "SHA-256", salt: header, info: KDF_INFO },
375
+ ikm,
376
+ { name: "AES-GCM", length: 256 },
377
+ false,
378
+ ["encrypt", "decrypt"],
379
+ );
380
+ }
381
+
382
+ function configBytes(config: Record<string, unknown> | string | Uint8Array): Uint8Array {
383
+
384
+ if (config instanceof Uint8Array) return config;
385
+ if (typeof config === "string") return new TextEncoder().encode(config);
386
+ return new TextEncoder().encode(JSON.stringify(config));
387
+ }
388
+
389
+ /**
390
+ * Copy into a view whose backing store TypeScript knows is a plain ArrayBuffer.
391
+ *
392
+ * WebCrypto's `BufferSource` excludes `SharedArrayBuffer`-backed views, and a
393
+ * `Uint8Array` parameter is `Uint8Array<ArrayBufferLike>`, which could be one.
394
+ * The copy is what makes that provably false rather than asserted away with a
395
+ * cast; the buffers here are a few kilobytes at most.
396
+ */
397
+ function bufferSource(b: Uint8Array): Uint8Array<ArrayBuffer> {
398
+ return new Uint8Array(b);
399
+ }
400
+
401
+ function concat(...parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
402
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
403
+ let offset = 0;
404
+ for (const p of parts) {
405
+ out.set(p, offset);
406
+ offset += p.length;
407
+ }
408
+ return out;
409
+ }
410
+
411
+ function toHex(b: Uint8Array): string {
412
+ return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
413
+ }
414
+
415
+ // --- a CBOR subset ----------------------------------------------------------
416
+ //
417
+ // Enough of RFC 8949 to read an NSM attestation document and re-encode the
418
+ // fixed four-element Sig_structure. Hand-rolled because this SDK ships with no
419
+ // runtime dependencies, which is a published contract. Indefinite-length items
420
+ // are refused rather than supported: a genuine NSM document uses definite
421
+ // lengths throughout, and accepting two encodings for one value is how
422
+ // signature checks get bypassed.
423
+
424
+ function decodeCoseSign1(doc: Uint8Array): {
425
+ protected: Uint8Array;
426
+ payload: Uint8Array;
427
+ signature: Uint8Array;
428
+ } {
429
+ const body = doc[0] === 0xd2 ? doc.subarray(1) : doc; // CBOR tag 18
430
+ const value = cborDecode(body);
431
+ if (!Array.isArray(value) || value.length !== 4) {
432
+ throw new EnclaveAttestationError("COSE_Sign1 must be a 4-element array");
433
+ }
434
+ const [prot, , payload, signature] = value;
435
+ for (const [name, item] of [
436
+ ["protected header", prot],
437
+ ["payload", payload],
438
+ ["signature", signature],
439
+ ] as const) {
440
+ if (!(item instanceof Uint8Array)) {
441
+ throw new EnclaveAttestationError(`COSE_Sign1 ${name} is not a byte string`);
442
+ }
443
+ }
444
+ return {
445
+ protected: prot as Uint8Array,
446
+ payload: payload as Uint8Array,
447
+ signature: signature as Uint8Array,
448
+ };
449
+ }
450
+
451
+ function checkAlgEs384(protectedBytes: Uint8Array): void {
452
+ if (protectedBytes.length === 0) {
453
+ throw new EnclaveAttestationError("empty COSE protected header (no alg)");
454
+ }
455
+ const header = cborDecode(protectedBytes);
456
+ if (!(header instanceof Map) || !header.has(1)) {
457
+ throw new EnclaveAttestationError("COSE protected header missing alg");
458
+ }
459
+ const alg = header.get(1);
460
+ if (alg !== COSE_ALG_ES384) {
461
+ throw new EnclaveAttestationError(`COSE alg is ${alg}, want ES384 (${COSE_ALG_ES384})`);
462
+ }
463
+ }
464
+
465
+ type CborValue = number | string | Uint8Array | boolean | null | CborValue[] | Map<CborValue, CborValue>;
466
+
467
+ function cborDecode(data: Uint8Array): CborValue {
468
+ const [value, offset] = cborRead(data, 0);
469
+ if (offset !== data.length) {
470
+ throw new EnclaveAttestationError(
471
+ `${data.length - offset} trailing bytes after the CBOR value`,
472
+ );
473
+ }
474
+ return value;
475
+ }
476
+
477
+ function cborRead(data: Uint8Array, start: number): [CborValue, number] {
478
+ if (start >= data.length) throw new EnclaveAttestationError("CBOR input ended mid-value");
479
+ const initial = data[start]!;
480
+ const major = initial >> 5;
481
+ const info = initial & 0x1f;
482
+ let i = start + 1;
483
+ let arg: number;
484
+
485
+ if (info < 24) {
486
+ arg = info;
487
+ } else if (info === 24) [arg, i] = cborUint(data, i, 1);
488
+ else if (info === 25) [arg, i] = cborUint(data, i, 2);
489
+ else if (info === 26) [arg, i] = cborUint(data, i, 4);
490
+ else if (info === 27) [arg, i] = cborUint(data, i, 8);
491
+ else if (info === 31) {
492
+ throw new EnclaveAttestationError("indefinite-length CBOR items are not accepted");
493
+ } else throw new EnclaveAttestationError(`reserved CBOR additional information ${info}`);
494
+
495
+ switch (major) {
496
+ case 0:
497
+ return [arg, i];
498
+ case 1:
499
+ return [-1 - arg, i];
500
+ case 2:
501
+ case 3: {
502
+ const end = i + arg;
503
+ if (end > data.length) {
504
+ throw new EnclaveAttestationError("CBOR string runs past the end of the input");
505
+ }
506
+ const chunk = data.subarray(i, end);
507
+ return [major === 2 ? chunk : new TextDecoder("utf-8", { fatal: true }).decode(chunk), end];
508
+ }
509
+ case 4: {
510
+ const items: CborValue[] = [];
511
+ for (let n = 0; n < arg; n++) {
512
+ const [item, next] = cborRead(data, i);
513
+ items.push(item);
514
+ i = next;
515
+ }
516
+ return [items, i];
517
+ }
518
+ case 5: {
519
+ const out = new Map<CborValue, CborValue>();
520
+ for (let n = 0; n < arg; n++) {
521
+ const [key, afterKey] = cborRead(data, i);
522
+ const [val, afterVal] = cborRead(data, afterKey);
523
+ if (out.has(key)) {
524
+ // Two readers could disagree about the same document. Refuse.
525
+ throw new EnclaveAttestationError(`duplicate CBOR map key ${String(key)}`);
526
+ }
527
+ out.set(key, val);
528
+ i = afterVal;
529
+ }
530
+ return [out, i];
531
+ }
532
+ case 6: // tag: transparent, the tagged value is what matters
533
+ return cborRead(data, i);
534
+ case 7:
535
+ if (info === 20) return [false, i];
536
+ if (info === 21) return [true, i];
537
+ if (info === 22) return [null, i];
538
+ throw new EnclaveAttestationError(`unsupported CBOR simple value ${info}`);
539
+ default:
540
+ throw new EnclaveAttestationError(`unsupported CBOR major type ${major}`);
541
+ }
542
+ }
543
+
544
+ function cborUint(data: Uint8Array, i: number, width: number): [number, number] {
545
+ if (i + width > data.length) {
546
+ throw new EnclaveAttestationError("CBOR integer runs past the end of the input");
547
+ }
548
+ let value = 0;
549
+ for (let n = 0; n < width; n++) value = value * 256 + data[i + n]!;
550
+ if (!Number.isSafeInteger(value)) {
551
+ throw new EnclaveAttestationError("CBOR integer exceeds the safe integer range");
552
+ }
553
+ return [value, i + width];
554
+ }
555
+
556
+ function cborEncode(value: CborValue): Uint8Array {
557
+ if (Array.isArray(value)) {
558
+ return concat(cborHead(4, value.length), ...value.map(cborEncode));
559
+ }
560
+ if (typeof value === "string") {
561
+ const raw = new TextEncoder().encode(value);
562
+ return concat(cborHead(3, raw.length), raw);
563
+ }
564
+ if (value instanceof Uint8Array) {
565
+ return concat(cborHead(2, value.length), value);
566
+ }
567
+ throw new EnclaveAttestationError(`cannot CBOR-encode ${typeof value}`);
568
+ }
569
+
570
+ function cborHead(major: number, arg: number): Uint8Array {
571
+ const base = major << 5;
572
+ if (arg < 24) return new Uint8Array([base | arg]);
573
+ if (arg < 0x100) return new Uint8Array([base | 24, arg]);
574
+ if (arg < 0x10000) return new Uint8Array([base | 25, arg >> 8, arg & 0xff]);
575
+ return new Uint8Array([
576
+ base | 26,
577
+ (arg >>> 24) & 0xff,
578
+ (arg >>> 16) & 0xff,
579
+ (arg >>> 8) & 0xff,
580
+ arg & 0xff,
581
+ ]);
582
+ }
package/src/errors.ts CHANGED
@@ -101,14 +101,59 @@ export class TimeoutError extends BurnLedgerError {
101
101
 
102
102
  export class VerificationError extends BurnLedgerError {
103
103
  readonly reason: string;
104
-
105
- constructor(reason: string) {
104
+ /**
105
+ * A stable discriminator for the one rejection a caller must be able to tell
106
+ * apart from the rest: an authentic status statement that says REVOKED.
107
+ *
108
+ * Every other rejection on the status path — unknown key, revoked key, bad
109
+ * signature, a statement about a different certificate — means the statement
110
+ * could not be authenticated, which is not the same claim and must not be
111
+ * rendered as one. `reason` is prose and may be reworded; this is not.
112
+ *
113
+ * Undefined on every rejection that is not specifically coded.
114
+ */
115
+ readonly code?: string;
116
+
117
+ constructor(reason: string, code?: string) {
106
118
  super(reason);
107
119
  this.name = "VerificationError";
108
120
  this.reason = reason;
121
+ this.code = code;
109
122
  }
110
123
  }
111
124
 
125
+ /** {@link VerificationError.code} for a verified statement that says REVOKED. */
126
+ export const CERTIFICATE_REVOKED = "CERTIFICATE_REVOKED";
127
+
128
+ /**
129
+ * The record is in a format this SDK does not know how to read.
130
+ *
131
+ * This is the second rejection a caller must be able to tell apart, for the
132
+ * same reason as the first: it is not a claim about the document. Before this
133
+ * code existed, a record in a newer format fell through to the oldest payload
134
+ * shape, the rebuilt bytes did not match, and the SDK threw "certificate
135
+ * signature is invalid" - telling a holder their genuine evidence had been
136
+ * tampered with when the answer was "upgrade this library".
137
+ *
138
+ * A UI must render it as "cannot check", never as a failure.
139
+ */
140
+ export const UNSUPPORTED_FORMAT_VERSION = "UNSUPPORTED_FORMAT_VERSION";
141
+
142
+ /**
143
+ * The record names a signature scheme this SDK cannot check.
144
+ *
145
+ * The third rejection a caller must tell apart, for the same reason as the
146
+ * other two: it is not a claim about the document. Records below format 7.0
147
+ * name no algorithm and every verifier simply assumed Ed25519, so had Ed25519
148
+ * needed replacing, a record signed under the replacement would have been fed
149
+ * to an Ed25519 verifier, failed, and been reported as a bad signature — a
150
+ * genuine record accused of forgery for being signed with the algorithm it says
151
+ * it was signed with.
152
+ *
153
+ * A UI must render it as "cannot check", never as a failure.
154
+ */
155
+ export const UNSUPPORTED_ALGORITHM = "UNSUPPORTED_ALGORITHM";
156
+
112
157
  const STATUS_TO_CLASS: Record<
113
158
  number,
114
159
  new (opts: {