UNPKG

@bsv/wallet-toolbox

Version:

BRC100 conforming wallet, wallet storage and wallet signer components

584 lines 29.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const WalletPermissionsManager_fixtures_1 = require("./WalletPermissionsManager.fixtures"); const WalletPermissionsManager_1 = require("../WalletPermissionsManager"); jest.mock('@bsv/sdk', () => WalletPermissionsManager_fixtures_1.MockedBSV_SDK); describe('WalletPermissionsManager - Regression & Integration with Underlying Wallet', () => { let underlying; let manager; beforeEach(() => { // Create a fresh underlying mock wallet underlying = (0, WalletPermissionsManager_fixtures_1.mockUnderlyingWallet)(); // Default config: everything enforced for maximum coverage const defaultConfig = { seekProtocolPermissionsForSigning: true, seekProtocolPermissionsForEncrypting: true, seekProtocolPermissionsForHMAC: true, seekPermissionsForKeyLinkageRevelation: true, seekPermissionsForPublicKeyRevelation: true, seekPermissionsForIdentityKeyRevelation: true, seekPermissionsForIdentityResolution: true, seekBasketInsertionPermissions: true, seekBasketRemovalPermissions: true, seekBasketListingPermissions: true, seekPermissionWhenApplyingActionLabels: true, seekPermissionWhenListingActionsByLabel: true, seekCertificateDisclosurePermissions: true, seekCertificateAcquisitionPermissions: true, seekCertificateRelinquishmentPermissions: true, seekCertificateListingPermissions: true, encryptWalletMetadata: true, seekSpendingPermissions: true, differentiatePrivilegedOperations: true }; // We pass "admin.test" as the admin origin manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.test', defaultConfig); // For these tests, we don't want to deal with UI prompts or real user interactions. // We stub out any permission requests by auto-granting ephemeral in all cases manager.bindCallback('onProtocolPermissionRequested', async (req) => { await manager.grantPermission({ requestID: req.requestID, ephemeral: true }); }); manager.bindCallback('onBasketAccessRequested', async (req) => { await manager.grantPermission({ requestID: req.requestID, ephemeral: true }); }); manager.bindCallback('onCertificateAccessRequested', async (req) => { await manager.grantPermission({ requestID: req.requestID, ephemeral: true }); }); manager.bindCallback('onSpendingAuthorizationRequested', async (req) => { // If the request is for a netSpent above some threshold, let's simulate a denial for one test scenario // By default, we'll just ephemeral-grant. await manager.grantPermission({ requestID: req.requestID, ephemeral: true }); }); }); afterEach(() => { jest.clearAllMocks(); }); /* ------------------------------------------------------------------------- * createAction / signAction / abortAction * ----------------------------------------------------------------------- */ it('should pass createAction calls through, label them, handle metadata encryption, and check spending authorization', async () => { try { // We'll mock the "netSpent" scenario to be >0 by returning some mock input & output satoshis from the signableTransaction. // The underlying mock createAction returns a signableTransaction with tx = [] // We can stub out the mock so that the manager sees inputs/outputs with certain sat amounts. // But we have to remember the manager is parsing the signableTransaction via fromAtomicBEEF(…). // We'll control that by adjusting the mock signableTransaction in the underlying. // let's set a custom signableTransaction that returns 500 sat in inputs, 1000 in outputs, and 100 in fee underlying.createAction.mockResolvedValueOnce({ signableTransaction: { // The manager calls Transaction.fromAtomicBEEF() on this tx: [0xde, 0xad], // not used in detail, but let's just pass some array reference: 'test-ref' } }); // We also need to configure the fromAtomicBEEF mock so it returns a transaction with the specified inputs/outputs const mockTx = new WalletPermissionsManager_fixtures_1.MockTransaction(); mockTx.fee = 100; // We'll define exactly one input we consider "originator-provided" with 500 sat mockTx.inputs = [ { sourceTXID: 'aaa', sourceOutputIndex: 0, sourceTransaction: { outputs: [{ satoshis: 500 }] } } ]; // We'll define 2 outputs. The manager will read the output amounts from the createAction call's "args.outputs" too, // but we also set them here in case it cross-references them. We'll keep it consistent (2 outputs with total 1000). mockTx.outputs = [{ satoshis: 600 }, { satoshis: 400 }]; WalletPermissionsManager_fixtures_1.MockedBSV_SDK.Transaction.fromAtomicBEEF.mockReturnValue(mockTx); // Attempt to create an action from a non-admin origin await manager.createAction({ description: 'User purchase', inputs: [ { outpoint: 'aaa.0', unlockingScriptLength: 73, inputDescription: 'My input' } ], outputs: [ { lockingScript: '00abcd', satoshis: 1000, outputDescription: 'Purchase output', basket: 'my-basket' } ], labels: ['user-label', 'something-else'] }, 'shop.example.com'); // The manager should have: // 1) Called underlying.createAction // 2) Inserted "admin originator shop.example.com" & "admin month YYYY-MM" into labels // 3) Encrypted the metadata fields (description, inputDescription, outputDescription) // 4) Ensured we needed spending permission for netSpent= (1000 + fee100) - 500 = 600 // The onSpendingAuthorizationRequested callback ephemeral-granted it. expect(underlying.createAction).toHaveBeenCalledTimes(1); const callArgs = underlying.createAction.mock.calls[0][0]; expect(callArgs.labels).toContain('admin originator shop.example.com'); expect(callArgs.labels).toEqual(expect.arrayContaining([ expect.stringContaining('admin month'), 'user-label', 'something-else', 'admin originator shop.example.com' ])); // Confirm the metadata was replaced with some ciphertext array in createAction call expect(callArgs.description).not.toBe('User purchase'); // manager encrypts it if (callArgs.inputs[0].inputDescription) { expect(callArgs.inputs[0].inputDescription).not.toBe('My input'); } if (callArgs.outputs[0].outputDescription) { expect(callArgs.outputs[0].outputDescription).not.toBe('Purchase output'); } // Also confirm we set signAndProcess to false if origin is non-admin expect(callArgs.options.signAndProcess).toBe(false); // The manager will parse the resulting signableTransaction, see netSpent=600, and request spending permission. // Our callback ephemeral-granted. So everything should proceed with no error. // The manager returns the partial result from underlying // We don't have a final sign call from the manager because signAndProcess is forcibly false. } catch (eu) { expect(true).toBe(false); } }); it('should abort the action if spending permission is denied', async () => { // This time let's forcibly DENY the onSpendingAuthorizationRequested callback manager.unbindCallback('onSpendingAuthorizationRequested', 0); // Unbind the ephemeral-grant manager.bindCallback('onSpendingAuthorizationRequested', async (req) => { await manager.denyPermission(req.requestID); }); // We'll use the same approach: netSpent > 0 triggers the spending authorization check. underlying.createAction.mockResolvedValueOnce({ signableTransaction: { tx: [0xde], reference: 'test-ref-2' } }); // Mock parse tx for netSpent const mockTx = new WalletPermissionsManager_fixtures_1.MockTransaction(); mockTx.fee = 100; mockTx.inputs = [ { sourceTXID: 'bbb', sourceOutputIndex: 0, sourceTransaction: { outputs: [{ satoshis: 0 }] } } ]; mockTx.outputs = [{ satoshis: 100 }]; WalletPermissionsManager_fixtures_1.MockedBSV_SDK.Transaction.fromAtomicBEEF.mockReturnValue(mockTx); await expect(manager.createAction({ description: 'User tries to spend 100 + fee=100 from 0 input => netSpent=200', outputs: [ { lockingScript: 'abc123', satoshis: 100, outputDescription: 'some out desc', basket: 'some-basket' } ] }, 'user.example.com')).rejects.toThrow(/Permission denied/); // We expect the manager to call underlying.abortAction with reference 'test-ref-2' expect(underlying.abortAction).toHaveBeenCalledTimes(1); expect(underlying.abortAction).toHaveBeenCalledWith({ reference: 'test-ref-2' }); }); it('should throw an error if a non-admin tries signAndProcess=true', async () => { // Non-admin tries signAndProcess=true => manager throws await expect(manager.createAction({ description: 'Trying signAndProcess from non-admin', outputs: [ { lockingScript: '1234', satoshis: 50, basket: 'user-basket', outputDescription: 'Description' } ], options: { signAndProcess: true } }, 'someuser.com')).rejects.toThrow(/Only the admin originator can set signAndProcess=true/); }); it('should proxy signAction calls directly if invoked by the user', async () => { // Typically, signAction is used after createAction returns a partial signableTransaction // We'll confirm it passes arguments verbatim to underlying const result = await manager.signAction({ reference: 'my-ref', spends: { 0: { unlockingScript: 'my-script' } } }, 'nonadmin.com'); expect(underlying.signAction).toHaveBeenCalledTimes(1); expect(underlying.signAction).toHaveBeenCalledWith({ reference: 'my-ref', spends: { 0: { unlockingScript: 'my-script' } } }, 'nonadmin.com'); // returns the underlying result expect(result.txid).toBe('fake-txid'); }); it('should proxy abortAction calls directly', async () => { const result = await manager.abortAction({ reference: 'abort-me' }, 'someuser.com'); expect(underlying.abortAction).toHaveBeenCalledTimes(1); expect(underlying.abortAction).toHaveBeenCalledWith({ reference: 'abort-me' }, 'someuser.com'); expect(result).toEqual({ aborted: true }); }); /* ------------------------------------------------------------------------- * listActions / internalizeAction * ----------------------------------------------------------------------- */ it('should call listActions on the underlying wallet and decrypt metadata fields if encryptWalletMetadata=true', async () => { // Underlying returns some encrypted metadata underlying.listActions.mockResolvedValueOnce({ totalActions: 1, actions: [ { actionTXID: 'aaa', description: 'EncryptedStuff', inputs: [ { outpoint: 'xxx.0', inputDescription: 'EncryptedIn' } ], outputs: [ { lockingScript: 'deadbeef', outputDescription: 'EncryptedOut', customInstructions: 'EncryptedCustom' } ], labels: ['user-label'] } ] }); // We'll have the manager attempt to decrypt. The manager calls `underlying.decrypt` // which is mocked to return plaintext [42, 42, 42, 42, 42, 42, 42]. That is "asterisk-asterisk" in ASCII // So let's see how the manager transforms it back to a string: fromCharCode(42,42) => "**" // However, note that the manager's "maybeDecryptMetadata()" tries to decrypt the field // If it works, it returns the decrypted string. Our underlying mock decrypt => "[42,42]" => "**" // So let's expect the final returned fields to be "**". const result = await manager.listActions({ labels: ['some-label'] }, 'nonadmin.com'); expect(underlying.listActions).toHaveBeenCalledTimes(1); // The manager calls ensureLabelAccess first, which triggers a protocol permission request // we ephemeral-grant. Then it calls underlying.listActions. expect(result.actions[0].description).toBe('*****'); // Decrypted from [42, 42, 42, 42, 42, 42, 42] expect(result.actions[0].inputs[0].inputDescription).toBe('*****'); expect(result.actions[0].outputs[0].outputDescription).toBe('*****'); expect(result.actions[0].outputs[0].customInstructions).toBe('*****'); }); it('should pass internalizeAction calls to underlying, after ensuring basket permissions and encrypting customInstructions if config=on', async () => { await manager.internalizeAction({ tx: [], description: 'Internalizing outputs with basket insertion', outputs: [ { outputIndex: 0, protocol: 'basket insertion', insertionRemittance: { basket: 'some-basket', customInstructions: 'plaintext instructions' } } ] }, 'someuser.com'); // The manager ensures basket insertion => ephemeral permission granted // Then it encrypts 'plaintext instructions' before passing it to underlying expect(underlying.internalizeAction).toHaveBeenCalledTimes(1); const callArgs = underlying.internalizeAction.mock.calls[0][0]; expect(callArgs.outputs[0].insertionRemittance.customInstructions).not.toBe('plaintext instructions'); // There's no direct check that the string is "**" or something, because it's encrypted. // We just confirm it was changed from the original plaintext. }); /* ------------------------------------------------------------------------- * listOutputs / relinquishOutput * ----------------------------------------------------------------------- */ it('should ensure basket listing permission then call listOutputs, decrypting customInstructions', async () => { 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: () => 'mockLockingScriptHex' } } ]; return mockTx; }); underlying.listOutputs.mockResolvedValue({ totalOutputs: 1, outputs: [ { outpoint: 'zzz.0', satoshis: 100, lockingScript: 'mockscript', customInstructions: 'EncryptedWeird' } ] }); const result = await manager.listOutputs({ basket: 'user-basket' }, 'app.example.com'); // manager ephemeral-grants basket permission expect(underlying.listOutputs).toHaveBeenCalledTimes(2); expect(underlying.listOutputs.mock.calls).toEqual([ [ { basket: 'admin basket-access', include: 'entire transactions', tagQueryMode: 'all', tags: ['originator app.example.com', 'basket user-basket'] }, 'admin.test' // querying to see if we have permission ], [ { basket: 'user-basket' }, 'app.example.com' // the actual underlying call ] ]); expect(result.outputs[0].customInstructions).toBe('*****'); // from [42,42] decryption }); it('should ensure basket removal permission then call relinquishOutput', async () => { await manager.relinquishOutput({ output: 'xxx.0', basket: 'some-basket' }, 'nonadmin.com'); expect(underlying.relinquishOutput).toHaveBeenCalledTimes(1); expect(underlying.relinquishOutput).toHaveBeenCalledWith({ output: 'xxx.0', basket: 'some-basket' }, 'nonadmin.com'); }); /* ------------------------------------------------------------------------- * getPublicKey / revealCounterpartyKeyLinkage / revealSpecificKeyLinkage * ----------------------------------------------------------------------- */ it('should call getPublicKey on underlying after ensuring protocol permission', async () => { const result = await manager.getPublicKey({ protocolID: [1, 'test-pubkey'], keyID: 'my-key' }, 'user.example.com'); expect(underlying.getPublicKey).toHaveBeenCalledTimes(1); expect(underlying.getPublicKey).toHaveBeenCalledWith({ protocolID: [1, 'test-pubkey'], keyID: 'my-key' }, 'user.example.com'); expect(result.publicKey).toBe('029999...'); }); it('should call revealCounterpartyKeyLinkage with permission check, pass result', async () => { const result = await manager.revealCounterpartyKeyLinkage({ privileged: true, verifier: '0222aaa', counterparty: '02bbbccc', privilegedReason: 'test reason' }, 'user.example.com'); expect(underlying.revealCounterpartyKeyLinkage).toHaveBeenCalledTimes(1); expect(underlying.revealCounterpartyKeyLinkage).toHaveBeenCalledWith({ privileged: true, verifier: '0222aaa', counterparty: '02bbbccc', privilegedReason: 'test reason' }, 'user.example.com'); expect(result.prover).toBe('02abcdef...'); }); it('should call revealSpecificKeyLinkage with permission check, pass result', async () => { const result = await manager.revealSpecificKeyLinkage({ privileged: false, verifier: '0222ddd', protocolID: [2, 'special'], keyID: '5', counterparty: '022222', privilegedReason: 'need to check link' }, 'user.example.com'); expect(underlying.revealSpecificKeyLinkage).toHaveBeenCalledTimes(1); expect(underlying.revealSpecificKeyLinkage).toHaveBeenCalledWith({ privileged: false, verifier: '0222ddd', protocolID: [2, 'special'], keyID: '5', counterparty: '022222', privilegedReason: 'need to check link' }, 'user.example.com'); expect(result.prover).toBe('02abcdef...'); }); /* ------------------------------------------------------------------------- * encrypt / decrypt / createHmac / verifyHmac / createSignature / verifySignature * ----------------------------------------------------------------------- */ it('should proxy encrypt() calls after checking protocol permission', async () => { const result = await manager.encrypt({ protocolID: [1, 'secret-proto'], plaintext: [1, 2, 3], keyID: 'mykey' }, 'user.example.com'); expect(underlying.encrypt).toHaveBeenCalledTimes(1); expect(result.ciphertext).toEqual([42, 42, 42, 42, 42, 42, 42]); // from the mock }); it('should proxy decrypt() calls after checking protocol permission', async () => { const result = await manager.decrypt({ protocolID: [1, 'secret-proto'], ciphertext: [99, 99], keyID: 'somekey' }, 'user.example.com'); expect(underlying.decrypt).toHaveBeenCalledTimes(1); expect(result.plaintext).toEqual([42, 42, 42, 42, 42]); }); it('should proxy createHmac() calls', async () => { const result = await manager.createHmac({ protocolID: [2, 'hmac-proto'], data: [11, 22], keyID: 'hmacKey' }, 'someone.com'); expect(underlying.createHmac).toHaveBeenCalledTimes(1); expect(result.hmac).toEqual([0xaa]); }); it('should proxy verifyHmac() calls', async () => { const result = await manager.verifyHmac({ protocolID: [2, 'hmac-proto'], data: [11, 22], hmac: [0xaa], keyID: 'hmacKey' }, 'someone.com'); expect(underlying.verifyHmac).toHaveBeenCalledTimes(1); expect(result.valid).toBe(true); }); it('should proxy createSignature() calls (already tested the netSpent logic in createAction, but let’s double-check)', async () => { // We tested permission checks for signing in earlier tests, but let's confirm pass-through const result = await manager.createSignature({ protocolID: [1, 'sign-proto'], data: [10, 20], keyID: '1' }, 'user.com'); expect(underlying.createSignature).toHaveBeenCalledTimes(1); expect(result.signature).toEqual([0x30, 0x44]); }); it('should proxy verifySignature() calls', async () => { const result = await manager.verifySignature({ protocolID: [1, 'verify-proto'], data: [3, 4], signature: [0x30, 0x44], keyID: '2' }, 'user.com'); expect(underlying.verifySignature).toHaveBeenCalledTimes(1); expect(result.valid).toBe(true); }); /* ------------------------------------------------------------------------- * acquireCertificate / listCertificates / proveCertificate / relinquishCertificate * ----------------------------------------------------------------------- */ it('should call acquireCertificate, verifying permission if config.seekCertificateAcquisitionPermissions=true', async () => { const result = await manager.acquireCertificate({ type: 'my-cert', certifier: '02aaaa...', acquisitionProtocol: 'direct', fields: { hello: 'world' } }, 'user.cert.com'); expect(underlying.acquireCertificate).toHaveBeenCalledTimes(1); expect(result.type).toBe('some-cert-type'); }); it('should call listCertificates, verifying permission if config.seekCertificateListingPermissions=true', async () => { const result = await manager.listCertificates({ privileged: false, certifiers: [], types: [] }, 'some.corp'); expect(underlying.listCertificates).toHaveBeenCalledTimes(1); expect(result.totalCertificates).toBe(0); }); it('should call proveCertificate after ensuring certificate permission', async () => { const result = await manager.proveCertificate({ privileged: true, verifier: '02vvvv', certificate: { type: 'kyc', subject: '02aaaa...', certifier: '02cccc...', fields: { name: 'Alice' } }, fieldsToReveal: ['name'] }, 'user.corp'); expect(underlying.proveCertificate).toHaveBeenCalledTimes(1); expect(result.keyringForVerifier).toBeDefined(); }); it('should call relinquishCertificate if config.seekCertificateRelinquishmentPermissions=true', async () => { const result = await manager.relinquishCertificate({ type: 'some-cert', serialNumber: 'raisin bran', certifier: '023333' }, 'user-abc.com'); expect(underlying.relinquishCertificate).toHaveBeenCalledTimes(1); expect(result).toEqual({ relinquished: true }); }); /* ------------------------------------------------------------------------- * discoverByIdentityKey / discoverByAttributes * ----------------------------------------------------------------------- */ it('should call discoverByIdentityKey after ensuring identity resolution permission', async () => { const result = await manager.discoverByIdentityKey({ identityKey: '0222fff...' }, 'someone-trying-lookup.com'); expect(underlying.discoverByIdentityKey).toHaveBeenCalledTimes(1); expect(result.certificates.length).toBe(0); }); it('should call discoverByAttributes after ensuring identity resolution permission', async () => { const result = await manager.discoverByAttributes({ attributes: { name: 'Bob' } }, 'someone-trying-lookup.com'); expect(underlying.discoverByAttributes).toHaveBeenCalledTimes(1); expect(result.certificates.length).toBe(0); }); /* ------------------------------------------------------------------------- * isAuthenticated / waitForAuthentication / getHeight / getHeaderForHeight * getNetwork / getVersion * ----------------------------------------------------------------------- */ it('should proxy isAuthenticated without any special permission checks', async () => { const result = await manager.isAuthenticated({}, 'someone.com'); expect(result.authenticated).toBe(true); expect(underlying.isAuthenticated).toHaveBeenCalledTimes(1); }); it('should proxy waitForAuthentication without any special permission checks', async () => { const result = await manager.waitForAuthentication({}, 'someone.com'); expect(result.authenticated).toBe(true); expect(underlying.waitForAuthentication).toHaveBeenCalledTimes(1); }, 30000); it('should proxy getHeight', async () => { const result = await manager.getHeight({}, 'someone.com'); expect(result.height).toBe(777777); expect(underlying.getHeight).toHaveBeenCalledTimes(1); }); it('should proxy getHeaderForHeight', async () => { const result = await manager.getHeaderForHeight({ height: 100000 }, 'someone.com'); expect(result.header).toMatch(/000000000000abc/); expect(underlying.getHeaderForHeight).toHaveBeenCalledTimes(1); }); it('should proxy getNetwork', async () => { const result = await manager.getNetwork({}, 'someone.com'); expect(result.network).toBe('testnet'); expect(underlying.getNetwork).toHaveBeenCalledTimes(1); }); it('should proxy getVersion', async () => { const result = await manager.getVersion({}, 'someone.com'); expect(result.version).toBe('vendor-1.0.0'); expect(underlying.getVersion).toHaveBeenCalledTimes(1); }); /* ------------------------------------------------------------------------- * Error propagation from underlying * ----------------------------------------------------------------------- */ it('should propagate errors from the underlying wallet calls', async () => { // Let's have underlying.createAction throw underlying.createAction.mockRejectedValueOnce(new Error('Under-wallet failure')); await expect(manager.createAction({ description: 'test error', outputs: [] }, 'someuser.com')).rejects.toThrow(/Under-wallet failure/); }); }); //# sourceMappingURL=WalletPermissionsManager.proxying.test.js.map