burnledger 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -4
- package/dist/cjs/anchor.d.ts +71 -0
- package/dist/cjs/anchor.d.ts.map +1 -0
- package/dist/cjs/anchor.js +279 -0
- package/dist/cjs/anchor.js.map +1 -0
- package/dist/cjs/client.d.ts +26 -2
- package/dist/cjs/client.d.ts.map +1 -1
- package/dist/cjs/client.js +27 -5
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/enclave-seal.d.ts +101 -0
- package/dist/cjs/enclave-seal.d.ts.map +1 -0
- package/dist/cjs/enclave-seal.js +479 -0
- package/dist/cjs/enclave-seal.js.map +1 -0
- package/dist/cjs/errors.d.ts +42 -1
- package/dist/cjs/errors.d.ts.map +1 -1
- package/dist/cjs/errors.js +44 -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 +16 -11
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +20 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/keys.d.ts +66 -0
- package/dist/cjs/keys.d.ts.map +1 -1
- package/dist/cjs/keys.js +128 -3
- package/dist/cjs/keys.js.map +1 -1
- package/dist/cjs/models.d.ts +41 -3
- package/dist/cjs/models.d.ts.map +1 -1
- package/dist/cjs/models.js +25 -4
- package/dist/cjs/models.js.map +1 -1
- package/dist/cjs/verify.d.ts +72 -6
- package/dist/cjs/verify.d.ts.map +1 -1
- package/dist/cjs/verify.js +392 -62
- package/dist/cjs/verify.js.map +1 -1
- package/dist/cjs/web-verifier.d.ts +54 -5
- package/dist/cjs/web-verifier.d.ts.map +1 -1
- package/dist/cjs/web-verifier.js +36 -3
- package/dist/cjs/web-verifier.js.map +1 -1
- package/dist/esm/anchor.d.ts +71 -0
- package/dist/esm/anchor.d.ts.map +1 -0
- package/dist/esm/anchor.js +275 -0
- package/dist/esm/anchor.js.map +1 -0
- package/dist/esm/cli.d.ts +45 -14
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +357 -50
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/client.d.ts +26 -2
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +27 -5
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/enclave-seal.d.ts +101 -0
- package/dist/esm/enclave-seal.d.ts.map +1 -0
- package/dist/esm/enclave-seal.js +472 -0
- package/dist/esm/enclave-seal.js.map +1 -0
- package/dist/esm/errors.d.ts +42 -1
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +43 -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 +16 -11
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +11 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/keys.d.ts +66 -0
- package/dist/esm/keys.d.ts.map +1 -1
- package/dist/esm/keys.js +125 -3
- package/dist/esm/keys.js.map +1 -1
- package/dist/esm/models.d.ts +41 -3
- package/dist/esm/models.d.ts.map +1 -1
- package/dist/esm/models.js +24 -4
- package/dist/esm/models.js.map +1 -1
- package/dist/esm/verify.d.ts +72 -6
- package/dist/esm/verify.d.ts.map +1 -1
- package/dist/esm/verify.js +390 -63
- package/dist/esm/verify.js.map +1 -1
- package/dist/esm/web-verifier.d.ts +54 -5
- package/dist/esm/web-verifier.d.ts.map +1 -1
- package/dist/esm/web-verifier.js +40 -3
- package/dist/esm/web-verifier.js.map +1 -1
- package/package.json +1 -1
- package/src/anchor.ts +330 -0
- package/src/cli.ts +389 -54
- package/src/client.ts +46 -4
- package/src/enclave-seal.ts +582 -0
- package/src/errors.ts +47 -2
- package/src/http.ts +79 -12
- package/src/index.browser.ts +2 -2
- package/src/index.ts +28 -9
- package/src/keys.ts +176 -3
- package/src/models.ts +72 -7
- package/src/verify.ts +467 -72
- package/src/web-verifier.ts +58 -5
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** BurnLedger TypeScript SDK — public API (Node.js entry point). */
|
|
2
2
|
|
|
3
|
-
export { BurnLedger } from "./client.js";
|
|
3
|
+
export { BurnLedger, MAX_BATCH_REVOKE } from "./client.js";
|
|
4
4
|
export type { BurnLedgerOptions } from "./client.js";
|
|
5
5
|
|
|
6
6
|
export {
|
|
@@ -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,
|
|
@@ -47,6 +51,8 @@ export type {
|
|
|
47
51
|
ConsistencyProof,
|
|
48
52
|
BatchAttestationResponse,
|
|
49
53
|
BatchAttestationError,
|
|
54
|
+
BatchRevokeResponse,
|
|
55
|
+
BatchRevokeError,
|
|
50
56
|
MonthlyCount,
|
|
51
57
|
CertificateStats,
|
|
52
58
|
WebhookRotateResponse,
|
|
@@ -76,7 +82,7 @@ import {
|
|
|
76
82
|
verifyConsistency as _verifyConsistency,
|
|
77
83
|
publicKeyFromHex as _publicKeyFromHex,
|
|
78
84
|
} from "./verify.js";
|
|
79
|
-
import type { PublicKeyInfo } from "./verify.js";
|
|
85
|
+
import type { PublicKeyInfo, PublicKeyOptions } from "./verify.js";
|
|
80
86
|
import type { VerificationResult, TransparencyResult } from "./models.js";
|
|
81
87
|
|
|
82
88
|
type Cert = Record<string, unknown>;
|
|
@@ -123,13 +129,7 @@ export function verifyTransparency(
|
|
|
123
129
|
*/
|
|
124
130
|
export function publicKeyFromHex(
|
|
125
131
|
hexKey: string,
|
|
126
|
-
opts?:
|
|
127
|
-
revoked?: boolean;
|
|
128
|
-
keyStatus?: string;
|
|
129
|
-
notBefore?: string;
|
|
130
|
-
notAfter?: string;
|
|
131
|
-
compromisedFrom?: string;
|
|
132
|
-
},
|
|
132
|
+
opts?: PublicKeyOptions,
|
|
133
133
|
): Promise<PublicKeyInfo> {
|
|
134
134
|
return _publicKeyFromHex(nodeCrypto, hexKey, opts);
|
|
135
135
|
}
|
|
@@ -146,3 +146,22 @@ export function verifyConsistency(
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
export { verifyWebhookSignature, isTimestampFresh } from "./webhooks.js";
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Client-side connection-config sealing.
|
|
152
|
+
*
|
|
153
|
+
* Node only, and absent from the browser entry point on purpose: verifying an
|
|
154
|
+
* attestation document needs X.509 chain building, which browsers do not
|
|
155
|
+
* expose, and sealing a datastore credential is not something a browser should
|
|
156
|
+
* be doing.
|
|
157
|
+
*/
|
|
158
|
+
export {
|
|
159
|
+
EnclaveAttestationError,
|
|
160
|
+
ENVELOPE_MAGIC,
|
|
161
|
+
HEADER_SIZE,
|
|
162
|
+
NITRO_ROOT_G1_PEM,
|
|
163
|
+
sealConnectionConfig,
|
|
164
|
+
sealToKey,
|
|
165
|
+
verifyEnclaveConfigSealKey,
|
|
166
|
+
} from "./enclave-seal.js";
|
|
167
|
+
export type { SealOptions } from "./enclave-seal.js";
|
package/src/keys.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
// Public-key file parsing shared by the CLI
|
|
1
|
+
// Public-key file parsing shared by the CLI and the browser verifier, and the
|
|
2
|
+
// key_list.v3 verdict (ADR-017 §5a) — unit-testable in isolation.
|
|
3
|
+
|
|
4
|
+
import type { CryptoOps } from "./crypto.js";
|
|
5
|
+
import {
|
|
6
|
+
buildKeyListPayload,
|
|
7
|
+
evaluateKey,
|
|
8
|
+
formatTimestamp,
|
|
9
|
+
hexToBytes,
|
|
10
|
+
keyIsUsable,
|
|
11
|
+
publicKeyFromHex,
|
|
12
|
+
} from "./verify.js";
|
|
2
13
|
|
|
3
14
|
export type KeyEntry = {
|
|
4
15
|
key_id: string;
|
|
@@ -12,6 +23,25 @@ export type KeyEntry = {
|
|
|
12
23
|
compromised_from?: string | null;
|
|
13
24
|
};
|
|
14
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The published key set as /.well-known/burnledger-keys serves it: the `keys`
|
|
28
|
+
* array, and BESIDE it — in the same object — the fields of a signed
|
|
29
|
+
* key_list.v3 statement when the server signs one (ADR-017 §5a). A body with no
|
|
30
|
+
* `signature` is the unsigned list the endpoint has always served; that is not
|
|
31
|
+
* an error, it is the state of the world before §5a is deployed.
|
|
32
|
+
*
|
|
33
|
+
* Mirrors core.KeyListDocument.
|
|
34
|
+
*/
|
|
35
|
+
export type KeyListDocument = {
|
|
36
|
+
keys: KeyEntry[];
|
|
37
|
+
statement_issued_at?: string;
|
|
38
|
+
statement_expires_at?: string;
|
|
39
|
+
sth_tree_size?: number;
|
|
40
|
+
sth_root_hash?: string;
|
|
41
|
+
signature?: string;
|
|
42
|
+
key_id?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
15
45
|
/**
|
|
16
46
|
* Normalize a parsed keys file to an array of key entries. Accepts a bare
|
|
17
47
|
* JSON array (the generated keys.json shape), a { "keys": [...] } envelope,
|
|
@@ -19,10 +49,153 @@ export type KeyEntry = {
|
|
|
19
49
|
* actually publishes. Throws on any other shape.
|
|
20
50
|
*/
|
|
21
51
|
export function parseKeyEntries(parsed: unknown): KeyEntry[] {
|
|
52
|
+
return parseKeyListDocument(parsed).keys;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read a published key set in any shape this project has ever served — a bare
|
|
57
|
+
* array, a {"keys": [...]} object, or either inside the API's {"data": ...}
|
|
58
|
+
* envelope — keeping the statement fields when the object forms carry them.
|
|
59
|
+
* Mirrors core.ParseKeyListDocument, so this CLI, the Go CLI and the browser
|
|
60
|
+
* cannot disagree about which files are key sets.
|
|
61
|
+
*/
|
|
62
|
+
export function parseKeyListDocument(parsed: unknown): KeyListDocument {
|
|
22
63
|
const root = (parsed as { data?: unknown })?.data ?? parsed;
|
|
23
|
-
|
|
64
|
+
if (Array.isArray(root)) return { keys: root as KeyEntry[] };
|
|
65
|
+
const entries = (root as { keys?: unknown })?.keys;
|
|
24
66
|
if (!Array.isArray(entries)) {
|
|
25
67
|
throw new Error('--keys file must be a JSON array of keys or a {"keys": [...]} object');
|
|
26
68
|
}
|
|
27
|
-
return
|
|
69
|
+
return root as KeyListDocument;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Verdicts about a key list. Mirrors core.KeyListResult, plus the one the CLI
|
|
73
|
+
* adds (SIGNING_KEY_NOT_IN_LIST) because core never reaches it: VerifyKeyList
|
|
74
|
+
* is told which key to trust, and this is the case where there is none to offer. */
|
|
75
|
+
export const KEY_LIST_VALID = "VALID";
|
|
76
|
+
export const KEY_LIST_MALFORMED = "MALFORMED_KEY_LIST";
|
|
77
|
+
export const KEY_LIST_SIGNING_KEY_UNUSABLE = "KEY_LIST_SIGNING_KEY_UNUSABLE";
|
|
78
|
+
export const KEY_LIST_INVALID_SIGNATURE = "INVALID_KEY_LIST_SIGNATURE";
|
|
79
|
+
export const KEY_LIST_STALE = "KEY_LIST_STALE";
|
|
80
|
+
export const KEY_LIST_SIGNING_KEY_ABSENT = "SIGNING_KEY_NOT_IN_LIST";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What a run learned about where the key statuses and dates came from.
|
|
84
|
+
* Mirrors keyListEvidence in cmd/cli/keys.go: it is not a pass/fail input to
|
|
85
|
+
* certificate verification, it qualifies one. An auditor reading
|
|
86
|
+
* KEY_COMPROMISED needs to know whether that word arrived as evidence they can
|
|
87
|
+
* keep or as JSON from an endpoint trusted for one TCP connection.
|
|
88
|
+
*/
|
|
89
|
+
export interface KeyListEvidence {
|
|
90
|
+
/** Whether the file carried a signed statement at all. */
|
|
91
|
+
present: boolean;
|
|
92
|
+
result?: string;
|
|
93
|
+
/** The trusted key was taken from the list itself — trust-on-first-use, not proof. */
|
|
94
|
+
selfSigned: boolean;
|
|
95
|
+
/** statement_expires_at, normalized to the form Go prints. */
|
|
96
|
+
expiresAt?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The whole-second RFC 3339 form every published key date uses
|
|
100
|
+
* (core.KeyTimeFormat). Entry dates are validated against it strictly, as
|
|
101
|
+
* core.KeyEntry.PublicKeyInfo does, so a date Go refuses is refused here. */
|
|
102
|
+
const KEY_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
|
103
|
+
|
|
104
|
+
function requireKeyTime(keyId: string, field: string, value: unknown): void {
|
|
105
|
+
if (typeof value !== "string" || !KEY_TIME_RE.test(value)) {
|
|
106
|
+
throw new Error(`key ${keyId}: ${field} is not YYYY-MM-DDTHH:MM:SSZ`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function requireHex(field: string, value: unknown, bytes: number): Uint8Array {
|
|
111
|
+
if (typeof value !== "string" || value.length !== bytes * 2 || !/^[0-9a-fA-F]+$/.test(value)) {
|
|
112
|
+
throw new Error(`key list: ${field} is not ${bytes} bytes of hex`);
|
|
113
|
+
}
|
|
114
|
+
return hexToBytes(value);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check a key-list statement the way cmd/cli/keys.go does, so four verifiers
|
|
119
|
+
* agree: the trusted key is looked up IN THE LIST ITSELF (trust-on-first-use,
|
|
120
|
+
* and selfSigned says so), then core.VerifyKeyList's order — the list parses
|
|
121
|
+
* and has no duplicate, the signing key is usable at `now` under ADR-017's
|
|
122
|
+
* table, the signature verifies over the published entries, and only then
|
|
123
|
+
* freshness. A stale verdict is never returned for a signature never tested.
|
|
124
|
+
*
|
|
125
|
+
* THROWS where the Go CLI exits 2: an entry that does not parse, a key_id that
|
|
126
|
+
* is not sha256(public_key), or a statement whose fields will not read. Someone
|
|
127
|
+
* signed something the reader cannot read, and continuing as though the file
|
|
128
|
+
* were merely unsigned would throw that fact away.
|
|
129
|
+
*/
|
|
130
|
+
export async function verifyKeyList(
|
|
131
|
+
crypto: CryptoOps,
|
|
132
|
+
doc: KeyListDocument,
|
|
133
|
+
now: Date,
|
|
134
|
+
): Promise<KeyListEvidence> {
|
|
135
|
+
if (doc.keys.length === 0) throw new Error("keys file contains no keys");
|
|
136
|
+
const signed = doc.signature !== undefined && doc.signature !== "";
|
|
137
|
+
const keys = new Map<string, Awaited<ReturnType<typeof publicKeyFromHex>>>();
|
|
138
|
+
let duplicate = false;
|
|
139
|
+
for (const e of doc.keys) {
|
|
140
|
+
if (typeof e.public_key !== "string") throw new Error(`key ${e.key_id}: public_key is not a string`);
|
|
141
|
+
const pki = await publicKeyFromHex(crypto, e.public_key, {
|
|
142
|
+
keyStatus: e.key_status,
|
|
143
|
+
notBefore: e.not_before,
|
|
144
|
+
notAfter: e.not_after ?? undefined,
|
|
145
|
+
compromisedFrom: e.compromised_from ?? undefined,
|
|
146
|
+
});
|
|
147
|
+
// The strict Go loader rules — key_id is sha256(public_key), dates are
|
|
148
|
+
// whole-second UTC — apply to a SIGNED list, whose statement signs the
|
|
149
|
+
// published strings. An unsigned list keeps this CLI's existing
|
|
150
|
+
// tolerance (an absent key_id is allowed, and the map is keyed on the
|
|
151
|
+
// derived id either way); tightening the unsigned path would refuse files
|
|
152
|
+
// it accepts today.
|
|
153
|
+
if (signed) {
|
|
154
|
+
if (e.key_id !== pki.keyId) {
|
|
155
|
+
throw new Error(`key_id ${e.key_id} does not match sha256(public_key) (${pki.keyId})`);
|
|
156
|
+
}
|
|
157
|
+
if (e.not_before != null && e.not_before !== "") requireKeyTime(e.key_id, "not_before", e.not_before);
|
|
158
|
+
if (e.not_after != null) requireKeyTime(e.key_id, "not_after", e.not_after);
|
|
159
|
+
if (e.compromised_from != null) requireKeyTime(e.key_id, "compromised_from", e.compromised_from);
|
|
160
|
+
}
|
|
161
|
+
if (keys.has(pki.keyId)) duplicate = true;
|
|
162
|
+
keys.set(pki.keyId, pki);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!signed) {
|
|
166
|
+
return { present: false, selfSigned: false };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The statement's own fields. Refused rather than defaulted: a zero
|
|
170
|
+
// statement_expires_at would read as "expired", which is an answer.
|
|
171
|
+
requireHex("sth_root_hash", doc.sth_root_hash, 32);
|
|
172
|
+
const signature = requireHex("signature", doc.signature, 64);
|
|
173
|
+
if (typeof doc.key_id !== "string" || doc.key_id === "") throw new Error("key list: key_id is missing");
|
|
174
|
+
if (typeof doc.sth_tree_size !== "number" || !Number.isInteger(doc.sth_tree_size) || doc.sth_tree_size < 0) {
|
|
175
|
+
throw new Error("key list: sth_tree_size is not a non-negative integer");
|
|
176
|
+
}
|
|
177
|
+
const issued = formatTimestamp(doc.statement_issued_at);
|
|
178
|
+
const expires = formatTimestamp(doc.statement_expires_at);
|
|
179
|
+
|
|
180
|
+
const evidence: KeyListEvidence = { present: true, selfSigned: false, expiresAt: expires };
|
|
181
|
+
const signingKey = keys.get(doc.key_id);
|
|
182
|
+
if (signingKey === undefined) {
|
|
183
|
+
return { ...evidence, result: KEY_LIST_SIGNING_KEY_ABSENT };
|
|
184
|
+
}
|
|
185
|
+
evidence.selfSigned = true;
|
|
186
|
+
|
|
187
|
+
if (duplicate) return { ...evidence, result: KEY_LIST_MALFORMED };
|
|
188
|
+
|
|
189
|
+
const at = formatTimestamp(now.toISOString());
|
|
190
|
+
if (!keyIsUsable(evaluateKey(signingKey, Math.floor(Date.parse(at) / 1000)))) {
|
|
191
|
+
return { ...evidence, result: KEY_LIST_SIGNING_KEY_UNUSABLE };
|
|
192
|
+
}
|
|
193
|
+
const payload = buildKeyListPayload(doc as unknown as Record<string, unknown>);
|
|
194
|
+
if (!(await crypto.ed25519Verify(signingKey.keyBytes, payload, signature))) {
|
|
195
|
+
return { ...evidence, result: KEY_LIST_INVALID_SIGNATURE };
|
|
196
|
+
}
|
|
197
|
+
if (at < issued || at >= expires) {
|
|
198
|
+
return { ...evidence, result: KEY_LIST_STALE };
|
|
199
|
+
}
|
|
200
|
+
return { ...evidence, result: KEY_LIST_VALID };
|
|
28
201
|
}
|
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,7 +243,34 @@ export interface BatchAttestationResponse {
|
|
|
233
243
|
}
|
|
234
244
|
|
|
235
245
|
export interface BatchAttestationError {
|
|
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;
|
|
252
|
+
readonly error: string;
|
|
236
253
|
readonly subjectIdentifier: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Batch revocation types
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
/** Result of POST /v1/certificates/batch-revoke. The server answers 200
|
|
261
|
+
* whether all, some or none of the ids were revoked; `errors` is the only
|
|
262
|
+
* signal of partial completion. The records in `certificates` never carry a
|
|
263
|
+
* `statusStatement` (revoking invalidates the cached one). */
|
|
264
|
+
export interface BatchRevokeResponse {
|
|
265
|
+
readonly certificates: readonly CertificateResponse[];
|
|
266
|
+
readonly errors: readonly BatchRevokeError[];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface BatchRevokeError {
|
|
270
|
+
/** Position in the submitted certificateIds list. */
|
|
271
|
+
readonly index: number;
|
|
272
|
+
readonly certificateId: string;
|
|
273
|
+
/** Code and message, e.g. "NOT_FOUND: certificate not found". */
|
|
237
274
|
readonly error: string;
|
|
238
275
|
}
|
|
239
276
|
|
|
@@ -266,6 +303,7 @@ export interface WebhookRotateResponse {
|
|
|
266
303
|
readonly id: string;
|
|
267
304
|
readonly url: string;
|
|
268
305
|
readonly newSecret: string;
|
|
306
|
+
readonly rotationExpiresAt: Date | undefined;
|
|
269
307
|
}
|
|
270
308
|
|
|
271
309
|
export interface WebhookDelivery {
|
|
@@ -274,6 +312,7 @@ export interface WebhookDelivery {
|
|
|
274
312
|
readonly eventType: string;
|
|
275
313
|
readonly errorMessage: string;
|
|
276
314
|
readonly attempts: number;
|
|
315
|
+
readonly nextAttemptAt: Date | undefined;
|
|
277
316
|
readonly createdAt: Date;
|
|
278
317
|
}
|
|
279
318
|
|
|
@@ -287,6 +326,8 @@ export interface ApiKeyListItem {
|
|
|
287
326
|
readonly id: string;
|
|
288
327
|
readonly prefix: string;
|
|
289
328
|
readonly role: ApiKeyRole;
|
|
329
|
+
/** The team the key is pinned to; undefined for an unpinned key. */
|
|
330
|
+
readonly teamId: string | undefined;
|
|
290
331
|
readonly createdAt: Date;
|
|
291
332
|
readonly revokedAt: Date | undefined;
|
|
292
333
|
}
|
|
@@ -295,6 +336,8 @@ export interface ApiKeyResponse {
|
|
|
295
336
|
readonly id: string;
|
|
296
337
|
readonly prefix: string;
|
|
297
338
|
readonly key: string;
|
|
339
|
+
/** The team the key is pinned to; undefined for an unpinned key. */
|
|
340
|
+
readonly teamId: string | undefined;
|
|
298
341
|
readonly createdAt: Date;
|
|
299
342
|
}
|
|
300
343
|
|
|
@@ -396,6 +439,7 @@ export function parseCertificateResponse(d: Raw): CertificateResponse {
|
|
|
396
439
|
revokedAt: parseDtOpt(d.revoked_at),
|
|
397
440
|
revocationReason: d.revocation_reason ?? undefined,
|
|
398
441
|
certificate: d.certificate,
|
|
442
|
+
statusStatement: d.status_statement ?? undefined,
|
|
399
443
|
};
|
|
400
444
|
}
|
|
401
445
|
|
|
@@ -455,8 +499,9 @@ export function parseConsistencyProof(d: Raw): ConsistencyProof {
|
|
|
455
499
|
|
|
456
500
|
function parseBatchAttestationError(d: Raw): BatchAttestationError {
|
|
457
501
|
return {
|
|
458
|
-
|
|
502
|
+
index: d.index,
|
|
459
503
|
error: d.error,
|
|
504
|
+
subjectIdentifier: d.subject_identifier ?? "",
|
|
460
505
|
};
|
|
461
506
|
}
|
|
462
507
|
|
|
@@ -467,6 +512,21 @@ export function parseBatchAttestationResponse(d: Raw): BatchAttestationResponse
|
|
|
467
512
|
};
|
|
468
513
|
}
|
|
469
514
|
|
|
515
|
+
function parseBatchRevokeError(d: Raw): BatchRevokeError {
|
|
516
|
+
return {
|
|
517
|
+
index: d.index,
|
|
518
|
+
certificateId: d.certificate_id,
|
|
519
|
+
error: d.error,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function parseBatchRevokeResponse(d: Raw): BatchRevokeResponse {
|
|
524
|
+
return {
|
|
525
|
+
certificates: (d.certificates ?? []).map(parseCertificateResponse),
|
|
526
|
+
errors: (d.errors ?? []).map(parseBatchRevokeError),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
470
530
|
function parseMonthlyCount(d: Raw): MonthlyCount {
|
|
471
531
|
return {
|
|
472
532
|
month: d.month,
|
|
@@ -492,6 +552,7 @@ export function parseWebhookRotateResponse(d: Raw): WebhookRotateResponse {
|
|
|
492
552
|
id: d.id,
|
|
493
553
|
url: d.url,
|
|
494
554
|
newSecret: d.new_secret,
|
|
555
|
+
rotationExpiresAt: parseDtOpt(d.rotation_expires_at),
|
|
495
556
|
};
|
|
496
557
|
}
|
|
497
558
|
|
|
@@ -502,6 +563,7 @@ export function parseWebhookDelivery(d: Raw): WebhookDelivery {
|
|
|
502
563
|
eventType: d.event_type,
|
|
503
564
|
errorMessage: d.error_message,
|
|
504
565
|
attempts: d.attempts,
|
|
566
|
+
nextAttemptAt: parseDtOpt(d.next_attempt_at),
|
|
505
567
|
createdAt: parseDt(d.created_at),
|
|
506
568
|
};
|
|
507
569
|
}
|
|
@@ -511,6 +573,7 @@ export function parseApiKeyListItem(d: Raw): ApiKeyListItem {
|
|
|
511
573
|
id: d.id,
|
|
512
574
|
prefix: d.prefix,
|
|
513
575
|
role: d.role as ApiKeyRole,
|
|
576
|
+
teamId: d.team_id ?? undefined,
|
|
514
577
|
createdAt: parseDt(d.created_at),
|
|
515
578
|
revokedAt: parseDtOpt(d.revoked_at),
|
|
516
579
|
};
|
|
@@ -521,6 +584,7 @@ export function parseApiKeyResponse(d: Raw): ApiKeyResponse {
|
|
|
521
584
|
id: d.id,
|
|
522
585
|
prefix: d.prefix,
|
|
523
586
|
key: d.key,
|
|
587
|
+
teamId: d.team_id ?? undefined,
|
|
524
588
|
createdAt: parseDt(d.created_at),
|
|
525
589
|
};
|
|
526
590
|
}
|
|
@@ -544,6 +608,7 @@ export function parseProfile(d: Raw): Profile {
|
|
|
544
608
|
id: d.id,
|
|
545
609
|
name: d.name,
|
|
546
610
|
email: d.email,
|
|
611
|
+
pendingEmail: d.pending_email ?? undefined,
|
|
547
612
|
plan: d.plan,
|
|
548
613
|
planStartedAt: d.plan_started_at ?? undefined,
|
|
549
614
|
createdAt: d.created_at,
|
|
@@ -554,8 +619,8 @@ export function parseProfile(d: Raw): Profile {
|
|
|
554
619
|
export function parseSystemHealth(d: Raw): SystemHealth {
|
|
555
620
|
return {
|
|
556
621
|
systemId: d.system_id,
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
622
|
+
healthStatus: (d.health_status ?? "UNKNOWN") as HealthStatus,
|
|
623
|
+
healthCheckedAt: parseDtOpt(d.health_checked_at),
|
|
624
|
+
healthError: d.health_error ?? undefined,
|
|
560
625
|
};
|
|
561
626
|
}
|