diff --git a/client/package-lock.json b/client/package-lock.json index 636b4ab6..c533edf9 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bsv/wallet-toolbox-client", - "version": "2.1.11", + "version": "2.1.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bsv/wallet-toolbox-client", - "version": "2.1.11", + "version": "2.1.15", "license": "SEE LICENSE IN license.md", "dependencies": { "@bsv/sdk": "^2.0.13", diff --git a/client/package.json b/client/package.json index a91cc40f..6c729e32 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-toolbox-client", - "version": "2.1.12", + "version": "2.1.15", "description": "Client only Wallet Storage", "main": "./out/src/index.client.js", "types": "./out/src/index.client.d.ts", diff --git a/mobile/package-lock.json b/mobile/package-lock.json index ab9480d6..aa3e6206 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bsv/wallet-toolbox-mobile", - "version": "2.1.11", + "version": "2.1.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bsv/wallet-toolbox-mobile", - "version": "2.1.11", + "version": "2.1.15", "license": "SEE LICENSE IN license.md", "dependencies": { "@bsv/sdk": "^2.0.13", diff --git a/mobile/package.json b/mobile/package.json index 3427e707..632550df 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-toolbox-mobile", - "version": "2.1.12", + "version": "2.1.15", "description": "Client only Wallet Storage", "main": "./out/src/index.mobile.js", "types": "./out/src/index.mobile.d.ts", diff --git a/package-lock.json b/package-lock.json index ead5ea62..092ff0fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bsv/wallet-toolbox", - "version": "2.1.14", + "version": "2.1.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bsv/wallet-toolbox", - "version": "2.1.14", + "version": "2.1.15", "license": "SEE LICENSE IN license.md", "dependencies": { "@bsv/auth-express-middleware": "^2.0.4", @@ -1965,7 +1965,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -2861,7 +2863,9 @@ "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.8", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 485dfbdc..f54783a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-toolbox", - "version": "2.1.14", + "version": "2.1.15", "description": "BRC100 conforming wallet, wallet storage and wallet signer components", "main": "./out/src/index.js", "types": "./out/src/index.d.ts", diff --git a/src/CWIStyleWalletManager.ts b/src/CWIStyleWalletManager.ts index 10f785d0..bdedf949 100644 --- a/src/CWIStyleWalletManager.ts +++ b/src/CWIStyleWalletManager.ts @@ -1742,8 +1742,11 @@ export class CWIStyleWalletManager implements WalletInterface { profilesEncrypted = new SymmetricKey(rootPrimaryKey).encrypt(profilesBytes) as number[] } - // Construct the new UMP token data (preserve or upgrade to v3 with KDF metadata) - const kdfMetadata = this.currentUMPToken.passwordKdf ?? this.kdfConfig + // Construct the new UMP token data. + // IMPORTANT: For non-password updates (e.g. add/remove profile), preserve legacy + // KDF layout exactly. Upgrading legacy tokens to v3 here would require re-deriving + // passwordKey from plaintext password, which we do not have in this code path. + const kdfMetadata = this.currentUMPToken.passwordKdf const newTokenData: UMPToken = { passwordSalt, passwordPresentationPrimary: presentationPassword.encrypt(rootPrimaryKey) as number[], @@ -1775,8 +1778,12 @@ export class CWIStyleWalletManager implements WalletInterface { }) ).ciphertext, profilesEncrypted, // Add encrypted profiles - umpVersion: 3, // UMP protocol version 3 - passwordKdf: kdfMetadata // Preserve or upgrade KDF metadata + ...(kdfMetadata + ? { + umpVersion: 3, + passwordKdf: kdfMetadata + } + : {}) // currentOutpoint will be set after publishing } diff --git a/src/__tests/CWIStyleWalletManager.test.ts b/src/__tests/CWIStyleWalletManager.test.ts index 554c228e..0906bd42 100644 --- a/src/__tests/CWIStyleWalletManager.test.ts +++ b/src/__tests/CWIStyleWalletManager.test.ts @@ -790,6 +790,88 @@ describe('CWIStyleWalletManager Tests', () => { // Verify PBKDF2 was used (indirectly via successful auth with legacy token) }) + test('Legacy token profile update preserves legacy KDF metadata', async () => { + const legacyToken = makeLegacyToken(Random(32)) + mockInteractor.findByPresentationKeyHash = jest.fn(async () => legacyToken) + manager = makeManager() + + await manager.providePresentationKey(presentationKey) + await manager.providePassword('test-password') + + const mockGetFactor = jest.spyOn(manager as any, 'getFactor') + mockGetFactor.mockImplementation(async factorName => { + if (factorName === 'passwordKey') return passwordKey + if (factorName === 'presentationKey') return presentationKey + if (factorName === 'recoveryKey') return recoveryKey + if (factorName === 'privilegedKey') return Random(32) + return Random(32) + }) + + await manager.addProfile('legacy-profile') + + expect(mockInteractor.buildAndSend).toHaveBeenCalled() + const updatedToken = (mockInteractor.buildAndSend as any).mock.calls[0][2] as UMPToken + expect(updatedToken.umpVersion).toBeUndefined() + expect(updatedToken.passwordKdf).toBeUndefined() + }) + + test('Legacy user can relogin after profile change (regression)', async () => { + // This test proves the bug: legacy user logs in, adds profile, logs out, + // then cannot log back in because token was silently migrated to v3 metadata + // while factors were still wrapped with PBKDF2-derived key. + + const rootPrimary = Random(32) + const legacyToken = makeLegacyToken(rootPrimary) + + // Track what token gets published after addProfile + let publishedToken: UMPToken | undefined + mockInteractor.findByPresentationKeyHash = jest.fn(async () => legacyToken) + mockInteractor.buildAndSend = jest.fn(async (_w: any, _a: any, token: UMPToken) => { + publishedToken = token + return 'updated.0' + }) + + manager = makeManager() + + // Step 1: Legacy user logs in successfully + await manager.providePresentationKey(presentationKey) + await manager.providePassword('test-password') + expect(manager.authenticated).toBe(true) + + // Step 2: User adds a profile (this triggers updateAuthFactors) + const mockGetFactor = jest.spyOn(manager as any, 'getFactor') + mockGetFactor.mockImplementation(async factorName => { + if (factorName === 'passwordKey') return passwordKey + if (factorName === 'presentationKey') return presentationKey + if (factorName === 'recoveryKey') return recoveryKey + if (factorName === 'privilegedKey') return Random(32) + return Random(32) + }) + + await manager.addProfile('work') + expect(publishedToken).toBeDefined() + + // Step 3: User logs out (destroy manager) + manager.destroy() + + // Step 4: Simulate relogin - overlay now returns the updated token + mockInteractor.findByPresentationKeyHash = jest.fn(async () => ({ + ...publishedToken!, + currentOutpoint: 'updated.0' + })) + + const manager2 = makeManager() + await manager2.providePresentationKey(presentationKey) + + // Step 5: Try to login with password + // If token was incorrectly migrated to v3, this will fail because + // derivePasswordKey will use Argon2id but factors were wrapped with PBKDF2 key + await manager2.providePassword('test-password') + + // With the fix, this should succeed because token stays legacy + expect(manager2.authenticated).toBe(true) + }) + test('V3 token login uses Argon2id and respects iterations', async () => { const argon2PasswordKey = await deriveArgon2Key(3, 65536) const v3Token = makeV3Token(argon2PasswordKey, Random(32), 'v3.0', { iterations: 3, memoryKiB: 65536 })