@learncard/sss-key-manager 0.2.0 → 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 +13 -0
- package/dist/escrow-attestation.d.ts +5 -1
- package/dist/escrow-attestation.d.ts.map +1 -1
- package/dist/escrow-nitro-attestation.d.ts +32 -0
- package/dist/escrow-nitro-attestation.d.ts.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/sss-key-manager.cjs.development.js +2995 -320
- 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 +774 -170
- package/dist/sss-key-manager.esm.js.map +4 -4
- package/dist/sss-strategy.d.ts.map +1 -1
- package/dist/types.d.ts +15 -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/escrow-attestation.test.ts +7 -2
- package/src/escrow-attestation.ts +71 -10
- package/src/escrow-nitro-attestation.test.ts +204 -0
- package/src/escrow-nitro-attestation.ts +286 -0
- package/src/escrow-vectors.test.ts +198 -0
- package/src/index.ts +8 -1
- package/src/sss-session-writes.test.ts +2 -2
- package/src/sss-strategy.test.ts +95 -2
- package/src/sss-strategy.ts +128 -88
- package/src/types.ts +12 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { expect, it } from 'vitest';
|
|
5
|
+
import { base64ToBuffer } from './crypto';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
decryptEscrowBlob,
|
|
9
|
+
ESCROW_BLOB_INFO,
|
|
10
|
+
ESCROW_RELEASE_INFO,
|
|
11
|
+
encryptEscrowBlob,
|
|
12
|
+
generateEscrowKeyPair,
|
|
13
|
+
openEscrowRelease,
|
|
14
|
+
sealEscrowRelease,
|
|
15
|
+
} from './escrow-crypto';
|
|
16
|
+
import type { EscrowBlobPlaintext, EscrowEnvelope, EscrowReleasePlaintext } from './escrow-crypto';
|
|
17
|
+
|
|
18
|
+
type KeyPair = Awaited<ReturnType<typeof generateEscrowKeyPair>>;
|
|
19
|
+
interface Vectors {
|
|
20
|
+
// Public test-only keys, never used for real recovery data.
|
|
21
|
+
enclave: KeyPair;
|
|
22
|
+
client: KeyPair;
|
|
23
|
+
blobs: { plaintext: EscrowBlobPlaintext; envelope: EscrowEnvelope }[];
|
|
24
|
+
releases: { plaintext: EscrowReleasePlaintext; envelope: EscrowEnvelope }[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
it('decrypts shared TS/Rust escrow vectors (ESCROW_WRITE_VECTORS=1 regenerates)', async () => {
|
|
28
|
+
const directory = fileURLToPath(new URL('./__fixtures__/', import.meta.url));
|
|
29
|
+
const path = fileURLToPath(new URL('./__fixtures__/escrow-vectors.json', import.meta.url));
|
|
30
|
+
|
|
31
|
+
if (process.env.ESCROW_WRITE_VECTORS === '1') {
|
|
32
|
+
const enclave = await generateEscrowKeyPair();
|
|
33
|
+
const client = await generateEscrowKeyPair();
|
|
34
|
+
const inputs = [
|
|
35
|
+
{ recoveryShare: 'ABCDE', did: 'did:example:legacy', shareVersion: 1 },
|
|
36
|
+
{
|
|
37
|
+
recoveryShare: `0001${'AB'.repeat(48)}`,
|
|
38
|
+
did: 'did:example:pin',
|
|
39
|
+
shareVersion: 42,
|
|
40
|
+
pinVerifier: 'CD'.repeat(32),
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
recoveryShare: 'ef'.repeat(2048),
|
|
44
|
+
did: `did:${'x'.repeat(2042)}😀`,
|
|
45
|
+
shareVersion: 9_007_199_254_740_992,
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
const blobs = await Promise.all(
|
|
49
|
+
inputs.map(async (input, index) => {
|
|
50
|
+
const envelope = await encryptEscrowBlob(
|
|
51
|
+
input,
|
|
52
|
+
enclave.publicKey,
|
|
53
|
+
`test-key.${index}`
|
|
54
|
+
);
|
|
55
|
+
return {
|
|
56
|
+
plaintext: await decryptEscrowBlob(envelope, enclave.privateKey),
|
|
57
|
+
envelope,
|
|
58
|
+
};
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
const releases = await Promise.all(
|
|
62
|
+
inputs.slice(0, 2).map(async (input, index) => {
|
|
63
|
+
const envelope = await sealEscrowRelease(
|
|
64
|
+
{ ...input, holdId: `hold-${index}.test_1` },
|
|
65
|
+
client.publicKey
|
|
66
|
+
);
|
|
67
|
+
return {
|
|
68
|
+
plaintext: await openEscrowRelease(envelope, client.privateKey),
|
|
69
|
+
envelope,
|
|
70
|
+
};
|
|
71
|
+
})
|
|
72
|
+
);
|
|
73
|
+
const vectors: Vectors = { enclave, client, blobs, releases };
|
|
74
|
+
await mkdir(directory, { recursive: true });
|
|
75
|
+
await writeFile(path, `${JSON.stringify(vectors, null, 4)}\n`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const vectors: Vectors = JSON.parse(await readFile(path, 'utf8'));
|
|
79
|
+
expect(vectors.blobs).toHaveLength(3);
|
|
80
|
+
expect(vectors.releases).toHaveLength(2);
|
|
81
|
+
for (const { plaintext, envelope } of vectors.blobs) {
|
|
82
|
+
await expect(decryptEscrowBlob(envelope, vectors.enclave.privateKey)).resolves.toEqual(
|
|
83
|
+
plaintext
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
for (const { plaintext, envelope } of vectors.releases) {
|
|
87
|
+
await expect(openEscrowRelease(envelope, vectors.client.privateKey)).resolves.toEqual(
|
|
88
|
+
plaintext
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Independent WebCrypto decryption exposes raw JSON before the production validator
|
|
94
|
+
// normalizes it, so a numeric 1.0 cannot silently satisfy the integral-token assertion.
|
|
95
|
+
const decryptRawJson = async (
|
|
96
|
+
envelope: EscrowEnvelope,
|
|
97
|
+
privateKey: string,
|
|
98
|
+
info: string
|
|
99
|
+
): Promise<string> => {
|
|
100
|
+
const recipient = await crypto.subtle.importKey(
|
|
101
|
+
'pkcs8',
|
|
102
|
+
base64ToBuffer(privateKey),
|
|
103
|
+
{ name: 'ECDH', namedCurve: 'P-256' },
|
|
104
|
+
false,
|
|
105
|
+
['deriveBits']
|
|
106
|
+
);
|
|
107
|
+
const ephemeral = await crypto.subtle.importKey(
|
|
108
|
+
'raw',
|
|
109
|
+
base64ToBuffer(envelope.ephemeralPublicKey),
|
|
110
|
+
{ name: 'ECDH', namedCurve: 'P-256' },
|
|
111
|
+
false,
|
|
112
|
+
[]
|
|
113
|
+
);
|
|
114
|
+
const shared = await crypto.subtle.deriveBits(
|
|
115
|
+
{ name: 'ECDH', public: ephemeral },
|
|
116
|
+
recipient,
|
|
117
|
+
256
|
|
118
|
+
);
|
|
119
|
+
const material = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']);
|
|
120
|
+
const key = await crypto.subtle.deriveKey(
|
|
121
|
+
{
|
|
122
|
+
name: 'HKDF',
|
|
123
|
+
hash: 'SHA-256',
|
|
124
|
+
salt: base64ToBuffer(envelope.salt),
|
|
125
|
+
info: new TextEncoder().encode(info),
|
|
126
|
+
},
|
|
127
|
+
material,
|
|
128
|
+
{ name: 'AES-GCM', length: 256 },
|
|
129
|
+
false,
|
|
130
|
+
['decrypt']
|
|
131
|
+
);
|
|
132
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
133
|
+
{
|
|
134
|
+
name: 'AES-GCM',
|
|
135
|
+
iv: base64ToBuffer(envelope.iv),
|
|
136
|
+
additionalData: new TextEncoder().encode(
|
|
137
|
+
`${envelope.version}|${envelope.algorithm}|${envelope.keyId}`
|
|
138
|
+
),
|
|
139
|
+
},
|
|
140
|
+
key,
|
|
141
|
+
base64ToBuffer(envelope.ciphertext)
|
|
142
|
+
);
|
|
143
|
+
return new TextDecoder().decode(plaintext);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
it('opens Rust blobs and releases in WebCrypto and emits integral JSON share versions', async () => {
|
|
147
|
+
const ts: Vectors = JSON.parse(
|
|
148
|
+
await readFile(
|
|
149
|
+
fileURLToPath(new URL('./__fixtures__/escrow-vectors.json', import.meta.url)),
|
|
150
|
+
'utf8'
|
|
151
|
+
)
|
|
152
|
+
);
|
|
153
|
+
const rust: Omit<Vectors, 'client'> = JSON.parse(
|
|
154
|
+
await readFile(
|
|
155
|
+
fileURLToPath(new URL('./__fixtures__/escrow-vectors-rust.json', import.meta.url)),
|
|
156
|
+
'utf8'
|
|
157
|
+
)
|
|
158
|
+
);
|
|
159
|
+
expect(rust.blobs).toHaveLength(2);
|
|
160
|
+
expect(rust.releases).toHaveLength(2);
|
|
161
|
+
|
|
162
|
+
// Import Rust SPKI as well as PKCS#8, and verify the two exports describe one key.
|
|
163
|
+
const publicKey = await crypto.subtle.importKey(
|
|
164
|
+
'spki',
|
|
165
|
+
base64ToBuffer(rust.enclave.publicKey),
|
|
166
|
+
{ name: 'ECDH', namedCurve: 'P-256' },
|
|
167
|
+
true,
|
|
168
|
+
[]
|
|
169
|
+
);
|
|
170
|
+
const privateKey = await crypto.subtle.importKey(
|
|
171
|
+
'pkcs8',
|
|
172
|
+
base64ToBuffer(rust.enclave.privateKey),
|
|
173
|
+
{ name: 'ECDH', namedCurve: 'P-256' },
|
|
174
|
+
true,
|
|
175
|
+
['deriveBits']
|
|
176
|
+
);
|
|
177
|
+
const publicJwk = await crypto.subtle.exportKey('jwk', publicKey);
|
|
178
|
+
expect(await crypto.subtle.exportKey('jwk', privateKey)).toMatchObject({
|
|
179
|
+
crv: 'P-256',
|
|
180
|
+
x: publicJwk.x,
|
|
181
|
+
y: publicJwk.y,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
for (const { plaintext, envelope } of rust.blobs) {
|
|
185
|
+
await expect(decryptEscrowBlob(envelope, rust.enclave.privateKey)).resolves.toEqual(
|
|
186
|
+
plaintext
|
|
187
|
+
);
|
|
188
|
+
const raw = await decryptRawJson(envelope, rust.enclave.privateKey, ESCROW_BLOB_INFO);
|
|
189
|
+
expect(raw).toMatch(new RegExp(`"shareVersion":${plaintext.shareVersion}[,}]`));
|
|
190
|
+
expect(JSON.parse(raw)).toEqual(plaintext);
|
|
191
|
+
}
|
|
192
|
+
for (const { plaintext, envelope } of rust.releases) {
|
|
193
|
+
await expect(openEscrowRelease(envelope, ts.client.privateKey)).resolves.toEqual(plaintext);
|
|
194
|
+
const raw = await decryptRawJson(envelope, ts.client.privateKey, ESCROW_RELEASE_INFO);
|
|
195
|
+
expect(raw).toMatch(new RegExp(`"shareVersion":${plaintext.shareVersion}[,}]`));
|
|
196
|
+
expect(JSON.parse(raw)).toEqual(plaintext);
|
|
197
|
+
}
|
|
198
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -77,8 +77,15 @@ export {
|
|
|
77
77
|
|
|
78
78
|
export type { EscrowEnvelope, EscrowBlobPlaintext, EscrowReleasePlaintext } from './escrow-crypto';
|
|
79
79
|
export { verifyEnclaveAttestation } from './escrow-attestation';
|
|
80
|
+
export { verifyNitroAttestationDocument, NitroAttestationError } from './escrow-nitro-attestation';
|
|
81
|
+
export type { NitroAttestationReason } from './escrow-nitro-attestation';
|
|
80
82
|
export type { EscrowAttestationPolicy, EscrowHoldStatus, EscrowRecoveryStart } from './types';
|
|
81
|
-
export type {
|
|
83
|
+
export type {
|
|
84
|
+
EscrowEnrollmentOptions,
|
|
85
|
+
EscrowEnrollmentState,
|
|
86
|
+
EscrowBlobStaleReason,
|
|
87
|
+
EscrowPinStatus,
|
|
88
|
+
} from './types';
|
|
82
89
|
export {
|
|
83
90
|
EscrowPinMismatchError,
|
|
84
91
|
EscrowPinLockedError,
|
|
@@ -61,7 +61,7 @@ describe('session-scoped enrollment writes', () => {
|
|
|
61
61
|
vi.stubGlobal(
|
|
62
62
|
'fetch',
|
|
63
63
|
vi.fn(async (url: string) => {
|
|
64
|
-
if (url.
|
|
64
|
+
if (url.includes('/attestation?nonce=')) {
|
|
65
65
|
started.resolve();
|
|
66
66
|
return attestation.promise;
|
|
67
67
|
}
|
|
@@ -91,7 +91,7 @@ describe('session-scoped enrollment writes', () => {
|
|
|
91
91
|
return authWrite.promise;
|
|
92
92
|
}
|
|
93
93
|
if (url.endsWith('/challenge')) return response({ challenge: 'nonce' });
|
|
94
|
-
if (url.
|
|
94
|
+
if (url.includes('/attestation?nonce=')) return response({ attestation: {} });
|
|
95
95
|
return response(keyStatus);
|
|
96
96
|
})
|
|
97
97
|
);
|
package/src/sss-strategy.test.ts
CHANGED
|
@@ -160,6 +160,7 @@ describe('escrow strategy', () => {
|
|
|
160
160
|
let releasePolicy: 'hold' | 'pin';
|
|
161
161
|
let consumed: boolean;
|
|
162
162
|
let burned: string[];
|
|
163
|
+
let escrowStale: 'mode-mismatch' | 'key-rotated' | undefined;
|
|
163
164
|
const hold = () => ({
|
|
164
165
|
holdId: holdNumber <= 1 ? 'escrow-hold' : `escrow-hold-${holdNumber}`,
|
|
165
166
|
status: cancelled ? 'cancelled' : 'pending',
|
|
@@ -190,6 +191,7 @@ describe('escrow strategy', () => {
|
|
|
190
191
|
releasePolicy = 'hold';
|
|
191
192
|
consumed = false;
|
|
192
193
|
burned = [];
|
|
194
|
+
escrowStale = undefined;
|
|
193
195
|
config = {
|
|
194
196
|
serverUrl: 'https://test.example/api',
|
|
195
197
|
storage,
|
|
@@ -225,6 +227,7 @@ describe('escrow strategy', () => {
|
|
|
225
227
|
attemptsRemaining,
|
|
226
228
|
...(pinSalt ? { salt: pinSalt } : {}),
|
|
227
229
|
},
|
|
230
|
+
...(escrowStale ? { escrowStale } : {}),
|
|
228
231
|
});
|
|
229
232
|
}
|
|
230
233
|
if (path === '/keys/recovery' || path === '/keys/recovery/confirm')
|
|
@@ -266,6 +269,7 @@ describe('escrow strategy', () => {
|
|
|
266
269
|
blob = await decryptEscrowBlob(body.envelope, enclaveKeys.privateKey);
|
|
267
270
|
pinSalt = body.pinSalt;
|
|
268
271
|
attemptsRemaining = 10;
|
|
272
|
+
escrowStale = undefined;
|
|
269
273
|
expect(blob.did).toBe(did);
|
|
270
274
|
expect(blob.shareVersion).toBe(version);
|
|
271
275
|
expect(
|
|
@@ -480,7 +484,7 @@ describe('escrow strategy', () => {
|
|
|
480
484
|
});
|
|
481
485
|
methods[0].shareVersion = 1;
|
|
482
486
|
expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
|
|
483
|
-
state: '
|
|
487
|
+
state: 'stale',
|
|
484
488
|
});
|
|
485
489
|
methods[0].shareVersion = version;
|
|
486
490
|
methods[0].confirmedAt = undefined;
|
|
@@ -543,7 +547,7 @@ describe('escrow strategy', () => {
|
|
|
543
547
|
await strategy.setupRecoveryMethod!({ ...params, input: { method: 'phrase' } });
|
|
544
548
|
expect(config.onEscrowError).toHaveBeenCalledWith(
|
|
545
549
|
expect.objectContaining({
|
|
546
|
-
message: '
|
|
550
|
+
message: 'Escrow attestation mode mismatch',
|
|
547
551
|
})
|
|
548
552
|
);
|
|
549
553
|
});
|
|
@@ -571,6 +575,77 @@ describe('escrow strategy', () => {
|
|
|
571
575
|
});
|
|
572
576
|
expect(calls).toHaveLength(0);
|
|
573
577
|
});
|
|
578
|
+
it('getEscrowEnrollmentState reports stale with a reason when the server flags the blob', async () => {
|
|
579
|
+
await strategy.ensureEscrowEnrollment!(params);
|
|
580
|
+
escrowStale = 'mode-mismatch';
|
|
581
|
+
expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
|
|
582
|
+
state: 'stale',
|
|
583
|
+
staleReason: 'mode-mismatch',
|
|
584
|
+
});
|
|
585
|
+
escrowStale = 'key-rotated';
|
|
586
|
+
expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
|
|
587
|
+
state: 'stale',
|
|
588
|
+
staleReason: 'key-rotated',
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
it('does not trigger the stale-repair path when the server reports no staleness', async () => {
|
|
592
|
+
await strategy.ensureEscrowEnrollment!(params);
|
|
593
|
+
calls = [];
|
|
594
|
+
await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
|
|
595
|
+
enrolled: true,
|
|
596
|
+
changed: false,
|
|
597
|
+
});
|
|
598
|
+
expect(calls.some(call => call.path === '/keys/escrow/attestation')).toBe(false);
|
|
599
|
+
});
|
|
600
|
+
it('rotates a stale enrollment through the normal rotation path, at most once per session', async () => {
|
|
601
|
+
await strategy.ensureEscrowEnrollment!(params);
|
|
602
|
+
escrowStale = 'mode-mismatch';
|
|
603
|
+
await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
|
|
604
|
+
enrolled: true,
|
|
605
|
+
changed: true,
|
|
606
|
+
shareVersion: 3,
|
|
607
|
+
});
|
|
608
|
+
expect(version).toBe(3);
|
|
609
|
+
expect(blob?.shareVersion).toBe(3);
|
|
610
|
+
// A real re-enroll clears server-side staleness (the mock's '/keys/escrow'
|
|
611
|
+
// handler already reset it); force it stale again so this assertion proves
|
|
612
|
+
// the session-scoped rate limit — not the mock's own healing — stops the retry.
|
|
613
|
+
escrowStale = 'mode-mismatch';
|
|
614
|
+
calls = [];
|
|
615
|
+
await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
|
|
616
|
+
enrolled: true,
|
|
617
|
+
changed: false,
|
|
618
|
+
});
|
|
619
|
+
expect(calls.some(call => call.path === '/keys/escrow/attestation')).toBe(false);
|
|
620
|
+
expect(version).toBe(3);
|
|
621
|
+
// A fresh strategy instance (the next app session) attempts the repair again.
|
|
622
|
+
const nextSession = createSSSStrategy(config);
|
|
623
|
+
await expect(nextSession.ensureEscrowEnrollment!(params)).resolves.toEqual({
|
|
624
|
+
enrolled: true,
|
|
625
|
+
changed: true,
|
|
626
|
+
shareVersion: 4,
|
|
627
|
+
});
|
|
628
|
+
expect(version).toBe(4);
|
|
629
|
+
});
|
|
630
|
+
it('swallows a failed automatic stale-blob repair instead of throwing to the caller', async () => {
|
|
631
|
+
await strategy.ensureEscrowEnrollment!(params);
|
|
632
|
+
escrowStale = 'key-rotated';
|
|
633
|
+
attestationFailure = true;
|
|
634
|
+
await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
|
|
635
|
+
enrolled: true,
|
|
636
|
+
changed: false,
|
|
637
|
+
});
|
|
638
|
+
expect(version).toBe(2);
|
|
639
|
+
expect(config.onEscrowError).toHaveBeenCalledOnce();
|
|
640
|
+
vi.mocked(config.onEscrowError!).mockClear();
|
|
641
|
+
// Still rate-limited to one attempt this session, even though it failed.
|
|
642
|
+
await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
|
|
643
|
+
enrolled: true,
|
|
644
|
+
changed: false,
|
|
645
|
+
});
|
|
646
|
+
expect(config.onEscrowError).not.toHaveBeenCalled();
|
|
647
|
+
expect(version).toBe(2);
|
|
648
|
+
});
|
|
574
649
|
it('requires DID verification before attempting PIN recovery', async () => {
|
|
575
650
|
await expect(
|
|
576
651
|
strategy.executeRecovery({
|
|
@@ -585,6 +660,24 @@ describe('escrow strategy', () => {
|
|
|
585
660
|
expect(storage.clearAllShares).not.toHaveBeenCalled();
|
|
586
661
|
expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
|
|
587
662
|
});
|
|
663
|
+
it.each(['set', 'clear'] as const)(
|
|
664
|
+
'rejects explicit %s PIN on a stale account when the enclave is unreachable',
|
|
665
|
+
async action => {
|
|
666
|
+
await strategy.ensureEscrowEnrollment!(params);
|
|
667
|
+
escrowStale = 'key-rotated';
|
|
668
|
+
attestationFailure = true;
|
|
669
|
+
await expect(
|
|
670
|
+
action === 'set'
|
|
671
|
+
? strategy.setEscrowPin!({ ...params, pin: '135790' })
|
|
672
|
+
: strategy.clearEscrowPin!(params)
|
|
673
|
+
).rejects.toThrow();
|
|
674
|
+
expect(version).toBe(2);
|
|
675
|
+
expect(config.onEscrowError).not.toHaveBeenCalled();
|
|
676
|
+
// Explicit actions must not consume the automatic repair attempt.
|
|
677
|
+
await strategy.ensureEscrowEnrollment!(params);
|
|
678
|
+
expect(config.onEscrowError).toHaveBeenCalledOnce();
|
|
679
|
+
}
|
|
680
|
+
);
|
|
588
681
|
const recoverPin = (pin = '135790') =>
|
|
589
682
|
strategy.executeRecovery({
|
|
590
683
|
...params,
|
package/src/sss-strategy.ts
CHANGED
|
@@ -1054,6 +1054,10 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1054
1054
|
} | null = null;
|
|
1055
1055
|
|
|
1056
1056
|
let escrowEnrollmentInFlight: Promise<EscrowEnrollmentResult> | undefined;
|
|
1057
|
+
// A stale-blob repair is attempted at most once per strategy instance
|
|
1058
|
+
// (i.e. once per app session); a failed attempt is logged and left for
|
|
1059
|
+
// the next session rather than retried on every subsequent call.
|
|
1060
|
+
let staleEscrowReenrollAttempted = false;
|
|
1057
1061
|
|
|
1058
1062
|
// Material from a rotation whose escrow POST failed. Retained in memory so a
|
|
1059
1063
|
// retry within this session re-posts instead of burning another share version.
|
|
@@ -1143,11 +1147,16 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1143
1147
|
|
|
1144
1148
|
const fetchVerifiedEnclaveKey = async (): Promise<{ publicKey: string; keyId: string }> => {
|
|
1145
1149
|
if (!config.escrow?.enabled) throw new Error('Escrow enrollment is disabled');
|
|
1146
|
-
const
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1150
|
+
const nonce = crypto.getRandomValues(new Uint8Array(32));
|
|
1151
|
+
const nonceHex = Array.from(nonce, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
1152
|
+
const { attestation } = await escrowRequest<{ attestation: unknown }>(
|
|
1153
|
+
`/attestation?nonce=${nonceHex}`,
|
|
1154
|
+
{
|
|
1155
|
+
method: 'GET',
|
|
1156
|
+
headers: buildHeaders('', undefined, tenantId),
|
|
1157
|
+
}
|
|
1158
|
+
);
|
|
1159
|
+
return verifyEnclaveAttestation(attestation, config.escrow.attestation, nonce);
|
|
1151
1160
|
};
|
|
1152
1161
|
|
|
1153
1162
|
const enrollEscrow = async (
|
|
@@ -1231,9 +1240,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1231
1240
|
}
|
|
1232
1241
|
const status = await self.fetchServerKeyStatus(params.token, params.providerType);
|
|
1233
1242
|
if (status.escrowOptedOut) return { enrolled: false, reason: 'opted-out' };
|
|
1234
|
-
|
|
1235
|
-
!forceRotate &&
|
|
1236
|
-
pin === undefined &&
|
|
1243
|
+
const currentlyEnrolled =
|
|
1237
1244
|
status.shareVersion !== null &&
|
|
1238
1245
|
status.recoveryMethods.some(
|
|
1239
1246
|
method =>
|
|
@@ -1242,87 +1249,113 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1242
1249
|
(Boolean(method.confirmedAt) ||
|
|
1243
1250
|
('confirmationStatus' in method &&
|
|
1244
1251
|
method.confirmationStatus === 'confirmed'))
|
|
1245
|
-
)
|
|
1246
|
-
|
|
1252
|
+
);
|
|
1253
|
+
// A confirmed enrollment whose sealed blob no longer matches the active
|
|
1254
|
+
// enclave (P6 mode migration/rollback, or a rotated enclave key) still
|
|
1255
|
+
// needs the same rotation a missing enrollment would get — but only one
|
|
1256
|
+
// automatic attempt per session, so a still-unreachable enclave doesn't
|
|
1257
|
+
// retry on every ensureEscrowEnrollment call within the same session.
|
|
1258
|
+
const repairingStaleBlob =
|
|
1259
|
+
pin === undefined &&
|
|
1260
|
+
!forceRotate &&
|
|
1261
|
+
currentlyEnrolled &&
|
|
1262
|
+
!!status.escrowStale &&
|
|
1263
|
+
!staleEscrowReenrollAttempted;
|
|
1264
|
+
if (!forceRotate && pin === undefined && currentlyEnrolled && !repairingStaleBlob)
|
|
1247
1265
|
// Automatic repair preserves current enrollment regardless of PIN status.
|
|
1248
1266
|
// The enclave can carry an existing verifier when shares rotate.
|
|
1249
1267
|
return { enrolled: true, changed: false };
|
|
1268
|
+
if (repairingStaleBlob) staleEscrowReenrollAttempted = true;
|
|
1250
1269
|
if (!status.primaryDid) throw new Error('Cannot enroll escrow without a primary DID');
|
|
1251
1270
|
if (!params.signDidAuthVp) {
|
|
1252
1271
|
throw new Error('DID proof signing is required for escrow enrollment');
|
|
1253
1272
|
}
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1273
|
+
try {
|
|
1274
|
+
if (
|
|
1275
|
+
!forceRotate &&
|
|
1276
|
+
pin === undefined &&
|
|
1277
|
+
unenrolledRotation &&
|
|
1278
|
+
unenrolledRotation.shareVersion === status.shareVersion &&
|
|
1279
|
+
unenrolledRotation.primaryDid === status.primaryDid
|
|
1280
|
+
) {
|
|
1281
|
+
const retry = unenrolledRotation;
|
|
1282
|
+
await enrollEscrow(
|
|
1283
|
+
params.token,
|
|
1284
|
+
params.providerType,
|
|
1285
|
+
params.privateKey,
|
|
1286
|
+
retry.primaryDid,
|
|
1287
|
+
retry.shares,
|
|
1288
|
+
retry.shareVersion,
|
|
1289
|
+
params.signDidAuthVp,
|
|
1290
|
+
undefined,
|
|
1291
|
+
undefined,
|
|
1292
|
+
retry.clearPin
|
|
1293
|
+
);
|
|
1294
|
+
unenrolledRotation = undefined;
|
|
1295
|
+
return { enrolled: true, changed: true, shareVersion: retry.shareVersion };
|
|
1296
|
+
}
|
|
1297
|
+
// Verify the enclave before rotating: a bad attestation must not burn
|
|
1298
|
+
// a share version (and, repeated, the retained auth-share history).
|
|
1299
|
+
const verifiedKey = await fetchVerifiedEnclaveKey();
|
|
1300
|
+
const pinSalt = pin !== undefined ? generatePinSalt() : undefined;
|
|
1301
|
+
const pinMaterial =
|
|
1302
|
+
pin !== undefined && pinSalt !== undefined
|
|
1303
|
+
? { pinSalt, pinVerifier: await derivePinProof(pin, pinSalt) }
|
|
1304
|
+
: undefined;
|
|
1305
|
+
const storageId = activeStorageId;
|
|
1306
|
+
const primaryDid = status.primaryDid;
|
|
1307
|
+
const { shares, shareVersion } = await withShareUpdateLock(storage, storageId, () =>
|
|
1308
|
+
persistSharesWithoutLock(
|
|
1309
|
+
params.privateKey,
|
|
1310
|
+
serverUrl,
|
|
1311
|
+
params.token,
|
|
1312
|
+
params.providerType,
|
|
1313
|
+
primaryDid,
|
|
1314
|
+
storage,
|
|
1315
|
+
storageId,
|
|
1316
|
+
undefined,
|
|
1317
|
+
params.signDidAuthVp,
|
|
1318
|
+
tenantId
|
|
1319
|
+
)
|
|
1320
|
+
);
|
|
1321
|
+
lastEmailShare = shares.emailShare;
|
|
1322
|
+
lastShareVersion = shareVersion;
|
|
1323
|
+
lastServerSnapshot = {
|
|
1324
|
+
currentVersion: shareVersion,
|
|
1325
|
+
resolvedVersion: shareVersion,
|
|
1326
|
+
authShare: shares.authShare,
|
|
1327
|
+
primaryDid: status.primaryDid,
|
|
1328
|
+
};
|
|
1329
|
+
unenrolledRotation = pinMaterial
|
|
1330
|
+
? undefined
|
|
1331
|
+
: { shares, shareVersion, primaryDid: status.primaryDid, clearPin: forceRotate };
|
|
1262
1332
|
await enrollEscrow(
|
|
1263
1333
|
params.token,
|
|
1264
1334
|
params.providerType,
|
|
1265
1335
|
params.privateKey,
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1336
|
+
status.primaryDid,
|
|
1337
|
+
shares,
|
|
1338
|
+
shareVersion,
|
|
1269
1339
|
params.signDidAuthVp,
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1340
|
+
verifiedKey,
|
|
1341
|
+
pinMaterial,
|
|
1342
|
+
forceRotate
|
|
1273
1343
|
);
|
|
1274
1344
|
unenrolledRotation = undefined;
|
|
1275
|
-
return { enrolled: true, changed: true, shareVersion
|
|
1345
|
+
return { enrolled: true, changed: true, shareVersion };
|
|
1346
|
+
} catch (err) {
|
|
1347
|
+
// A background repair the caller never asked for must not surface as
|
|
1348
|
+
// a hard failure (mirrors tryEnrollEscrow's existing swallow-and-report
|
|
1349
|
+
// shape); an explicit not-yet-enrolled/PIN/forced rotation still throws.
|
|
1350
|
+
if (!repairingStaleBlob) throw err;
|
|
1351
|
+
try {
|
|
1352
|
+
if (config.onEscrowError) config.onEscrowError(err);
|
|
1353
|
+
else console.warn('SSS: escrow enrollment failed');
|
|
1354
|
+
} catch {
|
|
1355
|
+
console.warn('SSS: escrow error reporting failed');
|
|
1356
|
+
}
|
|
1357
|
+
return { enrolled: true, changed: false };
|
|
1276
1358
|
}
|
|
1277
|
-
// Verify the enclave before rotating: a bad attestation must not burn
|
|
1278
|
-
// a share version (and, repeated, the retained auth-share history).
|
|
1279
|
-
const verifiedKey = await fetchVerifiedEnclaveKey();
|
|
1280
|
-
const pinSalt = pin !== undefined ? generatePinSalt() : undefined;
|
|
1281
|
-
const pinMaterial =
|
|
1282
|
-
pin !== undefined && pinSalt !== undefined
|
|
1283
|
-
? { pinSalt, pinVerifier: await derivePinProof(pin, pinSalt) }
|
|
1284
|
-
: undefined;
|
|
1285
|
-
const storageId = activeStorageId;
|
|
1286
|
-
const primaryDid = status.primaryDid;
|
|
1287
|
-
const { shares, shareVersion } = await withShareUpdateLock(storage, storageId, () =>
|
|
1288
|
-
persistSharesWithoutLock(
|
|
1289
|
-
params.privateKey,
|
|
1290
|
-
serverUrl,
|
|
1291
|
-
params.token,
|
|
1292
|
-
params.providerType,
|
|
1293
|
-
primaryDid,
|
|
1294
|
-
storage,
|
|
1295
|
-
storageId,
|
|
1296
|
-
undefined,
|
|
1297
|
-
params.signDidAuthVp,
|
|
1298
|
-
tenantId
|
|
1299
|
-
)
|
|
1300
|
-
);
|
|
1301
|
-
lastEmailShare = shares.emailShare;
|
|
1302
|
-
lastShareVersion = shareVersion;
|
|
1303
|
-
lastServerSnapshot = {
|
|
1304
|
-
currentVersion: shareVersion,
|
|
1305
|
-
resolvedVersion: shareVersion,
|
|
1306
|
-
authShare: shares.authShare,
|
|
1307
|
-
primaryDid: status.primaryDid,
|
|
1308
|
-
};
|
|
1309
|
-
unenrolledRotation = pinMaterial
|
|
1310
|
-
? undefined
|
|
1311
|
-
: { shares, shareVersion, primaryDid: status.primaryDid, clearPin: forceRotate };
|
|
1312
|
-
await enrollEscrow(
|
|
1313
|
-
params.token,
|
|
1314
|
-
params.providerType,
|
|
1315
|
-
params.privateKey,
|
|
1316
|
-
status.primaryDid,
|
|
1317
|
-
shares,
|
|
1318
|
-
shareVersion,
|
|
1319
|
-
params.signDidAuthVp,
|
|
1320
|
-
verifiedKey,
|
|
1321
|
-
pinMaterial,
|
|
1322
|
-
forceRotate
|
|
1323
|
-
);
|
|
1324
|
-
unenrolledRotation = undefined;
|
|
1325
|
-
return { enrolled: true, changed: true, shareVersion };
|
|
1326
1359
|
};
|
|
1327
1360
|
|
|
1328
1361
|
const recordPromotion =
|
|
@@ -1740,6 +1773,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1740
1773
|
maskedRecoveryEmail: data.maskedRecoveryEmail ?? null,
|
|
1741
1774
|
escrowOptedOut: data.escrowOptedOut === true,
|
|
1742
1775
|
escrowPin: data.escrowPin,
|
|
1776
|
+
escrowStale: data.escrowStale,
|
|
1743
1777
|
sssActivationState: data.sssActivationState ?? 'active',
|
|
1744
1778
|
};
|
|
1745
1779
|
},
|
|
@@ -1810,19 +1844,25 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1810
1844
|
if (!config.escrow?.enabled) return { state: 'disabled' };
|
|
1811
1845
|
const status = await this.fetchServerKeyStatus(params.token, params.providerType);
|
|
1812
1846
|
if (status.escrowOptedOut) return { state: 'opted-out', escrowPin: status.escrowPin };
|
|
1813
|
-
const
|
|
1847
|
+
const confirmedEscrow = status.recoveryMethods.filter(
|
|
1848
|
+
method =>
|
|
1849
|
+
method.type === 'escrow' &&
|
|
1850
|
+
(Boolean(method.confirmedAt) ||
|
|
1851
|
+
('confirmationStatus' in method &&
|
|
1852
|
+
method.confirmationStatus === 'confirmed'))
|
|
1853
|
+
);
|
|
1854
|
+
const enrolled =
|
|
1814
1855
|
status.shareVersion !== null &&
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
return { state, escrowPin: status.escrowPin };
|
|
1856
|
+
confirmedEscrow.some(method => method.shareVersion === status.shareVersion);
|
|
1857
|
+
// Previously enrolled accounts must bypass the new-enrollment rollout
|
|
1858
|
+
// even when a rotation succeeded but resealing the escrow blob failed.
|
|
1859
|
+
if (confirmedEscrow.length > 0 && (!enrolled || status.escrowStale))
|
|
1860
|
+
return {
|
|
1861
|
+
state: 'stale',
|
|
1862
|
+
escrowPin: status.escrowPin,
|
|
1863
|
+
staleReason: status.escrowStale,
|
|
1864
|
+
};
|
|
1865
|
+
return { state: enrolled ? 'enrolled' : 'not-enrolled', escrowPin: status.escrowPin };
|
|
1826
1866
|
},
|
|
1827
1867
|
|
|
1828
1868
|
async disableEscrowRecovery(params): Promise<void> {
|
|
@@ -1878,7 +1918,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1878
1918
|
const result = await withRotationLock(() =>
|
|
1879
1919
|
ensureEscrowEnrollmentUnguarded(this, { ...params, options: { pin } }, storage)
|
|
1880
1920
|
);
|
|
1881
|
-
if (!result.enrolled) {
|
|
1921
|
+
if (!result.enrolled || !result.changed) {
|
|
1882
1922
|
throw new Error('Automatic recovery is not available for this account.');
|
|
1883
1923
|
}
|
|
1884
1924
|
},
|
|
@@ -1888,7 +1928,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
|
|
|
1888
1928
|
const result = await withRotationLock(() =>
|
|
1889
1929
|
ensureEscrowEnrollmentUnguarded(this, params, storage, true)
|
|
1890
1930
|
);
|
|
1891
|
-
if (!result.enrolled) {
|
|
1931
|
+
if (!result.enrolled || !result.changed) {
|
|
1892
1932
|
throw new Error('Automatic recovery is not available for this account.');
|
|
1893
1933
|
}
|
|
1894
1934
|
},
|