hd-wallet-ui 1.2.5 → 1.4.2

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/src/template.js CHANGED
@@ -410,6 +410,26 @@ export function getModalHTML() {
410
410
 
411
411
  <!-- Messaging Tab (Encrypt + Decrypt) -->
412
412
  <div id="messaging-tab-content" class="modal-tab-content">
413
+ <div class="glass-card messaging-key-config">
414
+ <div class="messaging-key-config-grid">
415
+ <div class="messaging-key-config-item">
416
+ <label>Key Type</label>
417
+ <select id="messaging-key-type" class="glass-input compact">
418
+ <option value="btc">Bitcoin (BTC) - secp256k1</option>
419
+ <option value="eth">Ethereum (ETH) - secp256k1</option>
420
+ <option value="sol">Solana (SOL) - X25519</option>
421
+ </select>
422
+ </div>
423
+ <div class="messaging-key-config-item">
424
+ <label>HD Path</label>
425
+ <div class="messaging-path-row">
426
+ <input type="text" id="messaging-hd-path" class="glass-input compact" value="m/44'/0'/0'/1/0" spellcheck="false" autocomplete="off">
427
+ <button id="messaging-hd-path-default" class="glass-btn small" title="Reset to default path">Default</button>
428
+ </div>
429
+ <div class="messaging-key-hint">Example: m/44'/60'/0'/1/0</div>
430
+ </div>
431
+ </div>
432
+ </div>
413
433
  <div class="messaging-sub-tabs">
414
434
  <button class="messaging-sub-tab active" data-messaging-sub="encrypt-sub">Encrypt</button>
415
435
  <button class="messaging-sub-tab" data-messaging-sub="decrypt-sub">Decrypt</button>
@@ -435,6 +455,10 @@ export function getModalHTML() {
435
455
  <label>Derivation Path</label>
436
456
  <code id="encrypt-sender-path">--</code>
437
457
  </div>
458
+ <div class="encrypt-key-detail">
459
+ <label>Key Algorithm</label>
460
+ <code id="encrypt-sender-algo">--</code>
461
+ </div>
438
462
  </div>
439
463
  <div class="encrypt-key-card glass-card">
440
464
  <div class="encrypt-key-header">
@@ -538,7 +562,7 @@ export function getModalHTML() {
538
562
  <form id="password-method" class="method-content active" onsubmit="return false;">
539
563
  <div class="glass-input-group"><input type="text" id="wallet-username" class="glass-input" placeholder="Username" autocomplete="username"></div>
540
564
  <div class="glass-input-group">
541
- <input type="password" id="wallet-password" class="glass-input" placeholder="Password (24+ chars)" autocomplete="new-password">
565
+ <div class="password-input-wrap"><input type="password" id="wallet-password" class="glass-input" placeholder="Password (24+ chars)" autocomplete="new-password"><button type="button" id="toggle-password-vis" class="password-toggle" title="Show password"><svg class="eye-open" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg><svg class="eye-closed" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:none"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></button></div>
542
566
  <div class="entropy-bar"><div class="entropy-fill" id="strength-fill"></div><div class="entropy-threshold"></div></div>
543
567
  <span class="entropy-label"><span id="entropy-bits">0</span> bits entropy</span>
544
568
  </div>
@@ -22,7 +22,15 @@ const ENCRYPTED_DATA_KEY = `${STORAGE_PREFIX}encrypted`;
22
22
  const PASSKEY_CREDENTIAL_KEY = `${STORAGE_PREFIX}passkey_credential`;
23
23
 
24
24
  // Version for future migrations
25
- const STORAGE_VERSION = 2;
25
+ const STORAGE_VERSION = 3;
26
+
27
+ // AES-GCM standard IV size (96-bit)
28
+ const AES_GCM_IV_LENGTH = 12;
29
+
30
+ // 6-digit PINs have low entropy; PBKDF2 must be expensive to slow offline brute force.
31
+ // (This is still not a substitute for rate-limiting when an attacker can query online.)
32
+ const PIN_PBKDF2_ITERATIONS = 600000;
33
+ const LEGACY_PIN_PBKDF2_ITERATIONS = 100000;
26
34
 
27
35
  // =============================================================================
28
36
  // Storage Method Enum
@@ -113,9 +121,9 @@ async function hkdfDerive(inputKeyMaterial, salt, info, length) {
113
121
  }
114
122
 
115
123
  /**
116
- * Derive encryption key and IV from key material
124
+ * Derive encryption key from key material (HKDF).
117
125
  */
118
- async function deriveKeyAndIV(keyMaterial, context) {
126
+ async function deriveEncryptionKey(keyMaterial, context) {
119
127
  const salt = new TextEncoder().encode(`wallet-storage-v${STORAGE_VERSION}`);
120
128
 
121
129
  const encryptionKey = await hkdfDerive(
@@ -125,13 +133,27 @@ async function deriveKeyAndIV(keyMaterial, context) {
125
133
  32
126
134
  );
127
135
 
136
+ return encryptionKey;
137
+ }
138
+
139
+ /**
140
+ * Legacy: v1/v2 storage used a deterministic IV derived via HKDF.
141
+ * This exists only to decrypt and upgrade old stored blobs.
142
+ */
143
+ async function deriveLegacyKeyAndIV(keyMaterial, context, version) {
144
+ const salt = new TextEncoder().encode(`wallet-storage-v${version}`);
145
+ const encryptionKey = await hkdfDerive(
146
+ keyMaterial,
147
+ salt,
148
+ `${context}-encryption-key`,
149
+ 32
150
+ );
128
151
  const iv = await hkdfDerive(
129
152
  keyMaterial,
130
153
  salt,
131
154
  `${context}-encryption-iv`,
132
- 12 // AES-GCM standard IV size
155
+ AES_GCM_IV_LENGTH
133
156
  );
134
-
135
157
  return { encryptionKey, iv };
136
158
  }
137
159
 
@@ -143,7 +165,7 @@ async function deriveKeyAndIV(keyMaterial, context) {
143
165
  * Derive key material from a 6-digit PIN
144
166
  * Uses PBKDF2 for additional security against brute-force attacks
145
167
  */
146
- async function deriveKeyFromPIN(pin, storedSalt) {
168
+ async function deriveKeyFromPIN(pin, storedSalt, iterations = PIN_PBKDF2_ITERATIONS) {
147
169
  if (!/^\d{6}$/.test(pin)) {
148
170
  throw new Error('PIN must be exactly 6 digits');
149
171
  }
@@ -169,7 +191,7 @@ async function deriveKeyFromPIN(pin, storedSalt) {
169
191
  name: 'PBKDF2',
170
192
  hash: 'SHA-256',
171
193
  salt: salt,
172
- iterations: 100000 // High iteration count for brute-force resistance
194
+ iterations
173
195
  },
174
196
  keyMaterial,
175
197
  256
@@ -303,11 +325,9 @@ export async function registerPasskey(options = {}) {
303
325
  keyMaterial = new Uint8Array(prfResult);
304
326
  hasPRF = true;
305
327
  } 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);
328
+ // SECURITY: Never derive encryption keys from public credential IDs.
329
+ // Without PRF, we cannot get stable secret key material from passkeys in a secure way.
330
+ throw new Error('Passkey PRF extension is required for secure wallet encryption on this device/browser');
311
331
  }
312
332
 
313
333
  return {
@@ -365,10 +385,7 @@ export async function authenticatePasskey(credentialId) {
365
385
  keyMaterial = new Uint8Array(prfResult);
366
386
  hasPRF = true;
367
387
  } 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);
388
+ throw new Error('Passkey PRF extension is required for secure wallet decryption on this device/browser');
372
389
  }
373
390
 
374
391
  return { keyMaterial, hasPRF };
@@ -381,9 +398,10 @@ export async function authenticatePasskey(credentialId) {
381
398
  /**
382
399
  * Encrypt data using AES-256-GCM
383
400
  */
384
- async function encryptData(data, encryptionKey, iv) {
401
+ async function encryptData(data, encryptionKey, aad) {
385
402
  const encoder = new TextEncoder();
386
403
  const plaintext = encoder.encode(JSON.stringify(data));
404
+ const iv = generateRandomBytes(AES_GCM_IV_LENGTH);
387
405
 
388
406
  const cryptoKey = await crypto.subtle.importKey(
389
407
  'raw',
@@ -393,19 +411,22 @@ async function encryptData(data, encryptionKey, iv) {
393
411
  ['encrypt']
394
412
  );
395
413
 
414
+ const alg = { name: 'AES-GCM', iv };
415
+ if (aad) alg.additionalData = aad;
416
+
396
417
  const ciphertext = await crypto.subtle.encrypt(
397
- { name: 'AES-GCM', iv },
418
+ alg,
398
419
  cryptoKey,
399
420
  plaintext
400
421
  );
401
422
 
402
- return new Uint8Array(ciphertext);
423
+ return { iv, ciphertext: new Uint8Array(ciphertext) };
403
424
  }
404
425
 
405
426
  /**
406
427
  * Decrypt data using AES-256-GCM
407
428
  */
408
- async function decryptData(ciphertext, encryptionKey, iv) {
429
+ async function decryptData(ciphertext, encryptionKey, iv, aad) {
409
430
  const cryptoKey = await crypto.subtle.importKey(
410
431
  'raw',
411
432
  encryptionKey,
@@ -414,8 +435,11 @@ async function decryptData(ciphertext, encryptionKey, iv) {
414
435
  ['decrypt']
415
436
  );
416
437
 
438
+ const alg = { name: 'AES-GCM', iv };
439
+ if (aad) alg.additionalData = aad;
440
+
417
441
  const plaintext = await crypto.subtle.decrypt(
418
- { name: 'AES-GCM', iv },
442
+ alg,
419
443
  cryptoKey,
420
444
  ciphertext
421
445
  );
@@ -428,6 +452,12 @@ async function decryptData(ciphertext, encryptionKey, iv) {
428
452
  // High-Level Storage API
429
453
  // =============================================================================
430
454
 
455
+ function getAadForMethod(method) {
456
+ // Small AAD to bind ciphertexts to this module + method.
457
+ // (Replay protection against localStorage rollback is not achievable without an external monotonic anchor.)
458
+ return new TextEncoder().encode(`wallet-storage|v${STORAGE_VERSION}|${method}`);
459
+ }
460
+
431
461
  /**
432
462
  * Get storage metadata
433
463
  * @returns {Object|null} Storage metadata or null if no wallet stored
@@ -477,14 +507,16 @@ export function getStorageMethod() {
477
507
  export async function storeWithPIN(pin, walletData) {
478
508
  // Derive key from PIN
479
509
  const { keyMaterial, salt } = await deriveKeyFromPIN(pin);
480
- const { encryptionKey, iv } = await deriveKeyAndIV(keyMaterial, 'pin');
510
+ const encryptionKey = await deriveEncryptionKey(keyMaterial, 'pin');
481
511
 
482
512
  // Encrypt wallet data
483
- const ciphertext = await encryptData(walletData, encryptionKey, iv);
513
+ const aad = getAadForMethod(StorageMethod.PIN);
514
+ const { ciphertext, iv } = await encryptData(walletData, encryptionKey, aad);
484
515
 
485
516
  // Store encrypted data
486
517
  const encryptedData = {
487
518
  ciphertext: arrayBufferToBase64(ciphertext),
519
+ iv: arrayBufferToBase64(iv),
488
520
  salt: arrayBufferToBase64(salt)
489
521
  };
490
522
  localStorage.setItem(ENCRYPTED_DATA_KEY, JSON.stringify(encryptedData));
@@ -520,13 +552,37 @@ export async function retrieveWithPIN(pin) {
520
552
  const encryptedData = JSON.parse(encryptedJson);
521
553
  const salt = base64ToUint8Array(encryptedData.salt);
522
554
  const ciphertext = base64ToUint8Array(encryptedData.ciphertext);
555
+ const iv = encryptedData.iv ? base64ToUint8Array(encryptedData.iv) : null;
523
556
 
524
557
  // Derive key from PIN with stored salt
525
- const { keyMaterial } = await deriveKeyFromPIN(pin, salt);
526
- const { encryptionKey, iv } = await deriveKeyAndIV(keyMaterial, 'pin');
558
+ const { keyMaterial } = await deriveKeyFromPIN(pin, salt, iv ? PIN_PBKDF2_ITERATIONS : LEGACY_PIN_PBKDF2_ITERATIONS);
559
+ const aad = getAadForMethod(StorageMethod.PIN);
527
560
 
528
561
  try {
529
- return await decryptData(ciphertext, encryptionKey, iv);
562
+ let walletData;
563
+ if (iv) {
564
+ const encryptionKey = await deriveEncryptionKey(keyMaterial, 'pin');
565
+ walletData = await decryptData(ciphertext, encryptionKey, iv, aad);
566
+ } else {
567
+ // Legacy v1/v2 deterministic-IV storage. Decrypt and upgrade in-place.
568
+ const legacyVersion = metadata.version || 2;
569
+ const { encryptionKey: legacyKey, iv: legacyIv } = await deriveLegacyKeyAndIV(keyMaterial, 'pin', legacyVersion);
570
+ walletData = await decryptData(ciphertext, legacyKey, legacyIv);
571
+
572
+ // Upgrade stored blob to v3 (random IV) after successful decrypt.
573
+ const newKey = await deriveEncryptionKey(keyMaterial, 'pin');
574
+ const { ciphertext: newCt, iv: newIv } = await encryptData(walletData, newKey, aad);
575
+ localStorage.setItem(ENCRYPTED_DATA_KEY, JSON.stringify({
576
+ ciphertext: arrayBufferToBase64(newCt),
577
+ iv: arrayBufferToBase64(newIv),
578
+ salt: arrayBufferToBase64(salt)
579
+ }));
580
+ localStorage.setItem(METADATA_KEY, JSON.stringify({
581
+ ...metadata,
582
+ version: STORAGE_VERSION
583
+ }));
584
+ }
585
+ return walletData;
530
586
  } catch (e) {
531
587
  throw new Error('Invalid PIN or corrupted data');
532
588
  }
@@ -540,14 +596,43 @@ export async function retrieveWithPIN(pin) {
540
596
  * @returns {Promise<boolean>}
541
597
  */
542
598
  export async function storeWithPasskey(walletData, options = {}) {
543
- // Register passkey and get key material
544
- const { credentialId, keyMaterial, hasPRF } = await registerPasskey(options);
599
+ // Prefer reusing an existing stored passkey credential to avoid creating duplicates.
600
+ let credentialId = null;
601
+ let keyMaterial = null;
602
+ let hasPRF = false;
603
+
604
+ try {
605
+ const existing = localStorage.getItem(PASSKEY_CREDENTIAL_KEY);
606
+ if (existing) {
607
+ const parsed = JSON.parse(existing);
608
+ if (parsed?.id) {
609
+ credentialId = parsed.id;
610
+ const auth = await authenticatePasskey(credentialId);
611
+ keyMaterial = auth.keyMaterial;
612
+ hasPRF = auth.hasPRF;
613
+ }
614
+ }
615
+ } catch {
616
+ // Ignore and fall back to registration below.
617
+ }
618
+
619
+ if (!keyMaterial) {
620
+ const reg = await registerPasskey(options);
621
+ credentialId = reg.credentialId;
622
+ keyMaterial = reg.keyMaterial;
623
+ hasPRF = reg.hasPRF;
624
+ }
625
+
626
+ if (!hasPRF) {
627
+ throw new Error('Passkey PRF extension is required to store wallet data securely');
628
+ }
545
629
 
546
630
  // Derive encryption key
547
- const { encryptionKey, iv } = await deriveKeyAndIV(keyMaterial, 'passkey');
631
+ const encryptionKey = await deriveEncryptionKey(keyMaterial, 'passkey');
548
632
 
549
633
  // Encrypt wallet data
550
- const ciphertext = await encryptData(walletData, encryptionKey, iv);
634
+ const aad = getAadForMethod(StorageMethod.PASSKEY);
635
+ const { ciphertext, iv } = await encryptData(walletData, encryptionKey, aad);
551
636
 
552
637
  // Store credential info
553
638
  const credentialData = {
@@ -558,7 +643,8 @@ export async function storeWithPasskey(walletData, options = {}) {
558
643
 
559
644
  // Store encrypted data
560
645
  const encryptedData = {
561
- ciphertext: arrayBufferToBase64(ciphertext)
646
+ ciphertext: arrayBufferToBase64(ciphertext),
647
+ iv: arrayBufferToBase64(iv)
562
648
  };
563
649
  localStorage.setItem(ENCRYPTED_DATA_KEY, JSON.stringify(encryptedData));
564
650
 
@@ -584,6 +670,9 @@ export async function retrieveWithPasskey() {
584
670
  if (!metadata || metadata.method !== StorageMethod.PASSKEY) {
585
671
  throw new Error('No passkey-encrypted wallet found');
586
672
  }
673
+ if (!metadata.hasPRF) {
674
+ throw new Error('Stored passkey wallet was created without PRF support and is insecure. Please forget it and use PIN storage.');
675
+ }
587
676
 
588
677
  const credentialJson = localStorage.getItem(PASSKEY_CREDENTIAL_KEY);
589
678
  if (!credentialJson) {
@@ -599,15 +688,41 @@ export async function retrieveWithPasskey() {
599
688
 
600
689
  const encryptedData = JSON.parse(encryptedJson);
601
690
  const ciphertext = base64ToUint8Array(encryptedData.ciphertext);
691
+ const iv = encryptedData.iv ? base64ToUint8Array(encryptedData.iv) : null;
602
692
 
603
693
  // Authenticate with passkey and get key material
604
- const { keyMaterial } = await authenticatePasskey(credentialData.id);
694
+ const { keyMaterial, hasPRF } = await authenticatePasskey(credentialData.id);
695
+ if (!hasPRF) {
696
+ throw new Error('Passkey PRF extension is required to decrypt wallet data securely');
697
+ }
605
698
 
606
699
  // Derive encryption key
607
- const { encryptionKey, iv } = await deriveKeyAndIV(keyMaterial, 'passkey');
700
+ const aad = getAadForMethod(StorageMethod.PASSKEY);
608
701
 
609
702
  try {
610
- return await decryptData(ciphertext, encryptionKey, iv);
703
+ let walletData;
704
+ if (iv) {
705
+ const encryptionKey = await deriveEncryptionKey(keyMaterial, 'passkey');
706
+ walletData = await decryptData(ciphertext, encryptionKey, iv, aad);
707
+ } else {
708
+ // Legacy v1/v2 deterministic-IV storage. Decrypt and upgrade in-place.
709
+ const legacyVersion = metadata.version || 2;
710
+ const { encryptionKey: legacyKey, iv: legacyIv } = await deriveLegacyKeyAndIV(keyMaterial, 'passkey', legacyVersion);
711
+ walletData = await decryptData(ciphertext, legacyKey, legacyIv);
712
+
713
+ const newKey = await deriveEncryptionKey(keyMaterial, 'passkey');
714
+ const { ciphertext: newCt, iv: newIv } = await encryptData(walletData, newKey, aad);
715
+ localStorage.setItem(ENCRYPTED_DATA_KEY, JSON.stringify({
716
+ ciphertext: arrayBufferToBase64(newCt),
717
+ iv: arrayBufferToBase64(newIv)
718
+ }));
719
+ localStorage.setItem(METADATA_KEY, JSON.stringify({
720
+ ...metadata,
721
+ version: STORAGE_VERSION,
722
+ hasPRF: true
723
+ }));
724
+ }
725
+ return walletData;
611
726
  } catch (e) {
612
727
  throw new Error('Passkey authentication failed or data corrupted');
613
728
  }
@@ -636,7 +751,7 @@ export function migrateStorage() {
636
751
  if (getStorageMetadata() !== null) return;
637
752
  if (!oldPinWallet && !oldPasskeyCredential) return;
638
753
 
639
- console.log('Migrating wallet storage from v1 to v2...');
754
+ console.log('Migrating wallet storage from legacy format...');
640
755
 
641
756
  if (oldPasskeyCredential && oldPasskeyWallet) {
642
757
  // Migrate passkey storage
@@ -656,7 +771,9 @@ export function migrateStorage() {
656
771
  localStorage.setItem(METADATA_KEY, JSON.stringify({
657
772
  method: StorageMethod.PASSKEY,
658
773
  timestamp: credential.timestamp || wallet.timestamp || Date.now(),
659
- version: STORAGE_VERSION,
774
+ // Preserve legacy version so decrypt can derive the correct legacy HKDF salt/IV,
775
+ // then upgrade in-place on first successful retrieval.
776
+ version: credential.version || wallet.version || 2,
660
777
  hasPRF: credential.hasPRF || false
661
778
  }));
662
779
 
package/styles/main.css CHANGED
@@ -450,6 +450,32 @@ body:has(.modal.active) .nav-bar {
450
450
  background: var(--white-10);
451
451
  }
452
452
 
453
+ .password-input-wrap {
454
+ position: relative;
455
+ }
456
+ .password-input-wrap .glass-input {
457
+ padding-right: 48px;
458
+ }
459
+ .password-toggle {
460
+ position: absolute;
461
+ right: 8px;
462
+ top: 50%;
463
+ transform: translateY(-50%);
464
+ background: none;
465
+ border: none;
466
+ color: var(--white-40);
467
+ cursor: pointer;
468
+ padding: 6px;
469
+ display: flex;
470
+ align-items: center;
471
+ justify-content: center;
472
+ border-radius: 6px;
473
+ transition: color 0.15s;
474
+ }
475
+ .password-toggle:hover {
476
+ color: var(--white-80);
477
+ }
478
+
453
479
  .glass-textarea {
454
480
  resize: vertical;
455
481
  min-height: 80px;
@@ -5221,6 +5247,53 @@ body:has(.modal.active) .nav-bar {
5221
5247
  display: block;
5222
5248
  }
5223
5249
 
5250
+ /* Messaging key config (derivation path + algorithm selection) */
5251
+ .messaging-key-config {
5252
+ padding: 14px;
5253
+ margin-bottom: 16px;
5254
+ }
5255
+
5256
+ .messaging-key-config-grid {
5257
+ display: grid;
5258
+ grid-template-columns: 1fr 2fr;
5259
+ gap: 12px;
5260
+ align-items: start;
5261
+ }
5262
+
5263
+ .messaging-key-config-item label {
5264
+ display: block;
5265
+ font-size: 0.7rem;
5266
+ text-transform: uppercase;
5267
+ letter-spacing: 0.06em;
5268
+ color: var(--white-40);
5269
+ margin-bottom: 6px;
5270
+ }
5271
+
5272
+ .messaging-path-row {
5273
+ display: flex;
5274
+ gap: 8px;
5275
+ align-items: center;
5276
+ }
5277
+
5278
+ .messaging-path-row input {
5279
+ flex: 1;
5280
+ min-width: 0;
5281
+ font-family: var(--font-mono);
5282
+ }
5283
+
5284
+ .messaging-key-hint {
5285
+ margin-top: 6px;
5286
+ font-size: 0.7rem;
5287
+ color: var(--white-40);
5288
+ font-family: var(--font-mono);
5289
+ }
5290
+
5291
+ @media (max-width: 640px) {
5292
+ .messaging-key-config-grid {
5293
+ grid-template-columns: 1fr;
5294
+ }
5295
+ }
5296
+
5224
5297
  @media (max-width: 640px) {
5225
5298
  .identity-card {
5226
5299
  grid-template-columns: 1fr;