hd-wallet-ui 1.2.6 → 1.4.3

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">
@@ -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
@@ -299,15 +321,15 @@ export async function registerPasskey(options = {}) {
299
321
  let hasPRF = false;
300
322
 
301
323
  if (prfResult && prfResult.byteLength > 0) {
302
- // PRF is supported - use the PRF output
303
324
  keyMaterial = new Uint8Array(prfResult);
304
325
  hasPRF = true;
305
326
  } else {
306
- // PRF not supported - derive key from credential ID
307
- // This is less secure but provides fallback functionality
327
+ // PRF not available derive key material from credential ID + a fixed salt.
308
328
  const rawId = new Uint8Array(credential.rawId);
309
- const hash = await crypto.subtle.digest('SHA-256', rawId);
310
- keyMaterial = new Uint8Array(hash);
329
+ const salt = new TextEncoder().encode('wallet-storage-credid-fallback-v1');
330
+ const base = await crypto.subtle.importKey('raw', rawId, 'HKDF', false, ['deriveBits']);
331
+ const bits = await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info: new Uint8Array(0) }, base, 256);
332
+ keyMaterial = new Uint8Array(bits);
311
333
  }
312
334
 
313
335
  return {
@@ -365,10 +387,12 @@ export async function authenticatePasskey(credentialId) {
365
387
  keyMaterial = new Uint8Array(prfResult);
366
388
  hasPRF = true;
367
389
  } else {
368
- // Fallback: derive from credential ID
390
+ // PRF not available — derive key material from credential ID + a fixed salt.
369
391
  const rawId = new Uint8Array(assertion.rawId);
370
- const hash = await crypto.subtle.digest('SHA-256', rawId);
371
- keyMaterial = new Uint8Array(hash);
392
+ const salt = new TextEncoder().encode('wallet-storage-credid-fallback-v1');
393
+ const base = await crypto.subtle.importKey('raw', rawId, 'HKDF', false, ['deriveBits']);
394
+ const bits = await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info: new Uint8Array(0) }, base, 256);
395
+ keyMaterial = new Uint8Array(bits);
372
396
  }
373
397
 
374
398
  return { keyMaterial, hasPRF };
@@ -381,9 +405,10 @@ export async function authenticatePasskey(credentialId) {
381
405
  /**
382
406
  * Encrypt data using AES-256-GCM
383
407
  */
384
- async function encryptData(data, encryptionKey, iv) {
408
+ async function encryptData(data, encryptionKey, aad) {
385
409
  const encoder = new TextEncoder();
386
410
  const plaintext = encoder.encode(JSON.stringify(data));
411
+ const iv = generateRandomBytes(AES_GCM_IV_LENGTH);
387
412
 
388
413
  const cryptoKey = await crypto.subtle.importKey(
389
414
  'raw',
@@ -393,19 +418,22 @@ async function encryptData(data, encryptionKey, iv) {
393
418
  ['encrypt']
394
419
  );
395
420
 
421
+ const alg = { name: 'AES-GCM', iv };
422
+ if (aad) alg.additionalData = aad;
423
+
396
424
  const ciphertext = await crypto.subtle.encrypt(
397
- { name: 'AES-GCM', iv },
425
+ alg,
398
426
  cryptoKey,
399
427
  plaintext
400
428
  );
401
429
 
402
- return new Uint8Array(ciphertext);
430
+ return { iv, ciphertext: new Uint8Array(ciphertext) };
403
431
  }
404
432
 
405
433
  /**
406
434
  * Decrypt data using AES-256-GCM
407
435
  */
408
- async function decryptData(ciphertext, encryptionKey, iv) {
436
+ async function decryptData(ciphertext, encryptionKey, iv, aad) {
409
437
  const cryptoKey = await crypto.subtle.importKey(
410
438
  'raw',
411
439
  encryptionKey,
@@ -414,8 +442,11 @@ async function decryptData(ciphertext, encryptionKey, iv) {
414
442
  ['decrypt']
415
443
  );
416
444
 
445
+ const alg = { name: 'AES-GCM', iv };
446
+ if (aad) alg.additionalData = aad;
447
+
417
448
  const plaintext = await crypto.subtle.decrypt(
418
- { name: 'AES-GCM', iv },
449
+ alg,
419
450
  cryptoKey,
420
451
  ciphertext
421
452
  );
@@ -428,6 +459,12 @@ async function decryptData(ciphertext, encryptionKey, iv) {
428
459
  // High-Level Storage API
429
460
  // =============================================================================
430
461
 
462
+ function getAadForMethod(method) {
463
+ // Small AAD to bind ciphertexts to this module + method.
464
+ // (Replay protection against localStorage rollback is not achievable without an external monotonic anchor.)
465
+ return new TextEncoder().encode(`wallet-storage|v${STORAGE_VERSION}|${method}`);
466
+ }
467
+
431
468
  /**
432
469
  * Get storage metadata
433
470
  * @returns {Object|null} Storage metadata or null if no wallet stored
@@ -477,14 +514,16 @@ export function getStorageMethod() {
477
514
  export async function storeWithPIN(pin, walletData) {
478
515
  // Derive key from PIN
479
516
  const { keyMaterial, salt } = await deriveKeyFromPIN(pin);
480
- const { encryptionKey, iv } = await deriveKeyAndIV(keyMaterial, 'pin');
517
+ const encryptionKey = await deriveEncryptionKey(keyMaterial, 'pin');
481
518
 
482
519
  // Encrypt wallet data
483
- const ciphertext = await encryptData(walletData, encryptionKey, iv);
520
+ const aad = getAadForMethod(StorageMethod.PIN);
521
+ const { ciphertext, iv } = await encryptData(walletData, encryptionKey, aad);
484
522
 
485
523
  // Store encrypted data
486
524
  const encryptedData = {
487
525
  ciphertext: arrayBufferToBase64(ciphertext),
526
+ iv: arrayBufferToBase64(iv),
488
527
  salt: arrayBufferToBase64(salt)
489
528
  };
490
529
  localStorage.setItem(ENCRYPTED_DATA_KEY, JSON.stringify(encryptedData));
@@ -520,13 +559,37 @@ export async function retrieveWithPIN(pin) {
520
559
  const encryptedData = JSON.parse(encryptedJson);
521
560
  const salt = base64ToUint8Array(encryptedData.salt);
522
561
  const ciphertext = base64ToUint8Array(encryptedData.ciphertext);
562
+ const iv = encryptedData.iv ? base64ToUint8Array(encryptedData.iv) : null;
523
563
 
524
564
  // Derive key from PIN with stored salt
525
- const { keyMaterial } = await deriveKeyFromPIN(pin, salt);
526
- const { encryptionKey, iv } = await deriveKeyAndIV(keyMaterial, 'pin');
565
+ const { keyMaterial } = await deriveKeyFromPIN(pin, salt, iv ? PIN_PBKDF2_ITERATIONS : LEGACY_PIN_PBKDF2_ITERATIONS);
566
+ const aad = getAadForMethod(StorageMethod.PIN);
527
567
 
528
568
  try {
529
- return await decryptData(ciphertext, encryptionKey, iv);
569
+ let walletData;
570
+ if (iv) {
571
+ const encryptionKey = await deriveEncryptionKey(keyMaterial, 'pin');
572
+ walletData = await decryptData(ciphertext, encryptionKey, iv, aad);
573
+ } else {
574
+ // Legacy v1/v2 deterministic-IV storage. Decrypt and upgrade in-place.
575
+ const legacyVersion = metadata.version || 2;
576
+ const { encryptionKey: legacyKey, iv: legacyIv } = await deriveLegacyKeyAndIV(keyMaterial, 'pin', legacyVersion);
577
+ walletData = await decryptData(ciphertext, legacyKey, legacyIv);
578
+
579
+ // Upgrade stored blob to v3 (random IV) after successful decrypt.
580
+ const newKey = await deriveEncryptionKey(keyMaterial, 'pin');
581
+ const { ciphertext: newCt, iv: newIv } = await encryptData(walletData, newKey, aad);
582
+ localStorage.setItem(ENCRYPTED_DATA_KEY, JSON.stringify({
583
+ ciphertext: arrayBufferToBase64(newCt),
584
+ iv: arrayBufferToBase64(newIv),
585
+ salt: arrayBufferToBase64(salt)
586
+ }));
587
+ localStorage.setItem(METADATA_KEY, JSON.stringify({
588
+ ...metadata,
589
+ version: STORAGE_VERSION
590
+ }));
591
+ }
592
+ return walletData;
530
593
  } catch (e) {
531
594
  throw new Error('Invalid PIN or corrupted data');
532
595
  }
@@ -540,14 +603,39 @@ export async function retrieveWithPIN(pin) {
540
603
  * @returns {Promise<boolean>}
541
604
  */
542
605
  export async function storeWithPasskey(walletData, options = {}) {
543
- // Register passkey and get key material
544
- const { credentialId, keyMaterial, hasPRF } = await registerPasskey(options);
606
+ // Prefer reusing an existing stored passkey credential to avoid creating duplicates.
607
+ let credentialId = null;
608
+ let keyMaterial = null;
609
+ let hasPRF = false;
610
+
611
+ try {
612
+ const existing = localStorage.getItem(PASSKEY_CREDENTIAL_KEY);
613
+ if (existing) {
614
+ const parsed = JSON.parse(existing);
615
+ if (parsed?.id) {
616
+ credentialId = parsed.id;
617
+ const auth = await authenticatePasskey(credentialId);
618
+ keyMaterial = auth.keyMaterial;
619
+ hasPRF = auth.hasPRF;
620
+ }
621
+ }
622
+ } catch {
623
+ // Ignore and fall back to registration below.
624
+ }
625
+
626
+ if (!keyMaterial) {
627
+ const reg = await registerPasskey(options);
628
+ credentialId = reg.credentialId;
629
+ keyMaterial = reg.keyMaterial;
630
+ hasPRF = reg.hasPRF;
631
+ }
545
632
 
546
633
  // Derive encryption key
547
- const { encryptionKey, iv } = await deriveKeyAndIV(keyMaterial, 'passkey');
634
+ const encryptionKey = await deriveEncryptionKey(keyMaterial, 'passkey');
548
635
 
549
636
  // Encrypt wallet data
550
- const ciphertext = await encryptData(walletData, encryptionKey, iv);
637
+ const aad = getAadForMethod(StorageMethod.PASSKEY);
638
+ const { ciphertext, iv } = await encryptData(walletData, encryptionKey, aad);
551
639
 
552
640
  // Store credential info
553
641
  const credentialData = {
@@ -558,7 +646,8 @@ export async function storeWithPasskey(walletData, options = {}) {
558
646
 
559
647
  // Store encrypted data
560
648
  const encryptedData = {
561
- ciphertext: arrayBufferToBase64(ciphertext)
649
+ ciphertext: arrayBufferToBase64(ciphertext),
650
+ iv: arrayBufferToBase64(iv)
562
651
  };
563
652
  localStorage.setItem(ENCRYPTED_DATA_KEY, JSON.stringify(encryptedData));
564
653
 
@@ -584,7 +673,6 @@ export async function retrieveWithPasskey() {
584
673
  if (!metadata || metadata.method !== StorageMethod.PASSKEY) {
585
674
  throw new Error('No passkey-encrypted wallet found');
586
675
  }
587
-
588
676
  const credentialJson = localStorage.getItem(PASSKEY_CREDENTIAL_KEY);
589
677
  if (!credentialJson) {
590
678
  throw new Error('Passkey credential not found');
@@ -599,15 +687,38 @@ export async function retrieveWithPasskey() {
599
687
 
600
688
  const encryptedData = JSON.parse(encryptedJson);
601
689
  const ciphertext = base64ToUint8Array(encryptedData.ciphertext);
690
+ const iv = encryptedData.iv ? base64ToUint8Array(encryptedData.iv) : null;
602
691
 
603
692
  // Authenticate with passkey and get key material
604
693
  const { keyMaterial } = await authenticatePasskey(credentialData.id);
605
694
 
606
695
  // Derive encryption key
607
- const { encryptionKey, iv } = await deriveKeyAndIV(keyMaterial, 'passkey');
696
+ const aad = getAadForMethod(StorageMethod.PASSKEY);
608
697
 
609
698
  try {
610
- return await decryptData(ciphertext, encryptionKey, iv);
699
+ let walletData;
700
+ if (iv) {
701
+ const encryptionKey = await deriveEncryptionKey(keyMaterial, 'passkey');
702
+ walletData = await decryptData(ciphertext, encryptionKey, iv, aad);
703
+ } else {
704
+ // Legacy v1/v2 deterministic-IV storage. Decrypt and upgrade in-place.
705
+ const legacyVersion = metadata.version || 2;
706
+ const { encryptionKey: legacyKey, iv: legacyIv } = await deriveLegacyKeyAndIV(keyMaterial, 'passkey', legacyVersion);
707
+ walletData = await decryptData(ciphertext, legacyKey, legacyIv);
708
+
709
+ const newKey = await deriveEncryptionKey(keyMaterial, 'passkey');
710
+ const { ciphertext: newCt, iv: newIv } = await encryptData(walletData, newKey, aad);
711
+ localStorage.setItem(ENCRYPTED_DATA_KEY, JSON.stringify({
712
+ ciphertext: arrayBufferToBase64(newCt),
713
+ iv: arrayBufferToBase64(newIv)
714
+ }));
715
+ localStorage.setItem(METADATA_KEY, JSON.stringify({
716
+ ...metadata,
717
+ version: STORAGE_VERSION,
718
+ hasPRF: true
719
+ }));
720
+ }
721
+ return walletData;
611
722
  } catch (e) {
612
723
  throw new Error('Passkey authentication failed or data corrupted');
613
724
  }
@@ -636,7 +747,7 @@ export function migrateStorage() {
636
747
  if (getStorageMetadata() !== null) return;
637
748
  if (!oldPinWallet && !oldPasskeyCredential) return;
638
749
 
639
- console.log('Migrating wallet storage from v1 to v2...');
750
+ console.log('Migrating wallet storage from legacy format...');
640
751
 
641
752
  if (oldPasskeyCredential && oldPasskeyWallet) {
642
753
  // Migrate passkey storage
@@ -656,7 +767,9 @@ export function migrateStorage() {
656
767
  localStorage.setItem(METADATA_KEY, JSON.stringify({
657
768
  method: StorageMethod.PASSKEY,
658
769
  timestamp: credential.timestamp || wallet.timestamp || Date.now(),
659
- version: STORAGE_VERSION,
770
+ // Preserve legacy version so decrypt can derive the correct legacy HKDF salt/IV,
771
+ // then upgrade in-place on first successful retrieval.
772
+ version: credential.version || wallet.version || 2,
660
773
  hasPRF: credential.hasPRF || false
661
774
  }));
662
775
 
package/styles/main.css CHANGED
@@ -2704,6 +2704,48 @@ body:has(.modal.active) .nav-bar {
2704
2704
  justify-content: center;
2705
2705
  }
2706
2706
 
2707
+ .wallet-receive-peer-section {
2708
+ margin: 0 auto 16px;
2709
+ max-width: 320px;
2710
+ text-align: left;
2711
+ }
2712
+
2713
+ .wallet-receive-field {
2714
+ display: flex;
2715
+ align-items: baseline;
2716
+ gap: 8px;
2717
+ padding: 8px 0;
2718
+ border-bottom: 1px solid var(--white-05);
2719
+ }
2720
+
2721
+ .wallet-receive-field:last-child {
2722
+ border-bottom: none;
2723
+ }
2724
+
2725
+ .wallet-receive-field-label {
2726
+ flex-shrink: 0;
2727
+ width: 48px;
2728
+ font-size: 10px;
2729
+ text-transform: uppercase;
2730
+ letter-spacing: 0.05em;
2731
+ color: var(--white-50);
2732
+ }
2733
+
2734
+ .wallet-receive-field-value {
2735
+ flex: 1;
2736
+ font-size: 11px;
2737
+ color: var(--white-60);
2738
+ word-break: break-all;
2739
+ line-height: 1.4;
2740
+ font-family: var(--font-mono);
2741
+ }
2742
+
2743
+ .wallet-receive-field .glass-btn.small {
2744
+ flex-shrink: 0;
2745
+ padding: 4px 10px;
2746
+ font-size: 11px;
2747
+ }
2748
+
2707
2749
  .trust-summary {
2708
2750
  margin-top: 16px;
2709
2751
  padding: 24px;
@@ -5247,6 +5289,53 @@ body:has(.modal.active) .nav-bar {
5247
5289
  display: block;
5248
5290
  }
5249
5291
 
5292
+ /* Messaging key config (derivation path + algorithm selection) */
5293
+ .messaging-key-config {
5294
+ padding: 14px;
5295
+ margin-bottom: 16px;
5296
+ }
5297
+
5298
+ .messaging-key-config-grid {
5299
+ display: grid;
5300
+ grid-template-columns: 1fr 2fr;
5301
+ gap: 12px;
5302
+ align-items: start;
5303
+ }
5304
+
5305
+ .messaging-key-config-item label {
5306
+ display: block;
5307
+ font-size: 0.7rem;
5308
+ text-transform: uppercase;
5309
+ letter-spacing: 0.06em;
5310
+ color: var(--white-40);
5311
+ margin-bottom: 6px;
5312
+ }
5313
+
5314
+ .messaging-path-row {
5315
+ display: flex;
5316
+ gap: 8px;
5317
+ align-items: center;
5318
+ }
5319
+
5320
+ .messaging-path-row input {
5321
+ flex: 1;
5322
+ min-width: 0;
5323
+ font-family: var(--font-mono);
5324
+ }
5325
+
5326
+ .messaging-key-hint {
5327
+ margin-top: 6px;
5328
+ font-size: 0.7rem;
5329
+ color: var(--white-40);
5330
+ font-family: var(--font-mono);
5331
+ }
5332
+
5333
+ @media (max-width: 640px) {
5334
+ .messaging-key-config-grid {
5335
+ grid-template-columns: 1fr;
5336
+ }
5337
+ }
5338
+
5250
5339
  @media (max-width: 640px) {
5251
5340
  .identity-card {
5252
5341
  grid-template-columns: 1fr;