@bsv/wallet-toolbox
Version:
BRC100 conforming wallet, wallet storage and wallet signer components
335 lines • 17.4 kB
JavaScript
"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