burnledger 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +11 -4
  2. package/dist/cjs/anchor.d.ts +71 -0
  3. package/dist/cjs/anchor.d.ts.map +1 -0
  4. package/dist/cjs/anchor.js +279 -0
  5. package/dist/cjs/anchor.js.map +1 -0
  6. package/dist/cjs/client.d.ts +26 -2
  7. package/dist/cjs/client.d.ts.map +1 -1
  8. package/dist/cjs/client.js +27 -5
  9. package/dist/cjs/client.js.map +1 -1
  10. package/dist/cjs/enclave-seal.d.ts +101 -0
  11. package/dist/cjs/enclave-seal.d.ts.map +1 -0
  12. package/dist/cjs/enclave-seal.js +479 -0
  13. package/dist/cjs/enclave-seal.js.map +1 -0
  14. package/dist/cjs/errors.d.ts +42 -1
  15. package/dist/cjs/errors.d.ts.map +1 -1
  16. package/dist/cjs/errors.js +44 -2
  17. package/dist/cjs/errors.js.map +1 -1
  18. package/dist/cjs/http.d.ts +21 -1
  19. package/dist/cjs/http.d.ts.map +1 -1
  20. package/dist/cjs/http.js +76 -12
  21. package/dist/cjs/http.js.map +1 -1
  22. package/dist/cjs/index.browser.d.ts +2 -4
  23. package/dist/cjs/index.browser.d.ts.map +1 -1
  24. package/dist/cjs/index.browser.js.map +1 -1
  25. package/dist/cjs/index.d.ts +16 -11
  26. package/dist/cjs/index.d.ts.map +1 -1
  27. package/dist/cjs/index.js +20 -1
  28. package/dist/cjs/index.js.map +1 -1
  29. package/dist/cjs/keys.d.ts +66 -0
  30. package/dist/cjs/keys.d.ts.map +1 -1
  31. package/dist/cjs/keys.js +128 -3
  32. package/dist/cjs/keys.js.map +1 -1
  33. package/dist/cjs/models.d.ts +41 -3
  34. package/dist/cjs/models.d.ts.map +1 -1
  35. package/dist/cjs/models.js +25 -4
  36. package/dist/cjs/models.js.map +1 -1
  37. package/dist/cjs/verify.d.ts +72 -6
  38. package/dist/cjs/verify.d.ts.map +1 -1
  39. package/dist/cjs/verify.js +392 -62
  40. package/dist/cjs/verify.js.map +1 -1
  41. package/dist/cjs/web-verifier.d.ts +54 -5
  42. package/dist/cjs/web-verifier.d.ts.map +1 -1
  43. package/dist/cjs/web-verifier.js +36 -3
  44. package/dist/cjs/web-verifier.js.map +1 -1
  45. package/dist/esm/anchor.d.ts +71 -0
  46. package/dist/esm/anchor.d.ts.map +1 -0
  47. package/dist/esm/anchor.js +275 -0
  48. package/dist/esm/anchor.js.map +1 -0
  49. package/dist/esm/cli.d.ts +45 -14
  50. package/dist/esm/cli.d.ts.map +1 -1
  51. package/dist/esm/cli.js +357 -50
  52. package/dist/esm/cli.js.map +1 -1
  53. package/dist/esm/client.d.ts +26 -2
  54. package/dist/esm/client.d.ts.map +1 -1
  55. package/dist/esm/client.js +27 -5
  56. package/dist/esm/client.js.map +1 -1
  57. package/dist/esm/enclave-seal.d.ts +101 -0
  58. package/dist/esm/enclave-seal.d.ts.map +1 -0
  59. package/dist/esm/enclave-seal.js +472 -0
  60. package/dist/esm/enclave-seal.js.map +1 -0
  61. package/dist/esm/errors.d.ts +42 -1
  62. package/dist/esm/errors.d.ts.map +1 -1
  63. package/dist/esm/errors.js +43 -1
  64. package/dist/esm/errors.js.map +1 -1
  65. package/dist/esm/http.d.ts +21 -1
  66. package/dist/esm/http.d.ts.map +1 -1
  67. package/dist/esm/http.js +75 -12
  68. package/dist/esm/http.js.map +1 -1
  69. package/dist/esm/index.browser.d.ts +2 -4
  70. package/dist/esm/index.browser.d.ts.map +1 -1
  71. package/dist/esm/index.browser.js.map +1 -1
  72. package/dist/esm/index.d.ts +16 -11
  73. package/dist/esm/index.d.ts.map +1 -1
  74. package/dist/esm/index.js +11 -2
  75. package/dist/esm/index.js.map +1 -1
  76. package/dist/esm/keys.d.ts +66 -0
  77. package/dist/esm/keys.d.ts.map +1 -1
  78. package/dist/esm/keys.js +125 -3
  79. package/dist/esm/keys.js.map +1 -1
  80. package/dist/esm/models.d.ts +41 -3
  81. package/dist/esm/models.d.ts.map +1 -1
  82. package/dist/esm/models.js +24 -4
  83. package/dist/esm/models.js.map +1 -1
  84. package/dist/esm/verify.d.ts +72 -6
  85. package/dist/esm/verify.d.ts.map +1 -1
  86. package/dist/esm/verify.js +390 -63
  87. package/dist/esm/verify.js.map +1 -1
  88. package/dist/esm/web-verifier.d.ts +54 -5
  89. package/dist/esm/web-verifier.d.ts.map +1 -1
  90. package/dist/esm/web-verifier.js +40 -3
  91. package/dist/esm/web-verifier.js.map +1 -1
  92. package/package.json +1 -1
  93. package/src/anchor.ts +330 -0
  94. package/src/cli.ts +389 -54
  95. package/src/client.ts +46 -4
  96. package/src/enclave-seal.ts +582 -0
  97. package/src/errors.ts +47 -2
  98. package/src/http.ts +79 -12
  99. package/src/index.browser.ts +2 -2
  100. package/src/index.ts +28 -9
  101. package/src/keys.ts +176 -3
  102. package/src/models.ts +72 -7
  103. package/src/verify.ts +467 -72
  104. package/src/web-verifier.ts +58 -5
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, Retry-After respect on 429, and raw byte streaming for PDFs.
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
- // Backoff with ±25% jitter so coordinated 5xx events don't produce
87
- // a synchronized retry storm from every client at the same instant.
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
- const retryAfter = parseRetryAfter(resp);
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
- function parseRetryAfter(resp: Response): number | undefined {
176
- const raw = resp.headers.get("Retry-After");
177
- if (raw == null) return undefined;
178
- const n = Number(raw);
179
- return Number.isFinite(n) ? n : undefined;
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>> {
@@ -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?: { revoked?: boolean },
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 (and unit-testable in isolation).
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
- const entries = Array.isArray(root) ? root : (root as { keys?: unknown })?.keys;
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 entries as KeyEntry[];
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 status: HealthStatus;
222
- readonly lastCheckAt: string | undefined;
223
- readonly error: string | undefined;
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
- subjectIdentifier: d.subject_identifier,
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
- status: d.status as HealthStatus,
558
- lastCheckAt: d.last_check_at ?? undefined,
559
- error: d.error ?? undefined,
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
  }