UNPKG

@bsv/wallet-toolbox

Version:

BRC100 conforming wallet, wallet storage and wallet signer components

721 lines (654 loc) 26.6 kB
import { mockUnderlyingWallet, MockedBSV_SDK, MockTransaction } from './WalletPermissionsManager.fixtures' import { WalletPermissionsManager, PermissionsManagerConfig } from '../WalletPermissionsManager' jest.mock('@bsv/sdk', () => MockedBSV_SDK) describe('WalletPermissionsManager - Regression & Integration with Underlying Wallet', () => { let underlying: jest.Mocked<any> let manager: WalletPermissionsManager beforeEach(() => { // Create a fresh underlying mock wallet underlying = mockUnderlyingWallet() // Default config: everything enforced for maximum coverage const defaultConfig: PermissionsManagerConfig = { 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(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 () => { // 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 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 }] // Now override fromAtomicBEEF to return our mockTx: ;(MockedBSV_SDK.Transaction.fromAtomicBEEF as jest.Mock).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. }) 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 MockTransaction() mockTx.fee = 100 mockTx.inputs = [ { sourceTXID: 'bbb', sourceOutputIndex: 0, sourceTransaction: { outputs: [{ satoshis: 0 }] } } ] mockTx.outputs = [{ satoshis: 100 }] ;(MockedBSV_SDK.Transaction.fromAtomicBEEF as jest.Mock).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(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: () => '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/ ) }) })