UNPKG

@bsv/wallet-toolbox

Version:

BRC100 conforming wallet, wallet storage and wallet signer components

607 lines (515 loc) 25 kB
import { WalletInterface, Random, Hash, Utils, PrivateKey, SymmetricKey } from '@bsv/sdk' import { PrivilegedKeyManager } from '../sdk' import { CWIStyleWalletManager, PBKDF2_NUM_ROUNDS, UMPToken, UMPTokenInteractor } from '../CWIStyleWalletManager' import { jest } from '@jest/globals' jest.useFakeTimers() // ------------------------------------------------------------------------------------------ // Mocks and Utilities // ------------------------------------------------------------------------------------------ /** A utility to create an Outpoint string for test usage. */ function makeOutpoint(txid: string, vout: number): string { return `${txid}:${vout}` } /** * A mock underlying WalletInterface to verify that proxy methods: * 1. Are not callable if not authenticated * 2. Are disallowed if originator is admin * 3. Forward to the real method if conditions pass */ const mockUnderlyingWallet = { getPublicKey: jest.fn(), revealCounterpartyKeyLinkage: jest.fn(), revealSpecificKeyLinkage: jest.fn(), encrypt: jest.fn(), decrypt: jest.fn(), createHmac: jest.fn(), verifyHmac: jest.fn(), createSignature: jest.fn(), verifySignature: jest.fn(), createAction: jest.fn(), signAction: jest.fn(), abortAction: jest.fn(), listActions: jest.fn(), internalizeAction: jest.fn(), listOutputs: jest.fn(), relinquishOutput: jest.fn(), acquireCertificate: jest.fn(), listCertificates: jest.fn(), proveCertificate: jest.fn(), relinquishCertificate: jest.fn(), discoverByIdentityKey: jest.fn(), discoverByAttributes: jest.fn(), isAuthenticated: jest.fn(), waitForAuthentication: jest.fn(), getHeight: jest.fn(), getHeaderForHeight: jest.fn(), getNetwork: jest.fn(), getVersion: jest.fn() } as unknown as WalletInterface /** * A mock function that simulates building an underlying wallet. */ const mockWalletBuilder = jest.fn(async (primaryKey, privilegedKeyManager) => { // Return our mock underlying wallet object. return mockUnderlyingWallet }) /** * A mock UMPTokenInteractor implementation. * We can track whether buildAndSend is called with the right arguments, etc. */ const mockUMPTokenInteractor: UMPTokenInteractor = { findByPresentationKeyHash: jest.fn(async (hash: number[]) => undefined), findByRecoveryKeyHash: jest.fn(async (hash: number[]) => undefined), buildAndSend: jest.fn( async (wallet: WalletInterface, admin: string, token: UMPToken, oldToken?: UMPToken) => 'abcd.0' ) } /** * A mock "recoveryKeySaver" that claims it always saved the key successfully. */ const mockRecoveryKeySaver = jest.fn(async (key: number[]) => true as true) /** * A mock "passwordRetriever" that we can customize to return a specific password * or throw if needed. */ const mockPasswordRetriever = jest.fn(async () => 'test-password') const XOR = (n1: number[], n2: number[]): number[] => { if (n1.length !== n2.length) { throw new Error('lengths mismatch') } const r = new Array<number>(n1.length) for (let i = 0; i < n1.length; i++) { r[i] = n1[i] ^ n2[i] } return r } // Generate some globals const presentationKey = Random(32) const recoveryKey = Random(32) const passwordSalt = Random(32) const passwordKey = Hash.pbkdf2(Utils.toArray('test-password', 'utf8'), passwordSalt, PBKDF2_NUM_ROUNDS, 32, 'sha512') const primaryKey = Random(32) const privilegedKey = Random(32) /** * A helper function to create a minimal valid UMP token. * This can be used to mock a stored token for existing users. */ async function createMockUMPToken(): Promise<UMPToken> { const presentationPassword = new SymmetricKey(XOR(presentationKey, passwordKey)) const presentationRecovery = new SymmetricKey(XOR(presentationKey, recoveryKey)) const recoveryPassword = new SymmetricKey(XOR(recoveryKey, passwordKey)) const primaryPassword = new SymmetricKey(XOR(primaryKey, passwordKey)) const tempPrivilegedKeyManager = new PrivilegedKeyManager(async () => new PrivateKey(privilegedKey)) return { passwordSalt, passwordPresentationPrimary: presentationPassword.encrypt(primaryKey) as number[], passwordRecoveryPrimary: recoveryPassword.encrypt(primaryKey) as number[], presentationRecoveryPrimary: presentationRecovery.encrypt(primaryKey) as number[], passwordPrimaryPrivileged: primaryPassword.encrypt(privilegedKey) as number[], presentationRecoveryPrivileged: presentationRecovery.encrypt(privilegedKey) as number[], presentationHash: Hash.sha256(presentationKey), recoveryHash: Hash.sha256(recoveryKey), presentationKeyEncrypted: ( await tempPrivilegedKeyManager.encrypt({ plaintext: presentationKey, protocolID: [2, 'admin key wrapping'], keyID: '1' }) ).ciphertext, passwordKeyEncrypted: ( await tempPrivilegedKeyManager.encrypt({ plaintext: passwordKey, protocolID: [2, 'admin key wrapping'], keyID: '1' }) ).ciphertext, recoveryKeyEncrypted: ( await tempPrivilegedKeyManager.encrypt({ plaintext: recoveryKey, protocolID: [2, 'admin key wrapping'], keyID: '1' }) ).ciphertext, currentOutpoint: 'abcd:0' } } describe('CWIStyleWalletManager Tests', () => { let manager: CWIStyleWalletManager beforeEach(() => { // Reset all mock calls jest.clearAllMocks() // We create a new manager for each test, with no initial snapshot manager = new CWIStyleWalletManager( 'admin.walletvendor.com', // admin originator mockWalletBuilder, mockUMPTokenInteractor, mockRecoveryKeySaver, mockPasswordRetriever // no state snapshot ) }) // ---------------------------------------------------------------------------------------- // Private method tests (just to ensure coverage). // We'll call them via (manager as any).somePrivateMethod(...) if needed. // ---------------------------------------------------------------------------------------- test('XOR function: verifies correctness', () => { const fnXOR = (manager as any).XOR as (a: number[], b: number[]) => number[] const a = [0x00, 0xff, 0xaa] const b = [0xff, 0xff, 0x55] const result = fnXOR(a, b) // 0x00 ^ 0xFF = 0xFF // 0xFF ^ 0xFF = 0x00 // 0xAA ^ 0x55 = 0xFF expect(result).toEqual([0xff, 0x00, 0xff]) }) // ---------------------------------------------------------------------------------------- // Authentication flows // ---------------------------------------------------------------------------------------- describe('New user flow: presentation + password', () => { test('Successfully creates a new token and calls buildAndSend', async () => { // New wallet funder is a mock function const newWalletFunder = jest.fn(() => {}) ;(manager as any).newWalletFunder = newWalletFunder // Mock that no token is found by presentation key hash ;(mockUMPTokenInteractor.findByPresentationKeyHash as any).mockResolvedValueOnce(undefined) // Provide a presentation key await manager.providePresentationKey(presentationKey) expect(manager.authenticationFlow).toBe('new-user') // Provide a password mockPasswordRetriever.mockResolvedValueOnce('dummy-password') await manager.providePassword('dummy-password') // The wallet should now be built, so manager is authenticated expect(manager.authenticated).toBe(true) // Recovery key saver should have been called expect(mockRecoveryKeySaver).toHaveBeenCalledTimes(1) // The underlying wallet builder should have been called exactly once expect(mockWalletBuilder).toHaveBeenCalledTimes(1) // The manager should have called buildAndSend on the interactor expect(mockUMPTokenInteractor.buildAndSend).toHaveBeenCalledTimes(1) const buildArgs = (mockUMPTokenInteractor.buildAndSend as any).mock.calls[0] // [0] => the wallet, [1] => adminOriginator, [2] => newToken, [3] => oldToken expect(buildArgs[1]).toBe('admin.walletvendor.com') expect(buildArgs[2]).toHaveProperty('presentationHash') expect(buildArgs[3]).toBeUndefined() // Because it's a new user (no old token) expect(newWalletFunder).toHaveBeenCalled() // New wallet funder should have been called }) test('Throws if user tries to provide recovery key during new-user flow', async () => { // Mark it as new user flow by no token found ;(mockUMPTokenInteractor.findByPresentationKeyHash as any).mockResolvedValueOnce(undefined) await manager.providePresentationKey(Array.from({ length: 32 }, () => 1)) await expect(manager.provideRecoveryKey(Array.from({ length: 32 }, () => 2))).rejects.toThrow( 'Do not submit recovery key in new-user flow' ) }) }) describe('Existing user flow: presentation + password', () => { test('Decryption of primary key and building the wallet', async () => { // Provide a mock UMP token const mockToken = await createMockUMPToken() ;(mockUMPTokenInteractor.findByPresentationKeyHash as any).mockResolvedValueOnce(mockToken) // Provide presentation await manager.providePresentationKey(presentationKey) expect(manager.authenticationFlow).toBe('existing-user') // Provide password // The manager's internal code will do PBKDF2 with the password + token.passwordSalt // Then XOR that with the presentation key for decryption. await manager.providePassword('test-password') // Check that manager is authenticated expect(manager.authenticated).toBe(true) // Underlying wallet is built expect(mockWalletBuilder).toHaveBeenCalledTimes(1) }) }) describe('Existing user flow: presentation + recovery key', () => { beforeEach(async () => { manager.authenticationMode = 'presentation-key-and-recovery-key' manager.authenticationFlow = 'existing-user' }) test('Successfully decrypts with presentation+recovery', async () => { // Provide a mock UMP token const mockToken = await createMockUMPToken() ;(mockUMPTokenInteractor.findByPresentationKeyHash as any).mockResolvedValueOnce(mockToken) await manager.providePresentationKey(presentationKey) // Provide the recovery key. // In "presentation-key-and-recovery-key" mode, the manager won't need the password at all. await manager.provideRecoveryKey(recoveryKey) expect(manager.authenticated).toBe(true) expect(mockWalletBuilder).toHaveBeenCalledTimes(1) }) test('Throws if presentation key not provided first', async () => { const recoveryKey = Array.from({ length: 32 }, () => 8) await expect(manager.provideRecoveryKey(recoveryKey)).rejects.toThrow('Provide the presentation key first') }) }) describe('Existing user flow: recovery key + password', () => { beforeEach(async () => { manager.authenticationMode = 'recovery-key-and-password' manager.authenticationFlow = 'existing-user' }) test('Works with correct keys, sets mode as existing-user', async () => { const mockToken = await createMockUMPToken() ;(mockUMPTokenInteractor.findByRecoveryKeyHash as any).mockResolvedValueOnce(mockToken) // Provide recovery key await manager.provideRecoveryKey(recoveryKey) // Provide password await manager.providePassword('test-password') expect(manager.authenticated).toBe(true) expect(mockWalletBuilder).toHaveBeenCalledTimes(1) }) test('Throws if no token found by recovery key hash', async () => { ;(mockUMPTokenInteractor.findByRecoveryKeyHash as any).mockResolvedValueOnce(undefined) await expect(manager.provideRecoveryKey(recoveryKey)).rejects.toThrow('No user found with this recovery key') }) }) // ---------------------------------------------------------------------------------------- // Snapshots // ---------------------------------------------------------------------------------------- describe('saveSnapshot / loadSnapshot', () => { test('Saves a snapshot and can load it into a fresh manager instance', async () => { // We'll do a new user flow so that manager is authenticated with a real token. ;(mockUMPTokenInteractor.findByPresentationKeyHash as any).mockResolvedValueOnce(undefined) const presKey = Array.from({ length: 32 }, () => 0xa1) await manager.providePresentationKey(presKey) await manager.providePassword('mypassword') // triggers creation of new user const snapshot = manager.saveSnapshot() expect(Array.isArray(snapshot)).toBe(true) expect(snapshot.length).toBeGreaterThan(64) // 32 bytes + encrypted data // Now create a fresh manager: const freshManager = new CWIStyleWalletManager( 'admin.walletvendor.com', mockWalletBuilder, mockUMPTokenInteractor, mockRecoveryKeySaver, mockPasswordRetriever ) // Not authenticated yet await expect(() => freshManager.getPublicKey({ identityKey: true })).rejects.toThrow('User is not authenticated') // Load the snapshot await freshManager.loadSnapshot(snapshot) // The fresh manager is now authenticated (underlying wallet will be built). await expect(freshManager.getPublicKey({ identityKey: true })).resolves.not.toThrow() // It calls walletBuilder again expect(mockWalletBuilder).toHaveBeenCalledTimes(2) // once for the old manager, once for the fresh }) test('Throws error if saving snapshot while no primary key or token set', async () => { // Manager is not yet authenticated expect(() => manager.saveSnapshot()).toThrow('No root primary key or current UMP token set') }) test('Throws if snapshot is corrupt or cannot be decrypted', async () => { // Attempt to load an invalid snapshot await expect(() => manager.loadSnapshot([1, 2, 3])).rejects.toThrow('Failed to load snapshot') }) }) // ---------------------------------------------------------------------------------------- // Changing Keys // ---------------------------------------------------------------------------------------- describe('Change Password', () => { test('Requires authentication and updates the UMP token on-chain', async () => { ;(mockUMPTokenInteractor.findByPresentationKeyHash as any).mockResolvedValueOnce(undefined) manager = new CWIStyleWalletManager( 'admin.walletvendor.com', mockWalletBuilder, mockUMPTokenInteractor, mockRecoveryKeySaver, async () => 'test-password' ) await manager.providePresentationKey(presentationKey) await manager.providePassword('test-password') expect(manager.authenticated).toBe(true) await manager.changePassword('new-pass') expect(mockUMPTokenInteractor.buildAndSend).toHaveBeenCalledTimes(2) }) test('Throws if not authenticated', async () => { await expect(manager.changePassword('test-password')).rejects.toThrow( 'Not authenticated or missing required data.' ) }) }) describe('Change Recovery Key', () => { test('Prompts to save the new key, updates the token', async () => { ;(mockUMPTokenInteractor.findByPresentationKeyHash as any).mockResolvedValueOnce(undefined) manager = new CWIStyleWalletManager( 'admin.walletvendor.com', mockWalletBuilder, mockUMPTokenInteractor, mockRecoveryKeySaver, async () => 'test-password' ) await manager.providePresentationKey(presentationKey) await manager.providePassword('test-password') expect(manager.authenticated).toBe(true) ;(mockUMPTokenInteractor.buildAndSend as any).mockResolvedValueOnce(makeOutpoint('rcv1', 0)) await manager.changeRecoveryKey() // The user is prompted to store the new key expect(mockRecoveryKeySaver).toHaveBeenCalledTimes(2) // once when user created, once after changed // The UMP token is updated expect(mockUMPTokenInteractor.buildAndSend).toHaveBeenCalledTimes(2) }) test('Throws if not authenticated', async () => { await expect(manager.changeRecoveryKey()).rejects.toThrow('Not authenticated or missing required data.') }) }) describe('Change Presentation Key', () => { test('Requires authentication, re-publishes the token, old token consumed', async () => { ;(mockUMPTokenInteractor.findByPresentationKeyHash as any).mockResolvedValueOnce(undefined) manager = new CWIStyleWalletManager( 'admin.walletvendor.com', mockWalletBuilder, mockUMPTokenInteractor, mockRecoveryKeySaver, async () => 'test-password' ) await manager.providePresentationKey(presentationKey) await manager.providePassword('test-password') expect(manager.authenticated).toBe(true) ;(mockUMPTokenInteractor.buildAndSend as any).mockResolvedValueOnce(makeOutpoint('rcv1', 0)) const newPresKey = Array.from({ length: 32 }, () => 0xee) await manager.changePresentationKey(newPresKey) expect(mockUMPTokenInteractor.buildAndSend).toHaveBeenCalledTimes(2) }) }) test('Destroy callback clears sensitive data', async () => { // authenticate as new user ;(mockUMPTokenInteractor.findByPresentationKeyHash as any).mockResolvedValueOnce(undefined) await manager.providePresentationKey(Array.from({ length: 32 }, () => 12)) await manager.providePassword('some-pass') // manager is authenticated expect(manager.authenticated).toBe(true) // Destroy manager.destroy() expect(manager.authenticated).toBe(false) // And we can confirm that manager won't allow calls await expect(() => manager.getPublicKey({ identityKey: true })).rejects.toThrow('User is not authenticated') }) // ---------------------------------------------------------------------------------------- // Proxies / originator checks // ---------------------------------------------------------------------------------------- describe('Proxy method calls', () => { beforeEach(async () => { // authenticate ;(mockUMPTokenInteractor.findByPresentationKeyHash as any).mockResolvedValueOnce(undefined) await manager.providePresentationKey(presentationKey) await manager.providePassword('test-password') }) test('Throws if user is not authenticated', async () => { // force de-auth ;(manager as any).authenticated = false await expect(() => manager.getPublicKey({ identityKey: true })).rejects.toThrow('User is not authenticated.') }) test('Throws if originator is adminOriginator', async () => { await expect(manager.getPublicKey({ identityKey: true }, 'admin.walletvendor.com')).rejects.toThrow( 'External applications are not allowed to use the admin originator.' ) }) test('Passes if user is authenticated and originator is not admin', async () => { await manager.getPublicKey({ identityKey: true }, 'example.com') expect(mockUnderlyingWallet.getPublicKey).toHaveBeenCalledTimes(1) }) test('All proxied methods call underlying with correct arguments', async () => { // We'll do a quick spot-check of a few methods: await manager.encrypt({ plaintext: [1, 2, 3], protocolID: [1, 'tests'], keyID: '1' }, 'mydomain.com') expect(mockUnderlyingWallet.encrypt).toHaveBeenCalledWith( { plaintext: [1, 2, 3], protocolID: [1, 'tests'], keyID: '1' }, 'mydomain.com' ) // TODO: Test all other proxied methods }) test('isAuthenticated() rejects if originator is admin, resolves otherwise', async () => { // If admin tries: await expect(manager.isAuthenticated({}, 'admin.walletvendor.com')).rejects.toThrow( 'External applications are not allowed to use the admin originator.' ) // If normal domain: const result = await manager.isAuthenticated({}, 'normal.com') expect(result).toEqual({ authenticated: true }) }) test('waitForAuthentication() eventually resolves', async () => { // Already authenticated from beforeEach. So it should immediately return. await manager.waitForAuthentication({}, 'normal.com') expect(mockUnderlyingWallet.waitForAuthentication).toHaveBeenCalledTimes(1) }) }) describe('Additional Tests for Password Retriever Callback, Privileged Key Expiry, and UMP Token Serialization', () => { let manager: CWIStyleWalletManager beforeEach(() => { jest.clearAllMocks() manager = new CWIStyleWalletManager( 'admin.walletvendor.com', mockWalletBuilder, mockUMPTokenInteractor, mockRecoveryKeySaver, mockPasswordRetriever ) }) test('serializeUMPToken and deserializeUMPToken correctly round-trip a UMP token', async () => { const token = await createMockUMPToken() // We need a token with a currentOutpoint for serialization. expect(token.currentOutpoint).toBeDefined() const serializeFn = (manager as any).serializeUMPToken as (token: UMPToken) => number[] const deserializeFn = (manager as any).deserializeUMPToken as (bin: number[]) => UMPToken const serialized = serializeFn(token) expect(Array.isArray(serialized)).toBe(true) expect(serialized.length).toBeGreaterThan(0) const deserialized = deserializeFn(serialized) expect(deserialized).toEqual(token) }) test('Password retriever callback: the test function is passed and returns a boolean', async () => { let capturedTestFn: ((candidate: string) => boolean) | null = null const customPasswordRetriever = jest.fn(async (reason: string, testFn: (candidate: string) => boolean) => { capturedTestFn = testFn // In a real scenario the test function would validate a candidate. // For our test we simply return the correct password. return 'test-password' }) ;(manager as any).passwordRetriever = customPasswordRetriever // Force a new-user flow by having no token found. ;(mockUMPTokenInteractor.findByPresentationKeyHash as any).mockResolvedValueOnce(undefined) await manager.providePresentationKey(presentationKey) await manager.providePassword('test-password') expect(manager.authenticated).toBe(true) // Clear the privileged key so the callback gets ran jest.advanceTimersByTime(121_000) // Let's trigger a privileged operation await manager.changePassword('test-password') // trigger some privileged operation... expect(customPasswordRetriever).toHaveBeenCalled() expect(capturedTestFn).not.toBeNull() // Since the internal test function is defined inline, we simply check that its output is a boolean. // (Its logic uses the outer scope and may not use its argument correctly, but we verify that it at least returns a boolean.) const testResult = capturedTestFn!('any-input') expect(typeof testResult).toBe('boolean') expect(capturedTestFn!('any-input')).toBe(false) expect(capturedTestFn!('test-password')).toBe(true) }) test('Privileged key expiry: each call to decrypt via the privileged manager invokes passwordRetriever', async () => { // In a new-user flow, buildUnderlying is called without a privilegedKey, // so any later use of the privileged manager will trigger a password prompt. const customPasswordRetriever = jest.fn(async (reason: string, testFn: (candidate: string) => boolean) => { return 'test-password' }) ;(manager as any).passwordRetriever = customPasswordRetriever // New-user flow (no existing token) ;(mockUMPTokenInteractor.findByPresentationKeyHash as any).mockResolvedValueOnce(undefined) await manager.providePresentationKey(presentationKey) await manager.providePassword('test-password') // Clear any calls recorded during authentication. customPasswordRetriever.mockClear() // Call the underlying privileged key manager’s decrypt twice. // (For example, we use the ciphertext from one of the token’s encrypted fields.) await (manager as any).rootPrivilegedKeyManager.decrypt({ ciphertext: (manager as any).currentUMPToken.passwordKeyEncrypted, protocolID: [2, 'admin key wrapping'], keyID: '1' }) // Key expires after 2 minutes jest.advanceTimersByTime(121_000) await (manager as any).rootPrivilegedKeyManager.decrypt({ ciphertext: (manager as any).currentUMPToken.passwordKeyEncrypted, protocolID: [2, 'admin key wrapping'], keyID: '1' }) // Since no ephemeral privileged key was provided when building the underlying wallet, // each call to decrypt should have resulted in a call to passwordRetriever. expect(customPasswordRetriever).toHaveBeenCalledTimes(2) }) }) })