@learncard/sss-key-manager 0.2.0 → 0.2.2

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 (63) hide show
  1. package/README.md +13 -0
  2. package/dist/escrow-attestation.d.ts +2 -5
  3. package/dist/escrow-attestation.d.ts.map +1 -1
  4. package/dist/escrow-authority.d.ts +112 -0
  5. package/dist/escrow-authority.d.ts.map +1 -0
  6. package/dist/escrow-authority.types.d.ts +51 -0
  7. package/dist/escrow-authority.types.d.ts.map +1 -0
  8. package/dist/escrow-crypto.d.ts +9 -1
  9. package/dist/escrow-crypto.d.ts.map +1 -1
  10. package/dist/escrow-enrollment-intent.d.ts +13 -0
  11. package/dist/escrow-enrollment-intent.d.ts.map +1 -0
  12. package/dist/escrow-enrollment-intent.types.d.ts +28 -0
  13. package/dist/escrow-enrollment-intent.types.d.ts.map +1 -0
  14. package/dist/escrow-nitro-attestation.d.ts +34 -0
  15. package/dist/escrow-nitro-attestation.d.ts.map +1 -0
  16. package/dist/index.d.ts +11 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/sss-key-manager.cjs.development.js +4478 -424
  19. package/dist/sss-key-manager.cjs.development.js.map +4 -4
  20. package/dist/sss-key-manager.cjs.production.min.js +37 -32
  21. package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
  22. package/dist/sss-key-manager.esm.js +1813 -265
  23. package/dist/sss-key-manager.esm.js.map +4 -4
  24. package/dist/sss-strategy.d.ts.map +1 -1
  25. package/dist/types.d.ts +31 -5
  26. package/dist/types.d.ts.map +1 -1
  27. package/package.json +7 -3
  28. package/src/__fixtures__/escrow-authority-v1.json +96 -0
  29. package/src/__fixtures__/escrow-carry-contact-contract-v1.json +33 -0
  30. package/src/__fixtures__/escrow-enrollment-intent-v1.json +26 -0
  31. package/src/__fixtures__/escrow-vectors-rust.json +82 -0
  32. package/src/__fixtures__/escrow-vectors.json +103 -0
  33. package/src/__fixtures__/nitro-attestation/bad-signature.cbor.b64 +1 -0
  34. package/src/__fixtures__/nitro-attestation/debug-zero-pcrs.cbor.b64 +1 -0
  35. package/src/__fixtures__/nitro-attestation/legacy-user-data.cbor.b64 +1 -0
  36. package/src/__fixtures__/nitro-attestation/manifest.json +60 -0
  37. package/src/__fixtures__/nitro-attestation/nonce-mismatch.cbor.b64 +1 -0
  38. package/src/__fixtures__/nitro-attestation/root.pem +12 -0
  39. package/src/__fixtures__/nitro-attestation/stale-timestamp.cbor.b64 +1 -0
  40. package/src/__fixtures__/nitro-attestation/tampered-previous-key-ids.cbor.b64 +1 -0
  41. package/src/__fixtures__/nitro-attestation/truncated-chain.cbor.b64 +1 -0
  42. package/src/__fixtures__/nitro-attestation/valid.cbor.b64 +1 -0
  43. package/src/__fixtures__/nitro-attestation/wrong-pcr0.cbor.b64 +1 -0
  44. package/src/__fixtures__/nitro-attestation/wrong-root.cbor.b64 +1 -0
  45. package/src/__fixtures__/nitro-attestation-real/staging-nsm-document.json +16 -0
  46. package/src/escrow-attestation.test.ts +7 -2
  47. package/src/escrow-attestation.ts +72 -10
  48. package/src/escrow-authority.test.ts +643 -0
  49. package/src/escrow-authority.ts +597 -0
  50. package/src/escrow-authority.types.ts +56 -0
  51. package/src/escrow-crypto.ts +39 -1
  52. package/src/escrow-enrollment-intent.test.ts +224 -0
  53. package/src/escrow-enrollment-intent.ts +236 -0
  54. package/src/escrow-enrollment-intent.types.ts +28 -0
  55. package/src/escrow-nitro-attestation.test.ts +276 -0
  56. package/src/escrow-nitro-attestation.ts +317 -0
  57. package/src/escrow-vectors.test.ts +198 -0
  58. package/src/index.ts +35 -1
  59. package/src/sss-authority.test.ts +889 -0
  60. package/src/sss-session-writes.test.ts +2 -2
  61. package/src/sss-strategy.test.ts +95 -2
  62. package/src/sss-strategy.ts +596 -115
  63. package/src/types.ts +34 -4
@@ -0,0 +1,276 @@
1
+ // @vitest-environment node
2
+ import { readFileSync, readdirSync } from 'node:fs';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { decode, encode } from 'cborg';
5
+ import { base64ToBuffer, bufferToBase64 } from './crypto';
6
+ import { NitroAttestationError, verifyNitroAttestationDocument } from './escrow-nitro-attestation';
7
+ import { verifyEnclaveAttestation } from './escrow-attestation';
8
+ import manifest from './__fixtures__/nitro-attestation/manifest.json';
9
+ import type { EscrowAttestationPolicy } from './types';
10
+
11
+ const directory = new URL('./__fixtures__/nitro-attestation/', import.meta.url);
12
+ const fixture = (name: string): string =>
13
+ readFileSync(new URL(`${name}.cbor.b64`, directory), 'utf8').trim();
14
+ const nonce = Uint8Array.from(manifest.nonceHex.match(/../g)!, byte => parseInt(byte, 16));
15
+ const measurement = { pcr0: manifest.pcrs[0], pcr1: manifest.pcrs[1], pcr2: manifest.pcrs[2] };
16
+ const policy: Extract<EscrowAttestationPolicy, { mode: 'nitro' }> = {
17
+ mode: 'nitro',
18
+ pinnedMeasurements: [measurement],
19
+ rootCertificateSha256: manifest.rootSha256,
20
+ };
21
+ const options = { expectedNonce: nonce, policy, now: manifest.nowMs };
22
+ const reasonMap: Record<string, string> = {
23
+ 'debug PCRs': 'debug',
24
+ 'missing intermediate': 'chain',
25
+ 'root pin': 'root',
26
+ };
27
+
28
+ describe('FakeNsm Nitro attestation', () => {
29
+ afterEach(() => vi.restoreAllMocks());
30
+ it('covers every committed document fixture', () => {
31
+ expect(
32
+ readdirSync(directory)
33
+ .filter(file => file.endsWith('.cbor.b64'))
34
+ .sort()
35
+ ).toEqual(
36
+ Object.keys(manifest.cases)
37
+ .map(name => `${name}.cbor.b64`)
38
+ .sort()
39
+ );
40
+ });
41
+ for (const [name, testCase] of Object.entries(manifest.cases)) {
42
+ it(name, async () => {
43
+ const expectedNonce =
44
+ 'nonceHex' in testCase
45
+ ? Uint8Array.from(testCase.nonceHex.match(/../g)!, byte => parseInt(byte, 16))
46
+ : nonce;
47
+ const result = verifyNitroAttestationDocument(fixture(name), {
48
+ ...options,
49
+ expectedNonce,
50
+ });
51
+ if (testCase.accept) {
52
+ await expect(result).resolves.toMatchObject({
53
+ escrowPublicKeySpkiB64: manifest.userDataBase64,
54
+ timestamp: manifest.timestamp,
55
+ pcrs: manifest.pcrs,
56
+ previousKeyIds:
57
+ 'previousKeyIds' in testCase
58
+ ? testCase.previousKeyIds
59
+ : manifest.previousKeyIds,
60
+ });
61
+ } else if ('reason' in testCase) {
62
+ await expect(result).rejects.toMatchObject({
63
+ name: 'NitroAttestationError',
64
+ reason: reasonMap[testCase.reason] ?? testCase.reason,
65
+ });
66
+ }
67
+ });
68
+ }
69
+ it('accepts an optional outer tag 18', async () => {
70
+ const tagged = new Uint8Array([0xd2, ...base64ToBuffer(fixture('valid'))]);
71
+ await expect(
72
+ verifyNitroAttestationDocument(bufferToBase64(tagged.buffer), options)
73
+ ).resolves.toBeDefined();
74
+ });
75
+ it('rejects cross-combinations of pinned tuples', async () => {
76
+ await expect(
77
+ verifyNitroAttestationDocument(fixture('valid'), {
78
+ ...options,
79
+ policy: {
80
+ ...policy,
81
+ pinnedMeasurements: [
82
+ { ...measurement, pcr1: 'ff'.repeat(48) },
83
+ { ...measurement, pcr0: 'ff'.repeat(48) },
84
+ ],
85
+ },
86
+ })
87
+ ).rejects.toMatchObject({ reason: 'pcr' });
88
+ });
89
+ it('rejects legacy image-only pins', async () => {
90
+ await expect(
91
+ verifyNitroAttestationDocument(fixture('valid'), {
92
+ ...options,
93
+ policy: {
94
+ ...policy,
95
+ pinnedMeasurements: [{ imageSha384: measurement.pcr0 }],
96
+ },
97
+ })
98
+ ).rejects.toMatchObject({ reason: 'pcr' });
99
+ });
100
+ it('rejects unknown algorithms before signature verification', async () => {
101
+ const cose: unknown[] = decode(base64ToBuffer(fixture('valid')), { useMaps: true });
102
+ cose[0] = encode(new Map([[1, -7]]));
103
+ await expect(
104
+ verifyNitroAttestationDocument(
105
+ bufferToBase64(new Uint8Array(encode(cose)).buffer),
106
+ options
107
+ )
108
+ ).rejects.toMatchObject({ reason: 'alg' });
109
+ });
110
+ it('rejects the fake root under the default AWS pin', async () => {
111
+ await expect(
112
+ verifyNitroAttestationDocument(fixture('valid'), {
113
+ ...options,
114
+ policy: {
115
+ mode: 'nitro',
116
+ pinnedMeasurements: [measurement],
117
+ },
118
+ })
119
+ ).rejects.toMatchObject({ reason: 'root' });
120
+ });
121
+ it('enforces clock skew and configurable freshness', async () => {
122
+ await expect(
123
+ verifyNitroAttestationDocument(fixture('valid'), {
124
+ ...options,
125
+ now: manifest.timestamp - 60001,
126
+ })
127
+ ).rejects.toMatchObject({ reason: 'freshness' });
128
+ await expect(
129
+ verifyNitroAttestationDocument(fixture('valid'), {
130
+ ...options,
131
+ policy: { ...policy, maxAgeMs: 999 },
132
+ })
133
+ ).rejects.toMatchObject({ reason: 'freshness' });
134
+ });
135
+ it('returns typed CBOR errors', async () => {
136
+ await expect(verifyNitroAttestationDocument('bad!', options)).rejects.toBeInstanceOf(
137
+ NitroAttestationError
138
+ );
139
+ await expect(verifyNitroAttestationDocument('bad!', options)).rejects.toMatchObject({
140
+ reason: 'cbor',
141
+ });
142
+ });
143
+ it('binds the wrapper public key to user_data, ignoring unsigned measurements', async () => {
144
+ vi.spyOn(Date, 'now').mockReturnValue(manifest.nowMs);
145
+ const attestation = {
146
+ mode: 'nitro',
147
+ keyId: manifest.keyId,
148
+ previousKeyIds: manifest.previousKeyIds,
149
+ publicKey: manifest.userDataBase64,
150
+ document: fixture('valid'),
151
+ measurements: { pcr0: 'untrusted' },
152
+ issuedAt: new Date(manifest.nowMs).toISOString(),
153
+ };
154
+ await expect(verifyEnclaveAttestation(attestation, policy, nonce)).resolves.toEqual({
155
+ mode: 'nitro',
156
+ keyId: manifest.keyId,
157
+ previousKeyIds: manifest.previousKeyIds,
158
+ publicKey: manifest.userDataBase64,
159
+ });
160
+ await expect(
161
+ verifyEnclaveAttestation({ ...attestation, publicKey: 'e30=' }, policy, nonce)
162
+ ).rejects.toMatchObject({ reason: 'user-data' });
163
+ await expect(verifyEnclaveAttestation(attestation, policy)).rejects.toMatchObject({
164
+ reason: 'nonce',
165
+ });
166
+ });
167
+ // P9.2: the plaintext wire `keyId` — what a client actually seals new
168
+ // envelopes to — must exactly match what is bound inside the signed
169
+ // document; a divergent (tampered, or simply stale) plaintext keyId is
170
+ // rejected the same way a wrong public key is.
171
+ it('rejects a keyId that diverges from the signed document', async () => {
172
+ vi.spyOn(Date, 'now').mockReturnValue(manifest.nowMs);
173
+ const attestation = {
174
+ mode: 'nitro',
175
+ keyId: 'not-the-attested-key',
176
+ previousKeyIds: manifest.previousKeyIds,
177
+ publicKey: manifest.userDataBase64,
178
+ document: fixture('valid'),
179
+ measurements: {},
180
+ issuedAt: new Date(manifest.nowMs).toISOString(),
181
+ };
182
+ await expect(verifyEnclaveAttestation(attestation, policy, nonce)).rejects.toMatchObject({
183
+ reason: 'user-data',
184
+ });
185
+ });
186
+ // P9.2: the plaintext wire field must exactly match what is bound inside
187
+ // the signed document — a divergent (tampered, or simply stale) plaintext
188
+ // previousKeyIds is rejected the same way a wrong public key is.
189
+ it('rejects a previousKeyIds that diverges from the signed document', async () => {
190
+ vi.spyOn(Date, 'now').mockReturnValue(manifest.nowMs);
191
+ const attestation = {
192
+ mode: 'nitro',
193
+ keyId: manifest.keyId,
194
+ previousKeyIds: ['not-the-attested-list'],
195
+ publicKey: manifest.userDataBase64,
196
+ document: fixture('valid'),
197
+ measurements: {},
198
+ issuedAt: new Date(manifest.nowMs).toISOString(),
199
+ };
200
+ await expect(verifyEnclaveAttestation(attestation, policy, nonce)).rejects.toMatchObject({
201
+ reason: 'user-data',
202
+ });
203
+ });
204
+ });
205
+
206
+ describe('real AWS Nitro attestation', () => {
207
+ const real = JSON.parse(
208
+ readFileSync(
209
+ new URL(
210
+ './__fixtures__/nitro-attestation-real/staging-nsm-document.json',
211
+ import.meta.url
212
+ ),
213
+ 'utf8'
214
+ )
215
+ ) as {
216
+ nonceHex: string;
217
+ timestamp: number;
218
+ keyId: string;
219
+ measurements: { pcr0: string; pcr1: string; pcr2: string };
220
+ escrowPublicKeySpkiB64: string;
221
+ measurementHash: string;
222
+ document: string;
223
+ };
224
+ const realNonce = Uint8Array.from(real.nonceHex.match(/../g)!, byte => parseInt(byte, 16));
225
+ const realOptions = {
226
+ expectedNonce: realNonce,
227
+ policy: { mode: 'nitro' as const, pinnedMeasurements: [real.measurements] },
228
+ now: real.timestamp,
229
+ };
230
+
231
+ it('uses the indefinite-length payload map the NSM actually emits', () => {
232
+ const cose = decode(base64ToBuffer(real.document), { useMaps: true }) as unknown[];
233
+ expect((cose[2] as Uint8Array)[0]).toBe(0xbf);
234
+ });
235
+
236
+ it('verifies against the default AWS root, nonce and pinned measurements', async () => {
237
+ await expect(
238
+ verifyNitroAttestationDocument(real.document, realOptions)
239
+ ).resolves.toMatchObject({
240
+ keyId: real.keyId,
241
+ escrowPublicKeySpkiB64: real.escrowPublicKeySpkiB64,
242
+ measurementHash: real.measurementHash,
243
+ timestamp: real.timestamp,
244
+ });
245
+ });
246
+
247
+ it('still rejects a real document with a different nonce or unpinned measurements', async () => {
248
+ await expect(
249
+ verifyNitroAttestationDocument(real.document, {
250
+ ...realOptions,
251
+ expectedNonce: new Uint8Array(32),
252
+ })
253
+ ).rejects.toMatchObject({ reason: 'nonce' });
254
+ await expect(
255
+ verifyNitroAttestationDocument(real.document, {
256
+ ...realOptions,
257
+ policy: { mode: 'nitro', pinnedMeasurements: [measurement] },
258
+ })
259
+ ).rejects.toMatchObject({ reason: 'pcr' });
260
+ });
261
+
262
+ it('keeps rejecting indefinite-length encoding outside the signed payload', async () => {
263
+ const cose = decode(base64ToBuffer(real.document), { useMaps: true }) as unknown[];
264
+ const indefiniteEnvelope = new Uint8Array([
265
+ 0x9f,
266
+ ...encode(cose[0]),
267
+ ...encode(cose[1]),
268
+ ...encode(cose[2]),
269
+ ...encode(cose[3]),
270
+ 0xff,
271
+ ]);
272
+ await expect(
273
+ verifyNitroAttestationDocument(bufferToBase64(indefiniteEnvelope.buffer), realOptions)
274
+ ).rejects.toMatchObject({ reason: 'cbor' });
275
+ });
276
+ });
@@ -0,0 +1,317 @@
1
+ import 'reflect-metadata';
2
+ import { decode, encode } from 'cborg';
3
+ import {
4
+ BasicConstraintsExtension,
5
+ KeyUsagesExtension,
6
+ KeyUsageFlags,
7
+ X509Certificate,
8
+ } from '@peculiar/x509';
9
+ import { base64ToBuffer, bufferToBase64 } from './crypto';
10
+ import type { EscrowAttestationPolicy } from './types';
11
+
12
+ export type NitroAttestationReason =
13
+ | 'cbor'
14
+ | 'cose'
15
+ | 'alg'
16
+ | 'chain'
17
+ | 'root'
18
+ | 'signature'
19
+ | 'pcr'
20
+ | 'debug'
21
+ | 'nonce'
22
+ | 'freshness'
23
+ | 'user-data';
24
+
25
+ /** A fail-closed attestation failure with a stable, non-sensitive reason. */
26
+ export class NitroAttestationError extends Error {
27
+ constructor(public readonly reason: NitroAttestationReason) {
28
+ super(`Nitro attestation rejected: ${reason}`);
29
+ this.name = 'NitroAttestationError';
30
+ }
31
+ }
32
+
33
+ const AWS_ROOT_SHA256 = '641a0321a3e244efe456463195d606317ed7cdcc3c1756e09893f3c68f79bb5b';
34
+ type NitroPolicy = Extract<EscrowAttestationPolicy, { mode: 'nitro' }>;
35
+ const fail: (reason: NitroAttestationReason) => never = reason => {
36
+ throw new NitroAttestationError(reason);
37
+ };
38
+ const hex = (bytes: Uint8Array): string =>
39
+ Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
40
+ const bytes = (value: unknown, reason: NitroAttestationReason): Uint8Array =>
41
+ value instanceof Uint8Array && value.length > 0 ? value : fail(reason);
42
+ const map = (value: unknown, reason: NitroAttestationReason): Map<unknown, unknown> =>
43
+ value instanceof Map ? value : fail(reason);
44
+ /**
45
+ * `allowIndefinite` is only for the signed NSM payload: real AWS Nitro documents
46
+ * encode it as an indefinite-length map (0xbf). That is safe because the COSE
47
+ * signature below is verified over the original payload bytes, not a re-encoding.
48
+ * Everything else (COSE envelope, protected header, user_data) stays definite-only.
49
+ */
50
+ const decodeCbor = (value: Uint8Array, { allowIndefinite = false } = {}): unknown => {
51
+ try {
52
+ return decode(value, {
53
+ useMaps: true,
54
+ rejectDuplicateMapKeys: true,
55
+ allowIndefinite,
56
+ allowUndefined: false,
57
+ allowNaN: false,
58
+ allowInfinity: false,
59
+ allowBigInt: false,
60
+ });
61
+ } catch {
62
+ return fail('cbor');
63
+ }
64
+ };
65
+
66
+ /**
67
+ * P9.1: `user_data` is either the legacy raw SPKI DER (older enclave builds,
68
+ * accepted during the rollout transition), or a canonical CBOR map
69
+ * `{0: spki bytes, 1: keyId text, 2: previousKeyIds array<text>}` produced by
70
+ * `server::attest_user_data` in the enclave. Real P-256 SPKI DER always
71
+ * starts with the SEQUENCE tag `0x30`, which CBOR decodes as a bare negative
72
+ * integer (major type 1), never a 3-entry map — so requiring BOTH "decodes as
73
+ * CBOR" AND "is a map with exactly these three well-typed entries" safely
74
+ * disambiguates without any separate version marker. A non-throwing probe
75
+ * (unlike `decodeCbor`/`fail`, whose failure IS the production error path
76
+ * elsewhere in this file): failing to match either check here means "legacy
77
+ * format", not "malformed attestation".
78
+ */
79
+ const decodeUserData = (
80
+ userData: Uint8Array
81
+ ): { spki: Uint8Array; keyId: string; previousKeyIds: string[] } | undefined => {
82
+ let decoded: unknown;
83
+ try {
84
+ decoded = decode(userData, {
85
+ useMaps: true,
86
+ rejectDuplicateMapKeys: true,
87
+ allowIndefinite: false,
88
+ allowUndefined: false,
89
+ allowNaN: false,
90
+ allowInfinity: false,
91
+ allowBigInt: false,
92
+ });
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ if (!(decoded instanceof Map) || decoded.size !== 3) return undefined;
97
+ const spki = decoded.get(0);
98
+ const keyId = decoded.get(1);
99
+ const previousKeyIds = decoded.get(2);
100
+ const isKeyId = (value: unknown): value is string =>
101
+ typeof value === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(value);
102
+ if (
103
+ !(spki instanceof Uint8Array) ||
104
+ spki.length === 0 ||
105
+ !isKeyId(keyId) ||
106
+ !Array.isArray(previousKeyIds) ||
107
+ previousKeyIds.length > 3 ||
108
+ !previousKeyIds.every(isKeyId)
109
+ ) {
110
+ return undefined;
111
+ }
112
+ return { spki, keyId, previousKeyIds };
113
+ };
114
+
115
+ /** Verify signed NSM evidence before trusting its P-256 escrow key (not public_key/KMS key). */
116
+ export const verifyNitroAttestationDocument = async (
117
+ documentB64: string,
118
+ {
119
+ expectedNonce,
120
+ policy,
121
+ now = Date.now(),
122
+ }: {
123
+ expectedNonce: Uint8Array;
124
+ policy: NitroPolicy;
125
+ now?: number;
126
+ }
127
+ ): Promise<{
128
+ escrowPublicKeySpkiB64: string;
129
+ /** P9.1: the enclave's own current keyId, bound inside this same signed
130
+ * document. `undefined` for the legacy user_data format, which never
131
+ * carried a keyId — callers must not silently trust a plaintext keyId in
132
+ * that case any more than they did before this field existed. */
133
+ keyId: string | undefined;
134
+ /** P9.1: retired keyIds the enclave still accepts for decrypt only, bound
135
+ * inside this same signed document. Empty for the legacy user_data format. */
136
+ previousKeyIds: string[];
137
+ pcrs: Record<number, string>;
138
+ timestamp: number;
139
+ moduleId: string;
140
+ ledgerPublicKey?: string;
141
+ measurementHash?: string;
142
+ }> => {
143
+ let document: Uint8Array;
144
+ try {
145
+ if (
146
+ !documentB64 ||
147
+ documentB64.length > 65536 ||
148
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(documentB64)
149
+ )
150
+ fail('cbor');
151
+ document = base64ToBuffer(documentB64);
152
+ } catch {
153
+ return fail('cbor');
154
+ }
155
+ // Only an outer COSE_Sign1 tag is permitted; tags within headers/payload are rejected.
156
+ const cose = decodeCbor(document[0] === 0xd2 ? document.subarray(1) : document);
157
+ if (!Array.isArray(cose) || cose.length !== 4) fail('cose');
158
+ const [protectedValue, unprotectedValue, payloadValue, signatureValue] = cose;
159
+ const protectedBytes = bytes(protectedValue, 'cose');
160
+ const protectedHeader = map(decodeCbor(protectedBytes), 'alg');
161
+ if (protectedHeader.size !== 1 || protectedHeader.get(1) !== -35) fail('alg');
162
+ if (map(unprotectedValue, 'cose').size !== 0) fail('cose');
163
+ const payloadBytes = bytes(payloadValue, 'cose');
164
+ const signature = bytes(signatureValue, 'signature');
165
+ if (signature.length !== 96) fail('signature');
166
+ const payload = map(decodeCbor(payloadBytes, { allowIndefinite: true }), 'cbor');
167
+ const moduleId = payload.get('module_id');
168
+ const timestamp = payload.get('timestamp');
169
+ if (typeof moduleId !== 'string' || !moduleId || payload.get('digest') !== 'SHA384')
170
+ fail('cbor');
171
+ if (typeof timestamp !== 'number' || !Number.isSafeInteger(timestamp) || timestamp < 0)
172
+ fail('freshness');
173
+ const leafDer = bytes(payload.get('certificate'), 'chain');
174
+ const bundle = payload.get('cabundle');
175
+ if (!Array.isArray(bundle) || !bundle.length || bundle.length > 16) fail('chain');
176
+ const ders = bundle.map(value => bytes(value, 'chain'));
177
+ const rootHash = hex(
178
+ new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(ders[0])))
179
+ );
180
+ if (rootHash !== (policy.rootCertificateSha256 ?? AWS_ROOT_SHA256).toLowerCase()) fail('root');
181
+ let leaf: X509Certificate;
182
+ try {
183
+ const chain = [...ders, leafDer].map(der => new X509Certificate(new Uint8Array(der)));
184
+ for (let i = 0; i < chain.length; i++) {
185
+ const cert = chain[i];
186
+ const issuer = chain[Math.max(0, i - 1)];
187
+ if (
188
+ !Number.isFinite(now) ||
189
+ now < cert.notBefore.getTime() ||
190
+ now > cert.notAfter.getTime() ||
191
+ cert.issuer !== issuer.subject ||
192
+ !(await cert.verify({ publicKey: issuer.publicKey, date: new Date(now) }, crypto))
193
+ )
194
+ fail('chain');
195
+ if (i < chain.length - 1) {
196
+ const constraints = cert.getExtension(BasicConstraintsExtension);
197
+ const usage = cert.getExtension(KeyUsagesExtension);
198
+ if (
199
+ !constraints?.ca ||
200
+ (constraints.pathLength !== undefined &&
201
+ chain.length - i - 2 > constraints.pathLength) ||
202
+ (usage && !(usage.usages & KeyUsageFlags.keyCertSign))
203
+ )
204
+ fail('chain');
205
+ }
206
+ }
207
+ leaf = chain[chain.length - 1];
208
+ } catch {
209
+ return fail('chain');
210
+ }
211
+ try {
212
+ const key = await crypto.subtle.importKey(
213
+ 'spki',
214
+ leaf.publicKey.rawData,
215
+ { name: 'ECDSA', namedCurve: 'P-384' },
216
+ false,
217
+ ['verify']
218
+ );
219
+ const structure = encode(['Signature1', protectedBytes, new Uint8Array(), payloadBytes]);
220
+ if (
221
+ !(await crypto.subtle.verify(
222
+ { name: 'ECDSA', hash: 'SHA-384' },
223
+ key,
224
+ new Uint8Array(signature),
225
+ new Uint8Array(structure)
226
+ ))
227
+ )
228
+ fail('signature');
229
+ } catch {
230
+ return fail('signature');
231
+ }
232
+ const rawPcrs = map(payload.get('pcrs'), 'pcr');
233
+ const pcrs: Record<number, string> = {};
234
+ for (const [index, value] of rawPcrs) {
235
+ if (typeof index !== 'number' || !Number.isInteger(index) || index < 0 || index > 31)
236
+ fail('pcr');
237
+ const pcr = bytes(value, 'pcr');
238
+ if (pcr.length !== 48) fail('pcr');
239
+ pcrs[index] = hex(pcr);
240
+ }
241
+ if (pcrs[0] === '00'.repeat(48)) fail('debug');
242
+ if (
243
+ !policy.pinnedMeasurements.some(
244
+ pin =>
245
+ typeof pin.pcr0 === 'string' &&
246
+ typeof pin.pcr1 === 'string' &&
247
+ typeof pin.pcr2 === 'string' &&
248
+ pin.pcr0.toLowerCase() === pcrs[0] &&
249
+ pin.pcr1.toLowerCase() === pcrs[1] &&
250
+ pin.pcr2.toLowerCase() === pcrs[2]
251
+ )
252
+ )
253
+ fail('pcr');
254
+ const nonce = bytes(payload.get('nonce'), 'nonce');
255
+ if (
256
+ !(expectedNonce instanceof Uint8Array) ||
257
+ expectedNonce.length !== 32 ||
258
+ nonce.length !== expectedNonce.length
259
+ )
260
+ fail('nonce');
261
+ let difference = 0;
262
+ for (let i = 0; i < nonce.length; i++) difference |= nonce[i] ^ expectedNonce[i];
263
+ if (difference !== 0) fail('nonce');
264
+ const maxAge = policy.maxAgeMs ?? 300000;
265
+ if (
266
+ !Number.isFinite(maxAge) ||
267
+ maxAge < 0 ||
268
+ now - timestamp > maxAge ||
269
+ timestamp > now + 60000
270
+ )
271
+ fail('freshness');
272
+ const userData = bytes(payload.get('user_data'), 'user-data');
273
+ const newFormat = decodeUserData(new Uint8Array(userData));
274
+ const spki = newFormat ? newFormat.spki : userData;
275
+ try {
276
+ await crypto.subtle.importKey(
277
+ 'spki',
278
+ new Uint8Array(spki),
279
+ { name: 'ECDH', namedCurve: 'P-256' },
280
+ false,
281
+ []
282
+ );
283
+ } catch {
284
+ return fail('user-data');
285
+ }
286
+ const rawLedgerKey = payload.get('public_key');
287
+ let ledgerPublicKey: string | undefined;
288
+ let measurementHash: string | undefined;
289
+ if (rawLedgerKey !== undefined && rawLedgerKey !== null) {
290
+ const ledger = new Uint8Array(bytes(rawLedgerKey, 'user-data'));
291
+ if (ledger.length !== 65 || ledger[0] !== 4) fail('user-data');
292
+ try {
293
+ await crypto.subtle.importKey(
294
+ 'raw',
295
+ ledger,
296
+ { name: 'ECDSA', namedCurve: 'P-256' },
297
+ false,
298
+ ['verify']
299
+ );
300
+ } catch {
301
+ return fail('user-data');
302
+ }
303
+ const tuple = new Uint8Array(144);
304
+ for (let i = 0; i < 3; i++) tuple.set(bytes(rawPcrs.get(i), 'pcr'), i * 48);
305
+ measurementHash = hex(new Uint8Array(await crypto.subtle.digest('SHA-256', tuple)));
306
+ ledgerPublicKey = bufferToBase64(ledger.buffer);
307
+ }
308
+ return {
309
+ escrowPublicKeySpkiB64: bufferToBase64(new Uint8Array(spki).buffer),
310
+ keyId: newFormat?.keyId,
311
+ previousKeyIds: newFormat?.previousKeyIds ?? [],
312
+ pcrs,
313
+ timestamp,
314
+ moduleId,
315
+ ...(ledgerPublicKey ? { ledgerPublicKey, measurementHash } : {}),
316
+ };
317
+ };