hd-wallet-ui 1.0.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 +158 -0
- package/package.json +59 -0
- package/src/address-derivation.js +476 -0
- package/src/app.js +3302 -0
- package/src/blockchain-trust.js +699 -0
- package/src/constants.js +87 -0
- package/src/lib.js +63 -0
- package/src/template.js +348 -0
- package/src/trust-ui.js +745 -0
- package/src/wallet-storage.js +696 -0
- package/styles/main.css +4521 -0
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wallet Storage Module
|
|
3
|
+
*
|
|
4
|
+
* Industry-standard implementation for securely storing wallet credentials
|
|
5
|
+
* using WebAuthn PRF extension or PIN-based encryption.
|
|
6
|
+
*
|
|
7
|
+
* Based on best practices from:
|
|
8
|
+
* - Yubico WebAuthn PRF Developer Guide
|
|
9
|
+
* - wwWallet FUNKE implementation
|
|
10
|
+
* - W3C WebAuthn PRF Extension specification
|
|
11
|
+
*
|
|
12
|
+
* @module wallet-storage
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// Storage Keys
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
const STORAGE_PREFIX = 'wallet_storage_';
|
|
20
|
+
const METADATA_KEY = `${STORAGE_PREFIX}metadata`;
|
|
21
|
+
const ENCRYPTED_DATA_KEY = `${STORAGE_PREFIX}encrypted`;
|
|
22
|
+
const PASSKEY_CREDENTIAL_KEY = `${STORAGE_PREFIX}passkey_credential`;
|
|
23
|
+
|
|
24
|
+
// Version for future migrations
|
|
25
|
+
const STORAGE_VERSION = 2;
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// Storage Method Enum
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
export const StorageMethod = {
|
|
32
|
+
NONE: 'none',
|
|
33
|
+
PIN: 'pin',
|
|
34
|
+
PASSKEY: 'passkey'
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// Utility Functions
|
|
39
|
+
// =============================================================================
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Convert ArrayBuffer to base64 string
|
|
43
|
+
*/
|
|
44
|
+
function arrayBufferToBase64(buffer) {
|
|
45
|
+
const bytes = new Uint8Array(buffer);
|
|
46
|
+
let binary = '';
|
|
47
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
48
|
+
binary += String.fromCharCode(bytes[i]);
|
|
49
|
+
}
|
|
50
|
+
return btoa(binary);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Convert base64 string to Uint8Array
|
|
55
|
+
*/
|
|
56
|
+
function base64ToUint8Array(base64) {
|
|
57
|
+
const binary = atob(base64);
|
|
58
|
+
const bytes = new Uint8Array(binary.length);
|
|
59
|
+
for (let i = 0; i < binary.length; i++) {
|
|
60
|
+
bytes[i] = binary.charCodeAt(i);
|
|
61
|
+
}
|
|
62
|
+
return bytes;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Generate cryptographically secure random bytes
|
|
67
|
+
*/
|
|
68
|
+
function generateRandomBytes(length) {
|
|
69
|
+
const bytes = new Uint8Array(length);
|
|
70
|
+
crypto.getRandomValues(bytes);
|
|
71
|
+
return bytes;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// =============================================================================
|
|
75
|
+
// Key Derivation (HKDF - Industry Standard)
|
|
76
|
+
// =============================================================================
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Derive encryption key using HKDF (HMAC-based Key Derivation Function)
|
|
80
|
+
* This is the industry-standard approach recommended by Yubico and others.
|
|
81
|
+
*
|
|
82
|
+
* @param {Uint8Array} inputKeyMaterial - The input key material (from PRF or PIN hash)
|
|
83
|
+
* @param {Uint8Array} salt - Salt for HKDF
|
|
84
|
+
* @param {string} info - Context info string
|
|
85
|
+
* @param {number} length - Desired key length in bytes
|
|
86
|
+
* @returns {Promise<Uint8Array>} Derived key
|
|
87
|
+
*/
|
|
88
|
+
async function hkdfDerive(inputKeyMaterial, salt, info, length) {
|
|
89
|
+
const encoder = new TextEncoder();
|
|
90
|
+
|
|
91
|
+
// Import the input key material
|
|
92
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
93
|
+
'raw',
|
|
94
|
+
inputKeyMaterial,
|
|
95
|
+
'HKDF',
|
|
96
|
+
false,
|
|
97
|
+
['deriveBits']
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Derive bits using HKDF
|
|
101
|
+
const derivedBits = await crypto.subtle.deriveBits(
|
|
102
|
+
{
|
|
103
|
+
name: 'HKDF',
|
|
104
|
+
hash: 'SHA-256',
|
|
105
|
+
salt: salt,
|
|
106
|
+
info: encoder.encode(info)
|
|
107
|
+
},
|
|
108
|
+
keyMaterial,
|
|
109
|
+
length * 8
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return new Uint8Array(derivedBits);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Derive encryption key and IV from key material
|
|
117
|
+
*/
|
|
118
|
+
async function deriveKeyAndIV(keyMaterial, context) {
|
|
119
|
+
const salt = new TextEncoder().encode(`wallet-storage-v${STORAGE_VERSION}`);
|
|
120
|
+
|
|
121
|
+
const encryptionKey = await hkdfDerive(
|
|
122
|
+
keyMaterial,
|
|
123
|
+
salt,
|
|
124
|
+
`${context}-encryption-key`,
|
|
125
|
+
32
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const iv = await hkdfDerive(
|
|
129
|
+
keyMaterial,
|
|
130
|
+
salt,
|
|
131
|
+
`${context}-encryption-iv`,
|
|
132
|
+
12 // AES-GCM standard IV size
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return { encryptionKey, iv };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// =============================================================================
|
|
139
|
+
// PIN-Based Encryption
|
|
140
|
+
// =============================================================================
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Derive key material from a 6-digit PIN
|
|
144
|
+
* Uses PBKDF2 for additional security against brute-force attacks
|
|
145
|
+
*/
|
|
146
|
+
async function deriveKeyFromPIN(pin, storedSalt) {
|
|
147
|
+
if (!/^\d{6}$/.test(pin)) {
|
|
148
|
+
throw new Error('PIN must be exactly 6 digits');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const encoder = new TextEncoder();
|
|
152
|
+
const pinBytes = encoder.encode(pin);
|
|
153
|
+
|
|
154
|
+
// Use stored salt or generate new one
|
|
155
|
+
const salt = storedSalt || generateRandomBytes(16);
|
|
156
|
+
|
|
157
|
+
// Import PIN as key material
|
|
158
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
159
|
+
'raw',
|
|
160
|
+
pinBytes,
|
|
161
|
+
'PBKDF2',
|
|
162
|
+
false,
|
|
163
|
+
['deriveBits']
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Use PBKDF2 with high iteration count for PIN (since PINs have low entropy)
|
|
167
|
+
const derivedBits = await crypto.subtle.deriveBits(
|
|
168
|
+
{
|
|
169
|
+
name: 'PBKDF2',
|
|
170
|
+
hash: 'SHA-256',
|
|
171
|
+
salt: salt,
|
|
172
|
+
iterations: 100000 // High iteration count for brute-force resistance
|
|
173
|
+
},
|
|
174
|
+
keyMaterial,
|
|
175
|
+
256
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
keyMaterial: new Uint8Array(derivedBits),
|
|
180
|
+
salt
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// =============================================================================
|
|
185
|
+
// WebAuthn PRF Extension
|
|
186
|
+
// =============================================================================
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Check if WebAuthn/Passkeys are supported
|
|
190
|
+
*/
|
|
191
|
+
export function isPasskeySupported() {
|
|
192
|
+
return !!(
|
|
193
|
+
window.PublicKeyCredential &&
|
|
194
|
+
typeof window.PublicKeyCredential === 'function'
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Check if PRF extension is likely supported
|
|
200
|
+
* Note: Full support detection requires actually creating a credential
|
|
201
|
+
*/
|
|
202
|
+
export async function isPRFLikelySupported() {
|
|
203
|
+
if (!isPasskeySupported()) return false;
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
// Check if platform authenticator is available
|
|
207
|
+
const available = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
|
208
|
+
return available;
|
|
209
|
+
} catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Generate WebAuthn challenge
|
|
216
|
+
*/
|
|
217
|
+
function generateChallenge() {
|
|
218
|
+
return generateRandomBytes(32);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Create PRF input salts for key derivation
|
|
223
|
+
* Following the recommended pattern from Yubico for key rotation support
|
|
224
|
+
*/
|
|
225
|
+
function createPRFInputs() {
|
|
226
|
+
const encoder = new TextEncoder();
|
|
227
|
+
return {
|
|
228
|
+
// Primary key derivation salt
|
|
229
|
+
first: encoder.encode('wallet-storage-prf-v2-primary'),
|
|
230
|
+
// Secondary salt for future key rotation support
|
|
231
|
+
second: encoder.encode('wallet-storage-prf-v2-secondary')
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Register a new passkey and derive encryption key material
|
|
237
|
+
*
|
|
238
|
+
* @param {Object} options - Registration options
|
|
239
|
+
* @param {string} options.rpName - Relying party name (e.g., 'My App')
|
|
240
|
+
* @param {string} options.userName - User identifier
|
|
241
|
+
* @param {string} options.userDisplayName - User display name
|
|
242
|
+
* @returns {Promise<{credentialId: string, keyMaterial: Uint8Array, hasPRF: boolean}>}
|
|
243
|
+
*/
|
|
244
|
+
export async function registerPasskey(options = {}) {
|
|
245
|
+
if (!isPasskeySupported()) {
|
|
246
|
+
throw new Error('Passkeys are not supported on this device');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const {
|
|
250
|
+
rpName = 'Wallet Storage',
|
|
251
|
+
userName = 'wallet-user',
|
|
252
|
+
userDisplayName = 'Wallet User'
|
|
253
|
+
} = options;
|
|
254
|
+
|
|
255
|
+
const challenge = generateChallenge();
|
|
256
|
+
const userId = generateRandomBytes(16);
|
|
257
|
+
const prfInputs = createPRFInputs();
|
|
258
|
+
|
|
259
|
+
const publicKeyCredentialCreationOptions = {
|
|
260
|
+
challenge,
|
|
261
|
+
rp: {
|
|
262
|
+
name: rpName,
|
|
263
|
+
id: window.location.hostname
|
|
264
|
+
},
|
|
265
|
+
user: {
|
|
266
|
+
id: userId,
|
|
267
|
+
name: userName,
|
|
268
|
+
displayName: userDisplayName
|
|
269
|
+
},
|
|
270
|
+
pubKeyCredParams: [
|
|
271
|
+
{ alg: -7, type: 'public-key' }, // ES256 (P-256)
|
|
272
|
+
{ alg: -257, type: 'public-key' } // RS256
|
|
273
|
+
],
|
|
274
|
+
authenticatorSelection: {
|
|
275
|
+
authenticatorAttachment: 'platform',
|
|
276
|
+
userVerification: 'required',
|
|
277
|
+
residentKey: 'preferred' // Changed from 'required' for broader compatibility
|
|
278
|
+
},
|
|
279
|
+
timeout: 60000,
|
|
280
|
+
attestation: 'none',
|
|
281
|
+
extensions: {
|
|
282
|
+
prf: {
|
|
283
|
+
eval: {
|
|
284
|
+
first: prfInputs.first
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const credential = await navigator.credentials.create({
|
|
291
|
+
publicKey: publicKeyCredentialCreationOptions
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Extract PRF result or fall back to credential ID
|
|
295
|
+
const extensionResults = credential.getClientExtensionResults();
|
|
296
|
+
const prfResult = extensionResults?.prf?.results?.first;
|
|
297
|
+
|
|
298
|
+
let keyMaterial;
|
|
299
|
+
let hasPRF = false;
|
|
300
|
+
|
|
301
|
+
if (prfResult && prfResult.byteLength > 0) {
|
|
302
|
+
// PRF is supported - use the PRF output
|
|
303
|
+
keyMaterial = new Uint8Array(prfResult);
|
|
304
|
+
hasPRF = true;
|
|
305
|
+
} else {
|
|
306
|
+
// PRF not supported - derive key from credential ID
|
|
307
|
+
// This is less secure but provides fallback functionality
|
|
308
|
+
const rawId = new Uint8Array(credential.rawId);
|
|
309
|
+
const hash = await crypto.subtle.digest('SHA-256', rawId);
|
|
310
|
+
keyMaterial = new Uint8Array(hash);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
credentialId: arrayBufferToBase64(credential.rawId),
|
|
315
|
+
keyMaterial,
|
|
316
|
+
hasPRF
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Authenticate with existing passkey and derive encryption key material
|
|
322
|
+
*
|
|
323
|
+
* @param {string} credentialId - Base64-encoded credential ID
|
|
324
|
+
* @returns {Promise<{keyMaterial: Uint8Array, hasPRF: boolean}>}
|
|
325
|
+
*/
|
|
326
|
+
export async function authenticatePasskey(credentialId) {
|
|
327
|
+
if (!isPasskeySupported()) {
|
|
328
|
+
throw new Error('Passkeys are not supported on this device');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const challenge = generateChallenge();
|
|
332
|
+
const prfInputs = createPRFInputs();
|
|
333
|
+
const credentialIdBytes = base64ToUint8Array(credentialId);
|
|
334
|
+
|
|
335
|
+
const publicKeyCredentialRequestOptions = {
|
|
336
|
+
challenge,
|
|
337
|
+
allowCredentials: [{
|
|
338
|
+
id: credentialIdBytes,
|
|
339
|
+
type: 'public-key',
|
|
340
|
+
transports: ['internal', 'hybrid'] // Support both platform and cross-device
|
|
341
|
+
}],
|
|
342
|
+
userVerification: 'required',
|
|
343
|
+
timeout: 60000,
|
|
344
|
+
extensions: {
|
|
345
|
+
prf: {
|
|
346
|
+
eval: {
|
|
347
|
+
first: prfInputs.first
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const assertion = await navigator.credentials.get({
|
|
354
|
+
publicKey: publicKeyCredentialRequestOptions
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Extract PRF result or fall back to credential ID
|
|
358
|
+
const extensionResults = assertion.getClientExtensionResults();
|
|
359
|
+
const prfResult = extensionResults?.prf?.results?.first;
|
|
360
|
+
|
|
361
|
+
let keyMaterial;
|
|
362
|
+
let hasPRF = false;
|
|
363
|
+
|
|
364
|
+
if (prfResult && prfResult.byteLength > 0) {
|
|
365
|
+
keyMaterial = new Uint8Array(prfResult);
|
|
366
|
+
hasPRF = true;
|
|
367
|
+
} else {
|
|
368
|
+
// Fallback: derive from credential ID
|
|
369
|
+
const rawId = new Uint8Array(assertion.rawId);
|
|
370
|
+
const hash = await crypto.subtle.digest('SHA-256', rawId);
|
|
371
|
+
keyMaterial = new Uint8Array(hash);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return { keyMaterial, hasPRF };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// =============================================================================
|
|
378
|
+
// Encryption/Decryption
|
|
379
|
+
// =============================================================================
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Encrypt data using AES-256-GCM
|
|
383
|
+
*/
|
|
384
|
+
async function encryptData(data, encryptionKey, iv) {
|
|
385
|
+
const encoder = new TextEncoder();
|
|
386
|
+
const plaintext = encoder.encode(JSON.stringify(data));
|
|
387
|
+
|
|
388
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
389
|
+
'raw',
|
|
390
|
+
encryptionKey,
|
|
391
|
+
{ name: 'AES-GCM' },
|
|
392
|
+
false,
|
|
393
|
+
['encrypt']
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
397
|
+
{ name: 'AES-GCM', iv },
|
|
398
|
+
cryptoKey,
|
|
399
|
+
plaintext
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
return new Uint8Array(ciphertext);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Decrypt data using AES-256-GCM
|
|
407
|
+
*/
|
|
408
|
+
async function decryptData(ciphertext, encryptionKey, iv) {
|
|
409
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
410
|
+
'raw',
|
|
411
|
+
encryptionKey,
|
|
412
|
+
{ name: 'AES-GCM' },
|
|
413
|
+
false,
|
|
414
|
+
['decrypt']
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
418
|
+
{ name: 'AES-GCM', iv },
|
|
419
|
+
cryptoKey,
|
|
420
|
+
ciphertext
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const decoder = new TextDecoder();
|
|
424
|
+
return JSON.parse(decoder.decode(plaintext));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// =============================================================================
|
|
428
|
+
// High-Level Storage API
|
|
429
|
+
// =============================================================================
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Get storage metadata
|
|
433
|
+
* @returns {Object|null} Storage metadata or null if no wallet stored
|
|
434
|
+
*/
|
|
435
|
+
export function getStorageMetadata() {
|
|
436
|
+
try {
|
|
437
|
+
const metadataJson = localStorage.getItem(METADATA_KEY);
|
|
438
|
+
if (!metadataJson) return null;
|
|
439
|
+
|
|
440
|
+
const metadata = JSON.parse(metadataJson);
|
|
441
|
+
return {
|
|
442
|
+
method: metadata.method || StorageMethod.NONE,
|
|
443
|
+
timestamp: metadata.timestamp,
|
|
444
|
+
date: new Date(metadata.timestamp).toLocaleDateString(),
|
|
445
|
+
version: metadata.version,
|
|
446
|
+
hasPRF: metadata.hasPRF || false
|
|
447
|
+
};
|
|
448
|
+
} catch {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Check if a wallet is stored
|
|
455
|
+
* @returns {boolean}
|
|
456
|
+
*/
|
|
457
|
+
export function hasStoredWallet() {
|
|
458
|
+
return getStorageMetadata() !== null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Get the storage method used
|
|
463
|
+
* @returns {string} StorageMethod value
|
|
464
|
+
*/
|
|
465
|
+
export function getStorageMethod() {
|
|
466
|
+
const metadata = getStorageMetadata();
|
|
467
|
+
return metadata?.method || StorageMethod.NONE;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Store wallet data with PIN encryption
|
|
472
|
+
*
|
|
473
|
+
* @param {string} pin - 6-digit PIN
|
|
474
|
+
* @param {Object} walletData - Data to encrypt and store
|
|
475
|
+
* @returns {Promise<boolean>}
|
|
476
|
+
*/
|
|
477
|
+
export async function storeWithPIN(pin, walletData) {
|
|
478
|
+
// Derive key from PIN
|
|
479
|
+
const { keyMaterial, salt } = await deriveKeyFromPIN(pin);
|
|
480
|
+
const { encryptionKey, iv } = await deriveKeyAndIV(keyMaterial, 'pin');
|
|
481
|
+
|
|
482
|
+
// Encrypt wallet data
|
|
483
|
+
const ciphertext = await encryptData(walletData, encryptionKey, iv);
|
|
484
|
+
|
|
485
|
+
// Store encrypted data
|
|
486
|
+
const encryptedData = {
|
|
487
|
+
ciphertext: arrayBufferToBase64(ciphertext),
|
|
488
|
+
salt: arrayBufferToBase64(salt)
|
|
489
|
+
};
|
|
490
|
+
localStorage.setItem(ENCRYPTED_DATA_KEY, JSON.stringify(encryptedData));
|
|
491
|
+
|
|
492
|
+
// Store metadata
|
|
493
|
+
const metadata = {
|
|
494
|
+
method: StorageMethod.PIN,
|
|
495
|
+
timestamp: Date.now(),
|
|
496
|
+
version: STORAGE_VERSION
|
|
497
|
+
};
|
|
498
|
+
localStorage.setItem(METADATA_KEY, JSON.stringify(metadata));
|
|
499
|
+
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Retrieve wallet data with PIN
|
|
505
|
+
*
|
|
506
|
+
* @param {string} pin - 6-digit PIN
|
|
507
|
+
* @returns {Promise<Object>} Decrypted wallet data
|
|
508
|
+
*/
|
|
509
|
+
export async function retrieveWithPIN(pin) {
|
|
510
|
+
const metadata = getStorageMetadata();
|
|
511
|
+
if (!metadata || metadata.method !== StorageMethod.PIN) {
|
|
512
|
+
throw new Error('No PIN-encrypted wallet found');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const encryptedJson = localStorage.getItem(ENCRYPTED_DATA_KEY);
|
|
516
|
+
if (!encryptedJson) {
|
|
517
|
+
throw new Error('Encrypted data not found');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const encryptedData = JSON.parse(encryptedJson);
|
|
521
|
+
const salt = base64ToUint8Array(encryptedData.salt);
|
|
522
|
+
const ciphertext = base64ToUint8Array(encryptedData.ciphertext);
|
|
523
|
+
|
|
524
|
+
// Derive key from PIN with stored salt
|
|
525
|
+
const { keyMaterial } = await deriveKeyFromPIN(pin, salt);
|
|
526
|
+
const { encryptionKey, iv } = await deriveKeyAndIV(keyMaterial, 'pin');
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
return await decryptData(ciphertext, encryptionKey, iv);
|
|
530
|
+
} catch (e) {
|
|
531
|
+
throw new Error('Invalid PIN or corrupted data');
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Store wallet data with passkey encryption
|
|
537
|
+
*
|
|
538
|
+
* @param {Object} walletData - Data to encrypt and store
|
|
539
|
+
* @param {Object} options - Passkey options
|
|
540
|
+
* @returns {Promise<boolean>}
|
|
541
|
+
*/
|
|
542
|
+
export async function storeWithPasskey(walletData, options = {}) {
|
|
543
|
+
// Register passkey and get key material
|
|
544
|
+
const { credentialId, keyMaterial, hasPRF } = await registerPasskey(options);
|
|
545
|
+
|
|
546
|
+
// Derive encryption key
|
|
547
|
+
const { encryptionKey, iv } = await deriveKeyAndIV(keyMaterial, 'passkey');
|
|
548
|
+
|
|
549
|
+
// Encrypt wallet data
|
|
550
|
+
const ciphertext = await encryptData(walletData, encryptionKey, iv);
|
|
551
|
+
|
|
552
|
+
// Store credential info
|
|
553
|
+
const credentialData = {
|
|
554
|
+
id: credentialId,
|
|
555
|
+
hasPRF
|
|
556
|
+
};
|
|
557
|
+
localStorage.setItem(PASSKEY_CREDENTIAL_KEY, JSON.stringify(credentialData));
|
|
558
|
+
|
|
559
|
+
// Store encrypted data
|
|
560
|
+
const encryptedData = {
|
|
561
|
+
ciphertext: arrayBufferToBase64(ciphertext)
|
|
562
|
+
};
|
|
563
|
+
localStorage.setItem(ENCRYPTED_DATA_KEY, JSON.stringify(encryptedData));
|
|
564
|
+
|
|
565
|
+
// Store metadata
|
|
566
|
+
const metadata = {
|
|
567
|
+
method: StorageMethod.PASSKEY,
|
|
568
|
+
timestamp: Date.now(),
|
|
569
|
+
version: STORAGE_VERSION,
|
|
570
|
+
hasPRF
|
|
571
|
+
};
|
|
572
|
+
localStorage.setItem(METADATA_KEY, JSON.stringify(metadata));
|
|
573
|
+
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Retrieve wallet data with passkey
|
|
579
|
+
*
|
|
580
|
+
* @returns {Promise<Object>} Decrypted wallet data
|
|
581
|
+
*/
|
|
582
|
+
export async function retrieveWithPasskey() {
|
|
583
|
+
const metadata = getStorageMetadata();
|
|
584
|
+
if (!metadata || metadata.method !== StorageMethod.PASSKEY) {
|
|
585
|
+
throw new Error('No passkey-encrypted wallet found');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const credentialJson = localStorage.getItem(PASSKEY_CREDENTIAL_KEY);
|
|
589
|
+
if (!credentialJson) {
|
|
590
|
+
throw new Error('Passkey credential not found');
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const credentialData = JSON.parse(credentialJson);
|
|
594
|
+
|
|
595
|
+
const encryptedJson = localStorage.getItem(ENCRYPTED_DATA_KEY);
|
|
596
|
+
if (!encryptedJson) {
|
|
597
|
+
throw new Error('Encrypted data not found');
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const encryptedData = JSON.parse(encryptedJson);
|
|
601
|
+
const ciphertext = base64ToUint8Array(encryptedData.ciphertext);
|
|
602
|
+
|
|
603
|
+
// Authenticate with passkey and get key material
|
|
604
|
+
const { keyMaterial } = await authenticatePasskey(credentialData.id);
|
|
605
|
+
|
|
606
|
+
// Derive encryption key
|
|
607
|
+
const { encryptionKey, iv } = await deriveKeyAndIV(keyMaterial, 'passkey');
|
|
608
|
+
|
|
609
|
+
try {
|
|
610
|
+
return await decryptData(ciphertext, encryptionKey, iv);
|
|
611
|
+
} catch (e) {
|
|
612
|
+
throw new Error('Passkey authentication failed or data corrupted');
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Clear all stored wallet data
|
|
618
|
+
*/
|
|
619
|
+
export function clearStorage() {
|
|
620
|
+
localStorage.removeItem(METADATA_KEY);
|
|
621
|
+
localStorage.removeItem(ENCRYPTED_DATA_KEY);
|
|
622
|
+
localStorage.removeItem(PASSKEY_CREDENTIAL_KEY);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Migrate from old storage format (v1) to new format (v2)
|
|
627
|
+
* Call this on app initialization
|
|
628
|
+
*/
|
|
629
|
+
export function migrateStorage() {
|
|
630
|
+
// Check for old v1 keys
|
|
631
|
+
const oldPinWallet = localStorage.getItem('encrypted_wallet');
|
|
632
|
+
const oldPasskeyCredential = localStorage.getItem('passkey_credential');
|
|
633
|
+
const oldPasskeyWallet = localStorage.getItem('passkey_wallet');
|
|
634
|
+
|
|
635
|
+
// Already migrated or no old data
|
|
636
|
+
if (getStorageMetadata() !== null) return;
|
|
637
|
+
if (!oldPinWallet && !oldPasskeyCredential) return;
|
|
638
|
+
|
|
639
|
+
console.log('Migrating wallet storage from v1 to v2...');
|
|
640
|
+
|
|
641
|
+
if (oldPasskeyCredential && oldPasskeyWallet) {
|
|
642
|
+
// Migrate passkey storage
|
|
643
|
+
try {
|
|
644
|
+
const credential = JSON.parse(oldPasskeyCredential);
|
|
645
|
+
const wallet = JSON.parse(oldPasskeyWallet);
|
|
646
|
+
|
|
647
|
+
localStorage.setItem(PASSKEY_CREDENTIAL_KEY, JSON.stringify({
|
|
648
|
+
id: credential.id,
|
|
649
|
+
hasPRF: credential.hasPRF || false
|
|
650
|
+
}));
|
|
651
|
+
|
|
652
|
+
localStorage.setItem(ENCRYPTED_DATA_KEY, JSON.stringify({
|
|
653
|
+
ciphertext: wallet.ciphertext
|
|
654
|
+
}));
|
|
655
|
+
|
|
656
|
+
localStorage.setItem(METADATA_KEY, JSON.stringify({
|
|
657
|
+
method: StorageMethod.PASSKEY,
|
|
658
|
+
timestamp: credential.timestamp || wallet.timestamp || Date.now(),
|
|
659
|
+
version: STORAGE_VERSION,
|
|
660
|
+
hasPRF: credential.hasPRF || false
|
|
661
|
+
}));
|
|
662
|
+
|
|
663
|
+
// Clean up old keys
|
|
664
|
+
localStorage.removeItem('passkey_credential');
|
|
665
|
+
localStorage.removeItem('passkey_wallet');
|
|
666
|
+
|
|
667
|
+
console.log('Passkey storage migrated successfully');
|
|
668
|
+
} catch (e) {
|
|
669
|
+
console.error('Failed to migrate passkey storage:', e);
|
|
670
|
+
}
|
|
671
|
+
} else if (oldPinWallet) {
|
|
672
|
+
// Migrate PIN storage - can't fully migrate since we need the salt
|
|
673
|
+
// User will need to re-enter their wallet
|
|
674
|
+
console.log('PIN storage detected but cannot be migrated - user will need to re-login');
|
|
675
|
+
localStorage.removeItem('encrypted_wallet');
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// =============================================================================
|
|
680
|
+
// Export default object for convenience
|
|
681
|
+
// =============================================================================
|
|
682
|
+
|
|
683
|
+
export default {
|
|
684
|
+
StorageMethod,
|
|
685
|
+
isPasskeySupported,
|
|
686
|
+
isPRFLikelySupported,
|
|
687
|
+
getStorageMetadata,
|
|
688
|
+
hasStoredWallet,
|
|
689
|
+
getStorageMethod,
|
|
690
|
+
storeWithPIN,
|
|
691
|
+
retrieveWithPIN,
|
|
692
|
+
storeWithPasskey,
|
|
693
|
+
retrieveWithPasskey,
|
|
694
|
+
clearStorage,
|
|
695
|
+
migrateStorage
|
|
696
|
+
};
|