@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,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
|
@@ -43,6 +43,67 @@ export {
|
|
|
43
43
|
DEFAULT_KDF_PARAMS,
|
|
44
44
|
} from './crypto';
|
|
45
45
|
|
|
46
|
+
export {
|
|
47
|
+
EMAIL_RELAY_ALGORITHM,
|
|
48
|
+
EMAIL_RELAY_ENVELOPE_VERSION,
|
|
49
|
+
encryptEmailRelayPayload,
|
|
50
|
+
decryptEmailRelayPayload,
|
|
51
|
+
generateEmailRelayConfirmationCode,
|
|
52
|
+
parseEmailRelayEnvelope,
|
|
53
|
+
parseEmailRelayPlaintext,
|
|
54
|
+
} from './email-relay-crypto';
|
|
55
|
+
|
|
56
|
+
export type {
|
|
57
|
+
EmailRelayBranding,
|
|
58
|
+
EmailRelayEnvelope,
|
|
59
|
+
EmailRelayPlaintext,
|
|
60
|
+
} from './email-relay-crypto';
|
|
61
|
+
|
|
62
|
+
export {
|
|
63
|
+
ESCROW_ENVELOPE_VERSION,
|
|
64
|
+
ESCROW_ALGORITHM,
|
|
65
|
+
ESCROW_BLOB_INFO,
|
|
66
|
+
ESCROW_RELEASE_INFO,
|
|
67
|
+
ESCROW_CLIENT_KEY_ID,
|
|
68
|
+
parseEscrowEnvelope,
|
|
69
|
+
parseEscrowBlobPlaintext,
|
|
70
|
+
parseEscrowReleasePlaintext,
|
|
71
|
+
generateEscrowKeyPair,
|
|
72
|
+
encryptEscrowBlob,
|
|
73
|
+
decryptEscrowBlob,
|
|
74
|
+
sealEscrowRelease,
|
|
75
|
+
openEscrowRelease,
|
|
76
|
+
} from './escrow-crypto';
|
|
77
|
+
|
|
78
|
+
export type { EscrowEnvelope, EscrowBlobPlaintext, EscrowReleasePlaintext } from './escrow-crypto';
|
|
79
|
+
export { verifyEnclaveAttestation } from './escrow-attestation';
|
|
80
|
+
export { verifyNitroAttestationDocument, NitroAttestationError } from './escrow-nitro-attestation';
|
|
81
|
+
export type { NitroAttestationReason } from './escrow-nitro-attestation';
|
|
82
|
+
export type { EscrowAttestationPolicy, EscrowHoldStatus, EscrowRecoveryStart } from './types';
|
|
83
|
+
export type {
|
|
84
|
+
EscrowEnrollmentOptions,
|
|
85
|
+
EscrowEnrollmentState,
|
|
86
|
+
EscrowBlobStaleReason,
|
|
87
|
+
EscrowPinStatus,
|
|
88
|
+
} from './types';
|
|
89
|
+
export {
|
|
90
|
+
EscrowPinMismatchError,
|
|
91
|
+
EscrowPinLockedError,
|
|
92
|
+
EscrowPinThrottledError,
|
|
93
|
+
EscrowHoldRestartThrottledError,
|
|
94
|
+
EscrowPinUnavailableError,
|
|
95
|
+
} from './types';
|
|
96
|
+
export {
|
|
97
|
+
PIN_MIN_LENGTH,
|
|
98
|
+
PIN_MAX_LENGTH,
|
|
99
|
+
ESCROW_PIN_MAX_ATTEMPTS,
|
|
100
|
+
normalizePin,
|
|
101
|
+
validatePin,
|
|
102
|
+
generatePinSalt,
|
|
103
|
+
derivePinProof,
|
|
104
|
+
constantTimeEqualHex,
|
|
105
|
+
} from './escrow-pin';
|
|
106
|
+
|
|
46
107
|
export {
|
|
47
108
|
createPasskeyCredential,
|
|
48
109
|
deriveKeyFromPasskey,
|
|
@@ -60,6 +121,7 @@ export {
|
|
|
60
121
|
generateRecoveryPhrase,
|
|
61
122
|
validateRecoveryPhrase,
|
|
62
123
|
countWords,
|
|
124
|
+
buildRecoveryPhraseChallengeOptions,
|
|
63
125
|
} from './recovery-phrase';
|
|
64
126
|
|
|
65
127
|
export type { RecoveryPhraseData } from './recovery-phrase';
|
|
@@ -71,6 +133,7 @@ export {
|
|
|
71
133
|
atomicRecovery,
|
|
72
134
|
ShareVerificationError,
|
|
73
135
|
AtomicUpdateError,
|
|
136
|
+
ShareWriteRejectedError,
|
|
74
137
|
} from './atomic-operations';
|
|
75
138
|
|
|
76
139
|
export type {
|
|
@@ -79,7 +142,11 @@ export type {
|
|
|
79
142
|
StorageOperations,
|
|
80
143
|
} from './atomic-operations';
|
|
81
144
|
|
|
82
|
-
export {
|
|
145
|
+
export {
|
|
146
|
+
createSSSStrategy,
|
|
147
|
+
IdentityRecoverySessionConsumedError,
|
|
148
|
+
EscrowRequestError,
|
|
149
|
+
} from './sss-strategy';
|
|
83
150
|
|
|
84
151
|
export type { SSSStorageFunctions, SSSStrategyConfig } from './sss-strategy';
|
|
85
152
|
|
|
@@ -125,6 +192,7 @@ export type {
|
|
|
125
192
|
AuthProvider,
|
|
126
193
|
AuthUser,
|
|
127
194
|
AuthProviderType,
|
|
195
|
+
DidAuthVpSigner,
|
|
128
196
|
ContactMethod,
|
|
129
197
|
ContactMethodType,
|
|
130
198
|
RecoveryMethod,
|
|
@@ -134,6 +202,7 @@ export type {
|
|
|
134
202
|
RecoveryResult,
|
|
135
203
|
RecoverySetupInput,
|
|
136
204
|
RecoverySetupResult,
|
|
205
|
+
RecoveryConfirmationInput,
|
|
137
206
|
PasskeyRecoveryMethod,
|
|
138
207
|
BackupFileRecoveryMethod,
|
|
139
208
|
RecoveryPhraseRecoveryMethod,
|
package/src/key-manager.ts
CHANGED
|
@@ -21,11 +21,7 @@ import {
|
|
|
21
21
|
deleteDeviceShare,
|
|
22
22
|
clearAllShares,
|
|
23
23
|
} from './storage';
|
|
24
|
-
import {
|
|
25
|
-
encryptWithPassword,
|
|
26
|
-
decryptWithPassword,
|
|
27
|
-
generateEd25519PrivateKey,
|
|
28
|
-
} from './crypto';
|
|
24
|
+
import { encryptWithPassword, decryptWithPassword, generateEd25519PrivateKey } from './crypto';
|
|
29
25
|
|
|
30
26
|
import {
|
|
31
27
|
createPasskeyCredential,
|
|
@@ -33,11 +29,9 @@ import {
|
|
|
33
29
|
decryptShareWithPasskey,
|
|
34
30
|
} from './passkey';
|
|
35
31
|
|
|
36
|
-
import {
|
|
37
|
-
shareToRecoveryPhrase,
|
|
38
|
-
recoveryPhraseToShare,
|
|
39
|
-
} from './recovery-phrase';
|
|
32
|
+
import { shareToRecoveryPhrase, recoveryPhraseToShare } from './recovery-phrase';
|
|
40
33
|
|
|
34
|
+
/** @deprecated Use `createSSSStrategy` with AuthCoordinator. */
|
|
41
35
|
export class SSSKeyManager implements SSSKeyDerivationProvider {
|
|
42
36
|
readonly name = 'sss';
|
|
43
37
|
|
|
@@ -318,6 +312,7 @@ export class SSSKeyManager implements SSSKeyDerivationProvider {
|
|
|
318
312
|
}
|
|
319
313
|
}
|
|
320
314
|
|
|
315
|
+
/** @deprecated Use `createSSSStrategy` with AuthCoordinator. */
|
|
321
316
|
export function createSSSKeyManager(config: SSSKeyManagerConfig): SSSKeyManager {
|
|
322
317
|
return new SSSKeyManager(config);
|
|
323
318
|
}
|
package/src/qr-login.ts
CHANGED
|
@@ -55,21 +55,19 @@ export interface QrPayload {
|
|
|
55
55
|
/** Result of polling — either still waiting or the device share is ready */
|
|
56
56
|
export type PollResult =
|
|
57
57
|
| { status: 'pending' }
|
|
58
|
-
| {
|
|
58
|
+
| {
|
|
59
|
+
status: 'approved';
|
|
60
|
+
deviceShare: string;
|
|
61
|
+
approverDid: string;
|
|
62
|
+
accountHint?: string;
|
|
63
|
+
shareVersion?: number;
|
|
64
|
+
};
|
|
59
65
|
|
|
60
66
|
// ---------------------------------------------------------------------------
|
|
61
67
|
// API helpers
|
|
62
68
|
// ---------------------------------------------------------------------------
|
|
63
69
|
|
|
64
|
-
const buildHeaders = (
|
|
65
|
-
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
66
|
-
|
|
67
|
-
if (token) {
|
|
68
|
-
headers.Authorization = `Bearer ${token}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return headers;
|
|
72
|
-
};
|
|
70
|
+
const buildHeaders = (): Record<string, string> => ({ 'Content-Type': 'application/json' });
|
|
73
71
|
|
|
74
72
|
// ---------------------------------------------------------------------------
|
|
75
73
|
// Requester (Device B — the new device)
|
|
@@ -180,7 +178,12 @@ export const pollUntilApproved = async (
|
|
|
180
178
|
onPoll?: (attempt: number) => void;
|
|
181
179
|
signal?: AbortSignal;
|
|
182
180
|
}
|
|
183
|
-
): Promise<{
|
|
181
|
+
): Promise<{
|
|
182
|
+
deviceShare: string;
|
|
183
|
+
approverDid: string;
|
|
184
|
+
accountHint?: string;
|
|
185
|
+
shareVersion?: number;
|
|
186
|
+
}> => {
|
|
184
187
|
const intervalMs = options?.intervalMs ?? 2000;
|
|
185
188
|
const timeoutMs = options?.timeoutMs ?? 120_000;
|
|
186
189
|
|
|
@@ -211,10 +214,14 @@ export const pollUntilApproved = async (
|
|
|
211
214
|
const timer = setTimeout(resolve, intervalMs);
|
|
212
215
|
|
|
213
216
|
if (options?.signal) {
|
|
214
|
-
options.signal.addEventListener(
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
217
|
+
options.signal.addEventListener(
|
|
218
|
+
'abort',
|
|
219
|
+
() => {
|
|
220
|
+
clearTimeout(timer);
|
|
221
|
+
reject(new Error('QR login polling aborted'));
|
|
222
|
+
},
|
|
223
|
+
{ once: true }
|
|
224
|
+
);
|
|
218
225
|
}
|
|
219
226
|
});
|
|
220
227
|
}
|
package/src/recovery-phrase.ts
CHANGED
|
@@ -132,3 +132,43 @@ export function countWords(phrase: string): number {
|
|
|
132
132
|
.split(/\s+/)
|
|
133
133
|
.filter(w => w.length > 0).length;
|
|
134
134
|
}
|
|
135
|
+
|
|
136
|
+
/** Uniform random integer in [0, max) without modulo bias. */
|
|
137
|
+
function randomIndex(max: number): number {
|
|
138
|
+
const limit = 0x1_0000_0000 - (0x1_0000_0000 % max);
|
|
139
|
+
for (;;) {
|
|
140
|
+
const value = crypto.getRandomValues(new Uint32Array(1))[0];
|
|
141
|
+
if (value === undefined) throw new Error('Failed to choose recovery phrase words');
|
|
142
|
+
if (value < limit) return value % max;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Builds tap-to-confirm choices for each challenged word: the correct word plus
|
|
148
|
+
* `optionCount - 1` decoys, shuffled. Decoys never appear anywhere in the phrase,
|
|
149
|
+
* so a choice can only be right for its own position.
|
|
150
|
+
*/
|
|
151
|
+
export async function buildRecoveryPhraseChallengeOptions(
|
|
152
|
+
phrase: string,
|
|
153
|
+
challengeWordIndices: number[],
|
|
154
|
+
optionCount = 6
|
|
155
|
+
): Promise<string[][]> {
|
|
156
|
+
const words = phrase.trim().split(/\s+/);
|
|
157
|
+
const phraseWords = new Set(words);
|
|
158
|
+
const decoyPool = (await getWordlist()).filter(word => !phraseWords.has(word));
|
|
159
|
+
|
|
160
|
+
return challengeWordIndices.map(wordIndex => {
|
|
161
|
+
const answer = words[wordIndex];
|
|
162
|
+
if (!answer) throw new Error('Recovery phrase challenge index is out of range');
|
|
163
|
+
|
|
164
|
+
const options = new Set<string>([answer]);
|
|
165
|
+
while (options.size < optionCount) options.add(decoyPool[randomIndex(decoyPool.length)]!);
|
|
166
|
+
|
|
167
|
+
const shuffled = [...options];
|
|
168
|
+
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
169
|
+
const j = randomIndex(i + 1);
|
|
170
|
+
[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!];
|
|
171
|
+
}
|
|
172
|
+
return shuffled;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createSSSStrategy, type SSSStorageFunctions } from './sss-strategy';
|
|
3
|
+
|
|
4
|
+
vi.mock('./escrow-attestation', () => ({
|
|
5
|
+
verifyEnclaveAttestation: async () => ({ publicKey: 'test-key', keyId: 'test-key' }),
|
|
6
|
+
}));
|
|
7
|
+
vi.mock('./escrow-crypto', () => ({ encryptEscrowBlob: async () => ({}) }));
|
|
8
|
+
|
|
9
|
+
const deferred = <T>() => {
|
|
10
|
+
let resolve!: (value: T) => void;
|
|
11
|
+
const promise = new Promise<T>(done => {
|
|
12
|
+
resolve = done;
|
|
13
|
+
});
|
|
14
|
+
return { promise, resolve };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const setup = () => {
|
|
18
|
+
const devices = new Map<string | undefined, string>([[undefined, 'previous-share']]);
|
|
19
|
+
const storage: SSSStorageFunctions = {
|
|
20
|
+
getDeviceShare: async id => devices.get(id) ?? null,
|
|
21
|
+
hasDeviceShare: async id => devices.has(id),
|
|
22
|
+
storeDeviceShare: vi.fn(async (share, id) => {
|
|
23
|
+
devices.set(id, share);
|
|
24
|
+
}),
|
|
25
|
+
clearAllShares: vi.fn(async id => {
|
|
26
|
+
devices.delete(id);
|
|
27
|
+
}),
|
|
28
|
+
getShareVersion: async () => 1,
|
|
29
|
+
storeShareVersion: vi.fn(async () => {}),
|
|
30
|
+
};
|
|
31
|
+
const strategy = createSSSStrategy({
|
|
32
|
+
serverUrl: 'https://example.test',
|
|
33
|
+
storage,
|
|
34
|
+
escrow: {
|
|
35
|
+
enabled: true,
|
|
36
|
+
attestation: { mode: 'software', pinnedPublicKeys: ['test-key'] },
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const enroll = () =>
|
|
40
|
+
strategy.ensureEscrowEnrollment!({
|
|
41
|
+
token: 'token',
|
|
42
|
+
providerType: 'firebase',
|
|
43
|
+
privateKey: 'a'.repeat(64),
|
|
44
|
+
signDidAuthVp: async () => 'proof',
|
|
45
|
+
});
|
|
46
|
+
return { strategy, storage, enroll };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
|
|
50
|
+
const keyStatus = { primaryDid: 'did:key:test', shareVersion: 1, recoveryMethods: [] };
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.unstubAllGlobals();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('session-scoped enrollment writes', () => {
|
|
57
|
+
it('does not write a device share when attestation resumes after forgetting the device', async () => {
|
|
58
|
+
const { strategy, storage, enroll } = setup();
|
|
59
|
+
const started = deferred<void>();
|
|
60
|
+
const attestation = deferred<Response>();
|
|
61
|
+
vi.stubGlobal(
|
|
62
|
+
'fetch',
|
|
63
|
+
vi.fn(async (url: string) => {
|
|
64
|
+
if (url.includes('/attestation?nonce=')) {
|
|
65
|
+
started.resolve();
|
|
66
|
+
return attestation.promise;
|
|
67
|
+
}
|
|
68
|
+
return response(keyStatus);
|
|
69
|
+
})
|
|
70
|
+
);
|
|
71
|
+
const pending = enroll();
|
|
72
|
+
await started.promise;
|
|
73
|
+
await strategy.clearLocalKeys();
|
|
74
|
+
attestation.resolve(response({ attestation: {} }));
|
|
75
|
+
await expect(pending).rejects.toThrow();
|
|
76
|
+
expect(storage.storeDeviceShare).not.toHaveBeenCalled();
|
|
77
|
+
expect(await strategy.getLocalKey()).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it.each([200, 500])(
|
|
81
|
+
'blocks late version writes and rollback after server response %s',
|
|
82
|
+
async status => {
|
|
83
|
+
const { strategy, storage, enroll } = setup();
|
|
84
|
+
const started = deferred<void>();
|
|
85
|
+
const authWrite = deferred<Response>();
|
|
86
|
+
vi.stubGlobal(
|
|
87
|
+
'fetch',
|
|
88
|
+
vi.fn(async (url: string, init?: RequestInit) => {
|
|
89
|
+
if (url.endsWith('/auth-share') && init?.method === 'PUT') {
|
|
90
|
+
started.resolve();
|
|
91
|
+
return authWrite.promise;
|
|
92
|
+
}
|
|
93
|
+
if (url.endsWith('/challenge')) return response({ challenge: 'nonce' });
|
|
94
|
+
if (url.includes('/attestation?nonce=')) return response({ attestation: {} });
|
|
95
|
+
return response(keyStatus);
|
|
96
|
+
})
|
|
97
|
+
);
|
|
98
|
+
const pending = enroll();
|
|
99
|
+
await started.promise;
|
|
100
|
+
expect(storage.storeDeviceShare).toHaveBeenCalledTimes(1);
|
|
101
|
+
await strategy.clearLocalKeys();
|
|
102
|
+
authWrite.resolve(response({ shareVersion: 2 }, status));
|
|
103
|
+
await expect(pending).rejects.toThrow();
|
|
104
|
+
expect(storage.storeDeviceShare).toHaveBeenCalledTimes(1);
|
|
105
|
+
expect(storage.storeShareVersion).not.toHaveBeenCalled();
|
|
106
|
+
expect(await strategy.getLocalKey()).toBeNull();
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
});
|