@learncard/sss-key-manager 0.1.23 → 0.2.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 (56) hide show
  1. package/README.md +12 -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 +8 -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-pin.d.ts +22 -0
  13. package/dist/escrow-pin.d.ts.map +1 -0
  14. package/dist/index.d.ts +13 -4
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/key-manager.d.ts +2 -0
  17. package/dist/key-manager.d.ts.map +1 -1
  18. package/dist/qr-login.d.ts.map +1 -1
  19. package/dist/recovery-phrase.d.ts +6 -0
  20. package/dist/recovery-phrase.d.ts.map +1 -1
  21. package/dist/sss-key-manager.cjs.development.js +3163 -440
  22. package/dist/sss-key-manager.cjs.development.js.map +4 -4
  23. package/dist/sss-key-manager.cjs.production.min.js +33 -33
  24. package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
  25. package/dist/sss-key-manager.esm.js +3163 -440
  26. package/dist/sss-key-manager.esm.js.map +4 -4
  27. package/dist/sss-strategy.d.ts +74 -4
  28. package/dist/sss-strategy.d.ts.map +1 -1
  29. package/dist/storage.d.ts +1 -0
  30. package/dist/storage.d.ts.map +1 -1
  31. package/dist/types.d.ts +87 -4
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +2 -2
  34. package/src/api-client.test.ts +122 -0
  35. package/src/api-client.ts +107 -116
  36. package/src/atomic-operations.test.ts +56 -16
  37. package/src/atomic-operations.ts +55 -19
  38. package/src/critical-paths.test.ts +51 -1
  39. package/src/email-relay-crypto.test.ts +87 -0
  40. package/src/email-relay-crypto.ts +293 -0
  41. package/src/escrow-attestation.test.ts +85 -0
  42. package/src/escrow-attestation.ts +64 -0
  43. package/src/escrow-crypto.test.ts +381 -0
  44. package/src/escrow-crypto.ts +319 -0
  45. package/src/escrow-pin.test.ts +119 -0
  46. package/src/escrow-pin.ts +83 -0
  47. package/src/index.ts +63 -1
  48. package/src/key-manager.ts +4 -9
  49. package/src/qr-login.ts +22 -15
  50. package/src/recovery-phrase.ts +40 -0
  51. package/src/sss-session-writes.test.ts +109 -0
  52. package/src/sss-strategy.test.ts +2642 -110
  53. package/src/sss-strategy.ts +2186 -224
  54. package/src/storage.test.ts +66 -8
  55. package/src/storage.ts +41 -29
  56. package/src/types.ts +107 -6
@@ -9,7 +9,9 @@
9
9
  * Run these tests before any release that touches SSS code.
10
10
  */
11
11
 
12
- import { describe, it, expect } from 'vitest';
12
+ import 'fake-indexeddb/auto';
13
+ import { describe, it, expect, vi } from 'vitest';
14
+ import { createSSSStrategy } from './sss-strategy';
13
15
 
14
16
  import { splitPrivateKey, reconstructFromShares, SSS_THRESHOLD } from './sss';
15
17
  import { generateEd25519PrivateKey, encryptWithPassword, decryptWithPassword } from './crypto';
@@ -20,6 +22,54 @@ import {
20
22
  } from './recovery-phrase';
21
23
 
22
24
  describe('Critical: Key must NEVER be lost', () => {
25
+ it('recovers a fresh account from encrypted IndexedDB after the auth-share reply is lost', async () => {
26
+ const config = { serverUrl: 'https://test.invalid/api' };
27
+ const strategy = createSSSStrategy(config);
28
+ strategy.setActiveUser!('indexeddb-lost-response');
29
+ const privateKey = await generateEd25519PrivateKey();
30
+ const expectedDid = 'did:key:test-owner';
31
+ let authShare = '';
32
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
33
+ if (init?.method === 'PUT') {
34
+ authShare = JSON.parse(String(init.body)).authShare.encryptedData;
35
+ throw new TypeError('Lost reply');
36
+ }
37
+ return new Response(
38
+ JSON.stringify({ authShare, shareVersion: 1, primaryDid: expectedDid })
39
+ );
40
+ });
41
+
42
+ try {
43
+ await expect(
44
+ strategy.atomicUpdateShares!({
45
+ token: 'token',
46
+ providerType: 'firebase',
47
+ privateKey,
48
+ did: expectedDid,
49
+ })
50
+ ).rejects.toMatchObject({ rolledBack: false });
51
+
52
+ const reloaded = createSSSStrategy(config);
53
+ reloaded.setActiveUser!('indexeddb-lost-response');
54
+ expect(await reloaded.hasLocalKey()).toBe(true);
55
+ const result = await reloaded.reconcileShares!({
56
+ token: 'token',
57
+ providerType: 'firebase',
58
+ expectedDid,
59
+ didFromPrivateKey: async key =>
60
+ key === privateKey ? expectedDid : 'did:key:wrong',
61
+ });
62
+ expect(result).toEqual({ privateKey, did: expectedDid });
63
+ expect(await reloaded.getLocalShareVersion!()).toBe(1);
64
+ expect(await reconstructFromShares([(await reloaded.getLocalKey())!, authShare])).toBe(
65
+ privateKey
66
+ );
67
+ } finally {
68
+ fetchMock.mockRestore();
69
+ await strategy.clearLocalKeys();
70
+ }
71
+ });
72
+
23
73
  describe('Share split verification (fuzz test)', () => {
24
74
  it('should verify all 6 share combinations reconstruct the key (100 iterations)', async () => {
25
75
  for (let i = 0; i < 100; i++) {
@@ -0,0 +1,87 @@
1
+ import { beforeAll, describe, expect, it } from 'vitest';
2
+
3
+ import { bufferToBase64 } from './crypto';
4
+ import {
5
+ decryptEmailRelayPayload,
6
+ encryptEmailRelayPayload,
7
+ generateEmailRelayConfirmationCode,
8
+ } from './email-relay-crypto';
9
+
10
+ describe('email relay encryption', () => {
11
+ let publicKeyBase64 = '';
12
+ let privateKeyBase64 = '';
13
+
14
+ beforeAll(async () => {
15
+ const keyPair = await crypto.subtle.generateKey(
16
+ { name: 'ECDH', namedCurve: 'P-256' },
17
+ true,
18
+ ['deriveBits']
19
+ );
20
+ const [publicKey, privateKey] = await Promise.all([
21
+ crypto.subtle.exportKey('spki', keyPair.publicKey),
22
+ crypto.subtle.exportKey('pkcs8', keyPair.privateKey),
23
+ ]);
24
+
25
+ publicKeyBase64 = bufferToBase64(publicKey);
26
+ privateKeyBase64 = bufferToBase64(privateKey);
27
+ });
28
+
29
+ it('sends no raw recovery key and round-trips with the relay private key', async () => {
30
+ const recoveryKey = `0001${'ab'.repeat(48)}`;
31
+ const confirmationCode = '483920';
32
+ const envelope = await encryptEmailRelayPayload(
33
+ {
34
+ targetEmail: 'Recovery@Example.com',
35
+ recoveryKey,
36
+ confirmationCode,
37
+ branding: { brandName: 'Test Tenant', fromDomain: 'example.com' },
38
+ },
39
+ publicKeyBase64,
40
+ 'test-key-1'
41
+ );
42
+
43
+ expect(JSON.stringify(envelope)).not.toContain(recoveryKey);
44
+ expect(JSON.stringify(envelope)).not.toContain(confirmationCode);
45
+ await expect(decryptEmailRelayPayload(envelope, privateKeyBase64)).resolves.toEqual({
46
+ version: 1,
47
+ targetEmail: 'recovery@example.com',
48
+ recoveryKey,
49
+ confirmationCode,
50
+ branding: { brandName: 'Test Tenant', fromDomain: 'example.com' },
51
+ });
52
+ });
53
+
54
+ it('authenticates envelope metadata and ciphertext', async () => {
55
+ const envelope = await encryptEmailRelayPayload(
56
+ {
57
+ targetEmail: 'recovery@example.com',
58
+ recoveryKey: `0002${'cd'.repeat(48)}`,
59
+ confirmationCode: '123456',
60
+ },
61
+ publicKeyBase64,
62
+ 'test-key-1'
63
+ );
64
+
65
+ await expect(
66
+ decryptEmailRelayPayload({ ...envelope, keyId: 'other-key' }, privateKeyBase64)
67
+ ).rejects.toThrow();
68
+
69
+ const lastCharacter = envelope.ciphertext.at(-1);
70
+ const tamperedCiphertext = `${envelope.ciphertext.slice(0, -1)}${
71
+ lastCharacter === 'A' ? 'B' : 'A'
72
+ }`;
73
+
74
+ await expect(
75
+ decryptEmailRelayPayload(
76
+ { ...envelope, ciphertext: tamperedCiphertext },
77
+ privateKeyBase64
78
+ )
79
+ ).rejects.toThrow();
80
+ });
81
+
82
+ it('generates exactly six decimal digits', () => {
83
+ const codes = Array.from({ length: 100 }, generateEmailRelayConfirmationCode);
84
+
85
+ expect(codes.every(code => /^\d{6}$/.test(code))).toBe(true);
86
+ });
87
+ });
@@ -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,85 @@
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({ mode: 'software', keyId: 'test-key', publicKey });
26
+ });
27
+ it('rejects an unknown key', async () => {
28
+ await expect(
29
+ verifyEnclaveAttestation(attestation(), {
30
+ mode: 'software',
31
+ pinnedPublicKeys: [(await generateEscrowKeyPair()).publicKey],
32
+ })
33
+ ).rejects.toThrow('not trusted');
34
+ });
35
+ it('rejects nitro under software policy', async () => {
36
+ await expect(
37
+ verifyEnclaveAttestation(
38
+ { ...attestation(), mode: 'nitro' },
39
+ { mode: 'software', pinnedPublicKeys: [publicKey] }
40
+ )
41
+ ).rejects.toThrow('mode mismatch');
42
+ });
43
+ it.each(['software', 'nitro'])(
44
+ 'fails closed under nitro policy (%s attestation)',
45
+ async mode => {
46
+ await expect(
47
+ verifyEnclaveAttestation(
48
+ { ...attestation(), mode },
49
+ { mode: 'nitro', pinnedMeasurements: [] }
50
+ )
51
+ ).rejects.toThrow('Nitro attestation verification is not implemented yet');
52
+ }
53
+ );
54
+ it.each([
55
+ { publicKey: 'e30=' },
56
+ { publicKey: 'not-base64' },
57
+ { keyId: 'invalid/key' },
58
+ { mode: 'unknown' },
59
+ { measurements: [] },
60
+ { measurements: { unknown: 'value' } },
61
+ { document: 3 },
62
+ { issuedAt: 'not-a-date' },
63
+ { extra: true },
64
+ ])('rejects malformed attestation %j', async overrides => {
65
+ await expect(
66
+ verifyEnclaveAttestation(
67
+ { ...attestation(), ...overrides },
68
+ { mode: 'software', pinnedPublicKeys: [publicKey] }
69
+ )
70
+ ).rejects.toThrow();
71
+ });
72
+ it('rejects SPKI on another curve', async () => {
73
+ const pair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-384' }, true, [
74
+ 'deriveBits',
75
+ ]);
76
+ const spki = await crypto.subtle.exportKey('spki', pair.publicKey);
77
+ const key = btoa(String.fromCharCode(...new Uint8Array(spki)));
78
+ await expect(
79
+ verifyEnclaveAttestation(
80
+ { ...attestation(), publicKey: key },
81
+ { mode: 'software', pinnedPublicKeys: [key] }
82
+ )
83
+ ).rejects.toThrow();
84
+ });
85
+ });
@@ -0,0 +1,64 @@
1
+ import { base64ToBuffer, bufferToBase64 } from './crypto';
2
+ import type { EscrowAttestationPolicy } from './types';
3
+
4
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
5
+ typeof value === 'object' && value !== null && !Array.isArray(value);
6
+
7
+ const normalizePublicKey = (value: string): string => {
8
+ const trimmed = value.trim();
9
+ if (
10
+ !trimmed ||
11
+ trimmed.length > 512 ||
12
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(trimmed)
13
+ ) {
14
+ throw new Error('Invalid escrow attestation public key');
15
+ }
16
+ return bufferToBase64(base64ToBuffer(trimmed).buffer);
17
+ };
18
+
19
+ /** Parse untrusted attestation data and enforce an explicit enclave trust policy. */
20
+ export const verifyEnclaveAttestation = async (
21
+ attestation: unknown,
22
+ policy: EscrowAttestationPolicy
23
+ ): Promise<{ publicKey: string; keyId: string; mode: 'software' | 'nitro' }> => {
24
+ const fields = ['mode', 'keyId', 'publicKey', 'measurements', 'document', 'issuedAt'];
25
+ if (
26
+ !isRecord(attestation) ||
27
+ Object.keys(attestation).some(key => !fields.includes(key)) ||
28
+ (attestation.mode !== 'software' && attestation.mode !== 'nitro') ||
29
+ typeof attestation.keyId !== 'string' ||
30
+ !/^[A-Za-z0-9._-]{1,128}$/.test(attestation.keyId) ||
31
+ typeof attestation.publicKey !== 'string' ||
32
+ !isRecord(attestation.measurements) ||
33
+ Object.entries(attestation.measurements).some(
34
+ ([key, value]) =>
35
+ !['imageSha384', 'pcr0', 'pcr1', 'pcr2'].includes(key) ||
36
+ typeof value !== 'string' ||
37
+ !value ||
38
+ value.length > 1024
39
+ ) ||
40
+ typeof attestation.document !== 'string' ||
41
+ !attestation.document ||
42
+ attestation.document.length > 65536 ||
43
+ typeof attestation.issuedAt !== 'string' ||
44
+ !Number.isFinite(Date.parse(attestation.issuedAt))
45
+ ) {
46
+ throw new Error('Invalid escrow attestation');
47
+ }
48
+ const publicKey = normalizePublicKey(attestation.publicKey);
49
+ await crypto.subtle.importKey(
50
+ 'spki',
51
+ base64ToBuffer(publicKey),
52
+ { name: 'ECDH', namedCurve: 'P-256' },
53
+ false,
54
+ []
55
+ );
56
+ if (policy.mode === 'nitro') {
57
+ throw new Error('Nitro attestation verification is not implemented yet');
58
+ }
59
+ if (attestation.mode !== policy.mode) throw new Error('Escrow attestation mode mismatch');
60
+ if (!policy.pinnedPublicKeys.some(pin => normalizePublicKey(pin) === publicKey)) {
61
+ throw new Error('Escrow attestation public key is not trusted');
62
+ }
63
+ return { publicKey, keyId: attestation.keyId, mode: attestation.mode };
64
+ };