burnledger 0.9.0 → 0.9.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.
Files changed (55) hide show
  1. package/README.md +9 -4
  2. package/dist/cjs/client.d.ts.map +1 -1
  3. package/dist/cjs/client.js +49 -59
  4. package/dist/cjs/client.js.map +1 -1
  5. package/dist/cjs/enclave-registration.d.ts +14 -2
  6. package/dist/cjs/enclave-registration.d.ts.map +1 -1
  7. package/dist/cjs/enclave-registration.js +14 -2
  8. package/dist/cjs/enclave-registration.js.map +1 -1
  9. package/dist/cjs/index.d.ts.map +1 -1
  10. package/dist/cjs/index.js +45 -0
  11. package/dist/cjs/index.js.map +1 -1
  12. package/dist/cjs/node-runtime.d.ts +40 -0
  13. package/dist/cjs/node-runtime.d.ts.map +1 -0
  14. package/dist/cjs/node-runtime.js +42 -0
  15. package/dist/cjs/node-runtime.js.map +1 -0
  16. package/dist/cjs/verify.d.ts.map +1 -1
  17. package/dist/cjs/verify.js +72 -35
  18. package/dist/cjs/verify.js.map +1 -1
  19. package/dist/cjs/webhooks.d.ts +9 -2
  20. package/dist/cjs/webhooks.d.ts.map +1 -1
  21. package/dist/cjs/webhooks.js +29 -11
  22. package/dist/cjs/webhooks.js.map +1 -1
  23. package/dist/esm/cli.d.ts +19 -0
  24. package/dist/esm/cli.d.ts.map +1 -1
  25. package/dist/esm/cli.js +58 -6
  26. package/dist/esm/cli.js.map +1 -1
  27. package/dist/esm/client.d.ts.map +1 -1
  28. package/dist/esm/client.js +49 -26
  29. package/dist/esm/client.js.map +1 -1
  30. package/dist/esm/enclave-registration.d.ts +14 -2
  31. package/dist/esm/enclave-registration.d.ts.map +1 -1
  32. package/dist/esm/enclave-registration.js +14 -2
  33. package/dist/esm/enclave-registration.js.map +1 -1
  34. package/dist/esm/index.d.ts.map +1 -1
  35. package/dist/esm/index.js +12 -0
  36. package/dist/esm/index.js.map +1 -1
  37. package/dist/esm/node-runtime.d.ts +40 -0
  38. package/dist/esm/node-runtime.d.ts.map +1 -0
  39. package/dist/esm/node-runtime.js +38 -0
  40. package/dist/esm/node-runtime.js.map +1 -0
  41. package/dist/esm/verify.d.ts.map +1 -1
  42. package/dist/esm/verify.js +72 -35
  43. package/dist/esm/verify.js.map +1 -1
  44. package/dist/esm/webhooks.d.ts +9 -2
  45. package/dist/esm/webhooks.d.ts.map +1 -1
  46. package/dist/esm/webhooks.js +29 -11
  47. package/dist/esm/webhooks.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/cli.ts +55 -5
  50. package/src/client.ts +50 -26
  51. package/src/enclave-registration.ts +14 -2
  52. package/src/index.ts +13 -0
  53. package/src/node-runtime.ts +57 -0
  54. package/src/verify.ts +76 -36
  55. package/src/webhooks.ts +28 -11
@@ -16,11 +16,18 @@
16
16
  /**
17
17
  * Verify that a webhook payload was signed by the expected secret.
18
18
  *
19
+ * During a secret rotation the server sends both signatures in one header as
20
+ * `<old>,<new>` for a 24 h window, so whichever secret a receiver holds matches
21
+ * one of them without dropping deliveries. The header is split on the comma,
22
+ * each part is trimmed and strictly hex-decoded, and it verifies if any part
23
+ * matches under `secret`. A single signature (no comma) is the ordinary case and
24
+ * one iteration of the same loop.
25
+ *
19
26
  * @param secret The webhook secret (raw UTF-8, as returned by the API)
20
27
  * @param timestamp The RFC3339 timestamp from the X-BurnLedger-Timestamp header
21
28
  * @param body The raw request body bytes
22
- * @param signature The hex-encoded HMAC-SHA256 signature from the X-BurnLedger-Signature header
23
- * @returns true if the signature is valid
29
+ * @param signature The hex-encoded HMAC-SHA256 signature(s) from the X-BurnLedger-Signature header
30
+ * @returns true if any signature in the header is valid
24
31
  */
25
32
  export declare function verifyWebhookSignature(secret: string, timestamp: string, body: Uint8Array | string, signature: string): boolean;
26
33
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"webhooks.d.ts","sourceRoot":"","sources":["../../src/webhooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAIH;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,UAAU,GAAG,MAAM,EACzB,SAAS,EAAE,MAAM,GAChB,OAAO,CAwBT;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,EACjB,gBAAgB,GAAE,MAAY,GAC7B,OAAO,CAKT"}
1
+ {"version":3,"file":"webhooks.d.ts","sourceRoot":"","sources":["../../src/webhooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAeH;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,UAAU,GAAG,MAAM,EACzB,SAAS,EAAE,MAAM,GAChB,OAAO,CAuBT;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,EACjB,gBAAgB,GAAE,MAAY,GAC7B,OAAO,CAKT"}
@@ -14,14 +14,31 @@
14
14
  * Node-only — webhooks are received server-side.
15
15
  */
16
16
  import { createHmac, timingSafeEqual } from "node:crypto";
17
+ /** Strictly hex-decode `value`, or undefined if it is not even-length pure hex.
18
+ * Buffer.from(_, "hex") silently truncates at the first non-hex character, which
19
+ * would decode only the first signature of a rotation header — so the input is
20
+ * validated as pure hex first. */
21
+ function decodeHexStrict(value) {
22
+ if (value.length === 0 || value.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(value)) {
23
+ return undefined;
24
+ }
25
+ return Buffer.from(value, "hex");
26
+ }
17
27
  /**
18
28
  * Verify that a webhook payload was signed by the expected secret.
19
29
  *
30
+ * During a secret rotation the server sends both signatures in one header as
31
+ * `<old>,<new>` for a 24 h window, so whichever secret a receiver holds matches
32
+ * one of them without dropping deliveries. The header is split on the comma,
33
+ * each part is trimmed and strictly hex-decoded, and it verifies if any part
34
+ * matches under `secret`. A single signature (no comma) is the ordinary case and
35
+ * one iteration of the same loop.
36
+ *
20
37
  * @param secret The webhook secret (raw UTF-8, as returned by the API)
21
38
  * @param timestamp The RFC3339 timestamp from the X-BurnLedger-Timestamp header
22
39
  * @param body The raw request body bytes
23
- * @param signature The hex-encoded HMAC-SHA256 signature from the X-BurnLedger-Signature header
24
- * @returns true if the signature is valid
40
+ * @param signature The hex-encoded HMAC-SHA256 signature(s) from the X-BurnLedger-Signature header
41
+ * @returns true if any signature in the header is valid
25
42
  */
26
43
  export function verifyWebhookSignature(secret, timestamp, body, signature) {
27
44
  if (secret.length === 0)
@@ -37,16 +54,17 @@ export function verifyWebhookSignature(secret, timestamp, body, signature) {
37
54
  .update(Buffer.from(`${timestamp}.`, "utf-8"))
38
55
  .update(bodyBytes)
39
56
  .digest();
40
- let received;
41
- try {
42
- received = Buffer.from(signature, "hex");
57
+ // Every candidate is compared in constant time; the loop does not return early
58
+ // on a match so the work does not reveal which position matched.
59
+ let matched = false;
60
+ for (const part of signature.split(",")) {
61
+ const received = decodeHexStrict(part.trim());
62
+ if (received === undefined || received.length !== expected.length)
63
+ continue;
64
+ if (timingSafeEqual(expected, received))
65
+ matched = true;
43
66
  }
44
- catch {
45
- return false;
46
- }
47
- if (received.length !== expected.length)
48
- return false;
49
- return timingSafeEqual(expected, received);
67
+ return matched;
50
68
  }
51
69
  /**
52
70
  * Verify that the webhook timestamp is within an acceptable freshness window.
@@ -1 +1 @@
1
- {"version":3,"file":"webhooks.js","sourceRoot":"","sources":["../../src/webhooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAE1D;;;;;;;;GAQG;AACH,MAAM,UAAU,sBAAsB,CACpC,MAAc,EACd,SAAiB,EACjB,IAAyB,EACzB,SAAiB;IAEjB,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACtC,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACzC,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IAEzC,0EAA0E;IAC1E,6CAA6C;IAC7C,MAAM,SAAS,GACb,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC5E,MAAM,QAAQ,GAAG,UAAU,CAAC,QAAQ,EAAE,MAAM,CAAC;SAC1C,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,SAAS,GAAG,EAAE,OAAO,CAAC,CAAC;SAC7C,MAAM,CAAC,SAAS,CAAC;SACjB,MAAM,EAAE,CAAC;IAEZ,IAAI,QAAgB,CAAC;IACrB,IAAI,CAAC;QACH,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;IAC3C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;IAED,IAAI,QAAQ,CAAC,MAAM,KAAK,QAAQ,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IAEtD,OAAO,eAAe,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;AAC7C,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAC9B,SAAiB,EACjB,gBAAgB,GAAW,GAAG;IAE9B,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;IACrC,IAAI,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC;QAAE,OAAO,KAAK,CAAC;IACvC,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,GAAG,IAAI,CAAC;IACxD,OAAO,UAAU,IAAI,gBAAgB,CAAC;AACxC,CAAC"}
1
+ {"version":3,"file":"webhooks.js","sourceRoot":"","sources":["../../src/webhooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAE1D;;;kCAGkC;AAClC,SAAS,eAAe,CAAC,KAAa;IACpC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QAClF,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,OAAO,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;AACnC,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,sBAAsB,CACpC,MAAc,EACd,SAAiB,EACjB,IAAyB,EACzB,SAAiB;IAEjB,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACtC,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACzC,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IAEzC,0EAA0E;IAC1E,6CAA6C;IAC7C,MAAM,SAAS,GACb,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC5E,MAAM,QAAQ,GAAG,UAAU,CAAC,QAAQ,EAAE,MAAM,CAAC;SAC1C,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,SAAS,GAAG,EAAE,OAAO,CAAC,CAAC;SAC7C,MAAM,CAAC,SAAS,CAAC;SACjB,MAAM,EAAE,CAAC;IAEZ,+EAA+E;IAC/E,iEAAiE;IACjE,IAAI,OAAO,GAAG,KAAK,CAAC;IACpB,KAAK,MAAM,IAAI,IAAI,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;QACxC,MAAM,QAAQ,GAAG,eAAe,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;QAC9C,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,CAAC,MAAM,KAAK,QAAQ,CAAC,MAAM;YAAE,SAAS;QAC5E,IAAI,eAAe,CAAC,QAAQ,EAAE,QAAQ,CAAC;YAAE,OAAO,GAAG,IAAI,CAAC;IAC1D,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAC9B,SAAiB,EACjB,gBAAgB,GAAW,GAAG;IAE9B,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;IACrC,IAAI,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC;QAAE,OAAO,KAAK,CAAC;IACvC,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,GAAG,IAAI,CAAC;IACxD,OAAO,UAAU,IAAI,gBAAgB,CAAC;AACxC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "burnledger",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "TypeScript SDK for the BurnLedger API — cryptographic deletion certificates",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",
package/src/cli.ts CHANGED
@@ -182,11 +182,61 @@ export async function loadKeys(path: string): Promise<{ keys: Map<string, Public
182
182
  * issuer's signature.
183
183
  *
184
184
  * No Authorization header is sent. The endpoint takes none, and sending an API
185
- * key to an arbitrary --api-url is a credential leak the check never needed. */
186
- async function fetchStatusStatement(apiUrl: string, certId: string): Promise<StatusStatement> {
187
- const res = await fetch(`${apiUrl.replace(/\/+$/, "")}/v1/certificates/${certId}/status`);
188
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
189
- return unwrapStatusStatement(await res.json());
185
+ * key to an arbitrary --api-url is a credential leak the check never needed.
186
+ *
187
+ * Read under a 1 MiB cap and a total deadline, matching the anchor fetch
188
+ * (anchor.ts) and the Go CLI (cmd/cli/online.go): a status statement is a few
189
+ * hundred bytes, so anything near the cap is a misbehaving or hostile server.
190
+ * The AbortController bounds the whole fetch including the body read, which a
191
+ * server dripping bytes could otherwise hold open. Exported for testing. */
192
+ export async function fetchStatusStatement(apiUrl: string, certId: string): Promise<StatusStatement> {
193
+ const url = `${apiUrl.replace(/\/+$/, "")}/v1/certificates/${certId}/status`;
194
+ const controller = new AbortController();
195
+ const timer = setTimeout(() => { controller.abort(); }, STATUS_FETCH_TIMEOUT_MS);
196
+ try {
197
+ const res = await fetch(url, { signal: controller.signal });
198
+ const body = await readCapped(res);
199
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
200
+ try {
201
+ return unwrapStatusStatement(JSON.parse(body));
202
+ } catch (e) {
203
+ throw new Error(`parse response: ${e instanceof Error ? e.message : String(e)}`);
204
+ }
205
+ } finally {
206
+ clearTimeout(timer);
207
+ }
208
+ }
209
+
210
+ /** How much of the status response to buffer (1 MiB) and how long the whole
211
+ * fetch may take (10 s), matching cmd/cli/online.go and anchor.ts. */
212
+ const MAX_STATUS_BYTES = 1 << 20;
213
+ const STATUS_FETCH_TIMEOUT_MS = 10_000;
214
+
215
+ /** Read a response body, stopping at the cap instead of buffering whatever a
216
+ * hostile server decides to send. The stream is cancelled on the way out, so an
217
+ * endless body costs one megabyte and not the process. */
218
+ async function readCapped(res: Response): Promise<string> {
219
+ const reader = res.body?.getReader();
220
+ if (reader === undefined) return "";
221
+ const chunks: Uint8Array[] = [];
222
+ let total = 0;
223
+ for (;;) {
224
+ const { done, value } = await reader.read();
225
+ if (done) break;
226
+ total += value.length;
227
+ if (total > MAX_STATUS_BYTES) {
228
+ await reader.cancel();
229
+ throw new Error(`read response: body exceeds ${MAX_STATUS_BYTES} bytes`);
230
+ }
231
+ chunks.push(value);
232
+ }
233
+ const joined = new Uint8Array(total);
234
+ let at = 0;
235
+ for (const c of chunks) {
236
+ joined.set(c, at);
237
+ at += c.length;
238
+ }
239
+ return new TextDecoder().decode(joined);
190
240
  }
191
241
 
192
242
  /** The verdicts a reader can act on, and therefore the ones that exit 0.
package/src/client.ts CHANGED
@@ -50,6 +50,10 @@ import {
50
50
  } from "./models.js";
51
51
  import type { KeyEnrollmentResult, SystemRegistrationCertificate } from "./models.js";
52
52
  import { Paginator } from "./pagination.js";
53
+ // Node-only work (enrolment, registration, savePdf) is reached through here, not
54
+ // by naming the Node modules — so the browser entry that shares this class never
55
+ // pulls node:crypto or node:fs into a bundle. See node-runtime.ts.
56
+ import { loadNodeRuntime } from "./node-runtime.js";
53
57
  // Types only: erased at build, so the browser entry that shares this module
54
58
  // never loads node:crypto. The code behind them is imported lazily, on use.
55
59
  import type { CustomerKeyGroup } from "./customer-keys.js";
@@ -155,7 +159,7 @@ export class BurnLedger {
155
159
  async getSystem(systemId: string): Promise<System> {
156
160
  const data = await this.transport.request(
157
161
  "GET",
158
- `/v1/systems/${systemId}`,
162
+ `/v1/systems/${encodeId(systemId)}`,
159
163
  );
160
164
  return parseSystem(data as Raw);
161
165
  }
@@ -167,13 +171,13 @@ export class BurnLedger {
167
171
  }
168
172
 
169
173
  async deregisterSystem(systemId: string): Promise<void> {
170
- await this.transport.request("DELETE", `/v1/systems/${systemId}`);
174
+ await this.transport.request("DELETE", `/v1/systems/${encodeId(systemId)}`);
171
175
  }
172
176
 
173
177
  async healthCheck(systemId: string): Promise<System> {
174
178
  const data = await this.transport.request(
175
179
  "POST",
176
- `/v1/systems/${systemId}/health-check`,
180
+ `/v1/systems/${encodeId(systemId)}/health-check`,
177
181
  );
178
182
  return parseSystem(data as Raw);
179
183
  }
@@ -206,7 +210,7 @@ export class BurnLedger {
206
210
  async getAttestation(attestationId: string): Promise<Attestation> {
207
211
  const data = await this.transport.request(
208
212
  "GET",
209
- `/v1/attestations/${attestationId}`,
213
+ `/v1/attestations/${encodeId(attestationId)}`,
210
214
  );
211
215
  return parseAttestation(data as Raw);
212
216
  }
@@ -232,7 +236,7 @@ export class BurnLedger {
232
236
  const body = { subject_identifier: subjectIdentifier };
233
237
  const data = await this.transport.request(
234
238
  "POST",
235
- `/v1/attestations/${attestationId}/verify`,
239
+ `/v1/attestations/${encodeId(attestationId)}/verify`,
236
240
  { json: body },
237
241
  );
238
242
  const result = parseVerifyResult(data as Raw);
@@ -246,7 +250,7 @@ export class BurnLedger {
246
250
  fetch: async () => {
247
251
  const d = await this.transport.request(
248
252
  "POST",
249
- `/v1/attestations/${attestationId}/verify`,
253
+ `/v1/attestations/${encodeId(attestationId)}/verify`,
250
254
  { json: body },
251
255
  );
252
256
  return parseVerifyResult(d as Raw);
@@ -281,7 +285,7 @@ export class BurnLedger {
281
285
  async getCertificate(certificateId: string): Promise<CertificateResponse> {
282
286
  const data = await this.transport.request(
283
287
  "GET",
284
- `/v1/certificates/${certificateId}`,
288
+ `/v1/certificates/${encodeId(certificateId)}`,
285
289
  );
286
290
  return parseCertificateResponse(data as Raw);
287
291
  }
@@ -300,20 +304,20 @@ export class BurnLedger {
300
304
  async downloadPdf(certificateId: string): Promise<Uint8Array> {
301
305
  return this.transport.requestBytes(
302
306
  "GET",
303
- `/v1/certificates/${certificateId}/pdf`,
307
+ `/v1/certificates/${encodeId(certificateId)}/pdf`,
304
308
  );
305
309
  }
306
310
 
307
311
  async savePdf(certificateId: string, path: string): Promise<void> {
308
312
  const pdf = await this.downloadPdf(certificateId);
309
- const { writeFile } = await import("node:fs/promises");
313
+ const { writeFile } = await loadNodeRuntime();
310
314
  await writeFile(path, pdf);
311
315
  }
312
316
 
313
317
  async getRevocationStatus(certificateId: string): Promise<RevocationStatus> {
314
318
  const data = await this.transport.request(
315
319
  "GET",
316
- `/v1/certificates/${certificateId}/revocation-status`,
320
+ `/v1/certificates/${encodeId(certificateId)}/revocation-status`,
317
321
  );
318
322
  return parseRevocationStatus(data as Raw);
319
323
  }
@@ -324,7 +328,7 @@ export class BurnLedger {
324
328
  ): Promise<CertificateResponse> {
325
329
  const data = await this.transport.request(
326
330
  "POST",
327
- `/v1/certificates/${certificateId}/revoke`,
331
+ `/v1/certificates/${encodeId(certificateId)}/revoke`,
328
332
  { json: { reason: opts.reason } },
329
333
  );
330
334
  return parseCertificateResponse(data as Raw);
@@ -389,13 +393,13 @@ export class BurnLedger {
389
393
  }
390
394
 
391
395
  async deleteWebhook(webhookId: string): Promise<void> {
392
- await this.transport.request("DELETE", `/v1/webhooks/${webhookId}`);
396
+ await this.transport.request("DELETE", `/v1/webhooks/${encodeId(webhookId)}`);
393
397
  }
394
398
 
395
399
  async rotateWebhookSecret(webhookId: string): Promise<WebhookRotateResponse> {
396
400
  const data = await this.transport.request(
397
401
  "POST",
398
- `/v1/webhooks/${webhookId}/rotate-secret`,
402
+ `/v1/webhooks/${encodeId(webhookId)}/rotate-secret`,
399
403
  );
400
404
  return parseWebhookRotateResponse(data as Raw);
401
405
  }
@@ -403,7 +407,7 @@ export class BurnLedger {
403
407
  async commitWebhookRotation(webhookId: string): Promise<void> {
404
408
  await this.transport.request(
405
409
  "POST",
406
- `/v1/webhooks/${webhookId}/commit-rotation`,
410
+ `/v1/webhooks/${encodeId(webhookId)}/commit-rotation`,
407
411
  );
408
412
  }
409
413
 
@@ -419,14 +423,14 @@ export class BurnLedger {
419
423
  async retryDelivery(deliveryId: string): Promise<void> {
420
424
  await this.transport.request(
421
425
  "POST",
422
- `/v1/webhooks/deliveries/${deliveryId}/retry`,
426
+ `/v1/webhooks/deliveries/${encodeId(deliveryId)}/retry`,
423
427
  );
424
428
  }
425
429
 
426
430
  async resolveDelivery(deliveryId: string): Promise<void> {
427
431
  await this.transport.request(
428
432
  "DELETE",
429
- `/v1/webhooks/deliveries/${deliveryId}`,
433
+ `/v1/webhooks/deliveries/${encodeId(deliveryId)}`,
430
434
  );
431
435
  }
432
436
 
@@ -453,7 +457,7 @@ export class BurnLedger {
453
457
  }
454
458
 
455
459
  async revokeApiKey(keyId: string): Promise<void> {
456
- await this.transport.request("DELETE", `/v1/api-keys/${keyId}`);
460
+ await this.transport.request("DELETE", `/v1/api-keys/${encodeId(keyId)}`);
457
461
  }
458
462
 
459
463
  // --- Profile ---
@@ -494,7 +498,7 @@ export class BurnLedger {
494
498
  * and every document they return must verify under its signing key.
495
499
  */
496
500
  async attestEnclaveIdentity(pin: EnclavePinOptions): Promise<EnclaveIdentity> {
497
- const flow = await import("./enclave-registration.js");
501
+ const { registration: flow } = await loadNodeRuntime();
498
502
  const { nonce, params } = flow.attestationParams(pin.nonce);
499
503
  const data = await this.transport.request("GET", "/v1/enclave/attestation", {
500
504
  params,
@@ -521,7 +525,7 @@ export class BurnLedger {
521
525
  /** The time to judge the answer's not_before against. Defaults to now. */
522
526
  now?: Date;
523
527
  }): Promise<KeyEnrollmentResult> {
524
- const flow = await import("./enclave-registration.js");
528
+ const { registration: flow } = await loadNodeRuntime();
525
529
  const { body, keyId, notAfter } = await flow.enrollBody(opts);
526
530
  const data = await this.transport.request("POST", "/v1/enclave/keys", { json: body });
527
531
  return flow.checkEnrollment(data, {
@@ -552,7 +556,7 @@ export class BurnLedger {
552
556
  /** The time to judge the answer's not_before against. Defaults to now. */
553
557
  now?: Date;
554
558
  }): Promise<KeyEnrollmentResult> {
555
- const flow = await import("./enclave-registration.js");
559
+ const { registration: flow } = await loadNodeRuntime();
556
560
  const { body, prevKeyId, nextKeyId, notAfter } = await flow.rotateBody(opts);
557
561
  const data = await this.transport.request("POST", "/v1/enclave/keys/rotate", { json: body });
558
562
  return flow.checkEnrollment(data, {
@@ -581,7 +585,7 @@ export class BurnLedger {
581
585
  queryTemplate: string;
582
586
  connectorType: string;
583
587
  }): Promise<SystemRegistrationCertificate> {
584
- const flow = await import("./enclave-registration.js");
588
+ const { registration: flow } = await loadNodeRuntime();
585
589
  const { body, keyId, configDigest } = await flow.registrationBody(opts);
586
590
  const data = await this.transport.request("POST", "/v1/enclave/registrations", { json: body });
587
591
  return flow.checkRegistration(data, {
@@ -615,8 +619,7 @@ export class BurnLedger {
615
619
  maxBytes?: number;
616
620
  queryTimeout?: string;
617
621
  }): Promise<RegisteredSystem> {
618
- const flow = await import("./enclave-registration.js");
619
- const { sealToKey } = await import("./enclave-seal.js");
622
+ const { registration: flow, seal } = await loadNodeRuntime();
620
623
  const config = flow.configBytes(resolveConnectionConfig(opts.connectionConfig, opts.dsn, opts.uri));
621
624
  const system = await this.registerSystem({
622
625
  name: opts.name,
@@ -627,7 +630,7 @@ export class BurnLedger {
627
630
  maxRecords: opts.maxRecords,
628
631
  maxBytes: opts.maxBytes,
629
632
  queryTimeout: opts.queryTimeout,
630
- sealedConnectionConfig: await sealToKey(opts.enclave.configSealKey, config),
633
+ sealedConnectionConfig: await seal.sealToKey(opts.enclave.configSealKey, config),
631
634
  });
632
635
  const registration = await this.registerSystemWithKey({
633
636
  teamId: opts.teamId,
@@ -647,7 +650,7 @@ export class BurnLedger {
647
650
  async getSystemHealth(systemId: string): Promise<SystemHealth> {
648
651
  const data = await this.transport.request(
649
652
  "GET",
650
- `/v1/systems/${systemId}/health`,
653
+ `/v1/systems/${encodeId(systemId)}/health`,
651
654
  );
652
655
  return parseSystemHealth(data as Raw);
653
656
  }
@@ -716,6 +719,20 @@ export class BurnLedger {
716
719
  // Connection config resolution
717
720
  // ---------------------------------------------------------------------------
718
721
 
722
+ /** Percent-encode an id before it goes into a URL path, and refuse `.`/`..`.
723
+ *
724
+ * A raw id lets untrusted input escape its segment: `deregisterSystem("x/../../
725
+ * api-keys/k1")` would otherwise resolve to `DELETE /v1/api-keys/k1`.
726
+ * encodeURIComponent escapes the `/`, but it leaves `.` and `..` untouched, and
727
+ * a segment that IS `.` or `..` is still traversal — so those are rejected
728
+ * outright. An empty id is rejected too: it collapses two path segments into one. */
729
+ function encodeId(id: string): string {
730
+ if (id === "" || id === "." || id === "..") {
731
+ throw new Error(`invalid id path segment: ${JSON.stringify(id)}`);
732
+ }
733
+ return encodeURIComponent(id);
734
+ }
735
+
719
736
  /** Standard base64, as Go decodes a []byte field. No Buffer: this module is
720
737
  * also the browser entry's. */
721
738
  function bytesToBase64(bytes: Uint8Array): string {
@@ -769,7 +786,14 @@ async function poll<T>(opts: {
769
786
  result = await opts.fetch();
770
787
  } catch (err) {
771
788
  if (err instanceof RateLimitError && err.retryAfter !== undefined) {
772
- await sleep(err.retryAfter * 1000);
789
+ const remaining = deadline - Date.now();
790
+ if (remaining <= 0) {
791
+ throw new TimeoutError(opts.operation, elapsed);
792
+ }
793
+ // Bound the wait to the caller's remaining budget: a server asking for a
794
+ // day, or an Infinity Retry-After, must not block past the timeout the
795
+ // caller set (Math.min(Infinity, remaining) === remaining).
796
+ await sleep(Math.min(err.retryAfter * 1000, remaining));
773
797
  continue;
774
798
  }
775
799
  throw err;
@@ -353,8 +353,20 @@ export async function registrationBody(opts: {
353
353
  };
354
354
  }
355
355
 
356
- /** Verify the registration under the attested enclave's signing key, then
357
- * refuse it unless it binds what this caller signed. */
356
+ /** Verify the registration under the attested enclave's signing key, then refuse
357
+ * it unless it binds the key id, system id, config digest and connector this
358
+ * caller signed.
359
+ *
360
+ * Two signed fields are deliberately NOT compared here, so the binding is
361
+ * narrower than "everything you signed". `query_template_hash` in the answer is
362
+ * HashQuery over the NORMALIZED subject query, while the request signs sha256 of
363
+ * the RAW template; the two are not byte-equal even for an honest registration,
364
+ * so comparing them would reject legitimate answers (see the README's
365
+ * "query-template normalization" note). `registered_at` is not checked for
366
+ * freshness either: there is no signed bound to hold it to. A relay could
367
+ * therefore substitute an older genuine registration for the same system, config,
368
+ * connector and key but a different query template — the customer path calls this
369
+ * immediately after its own POST, which narrows that window but does not close it. */
358
370
  export async function checkRegistration(
359
371
  data: unknown,
360
372
  opts: {
package/src/index.ts CHANGED
@@ -1,5 +1,18 @@
1
1
  /** BurnLedger TypeScript SDK — public API (Node.js entry point). */
2
2
 
3
+ // Wire the Node-only client capabilities (enrolment, registration, savePdf). The
4
+ // browser entry never does this, so those modules — and node:crypto/node:fs with
5
+ // them — stay out of any browser bundle. See node-runtime.ts.
6
+ import { setNodeRuntimeLoader } from "./node-runtime.js";
7
+ setNodeRuntimeLoader(async () => {
8
+ const [registration, seal, fs] = await Promise.all([
9
+ import("./enclave-registration.js"),
10
+ import("./enclave-seal.js"),
11
+ import("node:fs/promises"),
12
+ ]);
13
+ return { registration, seal, writeFile: (path, data) => fs.writeFile(path, data) };
14
+ });
15
+
3
16
  export { BurnLedger, MAX_BATCH_REVOKE } from "./client.js";
4
17
  export type { BurnLedgerOptions } from "./client.js";
5
18
 
@@ -0,0 +1,57 @@
1
+ /** The Node-only capabilities the shared client needs, injected at the entry.
2
+ *
3
+ * WHY THIS EXISTS. `client.ts` is imported by BOTH entry points — the Node one
4
+ * (index.ts) and the browser one (index.browser.ts). A few of its methods are
5
+ * Node-only by nature: key enrolment and system registration (ADR-025 §2 makes a
6
+ * client the customer runs locally the only place enrolment can happen), and
7
+ * `savePdf`, which writes to a filesystem path. Their implementations live in
8
+ * `enclave-registration.ts`, `enclave-seal.ts` (and, through them, `key-group.ts`
9
+ * and `crypto-node.ts`) and in `node:fs/promises` — all of which import
10
+ * `node:crypto` or another Node builtin.
11
+ *
12
+ * If the client named any of those modules directly — even behind a dynamic
13
+ * `import("./enclave-registration.js")` — a browser bundler following the browser
14
+ * entry's graph would drag `node:crypto` into the bundle and fail the build (the
15
+ * dashboard's vite build did exactly this). So the client never names them. The
16
+ * Node entry registers a loader for them here on import; the browser entry never
17
+ * does. A browser caller of a Node-only method therefore gets a clear error, and
18
+ * a browser bundle of the browser entry reaches no Node builtin at all.
19
+ *
20
+ * The imports below are type-only, so they are erased at build and add nothing to
21
+ * any bundle.
22
+ */
23
+
24
+ import type * as RegistrationModule from "./enclave-registration.js";
25
+ import type * as EnclaveSealModule from "./enclave-seal.js";
26
+
27
+ /** The Node-only surface the client reaches through {@link loadNodeRuntime}. */
28
+ export interface NodeRuntime {
29
+ readonly registration: typeof RegistrationModule;
30
+ readonly seal: typeof EnclaveSealModule;
31
+ /** `node:fs/promises`' `writeFile`, narrowed to what `savePdf` needs. */
32
+ writeFile(path: string, data: Uint8Array): Promise<void>;
33
+ }
34
+
35
+ type NodeRuntimeLoader = () => Promise<NodeRuntime>;
36
+
37
+ let loader: NodeRuntimeLoader | undefined;
38
+
39
+ /** Register the Node runtime. Called once by the Node entry point (index.ts) on
40
+ * import; never by the browser entry. */
41
+ export function setNodeRuntimeLoader(load: NodeRuntimeLoader): void {
42
+ loader = load;
43
+ }
44
+
45
+ /** Load the Node-only runtime, or throw if it was never registered — the case in
46
+ * a browser bundle built from `burnledger/browser`. */
47
+ export function loadNodeRuntime(): Promise<NodeRuntime> {
48
+ if (loader === undefined) {
49
+ return Promise.reject(
50
+ new Error(
51
+ "this operation is Node-only (key enrolment, system registration and " +
52
+ 'savePdf); import BurnLedger from "burnledger", not "burnledger/browser".',
53
+ ),
54
+ );
55
+ }
56
+ return loader();
57
+ }