@learncard/sss-key-manager 0.1.24 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +25 -95
  2. package/dist/api-client.d.ts +44 -7
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/atomic-operations.d.ts +10 -2
  5. package/dist/atomic-operations.d.ts.map +1 -1
  6. package/dist/email-relay-crypto.d.ts +49 -0
  7. package/dist/email-relay-crypto.d.ts.map +1 -0
  8. package/dist/escrow-attestation.d.ts +12 -0
  9. package/dist/escrow-attestation.d.ts.map +1 -0
  10. package/dist/escrow-crypto.d.ts +52 -0
  11. package/dist/escrow-crypto.d.ts.map +1 -0
  12. package/dist/escrow-nitro-attestation.d.ts +32 -0
  13. package/dist/escrow-nitro-attestation.d.ts.map +1 -0
  14. package/dist/escrow-pin.d.ts +22 -0
  15. package/dist/escrow-pin.d.ts.map +1 -0
  16. package/dist/index.d.ts +15 -4
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/key-manager.d.ts +2 -0
  19. package/dist/key-manager.d.ts.map +1 -1
  20. package/dist/qr-login.d.ts.map +1 -1
  21. package/dist/recovery-phrase.d.ts +6 -0
  22. package/dist/recovery-phrase.d.ts.map +1 -1
  23. package/dist/sss-key-manager.cjs.development.js +5564 -353
  24. package/dist/sss-key-manager.cjs.development.js.map +4 -4
  25. package/dist/sss-key-manager.cjs.production.min.js +33 -33
  26. package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
  27. package/dist/sss-key-manager.esm.js +3494 -354
  28. package/dist/sss-key-manager.esm.js.map +4 -4
  29. package/dist/sss-strategy.d.ts +74 -4
  30. package/dist/sss-strategy.d.ts.map +1 -1
  31. package/dist/storage.d.ts +1 -0
  32. package/dist/storage.d.ts.map +1 -1
  33. package/dist/types.d.ts +98 -4
  34. package/dist/types.d.ts.map +1 -1
  35. package/package.json +7 -3
  36. package/src/__fixtures__/escrow-vectors-rust.json +82 -0
  37. package/src/__fixtures__/escrow-vectors.json +103 -0
  38. package/src/__fixtures__/nitro-attestation/bad-signature.cbor.b64 +1 -0
  39. package/src/__fixtures__/nitro-attestation/debug-zero-pcrs.cbor.b64 +1 -0
  40. package/src/__fixtures__/nitro-attestation/legacy-user-data.cbor.b64 +1 -0
  41. package/src/__fixtures__/nitro-attestation/manifest.json +60 -0
  42. package/src/__fixtures__/nitro-attestation/nonce-mismatch.cbor.b64 +1 -0
  43. package/src/__fixtures__/nitro-attestation/root.pem +12 -0
  44. package/src/__fixtures__/nitro-attestation/stale-timestamp.cbor.b64 +1 -0
  45. package/src/__fixtures__/nitro-attestation/tampered-previous-key-ids.cbor.b64 +1 -0
  46. package/src/__fixtures__/nitro-attestation/truncated-chain.cbor.b64 +1 -0
  47. package/src/__fixtures__/nitro-attestation/valid.cbor.b64 +1 -0
  48. package/src/__fixtures__/nitro-attestation/wrong-pcr0.cbor.b64 +1 -0
  49. package/src/__fixtures__/nitro-attestation/wrong-root.cbor.b64 +1 -0
  50. package/src/api-client.test.ts +122 -0
  51. package/src/api-client.ts +107 -116
  52. package/src/atomic-operations.test.ts +56 -16
  53. package/src/atomic-operations.ts +55 -19
  54. package/src/critical-paths.test.ts +51 -1
  55. package/src/email-relay-crypto.test.ts +87 -0
  56. package/src/email-relay-crypto.ts +293 -0
  57. package/src/escrow-attestation.test.ts +90 -0
  58. package/src/escrow-attestation.ts +125 -0
  59. package/src/escrow-crypto.test.ts +381 -0
  60. package/src/escrow-crypto.ts +319 -0
  61. package/src/escrow-nitro-attestation.test.ts +204 -0
  62. package/src/escrow-nitro-attestation.ts +286 -0
  63. package/src/escrow-pin.test.ts +119 -0
  64. package/src/escrow-pin.ts +83 -0
  65. package/src/escrow-vectors.test.ts +198 -0
  66. package/src/index.ts +70 -1
  67. package/src/key-manager.ts +4 -9
  68. package/src/qr-login.ts +22 -15
  69. package/src/recovery-phrase.ts +40 -0
  70. package/src/sss-session-writes.test.ts +109 -0
  71. package/src/sss-strategy.test.ts +2736 -111
  72. package/src/sss-strategy.ts +2226 -224
  73. package/src/storage.test.ts +66 -8
  74. package/src/storage.ts +41 -29
  75. package/src/types.ts +118 -6
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Browser-compatible encryption for recovery-key delivery.
3
+ *
4
+ * This is an HPKE-style construction using Web Crypto primitives:
5
+ * ephemeral P-256 ECDH, HKDF-SHA-256, and AES-256-GCM. The relay public key
6
+ * is pinned in tenant/runtime configuration; only the isolated relay has the
7
+ * matching PKCS#8 private key.
8
+ */
9
+
10
+ import { base64ToBuffer, bufferToBase64 } from './crypto';
11
+
12
+ export const EMAIL_RELAY_ENVELOPE_VERSION = 1 as const;
13
+ export const EMAIL_RELAY_ALGORITHM = 'P-256-HKDF-SHA256-AES-256-GCM' as const;
14
+
15
+ const encodeUtf8 = (value: string): Uint8Array<ArrayBuffer> => {
16
+ const bytes = new TextEncoder().encode(value);
17
+ const copy = new Uint8Array(new ArrayBuffer(bytes.byteLength));
18
+ copy.set(bytes);
19
+ return copy;
20
+ };
21
+
22
+ const EMAIL_RELAY_INFO = encodeUtf8('learncard-email-relay-v1');
23
+ const SIX_DIGIT_CODE_RANGE = 900_000;
24
+ const UINT32_RANGE = 0x1_0000_0000;
25
+ const MAX_UNBIASED_UINT32 = Math.floor(UINT32_RANGE / SIX_DIGIT_CODE_RANGE) * SIX_DIGIT_CODE_RANGE;
26
+
27
+ export interface EmailRelayBranding {
28
+ brandName?: string;
29
+ logoUrl?: string;
30
+ logoAlt?: string;
31
+ primaryColor?: string;
32
+ primaryTextColor?: string;
33
+ supportEmail?: string;
34
+ websiteUrl?: string;
35
+ appUrl?: string;
36
+ fromDomain?: string;
37
+ copyrightHolder?: string;
38
+ }
39
+
40
+ export interface EmailRelayPlaintext {
41
+ version: typeof EMAIL_RELAY_ENVELOPE_VERSION;
42
+ targetEmail: string;
43
+ recoveryKey: string;
44
+ confirmationCode: string;
45
+ branding?: EmailRelayBranding;
46
+ }
47
+
48
+ export interface EmailRelayEnvelope {
49
+ version: typeof EMAIL_RELAY_ENVELOPE_VERSION;
50
+ algorithm: typeof EMAIL_RELAY_ALGORITHM;
51
+ keyId: string;
52
+ ephemeralPublicKey: string;
53
+ salt: string;
54
+ iv: string;
55
+ ciphertext: string;
56
+ }
57
+
58
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
59
+ typeof value === 'object' && value !== null && !Array.isArray(value);
60
+
61
+ const requireString = (
62
+ record: Record<string, unknown>,
63
+ key: string,
64
+ options: { min?: number; max: number; pattern?: RegExp }
65
+ ): string => {
66
+ const value = record[key];
67
+
68
+ if (
69
+ typeof value !== 'string' ||
70
+ value.length < (options.min ?? 1) ||
71
+ value.length > options.max ||
72
+ (options.pattern && !options.pattern.test(value))
73
+ ) {
74
+ throw new Error(`Invalid email relay ${key}`);
75
+ }
76
+
77
+ return value;
78
+ };
79
+
80
+ const parseBranding = (value: unknown): EmailRelayBranding | undefined => {
81
+ if (value === undefined) return undefined;
82
+ if (!isRecord(value)) throw new Error('Invalid email relay branding');
83
+
84
+ const branding: EmailRelayBranding = {};
85
+ const keys = [
86
+ 'brandName',
87
+ 'logoUrl',
88
+ 'logoAlt',
89
+ 'primaryColor',
90
+ 'primaryTextColor',
91
+ 'supportEmail',
92
+ 'websiteUrl',
93
+ 'appUrl',
94
+ 'fromDomain',
95
+ 'copyrightHolder',
96
+ ] as const;
97
+
98
+ for (const key of keys) {
99
+ const field = value[key];
100
+
101
+ if (field === undefined) continue;
102
+ if (typeof field !== 'string' || field.length > 2_048) {
103
+ throw new Error(`Invalid email relay branding.${key}`);
104
+ }
105
+
106
+ branding[key] = field;
107
+ }
108
+
109
+ return branding;
110
+ };
111
+
112
+ /** Validate an untrusted encrypted envelope before attempting decryption. */
113
+ export const parseEmailRelayEnvelope = (value: unknown): EmailRelayEnvelope => {
114
+ if (!isRecord(value)) throw new Error('Invalid email relay envelope');
115
+ if (value.version !== EMAIL_RELAY_ENVELOPE_VERSION) {
116
+ throw new Error('Unsupported email relay envelope version');
117
+ }
118
+ if (value.algorithm !== EMAIL_RELAY_ALGORITHM) {
119
+ throw new Error('Unsupported email relay algorithm');
120
+ }
121
+
122
+ return {
123
+ version: EMAIL_RELAY_ENVELOPE_VERSION,
124
+ algorithm: EMAIL_RELAY_ALGORITHM,
125
+ keyId: requireString(value, 'keyId', {
126
+ max: 128,
127
+ pattern: /^[A-Za-z0-9._-]+$/,
128
+ }),
129
+ ephemeralPublicKey: requireString(value, 'ephemeralPublicKey', { max: 256 }),
130
+ salt: requireString(value, 'salt', { max: 128 }),
131
+ iv: requireString(value, 'iv', { max: 64 }),
132
+ ciphertext: requireString(value, 'ciphertext', { max: 16_384 }),
133
+ };
134
+ };
135
+
136
+ /** Validate decrypted relay plaintext and copy only recognized fields. */
137
+ export const parseEmailRelayPlaintext = (value: unknown): EmailRelayPlaintext => {
138
+ if (!isRecord(value) || value.version !== EMAIL_RELAY_ENVELOPE_VERSION) {
139
+ throw new Error('Invalid email relay plaintext');
140
+ }
141
+
142
+ return {
143
+ version: EMAIL_RELAY_ENVELOPE_VERSION,
144
+ targetEmail: requireString(value, 'targetEmail', {
145
+ max: 320,
146
+ pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
147
+ })
148
+ .trim()
149
+ .toLowerCase(),
150
+ recoveryKey: requireString(value, 'recoveryKey', {
151
+ min: 5,
152
+ max: 4_096,
153
+ pattern: /^[0-9a-f]+$/i,
154
+ }),
155
+ confirmationCode: requireString(value, 'confirmationCode', {
156
+ max: 6,
157
+ pattern: /^\d{6}$/,
158
+ }),
159
+ branding: parseBranding(value.branding),
160
+ };
161
+ };
162
+
163
+ const getAad = (
164
+ envelope: Pick<EmailRelayEnvelope, 'version' | 'algorithm' | 'keyId'>
165
+ ): Uint8Array<ArrayBuffer> =>
166
+ encodeUtf8(`${envelope.version}|${envelope.algorithm}|${envelope.keyId}`);
167
+
168
+ const deriveAesKey = async (
169
+ sharedSecret: ArrayBuffer,
170
+ salt: Uint8Array<ArrayBuffer>,
171
+ usage: KeyUsage
172
+ ): Promise<CryptoKey> => {
173
+ const keyMaterial = await crypto.subtle.importKey('raw', sharedSecret, 'HKDF', false, [
174
+ 'deriveKey',
175
+ ]);
176
+
177
+ return crypto.subtle.deriveKey(
178
+ { name: 'HKDF', hash: 'SHA-256', salt, info: EMAIL_RELAY_INFO },
179
+ keyMaterial,
180
+ { name: 'AES-GCM', length: 256 },
181
+ false,
182
+ [usage]
183
+ );
184
+ };
185
+
186
+ /** Generate a uniformly distributed six-digit confirmation code in the browser. */
187
+ export const generateEmailRelayConfirmationCode = (): string => {
188
+ const random = new Uint32Array(1);
189
+ let value: number;
190
+
191
+ do {
192
+ crypto.getRandomValues(random);
193
+ value = random[0] ?? UINT32_RANGE;
194
+ } while (value >= MAX_UNBIASED_UINT32);
195
+
196
+ return String(100_000 + (value % SIX_DIGIT_CODE_RANGE));
197
+ };
198
+
199
+ /** Encrypt a complete recovery-email payload to a pinned relay SPKI public key. */
200
+ export const encryptEmailRelayPayload = async (
201
+ input: Omit<EmailRelayPlaintext, 'version'>,
202
+ publicKeyBase64: string,
203
+ keyId: string
204
+ ): Promise<EmailRelayEnvelope> => {
205
+ const plaintext = parseEmailRelayPlaintext({
206
+ ...input,
207
+ version: EMAIL_RELAY_ENVELOPE_VERSION,
208
+ });
209
+ const normalizedKeyId = keyId.trim();
210
+
211
+ if (!publicKeyBase64.trim()) throw new Error('Email relay public key is not configured');
212
+ if (!/^[A-Za-z0-9._-]{1,128}$/.test(normalizedKeyId)) {
213
+ throw new Error('Email relay key ID is not configured');
214
+ }
215
+
216
+ const recipientPublicKey = await crypto.subtle.importKey(
217
+ 'spki',
218
+ base64ToBuffer(publicKeyBase64.trim()).buffer as ArrayBuffer,
219
+ { name: 'ECDH', namedCurve: 'P-256' },
220
+ false,
221
+ []
222
+ );
223
+ const ephemeralKeyPair = await crypto.subtle.generateKey(
224
+ { name: 'ECDH', namedCurve: 'P-256' },
225
+ true,
226
+ ['deriveBits']
227
+ );
228
+ const sharedSecret = await crypto.subtle.deriveBits(
229
+ { name: 'ECDH', public: recipientPublicKey },
230
+ ephemeralKeyPair.privateKey,
231
+ 256
232
+ );
233
+ const salt = crypto.getRandomValues(new Uint8Array(32));
234
+ const iv = crypto.getRandomValues(new Uint8Array(12));
235
+ const aesKey = await deriveAesKey(sharedSecret, salt, 'encrypt');
236
+ const envelopeMetadata = {
237
+ version: EMAIL_RELAY_ENVELOPE_VERSION,
238
+ algorithm: EMAIL_RELAY_ALGORITHM,
239
+ keyId: normalizedKeyId,
240
+ };
241
+ const ciphertext = await crypto.subtle.encrypt(
242
+ { name: 'AES-GCM', iv, additionalData: getAad(envelopeMetadata) },
243
+ aesKey,
244
+ encodeUtf8(JSON.stringify(plaintext))
245
+ );
246
+ const ephemeralPublicKey = await crypto.subtle.exportKey('raw', ephemeralKeyPair.publicKey);
247
+
248
+ return {
249
+ ...envelopeMetadata,
250
+ ephemeralPublicKey: bufferToBase64(ephemeralPublicKey),
251
+ salt: bufferToBase64(salt.buffer),
252
+ iv: bufferToBase64(iv.buffer),
253
+ ciphertext: bufferToBase64(ciphertext),
254
+ };
255
+ };
256
+
257
+ /** Decrypt an envelope using the relay's PKCS#8 P-256 private key. */
258
+ export const decryptEmailRelayPayload = async (
259
+ value: unknown,
260
+ privateKeyBase64: string
261
+ ): Promise<EmailRelayPlaintext> => {
262
+ const envelope = parseEmailRelayEnvelope(value);
263
+ const privateKey = await crypto.subtle.importKey(
264
+ 'pkcs8',
265
+ base64ToBuffer(privateKeyBase64.trim()).buffer as ArrayBuffer,
266
+ { name: 'ECDH', namedCurve: 'P-256' },
267
+ false,
268
+ ['deriveBits']
269
+ );
270
+ const ephemeralPublicKey = await crypto.subtle.importKey(
271
+ 'raw',
272
+ base64ToBuffer(envelope.ephemeralPublicKey).buffer as ArrayBuffer,
273
+ { name: 'ECDH', namedCurve: 'P-256' },
274
+ false,
275
+ []
276
+ );
277
+ const sharedSecret = await crypto.subtle.deriveBits(
278
+ { name: 'ECDH', public: ephemeralPublicKey },
279
+ privateKey,
280
+ 256
281
+ );
282
+ const salt = base64ToBuffer(envelope.salt);
283
+ const iv = base64ToBuffer(envelope.iv);
284
+ const aesKey = await deriveAesKey(sharedSecret, salt, 'decrypt');
285
+ const decrypted = await crypto.subtle.decrypt(
286
+ { name: 'AES-GCM', iv, additionalData: getAad(envelope) },
287
+ aesKey,
288
+ base64ToBuffer(envelope.ciphertext)
289
+ );
290
+ const parsed: unknown = JSON.parse(new TextDecoder().decode(decrypted));
291
+
292
+ return parseEmailRelayPlaintext(parsed);
293
+ };
@@ -0,0 +1,90 @@
1
+ import { beforeAll, describe, expect, it } from 'vitest';
2
+ import { generateEscrowKeyPair } from './escrow-crypto';
3
+ import { verifyEnclaveAttestation } from './escrow-attestation';
4
+
5
+ describe('verifyEnclaveAttestation', () => {
6
+ let publicKey: string;
7
+ beforeAll(async () => {
8
+ publicKey = (await generateEscrowKeyPair()).publicKey;
9
+ });
10
+ const attestation = () => ({
11
+ mode: 'software',
12
+ keyId: 'test-key',
13
+ publicKey,
14
+ measurements: {},
15
+ document: 'e30=',
16
+ issuedAt: new Date().toISOString(),
17
+ });
18
+
19
+ it('accepts a pinned software key with surrounding whitespace', async () => {
20
+ await expect(
21
+ verifyEnclaveAttestation(
22
+ { ...attestation(), publicKey: ` ${publicKey}\n` },
23
+ { mode: 'software', pinnedPublicKeys: [`\n${publicKey} `] }
24
+ )
25
+ ).resolves.toEqual({
26
+ mode: 'software',
27
+ keyId: 'test-key',
28
+ previousKeyIds: [],
29
+ publicKey,
30
+ });
31
+ });
32
+ it('rejects an unknown key', async () => {
33
+ await expect(
34
+ verifyEnclaveAttestation(attestation(), {
35
+ mode: 'software',
36
+ pinnedPublicKeys: [(await generateEscrowKeyPair()).publicKey],
37
+ })
38
+ ).rejects.toThrow('not trusted');
39
+ });
40
+ it('rejects nitro under software policy', async () => {
41
+ await expect(
42
+ verifyEnclaveAttestation(
43
+ { ...attestation(), mode: 'nitro' },
44
+ { mode: 'software', pinnedPublicKeys: [publicKey] }
45
+ )
46
+ ).rejects.toThrow('mode mismatch');
47
+ });
48
+ it.each(['software', 'nitro'])(
49
+ 'fails closed under nitro policy (%s attestation)',
50
+ async mode => {
51
+ await expect(
52
+ verifyEnclaveAttestation(
53
+ { ...attestation(), mode },
54
+ { mode: 'nitro', pinnedMeasurements: [] }
55
+ )
56
+ ).rejects.toThrow(mode === 'software' ? 'mode mismatch' : 'nonce');
57
+ }
58
+ );
59
+ it.each([
60
+ { publicKey: 'e30=' },
61
+ { publicKey: 'not-base64' },
62
+ { keyId: 'invalid/key' },
63
+ { mode: 'unknown' },
64
+ { measurements: [] },
65
+ { measurements: { unknown: 'value' } },
66
+ { document: 3 },
67
+ { issuedAt: 'not-a-date' },
68
+ { extra: true },
69
+ ])('rejects malformed attestation %j', async overrides => {
70
+ await expect(
71
+ verifyEnclaveAttestation(
72
+ { ...attestation(), ...overrides },
73
+ { mode: 'software', pinnedPublicKeys: [publicKey] }
74
+ )
75
+ ).rejects.toThrow();
76
+ });
77
+ it('rejects SPKI on another curve', async () => {
78
+ const pair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-384' }, true, [
79
+ 'deriveBits',
80
+ ]);
81
+ const spki = await crypto.subtle.exportKey('spki', pair.publicKey);
82
+ const key = btoa(String.fromCharCode(...new Uint8Array(spki)));
83
+ await expect(
84
+ verifyEnclaveAttestation(
85
+ { ...attestation(), publicKey: key },
86
+ { mode: 'software', pinnedPublicKeys: [key] }
87
+ )
88
+ ).rejects.toThrow();
89
+ });
90
+ });
@@ -0,0 +1,125 @@
1
+ import { base64ToBuffer, bufferToBase64 } from './crypto';
2
+ import type { EscrowAttestationPolicy } from './types';
3
+ import { NitroAttestationError, verifyNitroAttestationDocument } from './escrow-nitro-attestation';
4
+
5
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
6
+ typeof value === 'object' && value !== null && !Array.isArray(value);
7
+
8
+ const normalizePublicKey = (value: string): string => {
9
+ const trimmed = value.trim();
10
+ if (
11
+ !trimmed ||
12
+ trimmed.length > 512 ||
13
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(trimmed)
14
+ ) {
15
+ throw new Error('Invalid escrow attestation public key');
16
+ }
17
+ return bufferToBase64(base64ToBuffer(trimmed).buffer);
18
+ };
19
+
20
+ /** Parse untrusted attestation data and enforce an explicit enclave trust policy. */
21
+ export const verifyEnclaveAttestation = async (
22
+ attestation: unknown,
23
+ policy: EscrowAttestationPolicy,
24
+ nonce?: Uint8Array
25
+ ): Promise<{
26
+ publicKey: string;
27
+ keyId: string;
28
+ /** P9.1: retired keyIds still accepted for decrypt. In nitro mode this is
29
+ * the value bound inside the signed attestation document, cross-checked
30
+ * against the plaintext field below — never the plaintext alone. */
31
+ previousKeyIds: string[];
32
+ mode: 'software' | 'nitro';
33
+ }> => {
34
+ const isKeyId = (value: unknown): value is string =>
35
+ typeof value === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(value);
36
+ const fields = [
37
+ 'mode',
38
+ 'keyId',
39
+ 'previousKeyIds',
40
+ 'publicKey',
41
+ 'measurements',
42
+ 'document',
43
+ 'issuedAt',
44
+ ];
45
+ if (
46
+ !isRecord(attestation) ||
47
+ Object.keys(attestation).some(key => !fields.includes(key)) ||
48
+ (attestation.mode !== 'software' && attestation.mode !== 'nitro') ||
49
+ !isKeyId(attestation.keyId) ||
50
+ (attestation.previousKeyIds !== undefined &&
51
+ (!Array.isArray(attestation.previousKeyIds) ||
52
+ attestation.previousKeyIds.length > 3 ||
53
+ !attestation.previousKeyIds.every(isKeyId))) ||
54
+ typeof attestation.publicKey !== 'string' ||
55
+ !isRecord(attestation.measurements) ||
56
+ Object.entries(attestation.measurements).some(
57
+ ([key, value]) =>
58
+ !['imageSha384', 'pcr0', 'pcr1', 'pcr2'].includes(key) ||
59
+ typeof value !== 'string' ||
60
+ !value ||
61
+ value.length > 1024
62
+ ) ||
63
+ typeof attestation.document !== 'string' ||
64
+ !attestation.document ||
65
+ attestation.document.length > 65536 ||
66
+ typeof attestation.issuedAt !== 'string' ||
67
+ !Number.isFinite(Date.parse(attestation.issuedAt))
68
+ ) {
69
+ throw new Error('Invalid escrow attestation');
70
+ }
71
+ const plaintextPreviousKeyIds = attestation.previousKeyIds ?? [];
72
+ if (attestation.mode !== policy.mode) throw new Error('Escrow attestation mode mismatch');
73
+ if (policy.mode === 'nitro') {
74
+ if (!nonce) throw new NitroAttestationError('nonce');
75
+ const verified = await verifyNitroAttestationDocument(attestation.document, {
76
+ expectedNonce: nonce,
77
+ policy,
78
+ });
79
+ try {
80
+ if (normalizePublicKey(attestation.publicKey) !== verified.escrowPublicKeySpkiB64) {
81
+ throw new NitroAttestationError('user-data');
82
+ }
83
+ } catch {
84
+ throw new NitroAttestationError('user-data');
85
+ }
86
+ // The wire JSON's `keyId` — the value a client actually seals new
87
+ // envelopes to — must match what is bound inside the signed document
88
+ // whenever the document carries one (legacy user_data never did, so
89
+ // there is nothing to cross-check there; keep today's behaviour).
90
+ if (verified.keyId !== undefined && verified.keyId !== attestation.keyId) {
91
+ throw new NitroAttestationError('user-data');
92
+ }
93
+ // Likewise the wire JSON's `previousKeyIds` must exactly match what is
94
+ // bound inside the signed document — never trust the plaintext value alone.
95
+ if (
96
+ plaintextPreviousKeyIds.length !== verified.previousKeyIds.length ||
97
+ !plaintextPreviousKeyIds.every((id, index) => id === verified.previousKeyIds[index])
98
+ ) {
99
+ throw new NitroAttestationError('user-data');
100
+ }
101
+ return {
102
+ publicKey: verified.escrowPublicKeySpkiB64,
103
+ keyId: attestation.keyId,
104
+ previousKeyIds: verified.previousKeyIds,
105
+ mode: 'nitro',
106
+ };
107
+ }
108
+ const publicKey = normalizePublicKey(attestation.publicKey);
109
+ await crypto.subtle.importKey(
110
+ 'spki',
111
+ base64ToBuffer(publicKey),
112
+ { name: 'ECDH', namedCurve: 'P-256' },
113
+ false,
114
+ []
115
+ );
116
+ if (!policy.pinnedPublicKeys.some(pin => normalizePublicKey(pin) === publicKey)) {
117
+ throw new Error('Escrow attestation public key is not trusted');
118
+ }
119
+ return {
120
+ publicKey,
121
+ keyId: attestation.keyId,
122
+ previousKeyIds: plaintextPreviousKeyIds,
123
+ mode: attestation.mode,
124
+ };
125
+ };