burnledger 0.4.0 → 0.5.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.
- package/README.md +3 -3
- package/dist/cjs/client.d.ts +10 -0
- package/dist/cjs/client.d.ts.map +1 -1
- package/dist/cjs/client.js +3 -0
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/errors.d.ts +28 -1
- package/dist/cjs/errors.d.ts.map +1 -1
- package/dist/cjs/errors.js +30 -2
- package/dist/cjs/errors.js.map +1 -1
- package/dist/cjs/http.d.ts +21 -1
- package/dist/cjs/http.d.ts.map +1 -1
- package/dist/cjs/http.js +76 -12
- package/dist/cjs/http.js.map +1 -1
- package/dist/cjs/index.browser.d.ts +2 -4
- package/dist/cjs/index.browser.d.ts.map +1 -1
- package/dist/cjs/index.browser.js.map +1 -1
- package/dist/cjs/index.d.ts +4 -9
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/models.d.ts +22 -4
- package/dist/cjs/models.d.ts.map +1 -1
- package/dist/cjs/models.js +9 -4
- package/dist/cjs/models.js.map +1 -1
- package/dist/cjs/verify.d.ts +41 -6
- package/dist/cjs/verify.d.ts.map +1 -1
- package/dist/cjs/verify.js +213 -33
- package/dist/cjs/verify.js.map +1 -1
- package/dist/cjs/web-verifier.d.ts +10 -5
- package/dist/cjs/web-verifier.d.ts.map +1 -1
- package/dist/cjs/web-verifier.js +7 -3
- package/dist/cjs/web-verifier.js.map +1 -1
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +76 -10
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/client.d.ts +10 -0
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +3 -0
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/errors.d.ts +28 -1
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +29 -1
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/http.d.ts +21 -1
- package/dist/esm/http.d.ts.map +1 -1
- package/dist/esm/http.js +75 -12
- package/dist/esm/http.js.map +1 -1
- package/dist/esm/index.browser.d.ts +2 -4
- package/dist/esm/index.browser.d.ts.map +1 -1
- package/dist/esm/index.browser.js.map +1 -1
- package/dist/esm/index.d.ts +4 -9
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/models.d.ts +22 -4
- package/dist/esm/models.d.ts.map +1 -1
- package/dist/esm/models.js +9 -4
- package/dist/esm/models.js.map +1 -1
- package/dist/esm/verify.d.ts +41 -6
- package/dist/esm/verify.d.ts.map +1 -1
- package/dist/esm/verify.js +213 -33
- package/dist/esm/verify.js.map +1 -1
- package/dist/esm/web-verifier.d.ts +10 -5
- package/dist/esm/web-verifier.d.ts.map +1 -1
- package/dist/esm/web-verifier.js +11 -3
- package/dist/esm/web-verifier.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +82 -9
- package/src/client.ts +11 -0
- package/src/errors.ts +32 -2
- package/src/http.ts +79 -12
- package/src/index.browser.ts +2 -2
- package/src/index.ts +6 -8
- package/src/models.ts +31 -8
- package/src/verify.ts +263 -41
- package/src/web-verifier.ts +14 -5
package/src/cli.ts
CHANGED
|
@@ -119,6 +119,45 @@ export function certVerdict(
|
|
|
119
119
|
);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/** Verdicts that are neither a pass nor a refusal, and what each one leaves
|
|
123
|
+
* unestablished. ADR-017 §2 calls them results a reader must be SHOWN; the
|
|
124
|
+
* verdict string alone leaves them to guess which half of the check is
|
|
125
|
+
* missing. Mirrors softKeyVerdictNote in cmd/cli/format.go. */
|
|
126
|
+
const SOFT_KEY_VERDICTS: Record<string, string> = {
|
|
127
|
+
VALID_KEY_WINDOW_UNKNOWN:
|
|
128
|
+
"signatures verify; the signing key states a validity interval and this " +
|
|
129
|
+
"certificate carries no transparency proof to date it against",
|
|
130
|
+
VALID_KEY_COMPROMISED_LATER:
|
|
131
|
+
"signatures verify and appear to predate the key's compromise; " +
|
|
132
|
+
"confirm against the witness's record",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** Qualifies everything this CLI says from
|
|
136
|
+
* GET /v1/certificates/{id}/revocation-status. That response is a plain JSON
|
|
137
|
+
* envelope with no signature over it, so it is the API's word rather than
|
|
138
|
+
* something the reader can re-check. The signed form exists — a
|
|
139
|
+
* certificate-status statement, which verifyCertificateWithStatus verifies —
|
|
140
|
+
* and this CLI does not fetch one; until it does, saying whose word this is
|
|
141
|
+
* beats printing the field as an established fact. Mirrors
|
|
142
|
+
* unsignedRevocationNote in cmd/cli/format.go. */
|
|
143
|
+
const UNSIGNED_REVOCATION_NOTE = "unsigned API response, not a signed status statement";
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The certificate formats whose signing payload covers enclave_pcr0.
|
|
147
|
+
* Mirrors core.SignatureCoversEnclavePCR0 (core/payload.go).
|
|
148
|
+
*
|
|
149
|
+
* A SET, not a single version, and a predicate rather than an equality test.
|
|
150
|
+
* It was `=== "5.0"`, which silently stopped being true the moment v6 was
|
|
151
|
+
* added: v6 signs the measurement exactly as v5 does, and this CLI told the
|
|
152
|
+
* reader the field was under no signature while the Go CLI said the opposite
|
|
153
|
+
* about the same file.
|
|
154
|
+
*/
|
|
155
|
+
const ENCLAVE_PCR0_SIGNED_IN: readonly string[] = Object.freeze(["5.0", "6.0"]);
|
|
156
|
+
|
|
157
|
+
function signatureCoversEnclavePcr0(version: string): boolean {
|
|
158
|
+
return ENCLAVE_PCR0_SIGNED_IN.includes(version);
|
|
159
|
+
}
|
|
160
|
+
|
|
122
161
|
/** Byte fields arrive as number arrays (Go [N]byte) or strings; show hex. */
|
|
123
162
|
function displayHash(v: unknown): string {
|
|
124
163
|
if (Array.isArray(v)) {
|
|
@@ -136,18 +175,32 @@ export function formatOutput(
|
|
|
136
175
|
const att = cert.attestation as Record<string, unknown>;
|
|
137
176
|
const L: string[] = [];
|
|
138
177
|
|
|
139
|
-
|
|
140
|
-
|
|
178
|
+
// A soft key verdict is not a forgery: every signature verified, and what
|
|
179
|
+
// could not be established is the key's authority. "INVALID" would send an
|
|
180
|
+
// auditor hunting for tampering that is not there, and "VALID" — which this
|
|
181
|
+
// CLI printed — claims a check that did not happen.
|
|
182
|
+
const note = SOFT_KEY_VERDICTS[sigResult];
|
|
183
|
+
if (note !== undefined) return `Verification record dc_${id}: ${sigResult} (${note})\n`;
|
|
184
|
+
if (sigResult !== "VALID") return `Verification record dc_${id}: INVALID (${sigResult})\n`;
|
|
185
|
+
if (transparencyFailed) return `Verification record dc_${id}: INVALID (transparency: ${transparency})\n`;
|
|
141
186
|
if (online && revocation?.status === "REVOKED") {
|
|
142
|
-
L.push(`
|
|
187
|
+
L.push(`Verification record dc_${id}: INVALID (REVOKED)`);
|
|
143
188
|
if (revocation.reason) L.push(` Revocation reason: ${revocation.reason}`);
|
|
189
|
+
// Fail closed on the API's word — an unbelieved revocation is the worse
|
|
190
|
+
// error — but name the source: this verdict rests on the same unsigned
|
|
191
|
+
// field as the one below.
|
|
192
|
+
L.push(` Revocation source: ${UNSIGNED_REVOCATION_NOTE}`);
|
|
144
193
|
return L.join("\n") + "\n";
|
|
145
194
|
}
|
|
146
195
|
|
|
147
|
-
|
|
148
|
-
|
|
196
|
+
// An --online answer is not a stronger answer, only a different one, so the
|
|
197
|
+
// qualifier is replaced rather than dropped.
|
|
198
|
+
const tag = online
|
|
199
|
+
? ` (the API reports it is not revoked; ${UNSIGNED_REVOCATION_NOTE})`
|
|
200
|
+
: " (offline — revocation status not checked)";
|
|
201
|
+
L.push(`Verification record dc_${id}: VALID${tag}`);
|
|
149
202
|
L.push(` Subject: sha256:${sub.slice(0, 6)}...`);
|
|
150
|
-
L.push(`
|
|
203
|
+
L.push(` Format version: ${cert.certificate_format_version as string}`);
|
|
151
204
|
|
|
152
205
|
// v3 carries ONE system list at the top level, with both counts on each
|
|
153
206
|
// record. Reading att.systems here is what made this CLI die on every real
|
|
@@ -167,10 +220,24 @@ export function formatOutput(
|
|
|
167
220
|
// Its own line, never appended to a verdict (ADR-020), and worded so it
|
|
168
221
|
// cannot be read as a checked fact: nothing offline can verify a measurement.
|
|
169
222
|
// Proof is the NSM attestation document.
|
|
223
|
+
//
|
|
224
|
+
// The attribution differs by version. From v5 the field is inside the signing
|
|
225
|
+
// payload, so it is the signer's claim about itself. On v4 — the format that
|
|
226
|
+
// is actually issued, and into which the enclave writes its real measurement —
|
|
227
|
+
// no signature covers it, so calling it the signer's claim names an author
|
|
228
|
+
// nothing established: it is whatever the last holder of the file wrote.
|
|
170
229
|
const issuer = cert.issuer as Record<string, unknown> | undefined;
|
|
171
230
|
const pcr0 = issuer?.enclave_pcr0;
|
|
172
231
|
if (typeof pcr0 === "string" && pcr0 !== "") {
|
|
173
|
-
|
|
232
|
+
const version = cert.certificate_format_version as string;
|
|
233
|
+
if (signatureCoversEnclavePcr0(version)) {
|
|
234
|
+
L.push(` Enclave image (claimed by signer, not verified here): ${pcr0}`);
|
|
235
|
+
} else {
|
|
236
|
+
L.push(
|
|
237
|
+
` Enclave image (format ${version} puts this field under no signature — ` +
|
|
238
|
+
`unattributable, not verified here): ${pcr0}`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
174
241
|
}
|
|
175
242
|
|
|
176
243
|
if (transparency === "INCLUDED") {
|
|
@@ -179,7 +246,9 @@ export function formatOutput(
|
|
|
179
246
|
} else {
|
|
180
247
|
L.push(` Transparency log: ${transparency === "NOT_AVAILABLE" ? "not included" : transparency}`);
|
|
181
248
|
}
|
|
182
|
-
if (online && revocation)
|
|
249
|
+
if (online && revocation) {
|
|
250
|
+
L.push(` Revocation status: ${revocation.status} (${UNSIGNED_REVOCATION_NOTE})`);
|
|
251
|
+
}
|
|
183
252
|
return L.join("\n") + "\n";
|
|
184
253
|
}
|
|
185
254
|
|
|
@@ -202,8 +271,12 @@ async function main(): Promise<void> {
|
|
|
202
271
|
const cert = unwrapCertificate(JSON.parse(await readFile(args.certPath, "utf-8")) as Cert);
|
|
203
272
|
const keys = await loadKeys(args.keysPath);
|
|
204
273
|
|
|
274
|
+
// The verifier's own verdict, never a hardcoded word. VALID_KEY_WINDOW_UNKNOWN
|
|
275
|
+
// and VALID_KEY_COMPROMISED_LATER are cases verifyCertificate deliberately
|
|
276
|
+
// refused to call VALID, and this is the tool an auditor runs in a script —
|
|
277
|
+
// the one whose exit code gets trusted.
|
|
205
278
|
let sigResult = "VALID";
|
|
206
|
-
try { await verifyCertificate(nodeCrypto, cert, keys); }
|
|
279
|
+
try { sigResult = await verifyCertificate(nodeCrypto, cert, keys); }
|
|
207
280
|
catch (e) { sigResult = e instanceof Error ? e.message : "unknown error"; }
|
|
208
281
|
|
|
209
282
|
// NOT_AVAILABLE (no proof) is a legitimate result; only a thrown error is a
|
package/src/client.ts
CHANGED
|
@@ -55,6 +55,14 @@ export interface BurnLedgerOptions {
|
|
|
55
55
|
baseUrl?: string;
|
|
56
56
|
timeout?: number;
|
|
57
57
|
maxRetries?: number;
|
|
58
|
+
/**
|
|
59
|
+
* Team to act as, sent as X-Team-ID beside the key.
|
|
60
|
+
*
|
|
61
|
+
* Omit it and the API uses the caller's default team — the first one they
|
|
62
|
+
* joined — which is the right answer for a single-team account and the wrong
|
|
63
|
+
* one for anybody else.
|
|
64
|
+
*/
|
|
65
|
+
teamId?: string;
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
export class BurnLedger {
|
|
@@ -66,6 +74,7 @@ export class BurnLedger {
|
|
|
66
74
|
apiKey: opts.apiKey,
|
|
67
75
|
timeout: opts.timeout ?? 30,
|
|
68
76
|
maxRetries: opts.maxRetries ?? 2,
|
|
77
|
+
teamId: opts.teamId,
|
|
69
78
|
});
|
|
70
79
|
}
|
|
71
80
|
|
|
@@ -405,6 +414,8 @@ export class BurnLedger {
|
|
|
405
414
|
return parseProfile(data as Raw);
|
|
406
415
|
}
|
|
407
416
|
|
|
417
|
+
/** Rename the account. `email` must be the current address: changing it
|
|
418
|
+
* needs a dashboard session, and an API key is refused with 403. */
|
|
408
419
|
async updateMe(params: { name: string; email: string }): Promise<Profile> {
|
|
409
420
|
const data = await this.transport.request("PATCH", "/v1/me", {
|
|
410
421
|
json: { name: params.name, email: params.email },
|
package/src/errors.ts
CHANGED
|
@@ -101,14 +101,44 @@ export class TimeoutError extends BurnLedgerError {
|
|
|
101
101
|
|
|
102
102
|
export class VerificationError extends BurnLedgerError {
|
|
103
103
|
readonly reason: string;
|
|
104
|
-
|
|
105
|
-
|
|
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
|
+
|
|
112
142
|
const STATUS_TO_CLASS: Record<
|
|
113
143
|
number,
|
|
114
144
|
new (opts: {
|
package/src/http.ts
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
/** HTTP transport layer wrapping native fetch.
|
|
2
2
|
*
|
|
3
3
|
* Handles: Bearer auth, response envelope unwrapping, retry on 5xx/transport
|
|
4
|
-
* errors
|
|
4
|
+
* errors and on 429, and raw byte streaming for PDFs.
|
|
5
|
+
*
|
|
6
|
+
* Retry delay: a `Retry-After` header (delta-seconds or HTTP-date) is honoured
|
|
7
|
+
* when the server sends one and it is at most `MAX_RETRY_AFTER`; otherwise
|
|
8
|
+
* exponential backoff with jitter. A 429 with no usable `Retry-After`, one
|
|
9
|
+
* beyond the cap, or one on the last attempt throws `RateLimitError` with
|
|
10
|
+
* `retryAfter` populated so the caller can decide.
|
|
5
11
|
*/
|
|
6
12
|
|
|
7
13
|
import { raiseForError } from "./errors.js";
|
|
8
14
|
|
|
15
|
+
/** Longest Retry-After, in seconds, the transport will wait on before
|
|
16
|
+
* throwing instead: a server asking for minutes gets the error and the
|
|
17
|
+
* number rather than a promise nobody can cancel. */
|
|
18
|
+
export const MAX_RETRY_AFTER = 30;
|
|
19
|
+
|
|
9
20
|
export interface TransportOptions {
|
|
10
21
|
baseUrl: string;
|
|
11
22
|
apiKey: string;
|
|
12
23
|
timeout: number;
|
|
13
24
|
maxRetries: number;
|
|
25
|
+
/** Team to act as. Omit to let the API use the caller's default team. */
|
|
26
|
+
teamId?: string;
|
|
14
27
|
}
|
|
15
28
|
|
|
16
29
|
export class Transport {
|
|
@@ -18,12 +31,18 @@ export class Transport {
|
|
|
18
31
|
private readonly apiKey: string;
|
|
19
32
|
private readonly timeout: number;
|
|
20
33
|
private readonly maxRetries: number;
|
|
34
|
+
// Which team the caller is acting as. The API resolves scope from this
|
|
35
|
+
// header and otherwise falls back to the caller's default team, so a member
|
|
36
|
+
// of more than one team who does not send it silently acts as whichever they
|
|
37
|
+
// joined first.
|
|
38
|
+
private readonly teamId?: string;
|
|
21
39
|
|
|
22
40
|
constructor(opts: TransportOptions) {
|
|
23
41
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
24
42
|
this.apiKey = opts.apiKey;
|
|
25
43
|
this.timeout = opts.timeout;
|
|
26
44
|
this.maxRetries = opts.maxRetries;
|
|
45
|
+
this.teamId = opts.teamId;
|
|
27
46
|
}
|
|
28
47
|
|
|
29
48
|
/** Make a request and return the unwrapped `data` field. */
|
|
@@ -56,6 +75,12 @@ export class Transport {
|
|
|
56
75
|
};
|
|
57
76
|
if (authenticated) {
|
|
58
77
|
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
78
|
+
// Alongside the key, never without it: the header names the scope the
|
|
79
|
+
// caller is acting in, which is meaningless for an unauthenticated
|
|
80
|
+
// request and is not a second credential.
|
|
81
|
+
if (this.teamId) {
|
|
82
|
+
headers["X-Team-ID"] = this.teamId;
|
|
83
|
+
}
|
|
59
84
|
}
|
|
60
85
|
|
|
61
86
|
let url = `${this.baseUrl}${path}`;
|
|
@@ -80,14 +105,14 @@ export class Transport {
|
|
|
80
105
|
}
|
|
81
106
|
|
|
82
107
|
let lastError: Error | undefined;
|
|
108
|
+
// Delay before the next attempt when the server named one; undefined
|
|
109
|
+
// means fall back to backoff.
|
|
110
|
+
let retryAfter: number | undefined;
|
|
83
111
|
|
|
84
112
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
85
113
|
if (attempt > 0) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const base = 500 * 2 ** (attempt - 1);
|
|
89
|
-
const jitter = base * 0.25 * (Math.random() * 2 - 1);
|
|
90
|
-
await sleep(base + jitter);
|
|
114
|
+
await sleep(retryDelayMs(attempt, retryAfter));
|
|
115
|
+
retryAfter = undefined;
|
|
91
116
|
}
|
|
92
117
|
|
|
93
118
|
const controller = new AbortController();
|
|
@@ -116,8 +141,19 @@ export class Transport {
|
|
|
116
141
|
return {};
|
|
117
142
|
}
|
|
118
143
|
|
|
144
|
+
retryAfter = parseRetryAfter(resp);
|
|
119
145
|
if (resp.status === 429) {
|
|
120
|
-
|
|
146
|
+
// Only on the server's say-so: without Retry-After there is no delay
|
|
147
|
+
// known to be enough, and beyond the cap the caller gets the number
|
|
148
|
+
// (RateLimitError.retryAfter) rather than a hung promise.
|
|
149
|
+
if (
|
|
150
|
+
retryAfter !== undefined &&
|
|
151
|
+
retryAfter <= MAX_RETRY_AFTER &&
|
|
152
|
+
attempt < this.maxRetries
|
|
153
|
+
) {
|
|
154
|
+
lastError = new Error(`Server error ${resp.status}`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
121
157
|
const errorBody = await safeJson(resp);
|
|
122
158
|
raiseForError(resp.status, errorBody, retryAfter);
|
|
123
159
|
}
|
|
@@ -150,6 +186,11 @@ export class Transport {
|
|
|
150
186
|
const headers: Record<string, string> = {};
|
|
151
187
|
if (authenticated) {
|
|
152
188
|
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
189
|
+
// A downloaded certificate is as team-scoped as the JSON one; omitting
|
|
190
|
+
// the header here would hand a member their default team's PDF.
|
|
191
|
+
if (this.teamId) {
|
|
192
|
+
headers["X-Team-ID"] = this.teamId;
|
|
193
|
+
}
|
|
153
194
|
}
|
|
154
195
|
|
|
155
196
|
const url = `${this.baseUrl}${path}`;
|
|
@@ -172,11 +213,37 @@ export class Transport {
|
|
|
172
213
|
}
|
|
173
214
|
}
|
|
174
215
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
216
|
+
/** Seconds to wait per the Retry-After header (RFC 9110 §10.2.3).
|
|
217
|
+
*
|
|
218
|
+
* Accepts both forms the header allows: non-negative delta-seconds and an
|
|
219
|
+
* HTTP-date, converted to seconds from now (a date already past reads as 0).
|
|
220
|
+
* Anything else is treated as absent.
|
|
221
|
+
*/
|
|
222
|
+
export function parseRetryAfter(resp: Response): number | undefined {
|
|
223
|
+
const raw = resp.headers.get("Retry-After")?.trim();
|
|
224
|
+
if (!raw) return undefined;
|
|
225
|
+
if (/^\d+(\.\d+)?$/.test(raw)) {
|
|
226
|
+
return Number(raw);
|
|
227
|
+
}
|
|
228
|
+
// Every HTTP-date form (IMF-fixdate, RFC 850, asctime) opens with a weekday
|
|
229
|
+
// name; Date.parse alone would also read "-3" or "2099" as a year.
|
|
230
|
+
if (!/^[A-Za-z]{3}/.test(raw)) return undefined;
|
|
231
|
+
const when = Date.parse(raw);
|
|
232
|
+
if (Number.isNaN(when)) return undefined;
|
|
233
|
+
return Math.max(0, (when - Date.now()) / 1000);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Milliseconds to sleep before retry `attempt` (1-based). A server-supplied
|
|
237
|
+
* delay within the cap wins; otherwise exponential backoff with ±25% jitter so
|
|
238
|
+
* coordinated 5xx events don't produce a synchronized retry storm from every
|
|
239
|
+
* client at the same instant. */
|
|
240
|
+
function retryDelayMs(attempt: number, retryAfter: number | undefined): number {
|
|
241
|
+
if (retryAfter !== undefined && retryAfter <= MAX_RETRY_AFTER) {
|
|
242
|
+
return retryAfter * 1000;
|
|
243
|
+
}
|
|
244
|
+
const base = Math.min(500 * 2 ** (attempt - 1), MAX_RETRY_AFTER * 1000);
|
|
245
|
+
const jitter = base * 0.25 * (Math.random() * 2 - 1);
|
|
246
|
+
return base + jitter;
|
|
180
247
|
}
|
|
181
248
|
|
|
182
249
|
async function safeJson(resp: Response): Promise<Record<string, unknown>> {
|
package/src/index.browser.ts
CHANGED
|
@@ -69,7 +69,7 @@ import {
|
|
|
69
69
|
verifyConsistency as _verifyConsistency,
|
|
70
70
|
publicKeyFromHex as _publicKeyFromHex,
|
|
71
71
|
} from "./verify.js";
|
|
72
|
-
import type { PublicKeyInfo } from "./verify.js";
|
|
72
|
+
import type { PublicKeyInfo, PublicKeyOptions } from "./verify.js";
|
|
73
73
|
import type { VerificationResult, TransparencyResult } from "./models.js";
|
|
74
74
|
|
|
75
75
|
type Cert = Record<string, unknown>;
|
|
@@ -93,7 +93,7 @@ export function verifyTransparency(
|
|
|
93
93
|
/** Construct a PublicKeyInfo from a hex-encoded Ed25519 public key. */
|
|
94
94
|
export function publicKeyFromHex(
|
|
95
95
|
hexKey: string,
|
|
96
|
-
opts?:
|
|
96
|
+
opts?: PublicKeyOptions,
|
|
97
97
|
): Promise<PublicKeyInfo> {
|
|
98
98
|
return _publicKeyFromHex(browserCrypto, hexKey, opts);
|
|
99
99
|
}
|
package/src/index.ts
CHANGED
|
@@ -14,11 +14,15 @@ export {
|
|
|
14
14
|
ServerError,
|
|
15
15
|
TimeoutError,
|
|
16
16
|
VerificationError,
|
|
17
|
+
CERTIFICATE_REVOKED,
|
|
18
|
+
UNSUPPORTED_FORMAT_VERSION,
|
|
17
19
|
} from "./errors.js";
|
|
18
20
|
|
|
19
21
|
export { Transport } from "./http.js";
|
|
20
22
|
export type { TransportOptions } from "./http.js";
|
|
21
23
|
|
|
24
|
+
export type { PublicKeyOptions } from "./verify.js";
|
|
25
|
+
|
|
22
26
|
export type {
|
|
23
27
|
ProofMode,
|
|
24
28
|
AttestationStatus,
|
|
@@ -76,7 +80,7 @@ import {
|
|
|
76
80
|
verifyConsistency as _verifyConsistency,
|
|
77
81
|
publicKeyFromHex as _publicKeyFromHex,
|
|
78
82
|
} from "./verify.js";
|
|
79
|
-
import type { PublicKeyInfo } from "./verify.js";
|
|
83
|
+
import type { PublicKeyInfo, PublicKeyOptions } from "./verify.js";
|
|
80
84
|
import type { VerificationResult, TransparencyResult } from "./models.js";
|
|
81
85
|
|
|
82
86
|
type Cert = Record<string, unknown>;
|
|
@@ -123,13 +127,7 @@ export function verifyTransparency(
|
|
|
123
127
|
*/
|
|
124
128
|
export function publicKeyFromHex(
|
|
125
129
|
hexKey: string,
|
|
126
|
-
opts?:
|
|
127
|
-
revoked?: boolean;
|
|
128
|
-
keyStatus?: string;
|
|
129
|
-
notBefore?: string;
|
|
130
|
-
notAfter?: string;
|
|
131
|
-
compromisedFrom?: string;
|
|
132
|
-
},
|
|
130
|
+
opts?: PublicKeyOptions,
|
|
133
131
|
): Promise<PublicKeyInfo> {
|
|
134
132
|
return _publicKeyFromHex(nodeCrypto, hexKey, opts);
|
|
135
133
|
}
|
package/src/models.ts
CHANGED
|
@@ -146,6 +146,13 @@ export interface CertificateResponse {
|
|
|
146
146
|
readonly revokedAt: Date | undefined;
|
|
147
147
|
readonly revocationReason: string | undefined;
|
|
148
148
|
readonly certificate: Record<string, unknown>;
|
|
149
|
+
/**
|
|
150
|
+
* Raw enclave-signed status statement stapled by the server. Best-effort on
|
|
151
|
+
* the server side, so undefined when it could not be produced; GET
|
|
152
|
+
* /v1/certificates/{id}/status is the call that errors instead. Kept raw so
|
|
153
|
+
* it can be handed straight to verifyCertificateWithStatus().
|
|
154
|
+
*/
|
|
155
|
+
readonly statusStatement: Record<string, unknown> | undefined;
|
|
149
156
|
}
|
|
150
157
|
|
|
151
158
|
export interface RevocationStatus {
|
|
@@ -206,6 +213,9 @@ export interface Profile {
|
|
|
206
213
|
readonly id: string;
|
|
207
214
|
readonly name: string;
|
|
208
215
|
readonly email: string;
|
|
216
|
+
/** A new address waiting for the code mailed to it; `email` stays the
|
|
217
|
+
* current one until the change is confirmed from the dashboard. */
|
|
218
|
+
readonly pendingEmail: string | undefined;
|
|
209
219
|
readonly plan: string;
|
|
210
220
|
readonly planStartedAt: string | undefined;
|
|
211
221
|
readonly createdAt: string;
|
|
@@ -218,9 +228,9 @@ export interface Profile {
|
|
|
218
228
|
|
|
219
229
|
export interface SystemHealth {
|
|
220
230
|
readonly systemId: string;
|
|
221
|
-
readonly
|
|
222
|
-
readonly
|
|
223
|
-
readonly
|
|
231
|
+
readonly healthStatus: HealthStatus;
|
|
232
|
+
readonly healthCheckedAt: Date | undefined;
|
|
233
|
+
readonly healthError: string | undefined;
|
|
224
234
|
}
|
|
225
235
|
|
|
226
236
|
// ---------------------------------------------------------------------------
|
|
@@ -233,8 +243,14 @@ export interface BatchAttestationResponse {
|
|
|
233
243
|
}
|
|
234
244
|
|
|
235
245
|
export interface BatchAttestationError {
|
|
236
|
-
|
|
246
|
+
/** Position in the submitted subjectIdentifiers list. index is the durable
|
|
247
|
+
* key: subjectIdentifier is verbatim on the live response, but a replayed
|
|
248
|
+
* (Idempotency-Key) response carries a "sha256:<hex>" placeholder instead,
|
|
249
|
+
* because the cached copy never holds a raw identifier (PRIV-2). Do not key
|
|
250
|
+
* a retry on it after a replay. */
|
|
251
|
+
readonly index: number;
|
|
237
252
|
readonly error: string;
|
|
253
|
+
readonly subjectIdentifier: string;
|
|
238
254
|
}
|
|
239
255
|
|
|
240
256
|
// ---------------------------------------------------------------------------
|
|
@@ -266,6 +282,7 @@ export interface WebhookRotateResponse {
|
|
|
266
282
|
readonly id: string;
|
|
267
283
|
readonly url: string;
|
|
268
284
|
readonly newSecret: string;
|
|
285
|
+
readonly rotationExpiresAt: Date | undefined;
|
|
269
286
|
}
|
|
270
287
|
|
|
271
288
|
export interface WebhookDelivery {
|
|
@@ -274,6 +291,7 @@ export interface WebhookDelivery {
|
|
|
274
291
|
readonly eventType: string;
|
|
275
292
|
readonly errorMessage: string;
|
|
276
293
|
readonly attempts: number;
|
|
294
|
+
readonly nextAttemptAt: Date | undefined;
|
|
277
295
|
readonly createdAt: Date;
|
|
278
296
|
}
|
|
279
297
|
|
|
@@ -396,6 +414,7 @@ export function parseCertificateResponse(d: Raw): CertificateResponse {
|
|
|
396
414
|
revokedAt: parseDtOpt(d.revoked_at),
|
|
397
415
|
revocationReason: d.revocation_reason ?? undefined,
|
|
398
416
|
certificate: d.certificate,
|
|
417
|
+
statusStatement: d.status_statement ?? undefined,
|
|
399
418
|
};
|
|
400
419
|
}
|
|
401
420
|
|
|
@@ -455,8 +474,9 @@ export function parseConsistencyProof(d: Raw): ConsistencyProof {
|
|
|
455
474
|
|
|
456
475
|
function parseBatchAttestationError(d: Raw): BatchAttestationError {
|
|
457
476
|
return {
|
|
458
|
-
|
|
477
|
+
index: d.index,
|
|
459
478
|
error: d.error,
|
|
479
|
+
subjectIdentifier: d.subject_identifier ?? "",
|
|
460
480
|
};
|
|
461
481
|
}
|
|
462
482
|
|
|
@@ -492,6 +512,7 @@ export function parseWebhookRotateResponse(d: Raw): WebhookRotateResponse {
|
|
|
492
512
|
id: d.id,
|
|
493
513
|
url: d.url,
|
|
494
514
|
newSecret: d.new_secret,
|
|
515
|
+
rotationExpiresAt: parseDtOpt(d.rotation_expires_at),
|
|
495
516
|
};
|
|
496
517
|
}
|
|
497
518
|
|
|
@@ -502,6 +523,7 @@ export function parseWebhookDelivery(d: Raw): WebhookDelivery {
|
|
|
502
523
|
eventType: d.event_type,
|
|
503
524
|
errorMessage: d.error_message,
|
|
504
525
|
attempts: d.attempts,
|
|
526
|
+
nextAttemptAt: parseDtOpt(d.next_attempt_at),
|
|
505
527
|
createdAt: parseDt(d.created_at),
|
|
506
528
|
};
|
|
507
529
|
}
|
|
@@ -544,6 +566,7 @@ export function parseProfile(d: Raw): Profile {
|
|
|
544
566
|
id: d.id,
|
|
545
567
|
name: d.name,
|
|
546
568
|
email: d.email,
|
|
569
|
+
pendingEmail: d.pending_email ?? undefined,
|
|
547
570
|
plan: d.plan,
|
|
548
571
|
planStartedAt: d.plan_started_at ?? undefined,
|
|
549
572
|
createdAt: d.created_at,
|
|
@@ -554,8 +577,8 @@ export function parseProfile(d: Raw): Profile {
|
|
|
554
577
|
export function parseSystemHealth(d: Raw): SystemHealth {
|
|
555
578
|
return {
|
|
556
579
|
systemId: d.system_id,
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
580
|
+
healthStatus: (d.health_status ?? "UNKNOWN") as HealthStatus,
|
|
581
|
+
healthCheckedAt: parseDtOpt(d.health_checked_at),
|
|
582
|
+
healthError: d.health_error ?? undefined,
|
|
560
583
|
};
|
|
561
584
|
}
|