UNPKG

@bsv/wallet-toolbox

Version:

BRC100 conforming wallet, wallet storage and wallet signer components

335 lines 17.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const WalletPermissionsManager_fixtures_1 = require("./WalletPermissionsManager.fixtures"); const WalletPermissionsManager_1 = require("../WalletPermissionsManager"); const globals_1 = require("@jest/globals"); const sdk_1 = require("@bsv/sdk"); globals_1.jest.mock('@bsv/sdk', () => WalletPermissionsManager_fixtures_1.MockedBSV_SDK); describe('WalletPermissionsManager - Metadata Encryption & Decryption', () => { let underlying; beforeEach(() => { // Create a fresh underlying mock wallet before each test underlying = (0, WalletPermissionsManager_fixtures_1.mockUnderlyingWallet)(); }); afterEach(() => { globals_1.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_1.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_1.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_1.WalletPermissionsManager(underlying, 'admin.domain.com', { encryptWalletMetadata: true }); underlying.decrypt.mockResolvedValueOnce({ plaintext: [72, 105] // 'Hi' }); const ciphertext = sdk_1.Utils.toBase64(sdk_1.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_1.WalletPermissionsManager(underlying, 'admin.domain.com', { encryptWalletMetadata: true }); underlying.decrypt.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_1.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); underlying.listActions.mockResolvedValueOnce({ totalActions: 1, actions: [ { description: sdk_1.Utils.toBase64(sdk_1.Utils.toArray('fake-encrypted-string-for-description')), inputs: [ { outpoint: 'txid1.0', inputDescription: sdk_1.Utils.toBase64(sdk_1.Utils.toArray('fake-encrypted-string-for-inputDesc')) } ], outputs: [ { lockingScript: 'OP_RETURN 1234', satoshis: 500, outputDescription: sdk_1.Utils.toBase64(sdk_1.Utils.toArray('fake-encrypted-string-for-outputDesc')), customInstructions: sdk_1.Utils.toBase64(sdk_1.Utils.toArray('fake-encrypted-string-for-customInstr')) } ] } ] }); // Also mock decrypt calls to simulate a correct round-trip const decryptMock = underlying.decrypt; 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.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_1.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(); underlying.listActions.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.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 () => { globals_1.jest.spyOn(WalletPermissionsManager_fixtures_1.MockedBSV_SDK.Transaction, 'fromBEEF').mockImplementation(() => { const mockTx = new WalletPermissionsManager_fixtures_1.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_1.WalletPermissionsManager(underlying, 'admin.domain.com', { encryptWalletMetadata: true }); manager.bindCallback('onBasketAccessRequested', x => { manager.grantPermission({ requestID: x.requestID, ephemeral: true }); }); underlying.listOutputs.mockResolvedValue({ totalOutputs: 1, outputs: [ { outpoint: 'fakeTxid.0', satoshis: 999, lockingScript: 'OP_RETURN something', basket: 'some-basket', customInstructions: sdk_1.Utils.toBase64(sdk_1.Utils.toArray('fake-encrypted-instructions-string')) } ] }); const originalInstr = 'Please do not reveal this data.'; underlying.decrypt.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 () => { globals_1.jest.spyOn(WalletPermissionsManager_fixtures_1.MockedBSV_SDK.Transaction, 'fromBEEF').mockImplementation(() => { const mockTx = new WalletPermissionsManager_fixtures_1.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 globals_1.jest.spyOn(WalletPermissionsManager_fixtures_1.MockedBSV_SDK.PushDrop, 'decode').mockReturnValue({ fields: [ // Values that will decrypt to the expected values for domain, expiry, and basket sdk_1.Utils.toArray('encoded-domain'), sdk_1.Utils.toArray('encoded-expiry'), sdk_1.Utils.toArray('encoded-basket') ] }); const manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.domain.com', { encryptWalletMetadata: true }); manager.bindCallback('onBasketAccessRequested', x => { manager.grantPermission({ requestID: x.requestID, ephemeral: true }); }); underlying.listOutputs.mockResolvedValue({ totalOutputs: 1, outputs: [ { outpoint: 'fakeTxid.0', satoshis: 500, lockingScript: 'OP_RETURN something', basket: 'some-basket', customInstructions: 'bad-ciphertext-of-some-kind' } ] }); underlying.decrypt.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'); }); }); }); //# sourceMappingURL=WalletPermissionsManager.encryption.test.js.map