UNPKG

@bsv/wallet-toolbox

Version:

BRC100 conforming wallet, wallet storage and wallet signer components

413 lines (365 loc) 15.4 kB
import { mockUnderlyingWallet, MockedBSV_SDK, MockTransaction } from './WalletPermissionsManager.fixtures' import { WalletPermissionsManager } from '../WalletPermissionsManager' import { jest } from '@jest/globals' import { Utils } from '@bsv/sdk' jest.mock('@bsv/sdk', () => MockedBSV_SDK) describe('WalletPermissionsManager - Metadata Encryption & Decryption', () => { let underlying: ReturnType<typeof mockUnderlyingWallet> beforeEach(() => { // Create a fresh underlying mock wallet before each test underlying = mockUnderlyingWallet() }) afterEach(() => { jest.clearAllMocks() }) describe('Unit Tests for metadata encryption helpers', () => { it('should call underlying.encrypt() with the correct protocol and key when encryptWalletMetadata=true', async () => { const manager = new WalletPermissionsManager(underlying, 'admin.domain.com', { encryptWalletMetadata: true }) const plaintext = 'Hello, world!' await manager['maybeEncryptMetadata'](plaintext) // We expect underlying.encrypt() to have been called exactly once expect(underlying.encrypt).toHaveBeenCalledTimes(1) // Check that the call was with the correct protocol ID and key expect(underlying.encrypt).toHaveBeenCalledWith( { plaintext: expect.any(Array), // byte array version of 'Hello, world!' protocolID: [2, 'admin metadata encryption'], keyID: '1' }, 'admin.domain.com' ) }) it('should NOT call underlying.encrypt() if encryptWalletMetadata=false', async () => { const manager = new WalletPermissionsManager(underlying, 'admin.domain.com', { encryptWalletMetadata: false }) const plaintext = 'No encryption needed!' const result = await manager['maybeEncryptMetadata'](plaintext) expect(result).toBe(plaintext) expect(underlying.encrypt).not.toHaveBeenCalled() }) it('should call underlying.decrypt() with correct protocol and key, returning plaintext on success', async () => { const manager = new WalletPermissionsManager(underlying, 'admin.domain.com', { encryptWalletMetadata: true }) // Underlying decrypt mock returns { plaintext: [42, 42] } by default // which would become "**" if using our ASCII interpretation ;(underlying.decrypt as any).mockResolvedValueOnce({ plaintext: [72, 105] // 'Hi' }) const ciphertext = Utils.toBase64(Utils.toArray('random-string-representing-ciphertext')) const result = await manager['maybeDecryptMetadata'](ciphertext) // We expect underlying.decrypt() to have been called expect(underlying.decrypt).toHaveBeenCalledTimes(1) expect(underlying.decrypt).toHaveBeenCalledWith( { ciphertext: expect.any(Array), // byte array version of ciphertext protocolID: [2, 'admin metadata encryption'], keyID: '1' }, 'admin.domain.com' ) // The manager returns the decrypted UTF-8 string expect(result).toBe('Hi') }) it('should fallback to original string if underlying.decrypt() fails', async () => { const manager = new WalletPermissionsManager(underlying, 'admin.domain.com', { encryptWalletMetadata: true }) // Make underlying.decrypt() throw an error to simulate failure ;(underlying.decrypt as any).mockImplementationOnce(() => { throw new Error('Decryption error') }) const ciphertext = 'this-was-not-valid-for-decryption' const result = await manager['maybeDecryptMetadata'](ciphertext) // The manager should return the original ciphertext if decryption throws expect(result).toBe(ciphertext) }) }) describe('Integration Tests for createAction + listActions (round-trip encryption)', () => { it('should encrypt metadata fields in createAction when encryptWalletMetadata=true, then decrypt them in listActions', async () => { const manager = new WalletPermissionsManager(underlying, 'admin.domain.com', { encryptWalletMetadata: true }) manager.bindCallback('onSpendingAuthorizationRequested', x => { manager.grantPermission({ requestID: x.requestID, ephemeral: true }) }) // We prepare an action with multiple metadata fields const actionDescription = 'User Action #1: Doing something important' const inputDesc = 'Some input desc' const outputDesc = 'Some output desc' const customInstr = 'Some custom instructions' // Our createAction call await manager.createAction( { description: actionDescription, inputs: [ { outpoint: '0231.0', unlockingScriptLength: 73, inputDescription: inputDesc } ], outputs: [ { lockingScript: '561234', satoshis: 500, outputDescription: outputDesc, customInstructions: customInstr } ] }, 'nonadmin.com' ) // 1) Confirm underlying.encrypt() was called for each field that is non-empty: // - description // - inputDescription // - outputDescription // - customInstructions // (We can't be certain how many times exactly, but we can check that it was at least 4.) expect(underlying.encrypt).toHaveBeenCalledTimes(4) // 2) Now we simulate listing actions. We'll have the manager call underlying.listActions. // Our mock underlying wallet returns an empty array by default, so let's override it // to return the "encrypted" data that the manager gave it. // But the manager doesn't store that data in the underlying wallet mock automatically. // We'll just pretend that the wallet returns some data, and ensure the manager tries to decrypt it. ;(underlying.listActions as any).mockResolvedValueOnce({ totalActions: 1, actions: [ { description: Utils.toBase64(Utils.toArray('fake-encrypted-string-for-description')), inputs: [ { outpoint: 'txid1.0', inputDescription: Utils.toBase64(Utils.toArray('fake-encrypted-string-for-inputDesc')) } ], outputs: [ { lockingScript: 'OP_RETURN 1234', satoshis: 500, outputDescription: Utils.toBase64(Utils.toArray('fake-encrypted-string-for-outputDesc')), customInstructions: Utils.toBase64(Utils.toArray('fake-encrypted-string-for-customInstr')) } ] } ] }) // Also mock decrypt calls to simulate a correct round-trip const decryptMock = underlying.decrypt as any decryptMock.mockResolvedValueOnce({ plaintext: Array.from(actionDescription).map(c => c.charCodeAt(0)) }) decryptMock.mockResolvedValueOnce({ plaintext: Array.from(inputDesc).map(c => c.charCodeAt(0)) }) decryptMock.mockResolvedValueOnce({ plaintext: Array.from(outputDesc).map(c => c.charCodeAt(0)) }) decryptMock.mockResolvedValueOnce({ plaintext: Array.from(customInstr).map(c => c.charCodeAt(0)) }) const result = await (manager as any).listActions({}, 'nonadmin.com') // We should get exactly 1 action expect(result.actions.length).toBe(1) const action = result.actions[0] // The manager is expected to have decrypted each field expect(action.description).toBe(actionDescription) expect(action.inputs[0].inputDescription).toBe(inputDesc) expect(action.outputs[0].outputDescription).toBe(outputDesc) expect(action.outputs[0].customInstructions).toBe(customInstr) }) it('should not encrypt metadata if encryptWalletMetadata=false, storing and retrieving plaintext', async () => { const manager = new WalletPermissionsManager(underlying, 'admin.domain.com', { encryptWalletMetadata: false }) manager.bindCallback('onSpendingAuthorizationRequested', x => { manager.grantPermission({ requestID: x.requestID, ephemeral: true }) }) const actionDescription = 'Plaintext action description' const inputDesc = 'Plaintext input desc' const outputDesc = 'Plaintext output desc' const customInstr = 'Plaintext instructions' await manager.createAction( { description: actionDescription, inputs: [ { outpoint: '9876.0', unlockingScriptLength: 73, inputDescription: inputDesc } ], outputs: [ { lockingScript: 'ABCD', satoshis: 123, outputDescription: outputDesc, customInstructions: customInstr } ] }, 'nonadmin.com' ) // Because encryption is disabled, underlying.encrypt() is not called expect(underlying.encrypt).not.toHaveBeenCalled() // Simulate that the wallet actually stored them in plaintext and is returning them as-is ;(underlying.listActions as any).mockResolvedValue({ totalActions: 1, actions: [ { description: actionDescription, inputs: [ { outpoint: '0123.0', inputDescription: inputDesc } ], outputs: [ { lockingScript: 'ABCD', satoshis: 123, outputDescription: outputDesc, customInstructions: customInstr } ] } ] }) // Decrypt is still called, because we try to decrypt regardless of whether encryption is enabled. // This allows us to disable it on a wallet that had it in the past. The result is that when not encrypted, // the plaintext is returned if decryption fails. If it was encrypted from metadata encryption being enabled in // the past (even when not enabled now), we will still decrypt and see the correct plaintext rather than garbage. // To simulate, we make decryption pass through. underlying.decrypt.mockImplementation(x => x) const listResult = await (manager as any).listActions({}, 'nonadmin.com') expect(underlying.decrypt).toHaveBeenCalledTimes(3) // Confirm the returned data is the same as originally provided (plaintext) const [first] = listResult.actions expect(first.description).toBe(actionDescription) expect(first.inputs[0].inputDescription).toBe(inputDesc) expect(first.outputs[0].outputDescription).toBe(outputDesc) expect(first.outputs[0].customInstructions).toBe(customInstr) }) }) describe('Integration Test for listOutputs decryption', () => { it('should decrypt customInstructions in listOutputs if encryptWalletMetadata=true', async () => { jest.spyOn(MockedBSV_SDK.Transaction, 'fromBEEF').mockImplementation(() => { const mockTx = new MockTransaction() // Add outputs with lockingScript mockTx.outputs = [ { lockingScript: { // Ensure this matches what PushDrop.decode expects to work with toHex: () => 'some script' } } ] // Add the toBEEF method mockTx.toBEEF = () => [] return mockTx }) const manager = new WalletPermissionsManager(underlying, 'admin.domain.com', { encryptWalletMetadata: true }) manager.bindCallback('onBasketAccessRequested', x => { manager.grantPermission({ requestID: x.requestID, ephemeral: true }) }) // Suppose we have an output with custom instructions that was stored encrypted ;(underlying.listOutputs as any).mockResolvedValue({ totalOutputs: 1, outputs: [ { outpoint: 'fakeTxid.0', satoshis: 999, lockingScript: 'OP_RETURN something', basket: 'some-basket', customInstructions: Utils.toBase64(Utils.toArray('fake-encrypted-instructions-string')) } ] }) const originalInstr = 'Please do not reveal this data.' // We'll mock decrypt() to interpret 'fake-encrypted-instructions-string' as a success ;(underlying.decrypt as any).mockResolvedValueOnce({ plaintext: Array.from(originalInstr).map(ch => ch.charCodeAt(0)) }) const outputsResult = await manager.listOutputs( { basket: 'some-basket' }, 'some-origin.com' ) expect(outputsResult.outputs.length).toBe(1) expect(outputsResult.outputs[0].customInstructions).toBe(originalInstr) // Confirm we tried to decrypt expect(underlying.decrypt).toHaveBeenCalledTimes(1) expect(underlying.decrypt).toHaveBeenCalledWith( { ciphertext: expect.any(Array), protocolID: [2, 'admin metadata encryption'], keyID: '1' }, 'admin.domain.com' ) }) it('should fallback to the original ciphertext if decrypt fails in listOutputs', async () => { jest.spyOn(MockedBSV_SDK.Transaction, 'fromBEEF').mockImplementation(() => { const mockTx = new MockTransaction() // Add outputs with lockingScript mockTx.outputs = [ { lockingScript: { // Ensure this matches what PushDrop.decode expects to work with toHex: () => 'some script' } } ] // Add the toBEEF method mockTx.toBEEF = () => [] return mockTx }) // Add this to your test alongside the Transaction.fromBEEF mock jest.spyOn(MockedBSV_SDK.PushDrop, 'decode').mockReturnValue({ fields: [ // Values that will decrypt to the expected values for domain, expiry, and basket Utils.toArray('encoded-domain'), Utils.toArray('encoded-expiry'), Utils.toArray('encoded-basket') ] }) const manager = new WalletPermissionsManager(underlying, 'admin.domain.com', { encryptWalletMetadata: true }) manager.bindCallback('onBasketAccessRequested', x => { manager.grantPermission({ requestID: x.requestID, ephemeral: true }) }) ;(underlying.listOutputs as any).mockResolvedValue({ totalOutputs: 1, outputs: [ { outpoint: 'fakeTxid.0', satoshis: 500, lockingScript: 'OP_RETURN something', basket: 'some-basket', customInstructions: 'bad-ciphertext-of-some-kind' } ] }) // Force an error from decrypt ;(underlying.decrypt as any).mockImplementationOnce(() => { throw new Error('Failed to decrypt') }) const outputsResult = await manager.listOutputs( { basket: 'some-basket' }, 'some-origin.com' ) expect(outputsResult.outputs.length).toBe(1) // Should fall back to the original 'bad-ciphertext-of-some-kind' expect(outputsResult.outputs[0].customInstructions).toBe('bad-ciphertext-of-some-kind') }) }) })