Skip to content
This repository was archived by the owner on Jun 12, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion client/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions mobile/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion mobile/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
12 changes: 8 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
15 changes: 11 additions & 4 deletions src/CWIStyleWalletManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand Down Expand Up @@ -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
}

Expand Down
82 changes: 82 additions & 0 deletions src/__tests/CWIStyleWalletManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
Loading