@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.
- package/README.md +25 -95
- package/dist/api-client.d.ts +44 -7
- package/dist/api-client.d.ts.map +1 -1
- package/dist/atomic-operations.d.ts +10 -2
- package/dist/atomic-operations.d.ts.map +1 -1
- package/dist/email-relay-crypto.d.ts +49 -0
- package/dist/email-relay-crypto.d.ts.map +1 -0
- package/dist/escrow-attestation.d.ts +12 -0
- package/dist/escrow-attestation.d.ts.map +1 -0
- package/dist/escrow-crypto.d.ts +52 -0
- package/dist/escrow-crypto.d.ts.map +1 -0
- package/dist/escrow-nitro-attestation.d.ts +32 -0
- package/dist/escrow-nitro-attestation.d.ts.map +1 -0
- package/dist/escrow-pin.d.ts +22 -0
- package/dist/escrow-pin.d.ts.map +1 -0
- package/dist/index.d.ts +15 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/key-manager.d.ts +2 -0
- package/dist/key-manager.d.ts.map +1 -1
- package/dist/qr-login.d.ts.map +1 -1
- package/dist/recovery-phrase.d.ts +6 -0
- package/dist/recovery-phrase.d.ts.map +1 -1
- package/dist/sss-key-manager.cjs.development.js +5564 -353
- package/dist/sss-key-manager.cjs.development.js.map +4 -4
- package/dist/sss-key-manager.cjs.production.min.js +33 -33
- package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
- package/dist/sss-key-manager.esm.js +3494 -354
- package/dist/sss-key-manager.esm.js.map +4 -4
- package/dist/sss-strategy.d.ts +74 -4
- package/dist/sss-strategy.d.ts.map +1 -1
- package/dist/storage.d.ts +1 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/types.d.ts +98 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -3
- package/src/__fixtures__/escrow-vectors-rust.json +82 -0
- package/src/__fixtures__/escrow-vectors.json +103 -0
- package/src/__fixtures__/nitro-attestation/bad-signature.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/debug-zero-pcrs.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/legacy-user-data.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/manifest.json +60 -0
- package/src/__fixtures__/nitro-attestation/nonce-mismatch.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/root.pem +12 -0
- package/src/__fixtures__/nitro-attestation/stale-timestamp.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/tampered-previous-key-ids.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/truncated-chain.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/valid.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/wrong-pcr0.cbor.b64 +1 -0
- package/src/__fixtures__/nitro-attestation/wrong-root.cbor.b64 +1 -0
- package/src/api-client.test.ts +122 -0
- package/src/api-client.ts +107 -116
- package/src/atomic-operations.test.ts +56 -16
- package/src/atomic-operations.ts +55 -19
- package/src/critical-paths.test.ts +51 -1
- package/src/email-relay-crypto.test.ts +87 -0
- package/src/email-relay-crypto.ts +293 -0
- package/src/escrow-attestation.test.ts +90 -0
- package/src/escrow-attestation.ts +125 -0
- package/src/escrow-crypto.test.ts +381 -0
- package/src/escrow-crypto.ts +319 -0
- package/src/escrow-nitro-attestation.test.ts +204 -0
- package/src/escrow-nitro-attestation.ts +286 -0
- package/src/escrow-pin.test.ts +119 -0
- package/src/escrow-pin.ts +83 -0
- package/src/escrow-vectors.test.ts +198 -0
- package/src/index.ts +70 -1
- package/src/key-manager.ts +4 -9
- package/src/qr-login.ts +22 -15
- package/src/recovery-phrase.ts +40 -0
- package/src/sss-session-writes.test.ts +109 -0
- package/src/sss-strategy.test.ts +2736 -111
- package/src/sss-strategy.ts +2226 -224
- package/src/storage.test.ts +66 -8
- package/src/storage.ts +41 -29
- package/src/types.ts +118 -6
|
@@ -0,0 +1,204 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,286 @@
|
|
|
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
|
+
const decodeCbor = (value: Uint8Array): unknown => {
|
|
45
|
+
try {
|
|
46
|
+
return decode(value, {
|
|
47
|
+
useMaps: true,
|
|
48
|
+
rejectDuplicateMapKeys: true,
|
|
49
|
+
allowIndefinite: false,
|
|
50
|
+
allowUndefined: false,
|
|
51
|
+
allowNaN: false,
|
|
52
|
+
allowInfinity: false,
|
|
53
|
+
allowBigInt: false,
|
|
54
|
+
});
|
|
55
|
+
} catch {
|
|
56
|
+
return fail('cbor');
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* P9.1: `user_data` is either the legacy raw SPKI DER (older enclave builds,
|
|
62
|
+
* accepted during the rollout transition), or a canonical CBOR map
|
|
63
|
+
* `{0: spki bytes, 1: keyId text, 2: previousKeyIds array<text>}` produced by
|
|
64
|
+
* `server::attest_user_data` in the enclave. Real P-256 SPKI DER always
|
|
65
|
+
* starts with the SEQUENCE tag `0x30`, which CBOR decodes as a bare negative
|
|
66
|
+
* integer (major type 1), never a 3-entry map — so requiring BOTH "decodes as
|
|
67
|
+
* CBOR" AND "is a map with exactly these three well-typed entries" safely
|
|
68
|
+
* disambiguates without any separate version marker. A non-throwing probe
|
|
69
|
+
* (unlike `decodeCbor`/`fail`, whose failure IS the production error path
|
|
70
|
+
* elsewhere in this file): failing to match either check here means "legacy
|
|
71
|
+
* format", not "malformed attestation".
|
|
72
|
+
*/
|
|
73
|
+
const decodeUserData = (
|
|
74
|
+
userData: Uint8Array
|
|
75
|
+
): { spki: Uint8Array; keyId: string; previousKeyIds: string[] } | undefined => {
|
|
76
|
+
let decoded: unknown;
|
|
77
|
+
try {
|
|
78
|
+
decoded = decode(userData, {
|
|
79
|
+
useMaps: true,
|
|
80
|
+
rejectDuplicateMapKeys: true,
|
|
81
|
+
allowIndefinite: false,
|
|
82
|
+
allowUndefined: false,
|
|
83
|
+
allowNaN: false,
|
|
84
|
+
allowInfinity: false,
|
|
85
|
+
allowBigInt: false,
|
|
86
|
+
});
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
if (!(decoded instanceof Map) || decoded.size !== 3) return undefined;
|
|
91
|
+
const spki = decoded.get(0);
|
|
92
|
+
const keyId = decoded.get(1);
|
|
93
|
+
const previousKeyIds = decoded.get(2);
|
|
94
|
+
const isKeyId = (value: unknown): value is string =>
|
|
95
|
+
typeof value === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(value);
|
|
96
|
+
if (
|
|
97
|
+
!(spki instanceof Uint8Array) ||
|
|
98
|
+
spki.length === 0 ||
|
|
99
|
+
!isKeyId(keyId) ||
|
|
100
|
+
!Array.isArray(previousKeyIds) ||
|
|
101
|
+
previousKeyIds.length > 3 ||
|
|
102
|
+
!previousKeyIds.every(isKeyId)
|
|
103
|
+
) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
return { spki, keyId, previousKeyIds };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Verify signed NSM evidence before trusting its P-256 escrow key (not public_key/KMS key). */
|
|
110
|
+
export const verifyNitroAttestationDocument = async (
|
|
111
|
+
documentB64: string,
|
|
112
|
+
{
|
|
113
|
+
expectedNonce,
|
|
114
|
+
policy,
|
|
115
|
+
now = Date.now(),
|
|
116
|
+
}: {
|
|
117
|
+
expectedNonce: Uint8Array;
|
|
118
|
+
policy: NitroPolicy;
|
|
119
|
+
now?: number;
|
|
120
|
+
}
|
|
121
|
+
): Promise<{
|
|
122
|
+
escrowPublicKeySpkiB64: string;
|
|
123
|
+
/** P9.1: the enclave's own current keyId, bound inside this same signed
|
|
124
|
+
* document. `undefined` for the legacy user_data format, which never
|
|
125
|
+
* carried a keyId — callers must not silently trust a plaintext keyId in
|
|
126
|
+
* that case any more than they did before this field existed. */
|
|
127
|
+
keyId: string | undefined;
|
|
128
|
+
/** P9.1: retired keyIds the enclave still accepts for decrypt only, bound
|
|
129
|
+
* inside this same signed document. Empty for the legacy user_data format. */
|
|
130
|
+
previousKeyIds: string[];
|
|
131
|
+
pcrs: Record<number, string>;
|
|
132
|
+
timestamp: number;
|
|
133
|
+
moduleId: string;
|
|
134
|
+
}> => {
|
|
135
|
+
let document: Uint8Array;
|
|
136
|
+
try {
|
|
137
|
+
if (
|
|
138
|
+
!documentB64 ||
|
|
139
|
+
documentB64.length > 65536 ||
|
|
140
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(documentB64)
|
|
141
|
+
)
|
|
142
|
+
fail('cbor');
|
|
143
|
+
document = base64ToBuffer(documentB64);
|
|
144
|
+
} catch {
|
|
145
|
+
return fail('cbor');
|
|
146
|
+
}
|
|
147
|
+
// Only an outer COSE_Sign1 tag is permitted; tags within headers/payload are rejected.
|
|
148
|
+
const cose = decodeCbor(document[0] === 0xd2 ? document.subarray(1) : document);
|
|
149
|
+
if (!Array.isArray(cose) || cose.length !== 4) fail('cose');
|
|
150
|
+
const [protectedValue, unprotectedValue, payloadValue, signatureValue] = cose;
|
|
151
|
+
const protectedBytes = bytes(protectedValue, 'cose');
|
|
152
|
+
const protectedHeader = map(decodeCbor(protectedBytes), 'alg');
|
|
153
|
+
if (protectedHeader.size !== 1 || protectedHeader.get(1) !== -35) fail('alg');
|
|
154
|
+
if (map(unprotectedValue, 'cose').size !== 0) fail('cose');
|
|
155
|
+
const payloadBytes = bytes(payloadValue, 'cose');
|
|
156
|
+
const signature = bytes(signatureValue, 'signature');
|
|
157
|
+
if (signature.length !== 96) fail('signature');
|
|
158
|
+
const payload = map(decodeCbor(payloadBytes), 'cbor');
|
|
159
|
+
const moduleId = payload.get('module_id');
|
|
160
|
+
const timestamp = payload.get('timestamp');
|
|
161
|
+
if (typeof moduleId !== 'string' || !moduleId || payload.get('digest') !== 'SHA384')
|
|
162
|
+
fail('cbor');
|
|
163
|
+
if (typeof timestamp !== 'number' || !Number.isSafeInteger(timestamp) || timestamp < 0)
|
|
164
|
+
fail('freshness');
|
|
165
|
+
const leafDer = bytes(payload.get('certificate'), 'chain');
|
|
166
|
+
const bundle = payload.get('cabundle');
|
|
167
|
+
if (!Array.isArray(bundle) || !bundle.length || bundle.length > 16) fail('chain');
|
|
168
|
+
const ders = bundle.map(value => bytes(value, 'chain'));
|
|
169
|
+
const rootHash = hex(
|
|
170
|
+
new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(ders[0])))
|
|
171
|
+
);
|
|
172
|
+
if (rootHash !== (policy.rootCertificateSha256 ?? AWS_ROOT_SHA256).toLowerCase()) fail('root');
|
|
173
|
+
let leaf: X509Certificate;
|
|
174
|
+
try {
|
|
175
|
+
const chain = [...ders, leafDer].map(der => new X509Certificate(new Uint8Array(der)));
|
|
176
|
+
for (let i = 0; i < chain.length; i++) {
|
|
177
|
+
const cert = chain[i];
|
|
178
|
+
const issuer = chain[Math.max(0, i - 1)];
|
|
179
|
+
if (
|
|
180
|
+
!Number.isFinite(now) ||
|
|
181
|
+
now < cert.notBefore.getTime() ||
|
|
182
|
+
now > cert.notAfter.getTime() ||
|
|
183
|
+
cert.issuer !== issuer.subject ||
|
|
184
|
+
!(await cert.verify({ publicKey: issuer.publicKey, date: new Date(now) }, crypto))
|
|
185
|
+
)
|
|
186
|
+
fail('chain');
|
|
187
|
+
if (i < chain.length - 1) {
|
|
188
|
+
const constraints = cert.getExtension(BasicConstraintsExtension);
|
|
189
|
+
const usage = cert.getExtension(KeyUsagesExtension);
|
|
190
|
+
if (
|
|
191
|
+
!constraints?.ca ||
|
|
192
|
+
(constraints.pathLength !== undefined &&
|
|
193
|
+
chain.length - i - 2 > constraints.pathLength) ||
|
|
194
|
+
(usage && !(usage.usages & KeyUsageFlags.keyCertSign))
|
|
195
|
+
)
|
|
196
|
+
fail('chain');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
leaf = chain[chain.length - 1];
|
|
200
|
+
} catch {
|
|
201
|
+
return fail('chain');
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const key = await crypto.subtle.importKey(
|
|
205
|
+
'spki',
|
|
206
|
+
leaf.publicKey.rawData,
|
|
207
|
+
{ name: 'ECDSA', namedCurve: 'P-384' },
|
|
208
|
+
false,
|
|
209
|
+
['verify']
|
|
210
|
+
);
|
|
211
|
+
const structure = encode(['Signature1', protectedBytes, new Uint8Array(), payloadBytes]);
|
|
212
|
+
if (
|
|
213
|
+
!(await crypto.subtle.verify(
|
|
214
|
+
{ name: 'ECDSA', hash: 'SHA-384' },
|
|
215
|
+
key,
|
|
216
|
+
new Uint8Array(signature),
|
|
217
|
+
new Uint8Array(structure)
|
|
218
|
+
))
|
|
219
|
+
)
|
|
220
|
+
fail('signature');
|
|
221
|
+
} catch {
|
|
222
|
+
return fail('signature');
|
|
223
|
+
}
|
|
224
|
+
const rawPcrs = map(payload.get('pcrs'), 'pcr');
|
|
225
|
+
const pcrs: Record<number, string> = {};
|
|
226
|
+
for (const [index, value] of rawPcrs) {
|
|
227
|
+
if (typeof index !== 'number' || !Number.isInteger(index) || index < 0 || index > 31)
|
|
228
|
+
fail('pcr');
|
|
229
|
+
const pcr = bytes(value, 'pcr');
|
|
230
|
+
if (pcr.length !== 48) fail('pcr');
|
|
231
|
+
pcrs[index] = hex(pcr);
|
|
232
|
+
}
|
|
233
|
+
if (pcrs[0] === '00'.repeat(48)) fail('debug');
|
|
234
|
+
if (
|
|
235
|
+
!policy.pinnedMeasurements.some(
|
|
236
|
+
pin =>
|
|
237
|
+
typeof pin.pcr0 === 'string' &&
|
|
238
|
+
typeof pin.pcr1 === 'string' &&
|
|
239
|
+
typeof pin.pcr2 === 'string' &&
|
|
240
|
+
pin.pcr0.toLowerCase() === pcrs[0] &&
|
|
241
|
+
pin.pcr1.toLowerCase() === pcrs[1] &&
|
|
242
|
+
pin.pcr2.toLowerCase() === pcrs[2]
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
fail('pcr');
|
|
246
|
+
const nonce = bytes(payload.get('nonce'), 'nonce');
|
|
247
|
+
if (
|
|
248
|
+
!(expectedNonce instanceof Uint8Array) ||
|
|
249
|
+
expectedNonce.length !== 32 ||
|
|
250
|
+
nonce.length !== expectedNonce.length
|
|
251
|
+
)
|
|
252
|
+
fail('nonce');
|
|
253
|
+
let difference = 0;
|
|
254
|
+
for (let i = 0; i < nonce.length; i++) difference |= nonce[i] ^ expectedNonce[i];
|
|
255
|
+
if (difference !== 0) fail('nonce');
|
|
256
|
+
const maxAge = policy.maxAgeMs ?? 300000;
|
|
257
|
+
if (
|
|
258
|
+
!Number.isFinite(maxAge) ||
|
|
259
|
+
maxAge < 0 ||
|
|
260
|
+
now - timestamp > maxAge ||
|
|
261
|
+
timestamp > now + 60000
|
|
262
|
+
)
|
|
263
|
+
fail('freshness');
|
|
264
|
+
const userData = bytes(payload.get('user_data'), 'user-data');
|
|
265
|
+
const newFormat = decodeUserData(new Uint8Array(userData));
|
|
266
|
+
const spki = newFormat ? newFormat.spki : userData;
|
|
267
|
+
try {
|
|
268
|
+
await crypto.subtle.importKey(
|
|
269
|
+
'spki',
|
|
270
|
+
new Uint8Array(spki),
|
|
271
|
+
{ name: 'ECDH', namedCurve: 'P-256' },
|
|
272
|
+
false,
|
|
273
|
+
[]
|
|
274
|
+
);
|
|
275
|
+
} catch {
|
|
276
|
+
return fail('user-data');
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
escrowPublicKeySpkiB64: bufferToBase64(new Uint8Array(spki).buffer),
|
|
280
|
+
keyId: newFormat?.keyId,
|
|
281
|
+
previousKeyIds: newFormat?.previousKeyIds ?? [],
|
|
282
|
+
pcrs,
|
|
283
|
+
timestamp,
|
|
284
|
+
moduleId,
|
|
285
|
+
};
|
|
286
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { base64ToBuffer, bufferToBase64, bytesToHex, deriveKeyFromPassword } from './crypto';
|
|
4
|
+
import {
|
|
5
|
+
ESCROW_PIN_MAX_ATTEMPTS,
|
|
6
|
+
PIN_MAX_LENGTH,
|
|
7
|
+
PIN_MIN_LENGTH,
|
|
8
|
+
constantTimeEqualHex,
|
|
9
|
+
derivePinProof,
|
|
10
|
+
generatePinSalt,
|
|
11
|
+
normalizePin,
|
|
12
|
+
validatePin,
|
|
13
|
+
} from './escrow-pin';
|
|
14
|
+
|
|
15
|
+
describe('escrow PIN rules', () => {
|
|
16
|
+
it('exports the policy limits', (): void => {
|
|
17
|
+
expect(PIN_MIN_LENGTH).toBe(6);
|
|
18
|
+
expect(PIN_MAX_LENGTH).toBe(12);
|
|
19
|
+
expect(ESCROW_PIN_MAX_ATTEMPTS).toBe(10);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it.each([
|
|
23
|
+
[' 135790\n', '135790'],
|
|
24
|
+
['13-57 90abc', '135790'],
|
|
25
|
+
['135790', '135790'],
|
|
26
|
+
['¹³⁵⁷⁹⁰', '135790'],
|
|
27
|
+
['001357', '001357'],
|
|
28
|
+
['abc! ', ''],
|
|
29
|
+
])('normalizes %j to %j', (raw, expected): void => {
|
|
30
|
+
expect(normalizePin(raw)).toBe(expected);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it.each(['', 'abc', '13579', '1357902468135', '12345'])(
|
|
34
|
+
'rejects invalid normalized length %j',
|
|
35
|
+
(pin): void => {
|
|
36
|
+
expect(validatePin(pin)).toEqual({ ok: false, reason: 'length' });
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
it.each([
|
|
41
|
+
'123456',
|
|
42
|
+
'654321',
|
|
43
|
+
'000000',
|
|
44
|
+
'111111',
|
|
45
|
+
'121212',
|
|
46
|
+
'112233',
|
|
47
|
+
'123123',
|
|
48
|
+
'696969',
|
|
49
|
+
'123321',
|
|
50
|
+
'999999999999',
|
|
51
|
+
'2222222',
|
|
52
|
+
'012345',
|
|
53
|
+
'2345678',
|
|
54
|
+
'0123456789',
|
|
55
|
+
'9876543210',
|
|
56
|
+
'8765432',
|
|
57
|
+
'123456',
|
|
58
|
+
' 12-12-12 ',
|
|
59
|
+
])('rejects trivial PIN %j', (pin): void => {
|
|
60
|
+
expect(validatePin(pin)).toEqual({ ok: false, reason: 'trivial' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it.each(['135790', '001357', '135790246813', '1234568', '6543217', '135790'])(
|
|
64
|
+
'accepts nontrivial PIN %j',
|
|
65
|
+
(pin): void => {
|
|
66
|
+
expect(validatePin(pin)).toEqual({ ok: true });
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('escrow PIN proof', () => {
|
|
72
|
+
it('generates independent 16-byte base64 salts', (): void => {
|
|
73
|
+
const salt = generatePinSalt();
|
|
74
|
+
expect(base64ToBuffer(salt)).toHaveLength(16);
|
|
75
|
+
expect(bufferToBase64(base64ToBuffer(salt).buffer)).toBe(salt);
|
|
76
|
+
expect(generatePinSalt()).not.toBe(salt);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('is deterministic, normalized, salt-sensitive and uses the default password KDF', async (): Promise<void> => {
|
|
80
|
+
const saltBytes = new Uint8Array(16).fill(1);
|
|
81
|
+
const salt = bufferToBase64(saltBytes.buffer);
|
|
82
|
+
const proof = await derivePinProof('135790', salt);
|
|
83
|
+
expect(proof).toMatch(/^[0-9a-f]{64}$/);
|
|
84
|
+
expect(await derivePinProof(' 13-5790 ', salt)).toBe(proof);
|
|
85
|
+
expect(proof).toBe(bytesToHex(await deriveKeyFromPassword('135790', saltBytes)));
|
|
86
|
+
expect(
|
|
87
|
+
await derivePinProof('135790', bufferToBase64(new Uint8Array(16).fill(2).buffer))
|
|
88
|
+
).not.toBe(proof);
|
|
89
|
+
expect(await derivePinProof('135791', salt)).not.toBe(proof);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it.each([0, 15, 17])('rejects a %i-byte salt', async (length): Promise<void> => {
|
|
93
|
+
await expect(
|
|
94
|
+
derivePinProof('135790', bufferToBase64(new Uint8Array(length).buffer))
|
|
95
|
+
).rejects.toThrow('salt length');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('rejects malformed base64', async (): Promise<void> => {
|
|
99
|
+
await expect(derivePinProof('135790', '!')).rejects.toThrow();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('constantTimeEqualHex', () => {
|
|
104
|
+
it.each([
|
|
105
|
+
['ab'.repeat(32), 'ab'.repeat(32), true],
|
|
106
|
+
['AB'.repeat(32), 'ab'.repeat(32), true],
|
|
107
|
+
['00', '00', true],
|
|
108
|
+
['00'.repeat(32), `01${'00'.repeat(31)}`, false],
|
|
109
|
+
['00'.repeat(32), `${'00'.repeat(31)}01`, false],
|
|
110
|
+
['00', '0000', false],
|
|
111
|
+
['', '', false],
|
|
112
|
+
['a', 'a', false],
|
|
113
|
+
['gg', 'gg', false],
|
|
114
|
+
['0g', '00', false],
|
|
115
|
+
['00', '0g', false],
|
|
116
|
+
])('compares %j and %j', (a, b, expected): void => {
|
|
117
|
+
expect(constantTimeEqualHex(a, b)).toBe(expected);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
base64ToBuffer,
|
|
3
|
+
bufferToBase64,
|
|
4
|
+
bytesToHex,
|
|
5
|
+
deriveKeyFromPassword,
|
|
6
|
+
hexToBytes,
|
|
7
|
+
} from './crypto';
|
|
8
|
+
|
|
9
|
+
export const PIN_MIN_LENGTH = 6;
|
|
10
|
+
export const PIN_MAX_LENGTH = 12;
|
|
11
|
+
export const ESCROW_PIN_MAX_ATTEMPTS = 10;
|
|
12
|
+
|
|
13
|
+
const TRIVIAL_PINS = new Set([
|
|
14
|
+
'123456',
|
|
15
|
+
'654321',
|
|
16
|
+
'000000',
|
|
17
|
+
'111111',
|
|
18
|
+
'121212',
|
|
19
|
+
'112233',
|
|
20
|
+
'123123',
|
|
21
|
+
'696969',
|
|
22
|
+
'123321',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/** Normalize compatibility digits before removing formatting and non-digits. */
|
|
26
|
+
export const normalizePin = (raw: string): string =>
|
|
27
|
+
raw.trim().normalize('NFKD').replace(/\D/g, '');
|
|
28
|
+
|
|
29
|
+
/** Validate the normalized PIN, rejecting full-length sequential and repeated digits. */
|
|
30
|
+
export const validatePin = (
|
|
31
|
+
pin: string
|
|
32
|
+
): { ok: true } | { ok: false; reason: 'length' | 'trivial' } => {
|
|
33
|
+
const normalized = normalizePin(pin);
|
|
34
|
+
if (normalized.length < PIN_MIN_LENGTH || normalized.length > PIN_MAX_LENGTH) {
|
|
35
|
+
return { ok: false, reason: 'length' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (
|
|
39
|
+
/^(\d)\1+$/.test(normalized) ||
|
|
40
|
+
'0123456789'.includes(normalized) ||
|
|
41
|
+
'9876543210'.includes(normalized) ||
|
|
42
|
+
TRIVIAL_PINS.has(normalized)
|
|
43
|
+
) {
|
|
44
|
+
return { ok: false, reason: 'trivial' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { ok: true };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Generate a public, random 16-byte salt encoded as standard base64. */
|
|
51
|
+
export const generatePinSalt = (): string =>
|
|
52
|
+
bufferToBase64(crypto.getRandomValues(new Uint8Array(16)).buffer);
|
|
53
|
+
|
|
54
|
+
/** Derive the enclave proof using the shared default Argon2id parameters. */
|
|
55
|
+
export const derivePinProof = async (pin: string, pinSaltBase64: string): Promise<string> => {
|
|
56
|
+
const salt = base64ToBuffer(pinSaltBase64);
|
|
57
|
+
if (salt.length !== 16) throw new Error('Invalid escrow PIN salt length');
|
|
58
|
+
|
|
59
|
+
const key = await deriveKeyFromPassword(normalizePin(pin), salt);
|
|
60
|
+
try {
|
|
61
|
+
return bytesToHex(key);
|
|
62
|
+
} finally {
|
|
63
|
+
key.fill(0);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Compare valid hex bytes without an early exit on differing contents.
|
|
69
|
+
* Format and length are public; JavaScript runtimes cannot guarantee constant-time execution.
|
|
70
|
+
*/
|
|
71
|
+
export const constantTimeEqualHex = (a: string, b: string): boolean => {
|
|
72
|
+
if (a.length !== b.length || !/^(?:[0-9a-f]{2})+$/i.test(a) || !/^(?:[0-9a-f]{2})+$/i.test(b)) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const aBytes = hexToBytes(a);
|
|
77
|
+
const bBytes = hexToBytes(b);
|
|
78
|
+
let difference = 0;
|
|
79
|
+
for (let i = 0; i < aBytes.length; i++) {
|
|
80
|
+
difference |= aBytes[i]! ^ bBytes[i]!;
|
|
81
|
+
}
|
|
82
|
+
return difference === 0;
|
|
83
|
+
};
|