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.
- package/README.md +9 -4
- package/dist/cjs/client.d.ts.map +1 -1
- package/dist/cjs/client.js +49 -59
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/enclave-registration.d.ts +14 -2
- package/dist/cjs/enclave-registration.d.ts.map +1 -1
- package/dist/cjs/enclave-registration.js +14 -2
- package/dist/cjs/enclave-registration.js.map +1 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +45 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/node-runtime.d.ts +40 -0
- package/dist/cjs/node-runtime.d.ts.map +1 -0
- package/dist/cjs/node-runtime.js +42 -0
- package/dist/cjs/node-runtime.js.map +1 -0
- package/dist/cjs/verify.d.ts.map +1 -1
- package/dist/cjs/verify.js +72 -35
- package/dist/cjs/verify.js.map +1 -1
- package/dist/cjs/webhooks.d.ts +9 -2
- package/dist/cjs/webhooks.d.ts.map +1 -1
- package/dist/cjs/webhooks.js +29 -11
- package/dist/cjs/webhooks.js.map +1 -1
- package/dist/esm/cli.d.ts +19 -0
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +58 -6
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +49 -26
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/enclave-registration.d.ts +14 -2
- package/dist/esm/enclave-registration.d.ts.map +1 -1
- package/dist/esm/enclave-registration.js +14 -2
- package/dist/esm/enclave-registration.js.map +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +12 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/node-runtime.d.ts +40 -0
- package/dist/esm/node-runtime.d.ts.map +1 -0
- package/dist/esm/node-runtime.js +38 -0
- package/dist/esm/node-runtime.js.map +1 -0
- package/dist/esm/verify.d.ts.map +1 -1
- package/dist/esm/verify.js +72 -35
- package/dist/esm/verify.js.map +1 -1
- package/dist/esm/webhooks.d.ts +9 -2
- package/dist/esm/webhooks.d.ts.map +1 -1
- package/dist/esm/webhooks.js +29 -11
- package/dist/esm/webhooks.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +55 -5
- package/src/client.ts +50 -26
- package/src/enclave-registration.ts +14 -2
- package/src/index.ts +13 -0
- package/src/node-runtime.ts +57 -0
- package/src/verify.ts +76 -36
- package/src/webhooks.ts +28 -11
package/dist/esm/webhooks.d.ts
CHANGED
|
@@ -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
|
|
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;
|
|
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"}
|
package/dist/esm/webhooks.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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.
|
package/dist/esm/webhooks.js.map
CHANGED
|
@@ -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
|
|
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
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
+
}
|