@learncard/sss-key-manager 0.1.14 → 0.1.16
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 +17 -17
- package/dist/sss-key-manager.cjs.development.js +126 -36
- package/dist/sss-key-manager.cjs.development.js.map +2 -2
- package/dist/sss-key-manager.cjs.production.min.js +6 -6
- package/dist/sss-key-manager.cjs.production.min.js.map +3 -3
- package/dist/sss-key-manager.esm.js +126 -36
- package/dist/sss-key-manager.esm.js.map +2 -2
- package/package.json +62 -52
- package/src/api-client.ts +257 -0
- package/src/atomic-operations.test.ts +327 -0
- package/src/atomic-operations.ts +275 -0
- package/src/auth-coordinator.test.ts +13 -0
- package/src/auth-coordinator.ts +12 -0
- package/src/critical-paths.test.ts +380 -0
- package/src/crypto.test.ts +214 -0
- package/src/crypto.ts +203 -0
- package/src/index.ts +146 -0
- package/src/key-manager.test.ts +330 -0
- package/src/key-manager.ts +323 -0
- package/src/passkey.test.ts +59 -0
- package/src/passkey.ts +222 -0
- package/src/qr-crypto.test.ts +122 -0
- package/src/qr-crypto.ts +206 -0
- package/src/qr-login-notify.test.ts +95 -0
- package/src/qr-login.test.ts +548 -0
- package/src/qr-login.ts +339 -0
- package/src/recovery-phrase.test.ts +287 -0
- package/src/recovery-phrase.ts +131 -0
- package/src/sss-strategy.test.ts +1956 -0
- package/src/sss-strategy.ts +1119 -0
- package/src/sss.test.ts +242 -0
- package/src/sss.ts +49 -0
- package/src/storage.test.ts +530 -0
- package/src/storage.ts +467 -0
- package/src/types.ts +200 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSS Key Manager - Main class
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
SSSKeyManagerConfig,
|
|
7
|
+
SSSKeyDerivationProvider,
|
|
8
|
+
RecoveryMethod,
|
|
9
|
+
RecoveryMethodInfo,
|
|
10
|
+
SecurityLevel,
|
|
11
|
+
BackupFile,
|
|
12
|
+
AuthProvider,
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
import { SSSApiClient } from './api-client';
|
|
16
|
+
import { splitPrivateKey, reconstructPrivateKey } from './sss';
|
|
17
|
+
import {
|
|
18
|
+
storeDeviceShare,
|
|
19
|
+
getDeviceShare,
|
|
20
|
+
hasDeviceShare,
|
|
21
|
+
deleteDeviceShare,
|
|
22
|
+
clearAllShares,
|
|
23
|
+
} from './storage';
|
|
24
|
+
import {
|
|
25
|
+
encryptWithPassword,
|
|
26
|
+
decryptWithPassword,
|
|
27
|
+
generateEd25519PrivateKey,
|
|
28
|
+
} from './crypto';
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
createPasskeyCredential,
|
|
32
|
+
encryptShareWithPasskey,
|
|
33
|
+
decryptShareWithPasskey,
|
|
34
|
+
} from './passkey';
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
shareToRecoveryPhrase,
|
|
38
|
+
recoveryPhraseToShare,
|
|
39
|
+
} from './recovery-phrase';
|
|
40
|
+
|
|
41
|
+
export class SSSKeyManager implements SSSKeyDerivationProvider {
|
|
42
|
+
readonly name = 'sss';
|
|
43
|
+
|
|
44
|
+
private config: SSSKeyManagerConfig;
|
|
45
|
+
private apiClient: SSSApiClient;
|
|
46
|
+
private initialized = false;
|
|
47
|
+
private currentPrivateKey: string | null = null;
|
|
48
|
+
|
|
49
|
+
constructor(config: SSSKeyManagerConfig) {
|
|
50
|
+
this.config = config;
|
|
51
|
+
this.apiClient = new SSSApiClient({
|
|
52
|
+
serverUrl: config.serverUrl,
|
|
53
|
+
authProvider: config.authProvider,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
isInitialized(): boolean {
|
|
58
|
+
return this.initialized;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async hasLocalKey(): Promise<boolean> {
|
|
62
|
+
return hasDeviceShare(this.config.deviceStorageKey);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async connect(): Promise<string> {
|
|
66
|
+
const deviceShare = await getDeviceShare(this.config.deviceStorageKey);
|
|
67
|
+
|
|
68
|
+
if (!deviceShare) {
|
|
69
|
+
throw new Error('No device share found. User needs to set up SSS or recover.');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const serverResponse = await this.apiClient.getAuthShare();
|
|
73
|
+
|
|
74
|
+
if (!serverResponse || !serverResponse.authShare) {
|
|
75
|
+
throw new Error('No auth share found on server. User may need to recover.');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (serverResponse.keyProvider !== 'sss') {
|
|
79
|
+
throw new Error('User has not migrated to SSS yet.');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const privateKey = await reconstructPrivateKey(
|
|
83
|
+
deviceShare,
|
|
84
|
+
serverResponse.authShare.encryptedData
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
this.currentPrivateKey = privateKey;
|
|
88
|
+
this.initialized = true;
|
|
89
|
+
|
|
90
|
+
return privateKey;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async disconnect(): Promise<void> {
|
|
94
|
+
this.currentPrivateKey = null;
|
|
95
|
+
this.initialized = false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async setupNewKey(): Promise<string> {
|
|
99
|
+
const privateKey = await generateEd25519PrivateKey();
|
|
100
|
+
await this.setupWithKey(privateKey);
|
|
101
|
+
return privateKey;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async setupWithKey(privateKey: string, primaryDid?: string): Promise<void> {
|
|
105
|
+
const shares = await splitPrivateKey(privateKey);
|
|
106
|
+
|
|
107
|
+
await storeDeviceShare(shares.deviceShare, this.config.deviceStorageKey);
|
|
108
|
+
|
|
109
|
+
const did = primaryDid || `did:key:placeholder-${Date.now()}`;
|
|
110
|
+
|
|
111
|
+
await this.apiClient.storeAuthShare({
|
|
112
|
+
authShare: {
|
|
113
|
+
encryptedData: shares.authShare,
|
|
114
|
+
encryptedDek: '',
|
|
115
|
+
iv: '',
|
|
116
|
+
},
|
|
117
|
+
primaryDid: did,
|
|
118
|
+
securityLevel: 'basic',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
this.currentPrivateKey = privateKey;
|
|
122
|
+
this.initialized = true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async migrate(privateKey: string): Promise<void> {
|
|
126
|
+
await this.setupWithKey(privateKey);
|
|
127
|
+
await this.apiClient.markMigrated();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async canMigrate(): Promise<boolean> {
|
|
131
|
+
const serverResponse = await this.apiClient.getAuthShare();
|
|
132
|
+
return serverResponse?.keyProvider === 'web3auth';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async addRecoveryMethod(method: RecoveryMethod): Promise<void> {
|
|
136
|
+
if (!this.currentPrivateKey) {
|
|
137
|
+
throw new Error('No active key. Connect first.');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const shares = await splitPrivateKey(this.currentPrivateKey);
|
|
141
|
+
const recoveryShare = shares.recoveryShare;
|
|
142
|
+
|
|
143
|
+
if (method.type === 'passkey') {
|
|
144
|
+
const user = await this.config.authProvider.getCurrentUser();
|
|
145
|
+
if (!user) {
|
|
146
|
+
throw new Error('No authenticated user');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const credential = await createPasskeyCredential(
|
|
150
|
+
user.id,
|
|
151
|
+
user.email || user.phone || user.id
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const encryptedShare = await encryptShareWithPasskey(
|
|
155
|
+
recoveryShare,
|
|
156
|
+
credential.credentialId
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
await this.apiClient.addRecoveryMethod({
|
|
160
|
+
type: 'passkey',
|
|
161
|
+
encryptedShare: {
|
|
162
|
+
encryptedData: encryptedShare.encryptedData,
|
|
163
|
+
iv: encryptedShare.iv,
|
|
164
|
+
},
|
|
165
|
+
credentialId: credential.credentialId,
|
|
166
|
+
});
|
|
167
|
+
} else if (method.type === 'backup') {
|
|
168
|
+
throw new Error('Use exportBackup() instead');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async generateRecoveryPhrase(): Promise<string> {
|
|
173
|
+
if (!this.currentPrivateKey) {
|
|
174
|
+
throw new Error('No active key. Connect first.');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const shares = await splitPrivateKey(this.currentPrivateKey);
|
|
178
|
+
const phrase = await shareToRecoveryPhrase(shares.recoveryShare);
|
|
179
|
+
|
|
180
|
+
return phrase;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async getRecoveryMethods(): Promise<RecoveryMethodInfo[]> {
|
|
184
|
+
const serverResponse = await this.apiClient.getAuthShare();
|
|
185
|
+
return serverResponse?.recoveryMethods || [];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async recover(method: RecoveryMethod): Promise<string> {
|
|
189
|
+
if (method.type === 'backup') {
|
|
190
|
+
const backup: BackupFile = JSON.parse(method.fileContents);
|
|
191
|
+
|
|
192
|
+
if (backup.version !== 1) {
|
|
193
|
+
throw new Error('Unsupported backup file version');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const recoveryShare = await decryptWithPassword(
|
|
197
|
+
backup.encryptedShare.ciphertext,
|
|
198
|
+
backup.encryptedShare.iv,
|
|
199
|
+
backup.encryptedShare.salt,
|
|
200
|
+
method.password,
|
|
201
|
+
backup.encryptedShare.kdfParams
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const serverResponse = await this.apiClient.getAuthShare();
|
|
205
|
+
|
|
206
|
+
if (!serverResponse || !serverResponse.authShare) {
|
|
207
|
+
throw new Error('No auth share found on server');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const privateKey = await reconstructPrivateKey(
|
|
211
|
+
recoveryShare,
|
|
212
|
+
serverResponse.authShare.encryptedData
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const newShares = await splitPrivateKey(privateKey);
|
|
216
|
+
await storeDeviceShare(newShares.deviceShare, this.config.deviceStorageKey);
|
|
217
|
+
|
|
218
|
+
this.currentPrivateKey = privateKey;
|
|
219
|
+
this.initialized = true;
|
|
220
|
+
|
|
221
|
+
return privateKey;
|
|
222
|
+
} else if (method.type === 'passkey') {
|
|
223
|
+
const result = await this.apiClient.getRecoveryShare('passkey', method.credentialId);
|
|
224
|
+
|
|
225
|
+
if (!result?.encryptedShare) {
|
|
226
|
+
throw new Error('No passkey recovery share found');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const recoveryShare = await decryptShareWithPasskey({
|
|
230
|
+
encryptedData: result.encryptedShare.encryptedData,
|
|
231
|
+
iv: result.encryptedShare.iv,
|
|
232
|
+
credentialId: method.credentialId || '',
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const serverResponse = await this.apiClient.getAuthShare();
|
|
236
|
+
|
|
237
|
+
if (!serverResponse || !serverResponse.authShare) {
|
|
238
|
+
throw new Error('No auth share found on server');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const privateKey = await reconstructPrivateKey(
|
|
242
|
+
recoveryShare,
|
|
243
|
+
serverResponse.authShare.encryptedData
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const newShares = await splitPrivateKey(privateKey);
|
|
247
|
+
await storeDeviceShare(newShares.deviceShare, this.config.deviceStorageKey);
|
|
248
|
+
|
|
249
|
+
this.currentPrivateKey = privateKey;
|
|
250
|
+
this.initialized = true;
|
|
251
|
+
|
|
252
|
+
return privateKey;
|
|
253
|
+
} else if (method.type === 'phrase') {
|
|
254
|
+
const recoveryShare = await recoveryPhraseToShare(method.phrase);
|
|
255
|
+
|
|
256
|
+
const serverResponse = await this.apiClient.getAuthShare();
|
|
257
|
+
|
|
258
|
+
if (!serverResponse || !serverResponse.authShare) {
|
|
259
|
+
throw new Error('No auth share found on server');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const privateKey = await reconstructPrivateKey(
|
|
263
|
+
recoveryShare,
|
|
264
|
+
serverResponse.authShare.encryptedData
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const newShares = await splitPrivateKey(privateKey);
|
|
268
|
+
await storeDeviceShare(newShares.deviceShare, this.config.deviceStorageKey);
|
|
269
|
+
|
|
270
|
+
this.currentPrivateKey = privateKey;
|
|
271
|
+
this.initialized = true;
|
|
272
|
+
|
|
273
|
+
return privateKey;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
throw new Error('Unknown recovery method');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async getSecurityLevel(): Promise<SecurityLevel> {
|
|
280
|
+
const serverResponse = await this.apiClient.getAuthShare();
|
|
281
|
+
return serverResponse?.securityLevel || 'basic';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async exportBackup(password: string): Promise<BackupFile> {
|
|
285
|
+
if (!this.currentPrivateKey) {
|
|
286
|
+
throw new Error('No active key. Connect first.');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const shares = await splitPrivateKey(this.currentPrivateKey);
|
|
290
|
+
const encrypted = await encryptWithPassword(shares.recoveryShare, password);
|
|
291
|
+
|
|
292
|
+
const serverResponse = await this.apiClient.getAuthShare();
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
version: 1,
|
|
296
|
+
createdAt: new Date().toISOString(),
|
|
297
|
+
primaryDid: serverResponse?.primaryDid || 'unknown',
|
|
298
|
+
encryptedShare: {
|
|
299
|
+
ciphertext: encrypted.ciphertext,
|
|
300
|
+
iv: encrypted.iv,
|
|
301
|
+
salt: encrypted.salt,
|
|
302
|
+
kdfParams: encrypted.kdfParams,
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async clearLocalData(): Promise<void> {
|
|
308
|
+
await deleteDeviceShare(this.config.deviceStorageKey);
|
|
309
|
+
this.currentPrivateKey = null;
|
|
310
|
+
this.initialized = false;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async deleteAccount(): Promise<void> {
|
|
314
|
+
await this.apiClient.deleteUserKey();
|
|
315
|
+
await clearAllShares();
|
|
316
|
+
this.currentPrivateKey = null;
|
|
317
|
+
this.initialized = false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function createSSSKeyManager(config: SSSKeyManagerConfig): SSSKeyManager {
|
|
322
|
+
return new SSSKeyManager(config);
|
|
323
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
isWebAuthnSupported,
|
|
4
|
+
isPRFSupported,
|
|
5
|
+
} from './passkey';
|
|
6
|
+
|
|
7
|
+
describe('Passkey utilities', () => {
|
|
8
|
+
describe('isWebAuthnSupported', () => {
|
|
9
|
+
it('should return false when window is undefined', () => {
|
|
10
|
+
const originalWindow = global.window;
|
|
11
|
+
// @ts-expect-error - Testing undefined window
|
|
12
|
+
delete global.window;
|
|
13
|
+
|
|
14
|
+
expect(isWebAuthnSupported()).toBe(false);
|
|
15
|
+
|
|
16
|
+
global.window = originalWindow;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should return false when PublicKeyCredential is undefined', () => {
|
|
20
|
+
const originalPKC = global.window?.PublicKeyCredential;
|
|
21
|
+
if (global.window) {
|
|
22
|
+
// @ts-expect-error - Testing undefined PublicKeyCredential
|
|
23
|
+
delete global.window.PublicKeyCredential;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
expect(isWebAuthnSupported()).toBe(false);
|
|
27
|
+
|
|
28
|
+
if (global.window && originalPKC) {
|
|
29
|
+
global.window.PublicKeyCredential = originalPKC;
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('isPRFSupported', () => {
|
|
35
|
+
it('should return false when WebAuthn is not supported', async () => {
|
|
36
|
+
const originalWindow = global.window;
|
|
37
|
+
// @ts-expect-error - Testing undefined window
|
|
38
|
+
delete global.window;
|
|
39
|
+
|
|
40
|
+
const result = await isPRFSupported();
|
|
41
|
+
expect(result).toBe(false);
|
|
42
|
+
|
|
43
|
+
global.window = originalWindow;
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('Module exports', () => {
|
|
48
|
+
it('should export all required functions', async () => {
|
|
49
|
+
const passkey = await import('./passkey');
|
|
50
|
+
|
|
51
|
+
expect(typeof passkey.isWebAuthnSupported).toBe('function');
|
|
52
|
+
expect(typeof passkey.isPRFSupported).toBe('function');
|
|
53
|
+
expect(typeof passkey.createPasskeyCredential).toBe('function');
|
|
54
|
+
expect(typeof passkey.deriveKeyFromPasskey).toBe('function');
|
|
55
|
+
expect(typeof passkey.encryptShareWithPasskey).toBe('function');
|
|
56
|
+
expect(typeof passkey.decryptShareWithPasskey).toBe('function');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
package/src/passkey.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebAuthn Passkey utilities for SSS recovery
|
|
3
|
+
* Uses the PRF (Pseudo-Random Function) extension to derive encryption keys
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { bufferToBase64, base64ToBuffer, bytesToHex, hexToBytes } from './crypto';
|
|
7
|
+
|
|
8
|
+
export interface PasskeyCredential {
|
|
9
|
+
credentialId: string;
|
|
10
|
+
publicKey: string;
|
|
11
|
+
transports?: AuthenticatorTransport[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PasskeyEncryptedShare {
|
|
15
|
+
encryptedData: string;
|
|
16
|
+
iv: string;
|
|
17
|
+
credentialId: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const RP_NAME = 'LearnCard';
|
|
21
|
+
const RP_ID = typeof window !== 'undefined' ? window.location.hostname : 'localhost';
|
|
22
|
+
|
|
23
|
+
const PRF_SALT = new TextEncoder().encode('learncard-sss-recovery-v1');
|
|
24
|
+
|
|
25
|
+
function isWebAuthnSupported(): boolean {
|
|
26
|
+
return typeof window !== 'undefined' &&
|
|
27
|
+
typeof window.PublicKeyCredential !== 'undefined' &&
|
|
28
|
+
typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function isPRFSupported(): Promise<boolean> {
|
|
32
|
+
if (!isWebAuthnSupported()) return false;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const available = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
|
36
|
+
return available;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function createPasskeyCredential(
|
|
43
|
+
userId: string,
|
|
44
|
+
userName: string
|
|
45
|
+
): Promise<PasskeyCredential> {
|
|
46
|
+
if (!isWebAuthnSupported()) {
|
|
47
|
+
throw new Error('WebAuthn is not supported in this browser');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const userIdBytes = new TextEncoder().encode(userId);
|
|
51
|
+
|
|
52
|
+
const createOptions: PublicKeyCredentialCreationOptions = {
|
|
53
|
+
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
|
54
|
+
rp: {
|
|
55
|
+
name: RP_NAME,
|
|
56
|
+
id: RP_ID,
|
|
57
|
+
},
|
|
58
|
+
user: {
|
|
59
|
+
id: userIdBytes,
|
|
60
|
+
name: userName,
|
|
61
|
+
displayName: userName,
|
|
62
|
+
},
|
|
63
|
+
pubKeyCredParams: [
|
|
64
|
+
{ alg: -7, type: 'public-key' }, // ES256
|
|
65
|
+
{ alg: -257, type: 'public-key' }, // RS256
|
|
66
|
+
],
|
|
67
|
+
authenticatorSelection: {
|
|
68
|
+
authenticatorAttachment: 'platform',
|
|
69
|
+
userVerification: 'required',
|
|
70
|
+
residentKey: 'required',
|
|
71
|
+
},
|
|
72
|
+
timeout: 60000,
|
|
73
|
+
attestation: 'none',
|
|
74
|
+
extensions: {
|
|
75
|
+
prf: {
|
|
76
|
+
eval: {
|
|
77
|
+
first: PRF_SALT,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
} as AuthenticationExtensionsClientInputs,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const credential = await navigator.credentials.create({
|
|
84
|
+
publicKey: createOptions,
|
|
85
|
+
}) as PublicKeyCredential;
|
|
86
|
+
|
|
87
|
+
if (!credential) {
|
|
88
|
+
throw new Error('Failed to create passkey credential');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const response = credential.response as AuthenticatorAttestationResponse;
|
|
92
|
+
|
|
93
|
+
const extensionResults = credential.getClientExtensionResults() as {
|
|
94
|
+
prf?: { enabled?: boolean; results?: { first?: ArrayBuffer } };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (!extensionResults.prf?.enabled && !extensionResults.prf?.results?.first) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
'PRF extension not available. Your browser or device does not support ' +
|
|
100
|
+
'the encryption required for passkey recovery. Please use a different recovery method.'
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
credentialId: bufferToBase64(credential.rawId),
|
|
106
|
+
publicKey: bufferToBase64(response.getPublicKey() as ArrayBuffer),
|
|
107
|
+
transports: response.getTransports?.() as AuthenticatorTransport[],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function deriveKeyFromPasskey(
|
|
112
|
+
credentialId: string
|
|
113
|
+
): Promise<CryptoKey> {
|
|
114
|
+
if (!isWebAuthnSupported()) {
|
|
115
|
+
throw new Error('WebAuthn is not supported in this browser');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const getOptions: PublicKeyCredentialRequestOptions = {
|
|
119
|
+
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
|
120
|
+
rpId: RP_ID,
|
|
121
|
+
allowCredentials: [{
|
|
122
|
+
id: base64ToBuffer(credentialId),
|
|
123
|
+
type: 'public-key',
|
|
124
|
+
}],
|
|
125
|
+
userVerification: 'required',
|
|
126
|
+
timeout: 60000,
|
|
127
|
+
extensions: {
|
|
128
|
+
prf: {
|
|
129
|
+
eval: {
|
|
130
|
+
first: PRF_SALT,
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
} as AuthenticationExtensionsClientInputs,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const assertion = await navigator.credentials.get({
|
|
137
|
+
publicKey: getOptions,
|
|
138
|
+
}) as PublicKeyCredential;
|
|
139
|
+
|
|
140
|
+
if (!assertion) {
|
|
141
|
+
throw new Error('Failed to get passkey assertion');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const extensionResults = assertion.getClientExtensionResults() as {
|
|
145
|
+
prf?: { results?: { first?: ArrayBuffer } };
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (!extensionResults.prf?.results?.first) {
|
|
149
|
+
throw new Error('PRF extension not available or failed. This passkey cannot be used for encryption.');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const prfOutput = new Uint8Array(extensionResults.prf.results.first);
|
|
153
|
+
|
|
154
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
155
|
+
'raw',
|
|
156
|
+
prfOutput,
|
|
157
|
+
{ name: 'AES-GCM' },
|
|
158
|
+
false,
|
|
159
|
+
['encrypt', 'decrypt']
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return cryptoKey;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function encryptShareWithPasskey(
|
|
166
|
+
share: string,
|
|
167
|
+
credentialId: string
|
|
168
|
+
): Promise<PasskeyEncryptedShare> {
|
|
169
|
+
const key = await deriveKeyFromPasskey(credentialId);
|
|
170
|
+
|
|
171
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
172
|
+
const encoder = new TextEncoder();
|
|
173
|
+
|
|
174
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
175
|
+
{ name: 'AES-GCM', iv },
|
|
176
|
+
key,
|
|
177
|
+
encoder.encode(share)
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
encryptedData: bufferToBase64(ciphertext),
|
|
182
|
+
iv: bufferToBase64(iv.buffer),
|
|
183
|
+
credentialId,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function decryptShareWithPasskey(
|
|
188
|
+
encryptedShare: PasskeyEncryptedShare
|
|
189
|
+
): Promise<string> {
|
|
190
|
+
const key = await deriveKeyFromPasskey(encryptedShare.credentialId);
|
|
191
|
+
|
|
192
|
+
const iv = base64ToBuffer(encryptedShare.iv);
|
|
193
|
+
const ciphertext = base64ToBuffer(encryptedShare.encryptedData);
|
|
194
|
+
|
|
195
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
196
|
+
{ name: 'AES-GCM', iv: iv.buffer },
|
|
197
|
+
key,
|
|
198
|
+
ciphertext.buffer
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const decoder = new TextDecoder();
|
|
202
|
+
return decoder.decode(plaintext);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function listStoredPasskeys(): Promise<PasskeyCredential[]> {
|
|
206
|
+
if (!isWebAuthnSupported()) {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (typeof PublicKeyCredential.isConditionalMediationAvailable !== 'function') {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const available = await PublicKeyCredential.isConditionalMediationAvailable();
|
|
215
|
+
if (!available) {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export { isWebAuthnSupported, isPRFSupported };
|