@learncard/sss-key-manager 0.1.23 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -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 +8 -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-pin.d.ts +22 -0
- package/dist/escrow-pin.d.ts.map +1 -0
- package/dist/index.d.ts +13 -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 +3163 -440
- 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 +3163 -440
- 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 +87 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- 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 +85 -0
- package/src/escrow-attestation.ts +64 -0
- package/src/escrow-crypto.test.ts +381 -0
- package/src/escrow-crypto.ts +319 -0
- package/src/escrow-pin.test.ts +119 -0
- package/src/escrow-pin.ts +83 -0
- package/src/index.ts +63 -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 +2642 -110
- package/src/sss-strategy.ts +2186 -224
- package/src/storage.test.ts +66 -8
- package/src/storage.ts +41 -29
- package/src/types.ts +107 -6
|
@@ -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
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -43,6 +43,60 @@ 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 type { EscrowAttestationPolicy, EscrowHoldStatus, EscrowRecoveryStart } from './types';
|
|
81
|
+
export type { EscrowEnrollmentOptions, EscrowEnrollmentState, EscrowPinStatus } from './types';
|
|
82
|
+
export {
|
|
83
|
+
EscrowPinMismatchError,
|
|
84
|
+
EscrowPinLockedError,
|
|
85
|
+
EscrowPinThrottledError,
|
|
86
|
+
EscrowHoldRestartThrottledError,
|
|
87
|
+
EscrowPinUnavailableError,
|
|
88
|
+
} from './types';
|
|
89
|
+
export {
|
|
90
|
+
PIN_MIN_LENGTH,
|
|
91
|
+
PIN_MAX_LENGTH,
|
|
92
|
+
ESCROW_PIN_MAX_ATTEMPTS,
|
|
93
|
+
normalizePin,
|
|
94
|
+
validatePin,
|
|
95
|
+
generatePinSalt,
|
|
96
|
+
derivePinProof,
|
|
97
|
+
constantTimeEqualHex,
|
|
98
|
+
} from './escrow-pin';
|
|
99
|
+
|
|
46
100
|
export {
|
|
47
101
|
createPasskeyCredential,
|
|
48
102
|
deriveKeyFromPasskey,
|
|
@@ -60,6 +114,7 @@ export {
|
|
|
60
114
|
generateRecoveryPhrase,
|
|
61
115
|
validateRecoveryPhrase,
|
|
62
116
|
countWords,
|
|
117
|
+
buildRecoveryPhraseChallengeOptions,
|
|
63
118
|
} from './recovery-phrase';
|
|
64
119
|
|
|
65
120
|
export type { RecoveryPhraseData } from './recovery-phrase';
|
|
@@ -71,6 +126,7 @@ export {
|
|
|
71
126
|
atomicRecovery,
|
|
72
127
|
ShareVerificationError,
|
|
73
128
|
AtomicUpdateError,
|
|
129
|
+
ShareWriteRejectedError,
|
|
74
130
|
} from './atomic-operations';
|
|
75
131
|
|
|
76
132
|
export type {
|
|
@@ -79,7 +135,11 @@ export type {
|
|
|
79
135
|
StorageOperations,
|
|
80
136
|
} from './atomic-operations';
|
|
81
137
|
|
|
82
|
-
export {
|
|
138
|
+
export {
|
|
139
|
+
createSSSStrategy,
|
|
140
|
+
IdentityRecoverySessionConsumedError,
|
|
141
|
+
EscrowRequestError,
|
|
142
|
+
} from './sss-strategy';
|
|
83
143
|
|
|
84
144
|
export type { SSSStorageFunctions, SSSStrategyConfig } from './sss-strategy';
|
|
85
145
|
|
|
@@ -125,6 +185,7 @@ export type {
|
|
|
125
185
|
AuthProvider,
|
|
126
186
|
AuthUser,
|
|
127
187
|
AuthProviderType,
|
|
188
|
+
DidAuthVpSigner,
|
|
128
189
|
ContactMethod,
|
|
129
190
|
ContactMethodType,
|
|
130
191
|
RecoveryMethod,
|
|
@@ -134,6 +195,7 @@ export type {
|
|
|
134
195
|
RecoveryResult,
|
|
135
196
|
RecoverySetupInput,
|
|
136
197
|
RecoverySetupResult,
|
|
198
|
+
RecoveryConfirmationInput,
|
|
137
199
|
PasskeyRecoveryMethod,
|
|
138
200
|
BackupFileRecoveryMethod,
|
|
139
201
|
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.endsWith('/attestation')) {
|
|
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.endsWith('/attestation')) 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
|
+
});
|