@sidhujag/sysweb3-keyring 1.0.612 → 1.0.613

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 CHANGED
@@ -14,6 +14,40 @@ The sysweb3-keyring provides a unified interface for managing accounts, transact
14
14
  - **Secure Session Management**: Encrypted private key handling with session transfer
15
15
  - **Transaction Management**: Full transaction lifecycle support for both network types
16
16
 
17
+ ## Vault encryption compatibility
18
+
19
+ New vaults require WebCrypto and use PBKDF2-HMAC-SHA-512 (900,000 production
20
+ iterations) with AES-256-GCM. Missing WebCrypto rejects the operation without
21
+ writing a weaker fallback vault. Restore WebCrypto availability before retrying
22
+ an unmarked legacy vault.
23
+
24
+ Historical fallback vaults used a 20,000-iteration key that also encrypted their
25
+ persisted account keys. After authenticating such a CBC vault, the keyring records
26
+ the explicit `pbkdf2-sha512-20000` compatibility profile and rewraps the vault with
27
+ AES-GCM. This preserves existing account access across restart; it is **not** a
28
+ full rekey of those account records to 900,000 iterations. The profile is written
29
+ before rewrapping, so interruption of either write remains recoverable. Arbitrary
30
+ KDF profiles or parameters are rejected.
31
+
32
+ Storage clients must return their asynchronous write/delete promises. The core
33
+ adapter preserves them so rejected writes do not silently advance vault migration.
34
+ Fresh wallet creation additionally requires conditional batch creation. Native
35
+ `chrome.storage.local` and `browser.storage.local` use a prefix-scoped Web Lock
36
+ around the absence check and awaited write, coordinating contexts in the same
37
+ extension origin and storage partition. Custom clients must provide an atomic
38
+ `createItemsIfAbsent(items)` backend operation over prefixed, JSON-serialized
39
+ values; the built-in memory client provides this operation synchronously.
40
+ There is no sequential or unlocked fallback. A backend shared across storage
41
+ partitions needs its own atomic implementation or a single owner.
42
+
43
+ ### Release order for 1.0.613
44
+
45
+ Publish `@sidhujag/sysweb3-core@1.0.29` before `@sidhujag/sysweb3-keyring@1.0.613`.
46
+ The updated lock entry describes that intended dependency; its integrity is
47
+ intentionally absent until publication. Regenerate and verify the lockfile from
48
+ the published registry artifacts before a clean registry installation. Local
49
+ validation uses the built package tarballs and does not establish publication.
50
+
17
51
  ## Installation
18
52
 
19
53
  ```bash
@@ -191,7 +225,11 @@ Main class for keyring operations:
191
225
 
192
226
  ## Security
193
227
 
194
- - Private keys are encrypted and stored in memory only
228
+ - `initializeSession`, `initializeWalletSecurely`, and the initialization factories create a new wallet only when both vault records are absent. Use `unlock()` to restore an existing wallet. Repeating initialization on a matching live session verifies the stored wallet without rewriting it.
229
+ - Fresh vault salt and ciphertext are created together only if both are absent, through the core conditional-creation operation. Native extension contexts sharing an origin/storage partition are coordinated by Web Locks; custom backends must provide atomic `createItemsIfAbsent`. Unsupported adapters are rejected before writing. Existing or incomplete records are preserved.
230
+ - UTXO signing authenticates the selected account's paths, public keys and spent scripts. A joint PSBT may include unfinished inputs for another signer: their HD/path hints are removed before the selected private signer runs, then their public metadata is restored to the returned partial PSBT. Another account in the same wallet remains unsigned. At least one unfinished input must authenticate to the selected account; a wholly finalized PSBT retains its existing handling. Standard P2WSH, wrapped P2WSH and P2SH multisig preserve external cosigner signatures and continuation. Hardware paths and single-address imports can reject unsupported joint inputs rather than widening signing authority.
231
+ - Hardware signing preserves already-finalized external inputs without requesting their private derivations. Trezor receives them as `EXTERNAL` inputs; Ledger retains their final scripts in PSBTv2 and skips signing/finalizing them again. Unfinished foreign hardware inputs remain unsupported.
232
+ - Persisted wallet and account secrets are encrypted; plaintext signing material is used in memory
195
233
  - Session data is cleared when the keyring is locked
196
234
  - Hardware wallet integration follows device security models
197
235
  - Secure memory management with explicit cleanup
@@ -51,6 +51,7 @@ const BIP84 = __importStar(require("syscoinjs-lib/bip84-replacement"));
51
51
  // Reference embedded bitcoinjs from syscoinjs-lib
52
52
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
53
  const bjs = syscoinjs.utils.bitcoinjs;
54
+ const LEGACY_ENCRYPTION_KDF = 'pbkdf2-sha512-20000';
54
55
  const SLH_DSA_DERIVATION_VERSION = 1;
55
56
  // Preserve Pali v1 SLH derivation: first 32 bytes of the account-scoped HMAC.
56
57
  // Pali expands this setup secret into the 48-byte SLH skSeed/skPrf/pkSeed tuple.
@@ -68,6 +69,7 @@ const trezor_1 = require("./trezor");
68
69
  const types_1 = require("./types");
69
70
  const blockbook_cache_1 = require("./utils/blockbook-cache");
70
71
  const derivation_paths_1 = require("./utils/derivation-paths");
72
+ const psbt_account_scope_1 = require("./utils/psbt-account-scope");
71
73
  // Dynamic ETH HD path generation - will be computed as needed
72
74
  /**
73
75
  * Secure Buffer implementation for sensitive data
@@ -374,7 +376,7 @@ class KeyringManager {
374
376
  if (!vaultKeys || !vaultKeys.salt) {
375
377
  throw new Error('Vault keys not found');
376
378
  }
377
- const genPwd = await this.encryptSHA512Async(pwd, vaultKeys.salt);
379
+ const genPwd = await this.deriveStoredSessionKey(pwd, vaultKeys);
378
380
  if (this.getSessionPasswordString() !== genPwd) {
379
381
  throw new Error('Invalid password');
380
382
  }
@@ -426,7 +428,7 @@ class KeyringManager {
426
428
  if (!vaultKeys || !vaultKeys.salt) {
427
429
  throw new Error('Vault keys not found');
428
430
  }
429
- const genPwd = await this.encryptSHA512Async(pwd, vaultKeys.salt);
431
+ const genPwd = await this.deriveStoredSessionKey(pwd, vaultKeys);
430
432
  if (this.getSessionPasswordString() !== genPwd) {
431
433
  throw new Error('Invalid password');
432
434
  }
@@ -558,7 +560,7 @@ class KeyringManager {
558
560
  if (!vaultKeys || !vaultKeys.salt) {
559
561
  throw new Error('Vault keys not found');
560
562
  }
561
- const genPwd = await this.encryptSHA512Async(pwd, vaultKeys.salt);
563
+ const genPwd = await this.deriveStoredSessionKey(pwd, vaultKeys);
562
564
  if (!this.sessionPassword) {
563
565
  throw new Error('Unlock wallet first');
564
566
  }
@@ -755,6 +757,33 @@ class KeyringManager {
755
757
  throw error;
756
758
  }
757
759
  };
760
+ this.capturePsbtSigningContext = () => {
761
+ const session = this.sessionPassword;
762
+ const vault = this.getVault();
763
+ const selected = { ...vault.activeAccount };
764
+ const account = vault.accounts[selected.type]?.[selected.id];
765
+ const identity = { address: account?.address, xpub: account?.xpub };
766
+ const network = { ...vault.activeNetwork };
767
+ const assertCurrent = () => {
768
+ const current = this.getVault();
769
+ const currentAccount = current.accounts[selected.type]?.[selected.id];
770
+ if (!session ||
771
+ session.isCleared() ||
772
+ this.sessionPassword !== session ||
773
+ current.activeAccount.id !== selected.id ||
774
+ current.activeAccount.type !== selected.type ||
775
+ currentAccount?.address !== identity.address ||
776
+ currentAccount?.xpub !== identity.xpub ||
777
+ current.activeNetwork.kind !== network.kind ||
778
+ current.activeNetwork.chainId !== network.chainId ||
779
+ current.activeNetwork.slip44 !== network.slip44 ||
780
+ current.activeNetwork.url !== network.url) {
781
+ throw new Error('Wallet signing context changed');
782
+ }
783
+ };
784
+ assertCurrent();
785
+ return assertCurrent;
786
+ };
758
787
  this.getSigner = () => {
759
788
  if (!this.sessionPassword) {
760
789
  throw new Error('Wallet is locked cant proceed with transaction');
@@ -766,6 +795,7 @@ class KeyringManager {
766
795
  const { activeAccount } = vault;
767
796
  const accountId = activeAccount.id;
768
797
  const accountType = activeAccount.type;
798
+ const assertCurrent = this.capturePsbtSigningContext();
769
799
  // Determine signer type: HD or WIF single-address
770
800
  let signerForUse;
771
801
  if (accountType === types_1.KeyringAccountType.HDAccount) {
@@ -801,6 +831,33 @@ class KeyringManager {
801
831
  else {
802
832
  throw new Error(`Unsupported account type for UTXO signing: ${accountType}`);
803
833
  }
834
+ const scope = {
835
+ account: vault.accounts[accountType][accountId],
836
+ accountId,
837
+ accountType,
838
+ network: vault.activeNetwork,
839
+ };
840
+ if (typeof signerForUse.getRootNode === 'function') {
841
+ const getRootNode = signerForUse.getRootNode.bind(signerForUse);
842
+ const guardPath = (0, psbt_account_scope_1.createPsbtDerivationGuard)(scope);
843
+ signerForUse.getRootNode = () => {
844
+ const root = getRootNode();
845
+ const scopedRoot = Object.create(root);
846
+ scopedRoot.derivePath = (path) => {
847
+ guardPath(path);
848
+ return root.derivePath(path);
849
+ };
850
+ return scopedRoot;
851
+ };
852
+ }
853
+ const sign = signerForUse.sign.bind(signerForUse);
854
+ signerForUse.sign = async (psbt) => {
855
+ assertCurrent();
856
+ (0, psbt_account_scope_1.assertPsbtAccountScope)(psbt, scope);
857
+ const result = await sign(psbt);
858
+ assertCurrent();
859
+ return result;
860
+ };
804
861
  // Create syscoinjs instance with current network (no need to attach signer for signing flow)
805
862
  const network = vault.activeNetwork;
806
863
  const networkConfig = (0, sysweb3_network_1.getNetworkConfig)(network.slip44, network.currency);
@@ -1022,51 +1079,65 @@ class KeyringManager {
1022
1079
  if (!BIP84.validateMnemonic(seedPhrase)) {
1023
1080
  throw new Error('Invalid Seed');
1024
1081
  }
1025
- let foundVaultKeys = true;
1026
- let salt = '';
1027
- const vaultKeys = await this.storage.get('vault-keys');
1028
- if (!vaultKeys || !vaultKeys.salt) {
1029
- foundVaultKeys = false;
1030
- salt = crypto_1.default.randomBytes(16).toString('hex');
1031
- }
1032
- else {
1033
- salt = vaultKeys.salt;
1034
- }
1035
- // v4 stores only the salt on disk (vault-keys.salt).
1036
- // The derived session encryption key must remain in memory only.
1037
- const sessionPasswordSaltedHash = await this.encryptSHA512Async(password, salt);
1038
- if (!foundVaultKeys) {
1039
- // Store vault-keys using the storage abstraction
1040
- await this.storage.set('vault-keys', {
1041
- salt,
1042
- });
1043
- }
1044
- // Check if already initialized with the same password (idempotent behavior)
1045
- if (this.sessionPassword) {
1046
- if (sessionPasswordSaltedHash === this.getSessionPasswordString()) {
1082
+ const [vault, vaultKeys] = await Promise.all([
1083
+ this.storage.get('vault'),
1084
+ this.storage.get('vault-keys'),
1085
+ ]);
1086
+ const hasVault = vault !== undefined && vault !== null;
1087
+ const hasVaultKeys = vaultKeys !== undefined && vaultKeys !== null;
1088
+ const livePassword = this.sessionPassword;
1089
+ const liveMnemonic = this.sessionMnemonic;
1090
+ // Only an already-unlocked, matching session may repeat initialization.
1091
+ // Restoring any existing wallet must use unlock(), never rewrite its vault.
1092
+ if (livePassword &&
1093
+ liveMnemonic &&
1094
+ !livePassword.isCleared() &&
1095
+ !liveMnemonic.isCleared()) {
1096
+ if (!hasVault || !hasVaultKeys || !vaultKeys.salt) {
1097
+ throw new Error('Cannot initialize a new vault over existing wallet storage');
1098
+ }
1099
+ const sessionPasswordSaltedHash = await this.deriveStoredSessionKey(password, vaultKeys);
1100
+ if (livePassword === this.sessionPassword &&
1101
+ liveMnemonic === this.sessionMnemonic &&
1102
+ sessionPasswordSaltedHash === this.getSessionPasswordString()) {
1047
1103
  // Same password - check if it's the same mnemonic to ensure full idempotency
1104
+ let currentMnemonic;
1048
1105
  try {
1049
- const currentMnemonic = this.withSecureData((sessionPwd, sessionMnemonic) => {
1106
+ currentMnemonic = this.withSecureData((sessionPwd, sessionMnemonic) => {
1050
1107
  return crypto_js_1.default.AES.decrypt(sessionMnemonic, sessionPwd).toString(crypto_js_1.default.enc.Utf8);
1051
1108
  });
1052
- if (currentMnemonic === seedPhrase) {
1053
- // Same mnemonic and password - already initialized
1054
- return;
1055
- }
1056
1109
  }
1057
1110
  catch (error) {
1058
1111
  // If we can't decrypt, fall through to error
1059
1112
  }
1113
+ if (currentMnemonic === seedPhrase) {
1114
+ const storedVault = await (0, storage_1.getDecryptedVault)(sessionPasswordSaltedHash);
1115
+ if (livePassword === this.sessionPassword &&
1116
+ liveMnemonic === this.sessionMnemonic &&
1117
+ !livePassword.isCleared() &&
1118
+ !liveMnemonic.isCleared() &&
1119
+ storedVault?.mnemonic === seedPhrase) {
1120
+ // This is a read-only identity check, not a second vault write.
1121
+ return;
1122
+ }
1123
+ }
1060
1124
  }
1061
1125
  // Different password or mnemonic - this is not a simple re-initialization
1062
- throw new Error('Wallet already initialized with different parameters. Create a new keyring instance for different parameters.');
1126
+ throw new Error('Wallet already initialized with different parameters. Unlock the existing wallet or explicitly remove it before starting setup.');
1127
+ }
1128
+ if (hasVault || hasVaultKeys) {
1129
+ throw new Error('Cannot initialize a new vault over existing wallet storage');
1063
1130
  }
1131
+ const salt = crypto_1.default.randomBytes(16).toString('hex');
1132
+ // v4 stores only the salt on disk. A genuinely fresh initialization always
1133
+ // commits its own salt and ciphertext together under the vault mutex.
1134
+ const sessionPasswordSaltedHash = await this.deriveStoredSessionKey(password, { salt });
1064
1135
  // Encrypt and store vault (mnemonic storage) - now uses single vault for all networks
1065
1136
  await (0, storage_1.setEncryptedVault)({
1066
1137
  mnemonic: seedPhrase, // Store plain mnemonic - setEncryptedVault will encrypt the entire vault
1067
1138
  },
1068
1139
  // v4 vault is encrypted with the derived session password key (PBKDF2 output)
1069
- sessionPasswordSaltedHash);
1140
+ sessionPasswordSaltedHash, { salt });
1070
1141
  await this.recreateSessionFromVault(sessionPasswordSaltedHash);
1071
1142
  };
1072
1143
  // NEW: Create first account without signer setup
@@ -1108,9 +1179,7 @@ class KeyringManager {
1108
1179
  };
1109
1180
  this.storage = sysweb3.sysweb3Di.getStateStorageDb();
1110
1181
  // Don't initialize secure buffers in constructor - they're created on unlock
1111
- this.storage.set('utf8Error', {
1112
- hasUtf8Error: false,
1113
- });
1182
+ this.writeUtf8Diagnostic(false);
1114
1183
  // NOTE: activeChain is now derived from vault state, not stored locally
1115
1184
  // NOTE: No more persistent signers - use getSigner() for fresh on-demand signers
1116
1185
  this.utf8Error = false;
@@ -1123,7 +1192,7 @@ class KeyringManager {
1123
1192
  this.trezorSigner = new trezor_1.TrezorKeyring();
1124
1193
  this.ledgerSigner = new ledger_1.LedgerKeyring(hardwareManager);
1125
1194
  // this.syscoinTransaction = SyscoinTransactions();
1126
- this.syscoinTransaction = new transactions_1.SyscoinTransactions(this.getSigner, this.getReadOnlySigner, this.getAccountsState, this.getAddress, this.ledgerSigner, this.trezorSigner);
1195
+ this.syscoinTransaction = new transactions_1.SyscoinTransactions(this.getSigner, this.getReadOnlySigner, this.getAccountsState, this.getAddress, this.ledgerSigner, this.trezorSigner, this.capturePsbtSigningContext);
1127
1196
  this.ethereumTransaction = new transactions_1.EthereumTransactions(this.getNetwork, this.getDecryptedPrivateKey, this.getAccountsState, this.ledgerSigner, this.trezorSigner);
1128
1197
  }
1129
1198
  // Static factory method for creating a fully initialized KeyringManager with slip44 support
@@ -1166,7 +1235,7 @@ class KeyringManager {
1166
1235
  const { salt } = vaultKeys;
1167
1236
  // Derive session encryption key (in-memory only) used to encrypt/decrypt xprv fields
1168
1237
  // IMPORTANT: This MUST NOT be the same value as the stored verifier above.
1169
- const sessionPasswordKey = await this.encryptSHA512Async(password, salt);
1238
+ let sessionPasswordKey = await this.deriveStoredSessionKey(password, vaultKeys);
1170
1239
  // Handle migration from old vault format with currentSessionSalt
1171
1240
  if (vaultKeys.currentSessionSalt) {
1172
1241
  console.log('[KeyringManager] Detected old vault format, handling session migration...');
@@ -1227,8 +1296,44 @@ class KeyringManager {
1227
1296
  }
1228
1297
  // v4 vault is encrypted with the derived session password key (PBKDF2 output).
1229
1298
  // If the password is wrong, this will throw and we return canLogin:false below.
1230
- await (0, storage_1.getDecryptedVault)(sessionPasswordKey);
1299
+ let decryptedVault;
1300
+ let legacyProfile = vaultKeys.keyDerivation === LEGACY_ENCRYPTION_KDF;
1301
+ try {
1302
+ decryptedVault = await (0, storage_1.getDecryptedVault)(sessionPasswordKey);
1303
+ }
1304
+ catch (error) {
1305
+ // Only the historical CBC fallback used the 20k key without metadata.
1306
+ // Never try it for GCM, malformed records, or platform/storage failures.
1307
+ const rawVault = error instanceof storage_1.VaultAuthenticationError &&
1308
+ !vaultKeys.keyDerivation &&
1309
+ !vaultKeys.currentSessionSalt
1310
+ ? await this.storage.get('vault')
1311
+ : undefined;
1312
+ if (typeof rawVault !== 'string' || rawVault.trim().startsWith('{')) {
1313
+ throw error;
1314
+ }
1315
+ const legacyKey = await this.deriveLegacySessionKey(password, salt);
1316
+ decryptedVault = await (0, storage_1.getDecryptedVault)(legacyKey);
1317
+ authenticated = true;
1318
+ sessionPasswordKey = legacyKey;
1319
+ // Keep the historical account wrapping key. Persist its profile before
1320
+ // GCM rewrapping so either interrupted write remains recoverable.
1321
+ await this.storage.set('vault-keys', {
1322
+ ...vaultKeys,
1323
+ keyDerivation: LEGACY_ENCRYPTION_KDF,
1324
+ });
1325
+ legacyProfile = true;
1326
+ }
1231
1327
  authenticated = true;
1328
+ if (legacyProfile && this.hasWebCrypto()) {
1329
+ const storedVault = await this.storage.get('vault');
1330
+ if (typeof storedVault === 'string' &&
1331
+ !storedVault.trim().startsWith('{')) {
1332
+ // A failed earlier rewrap can leave the authenticated profile plus
1333
+ // CBC. Retry that write without changing the account wrapping key.
1334
+ await (0, storage_1.setEncryptedVault)(decryptedVault, sessionPasswordKey);
1335
+ }
1336
+ }
1232
1337
  // If session data missing or corrupted, recreate from vault
1233
1338
  if (!this.sessionMnemonic) {
1234
1339
  await this.recreateSessionFromVault(sessionPasswordKey);
@@ -1238,7 +1343,7 @@ class KeyringManager {
1238
1343
  // so we must restore the real class instance on unlock.
1239
1344
  if (!this.syscoinTransaction ||
1240
1345
  typeof this.syscoinTransaction.getRecommendedFee !== 'function') {
1241
- this.syscoinTransaction = new transactions_1.SyscoinTransactions(this.getSigner, this.getReadOnlySigner, this.getAccountsState, this.getAddress, this.ledgerSigner, this.trezorSigner);
1346
+ this.syscoinTransaction = new transactions_1.SyscoinTransactions(this.getSigner, this.getReadOnlySigner, this.getAccountsState, this.getAddress, this.ledgerSigner, this.trezorSigner, this.capturePsbtSigningContext);
1242
1347
  }
1243
1348
  // NOTE: Active account management is now handled by vault state/Redux
1244
1349
  // No need to explicitly set active account after unlock - it's managed externally
@@ -1576,11 +1681,20 @@ class KeyringManager {
1576
1681
  return { isValid: false, message: error.message };
1577
1682
  }
1578
1683
  }
1684
+ writeUtf8Diagnostic(hasUtf8Error) {
1685
+ try {
1686
+ void Promise.resolve(this.storage.set('utf8Error', { hasUtf8Error })).catch(() => undefined);
1687
+ }
1688
+ catch {
1689
+ // This diagnostic must not interrupt authentication or become an
1690
+ // unhandled rejection when an asynchronous storage client is offline.
1691
+ }
1692
+ }
1579
1693
  validateAndHandleErrorByMessage(message) {
1580
1694
  const utf8ErrorMessage = 'Malformed UTF-8 data';
1581
1695
  if (message.includes(utf8ErrorMessage) ||
1582
1696
  message.toLowerCase().includes(utf8ErrorMessage.toLowerCase())) {
1583
- this.storage.set('utf8Error', { hasUtf8Error: true });
1697
+ this.writeUtf8Diagnostic(true);
1584
1698
  }
1585
1699
  }
1586
1700
  async pbkdf2WebCryptoHex(params) {
@@ -1608,7 +1722,33 @@ class KeyringManager {
1608
1722
  hash: 'SHA-512',
1609
1723
  });
1610
1724
  }
1611
- return this.encryptSHA512(password, salt);
1725
+ throw new Error('WebCrypto is required for vault key derivation');
1726
+ }
1727
+ async deriveLegacySessionKey(password, salt) {
1728
+ if (this.hasWebCrypto()) {
1729
+ return this.pbkdf2WebCryptoHex({
1730
+ password,
1731
+ saltHex: salt,
1732
+ iterations: 20000,
1733
+ lengthBytes: 32,
1734
+ hash: 'SHA-512',
1735
+ });
1736
+ }
1737
+ // Read compatibility only; new vault writes still require WebCrypto.
1738
+ return crypto_js_1.default.PBKDF2(password, crypto_js_1.default.enc.Hex.parse(salt), {
1739
+ keySize: 256 / 32,
1740
+ iterations: 20000,
1741
+ hasher: crypto_js_1.default.algo.SHA512,
1742
+ }).toString();
1743
+ }
1744
+ async deriveStoredSessionKey(password, vaultKeys) {
1745
+ if (vaultKeys.keyDerivation === LEGACY_ENCRYPTION_KDF) {
1746
+ return this.deriveLegacySessionKey(password, vaultKeys.salt);
1747
+ }
1748
+ if (vaultKeys.keyDerivation !== undefined) {
1749
+ throw new Error('Unsupported vault key derivation profile');
1750
+ }
1751
+ return this.encryptSHA512Async(password, vaultKeys.salt);
1612
1752
  }
1613
1753
  async _createTrezorAccount(coin, slip44, index, label) {
1614
1754
  const vault = this.getVault();