UNPKG

@bsv/wallet-toolbox

Version:

BRC100 conforming wallet, wallet storage and wallet signer components

642 lines 32.4 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 - Permission Checks', () => { let underlying; let manager; beforeEach(() => { // Fresh mock wallet before each test underlying = (0, WalletPermissionsManager_fixtures_1.mockUnderlyingWallet)(); }); afterEach(() => { jest.clearAllMocks(); }); /* ------------------------------------------------------ * 5) PROTOCOL USAGE (DPACP) TESTS * ------------------------------------------------------ */ describe('Protocol Usage (DPACP)', () => { it('should skip permission prompt if secLevel=0 (open usage)', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', { seekProtocolPermissionsForSigning: true // Typically enforced }); // Attempt createSignature with protocolID=[0, "someProtocol"] // Because securityLevel=0, the manager should skip checks await expect(manager.createSignature({ protocolID: [0, 'open-protocol'], data: [0x01, 0x02], keyID: '1' }, 'some-user.com')).resolves.not.toThrow(); // No permission request const activeRequests = manager.activeRequests; expect(activeRequests.size).toBe(0); // Underlying createSignature called once expect(underlying.createSignature).toHaveBeenCalledTimes(1); }); it('should prompt for protocol usage if securityLevel=1 and no existing token', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', { seekProtocolPermissionsForSigning: true }); // We'll bind a callback that grants ephemeral permission automatically manager.bindCallback('onProtocolPermissionRequested', async (request) => { // For tests, automatically grant ephemeral permission await manager.grantPermission({ requestID: request.requestID, ephemeral: true }); }); // Because secLevel=1, we need a valid DPACP token // We have no token => manager triggers a request => callback grants ephemeral => passes await expect(manager.createSignature({ protocolID: [1, 'test-protocol'], data: [0x99, 0xaa], keyID: '1' }, 'some-nonadmin.com')).resolves.not.toThrow(); // The underlying signature should succeed expect(underlying.createSignature).toHaveBeenCalledTimes(1); }); it('should deny protocol usage if user denies permission', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', {}); // The callback denies the request manager.bindCallback('onProtocolPermissionRequested', request => { manager.denyPermission(request.requestID); }); // Attempt an operation that requires protocol permission await expect(manager.encrypt({ protocolID: [1, 'needs-perm'], plaintext: [1, 2, 3], keyID: 'xyz' }, 'external-app.com')).rejects.toThrow(/Permission denied/); // Underlying encrypt was never called expect(underlying.encrypt).toHaveBeenCalledTimes(0); }); it('should enforce privileged token if differentiatePrivilegedOperations=true', async () => { // By default, differentiatePrivilegedOperations is true. manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', { seekProtocolPermissionsForSigning: true }); manager.bindCallback('onProtocolPermissionRequested', async (req) => { // The request has `privileged=true`, so the resulting token must also be privileged. // We'll grant ephemeral to simulate success quickly. await manager.grantPermission({ requestID: req.requestID, ephemeral: true }); }); // Attempt a privileged signature await expect(manager.createSignature({ protocolID: [1, 'high-level-crypto'], privileged: true, data: [0xc0, 0xff, 0xee], keyID: '1' }, 'nonadmin.app')).resolves.not.toThrow(); // Confirm underlying was ultimately called expect(underlying.createSignature).toHaveBeenCalledTimes(1); }); it('should ignore `privileged=true` if differentiatePrivilegedOperations=false', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', { differentiatePrivilegedOperations: false, // Forces privileged usage to be treated as non-privileged seekProtocolPermissionsForSigning: true }); // Because we treat privileged as false, the permission request does not need privileged credentials. manager.bindCallback('onProtocolPermissionRequested', async (req) => { await manager.grantPermission({ requestID: req.requestID, ephemeral: true }); }); await expect(manager.createSignature({ protocolID: [1, 'some-protocol'], privileged: true, // This flag will be ignored data: [0x99], keyID: 'keyXYZ' }, 'nonadmin.com')).resolves.not.toThrow(); }); it('should fail if protocol name is admin-reserved and caller is not admin', async () => { // admin-reserved means protocol name starts with "admin" or "p ". manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'secure.admin.com'); // Non-admin tries to do e.g. `createHmac` with protocol name "admin super-secret" await expect(manager.createHmac({ protocolID: [1, 'admin super-secret'], data: [0x01, 0x02], keyID: '1' }, 'not-an-admin.com')).rejects.toThrow(/admin-only/i); // Underlying call never invoked expect(underlying.createHmac).toHaveBeenCalledTimes(0); }); it('should prompt for renewal if token is found but expired', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', {}); // Suppose the user already had a token but it’s expired. We mock `findProtocolToken` so that // it returns an expired token, forcing a renewal request. const expiredToken = { tx: [], txid: 'oldtxid123', outputIndex: 0, outputScript: 'deadbeef', satoshis: 1, originator: 'some-nonadmin.com', expiry: 1, // definitely in the past privileged: false, securityLevel: 1, protocol: 'test-protocol', counterparty: 'self' }; jest.spyOn(manager, 'findProtocolToken').mockResolvedValue(expiredToken); // We'll bind a callback that grants a renewal ephemeral manager.bindCallback('onProtocolPermissionRequested', async (req) => { expect(req.renewal).toBe(true); expect(req.previousToken).toEqual(expiredToken); await manager.grantPermission({ requestID: req.requestID, ephemeral: true }); }); // Now call an operation that requires protocol usage await manager.createSignature({ protocolID: [1, 'test-protocol'], data: [0xfe], keyID: '1' }, 'some-nonadmin.com'); // Should succeed after renewal expect(underlying.createSignature).toHaveBeenCalledTimes(1); }); }); /* ------------------------------------------------------ * 6) BASKET USAGE (DBAP) TESTS * ------------------------------------------------------ */ describe('Basket Usage (DBAP)', () => { it('should fail immediately if using an admin-only basket as non-admin', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com'); // Attempt to createAction to insert into "admin secret-basket" from a non-admin origin await expect(manager.createAction({ description: 'Insert into admin basket', outputs: [ { lockingScript: 'abcd', satoshis: 100, basket: 'admin secret-basket', outputDescription: 'Nothing to see here' } ] }, 'non-admin.com')).rejects.toThrow(/admin-only/i); // Underlying createAction never called expect(underlying.createAction).toHaveBeenCalledTimes(0); }); it('should fail immediately if using the reserved basket "default" as non-admin', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com'); await expect(manager.createAction({ description: 'Insert to default basket', outputs: [ { lockingScript: '0x1234', satoshis: 1, basket: 'default', outputDescription: 'Nothing to see here' } ] }, 'some-nonadmin.com')).rejects.toThrow(/admin-only/i); }); it('should prompt for insertion permission if seekBasketInsertionPermissions=true', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', { seekBasketInsertionPermissions: true }); // auto-grant ephemeral manager.bindCallback('onBasketAccessRequested', async (req) => { await manager.grantPermission({ requestID: req.requestID, ephemeral: true }); }); // Also auto-grant unrelated spending authorization (since this is createAction) manager.bindCallback('onSpendingAuthorizationRequested', async (req) => { await manager.grantPermission({ requestID: req.requestID, ephemeral: true }); }); await expect(manager.createAction({ description: 'Insert to user-basket', outputs: [ { lockingScript: '7812', satoshis: 1, basket: 'user-basket', outputDescription: 'Nothing to see here' } ] }, 'some-nonadmin.com')).resolves.not.toThrow(); // Confirm underlying createAction was eventually invoked expect(underlying.createAction).toHaveBeenCalledTimes(1); }); it('should skip insertion permission if seekBasketInsertionPermissions=false', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', { seekBasketInsertionPermissions: false }); // Auto-grant unrelated spending authorization (since this is createAction) manager.bindCallback('onSpendingAuthorizationRequested', async (req) => { await manager.grantPermission({ requestID: req.requestID, ephemeral: true }); }); await manager.createAction({ description: 'Insert to user-basket', outputs: [ { lockingScript: '1234', satoshis: 1, basket: 'some-basket', outputDescription: 'Nothing to see here' } ] }, 'nonadmin.com'); // No requests queued, underlying is called const activeRequests = manager.activeRequests; expect(activeRequests.size).toBe(0); expect(underlying.createAction).toHaveBeenCalledTimes(1); }); it('should require listing permission if seekBasketListingPermissions=true and no token', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', { seekBasketListingPermissions: true }); manager.bindCallback('onBasketAccessRequested', async (req) => { // Deny for test manager.denyPermission(req.requestID); }); // Attempt to list a user basket await expect(manager.listOutputs({ basket: 'user-basket' }, 'some-user.com')).rejects.toThrow(/Permission denied/); // There is one underlying call: internally, we called listOutputs to check if we had permission // (we did not, we sought it, and the user denied). So we see this call here, but we DO NOT see // the actual proxied call (for listing outputs in user-basket), since it was denied. expect(underlying.listOutputs).toHaveBeenCalledTimes(1); expect(underlying.listOutputs).toHaveBeenLastCalledWith({ basket: 'admin basket-access', include: 'entire transactions', tagQueryMode: 'all', tags: ['originator some-user.com', 'basket user-basket'] }, 'admin.com'); }); it('should prompt for removal permission if seekBasketRemovalPermissions=true', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', { seekBasketRemovalPermissions: true }); manager.bindCallback('onBasketAccessRequested', async (req) => { // auto-grant ephemeral await manager.grantPermission({ requestID: req.requestID, ephemeral: true }); }); await expect(manager.relinquishOutput({ output: 'someTxid.1', basket: 'user-basket' }, 'some-user.com')).resolves.not.toThrow(); expect(underlying.relinquishOutput).toHaveBeenCalledTimes(1); }); }); /* ------------------------------------------------------ * 7) CERTIFICATE USAGE (DCAP) TESTS * ------------------------------------------------------ */ describe('Certificate Usage (DCAP)', () => { it('should skip certificate disclosure permission if config.seekCertificateDisclosurePermissions=false', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', { seekCertificateDisclosurePermissions: false }); // Directly call proveCertificate with no token => no prompt => immediate success await expect(manager.proveCertificate({ certificate: { type: 'KYC', subject: '02abcdef...', serialNumber: '123', certifier: '02ccc...', fields: { name: 'Alice', dob: '2000-01-01' } }, fieldsToReveal: ['name'], verifier: '02xyz...', privileged: false }, 'nonadmin.com')).resolves.not.toThrow(); expect(underlying.proveCertificate).toHaveBeenCalledTimes(1); }); it('should require permission if seekCertificateDisclosurePermissions=true, no valid token', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', { seekCertificateDisclosurePermissions: true }); // Auto-grant ephemeral for test manager.bindCallback('onCertificateAccessRequested', async (req) => { await manager.grantPermission({ requestID: req.requestID, ephemeral: true }); }); // Because we don't have a stored token, it triggers request -> ephemeral granted -> success await manager.proveCertificate({ certificate: { type: 'KYC', subject: '02abc..', serialNumber: 'xyz', certifier: '02dddd...', fields: { name: 'Bob', nationality: 'Mars' } }, fieldsToReveal: ['name'], verifier: '02xxxx..', privileged: false }, 'some-user.com'); expect(underlying.proveCertificate).toHaveBeenCalledTimes(1); }); it('should check that requested fields are a subset of the token’s fields', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', { seekCertificateDisclosurePermissions: true }); // Suppose we find an existing token that covers fields: ['name', 'dob', 'nationality'] const existingToken = { tx: [], txid: 'aabbcc', outputIndex: 0, outputScript: 'scriptHex', satoshis: 1, originator: 'some-user.com', expiry: 9999999999, // not expired privileged: false, certType: 'KYC', certFields: ['name', 'dob', 'nationality'], verifier: '02eeee...' }; jest .spyOn(manager, 'findCertificateToken') .mockImplementation(async (orig, priv, verif, ct, requestedFields) => { // if requestedFields includes "someMissingField", return undefined // else return the existingToken if (requestedFields.includes('someMissingField')) { return undefined; // forces a request } return existingToken; // forces immediate success }); // Attempt to prove certificate revealing only 'name' -> Should pass without prompt await manager.proveCertificate({ certificate: { type: 'KYC', certifier: '02eeee...', subject: '02some...', serialNumber: '', fields: { name: 'Charlie', dob: '1999-01-01', nationality: 'EU' } }, fieldsToReveal: ['name'], verifier: '02eeee...', privileged: false }, 'some-user.com'); expect(underlying.proveCertificate).toHaveBeenCalledTimes(1); // Attempt to reveal a field the token does NOT cover -> triggers request // Since the existing token does not cover 'someMissingField', we expect a prompt. Let’s deny it: manager.bindCallback('onCertificateAccessRequested', async (req) => { manager.denyPermission(req.requestID); }); const secondAttempt = manager.proveCertificate({ certificate: { type: 'KYC', certifier: '02eeee...', fields: { name: 'Charlie', dob: '1999-01-01', nationality: 'EU' } }, fieldsToReveal: ['dob', 'someMissingField'], verifier: '02eeee...', privileged: false }, 'some-user.com'); await expect(secondAttempt).rejects.toThrow(/Permission denied/); // Underlying proveCertificate not called for second attempt expect(underlying.proveCertificate).toHaveBeenCalledTimes(1); }); it('should prompt for renewal if token is expired', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', { seekCertificateDisclosurePermissions: true }); // Mock an expired token const expiredCertToken = { tx: [], txid: 'old-expired', outputIndex: 0, outputScript: 'deadbeef', satoshis: 1, originator: 'app.com', expiry: 1, privileged: false, certType: 'KYC', certFields: ['name', 'dob'], verifier: '02verifier' }; jest.spyOn(manager, 'findCertificateToken').mockResolvedValue(expiredCertToken); // Callback that grants renewal ephemeral manager.bindCallback('onCertificateAccessRequested', async (req) => { expect(req.renewal).toBe(true); await manager.grantPermission({ requestID: req.requestID, ephemeral: true }); }); await manager.proveCertificate({ certificate: { type: 'KYC', fields: { name: 'Bob', dob: '1970' }, certifier: '02verifier' }, fieldsToReveal: ['name'], verifier: '02verifier', privileged: false }, 'app.com'); // Succeeds after ephemeral renewal expect(underlying.proveCertificate).toHaveBeenCalledTimes(1); }); }); /* ------------------------------------------------------ * 8) SPENDING AUTHORIZATION (DSAP) TESTS * ------------------------------------------------------ */ describe('Spending Authorization (DSAP)', () => { it('should skip if seekSpendingPermissions=false', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', { seekSpendingPermissions: false }); // createAction that tries to net spend 200 sats const result = await manager.createAction({ description: 'Some spend transaction', outputs: [ { lockingScript: '1321', satoshis: 200, outputDescription: 'Nothing to see here' } ] }, 'user.com'); // No prompt triggered const activeRequests = manager.activeRequests; expect(activeRequests.size).toBe(0); // Underlying createAction definitely called expect(underlying.createAction).toHaveBeenCalledTimes(1); // If seekSpendingPermissions=false, the result should NOT? contain the signableTransaction expect(result.signableTransaction).not.toBeDefined(); }); it('should require spending token if netSpent > 0 and seekSpendingPermissions=true', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', { seekSpendingPermissions: true }); // We’ll also mock the signableTransaction return to help manager compute netSpent underlying.createAction.mockResolvedValueOnce({ signableTransaction: { tx: [0x00], // minimal reference: 'ref1' } }); // The manager tries to parse the transaction to find netSpent. // By default, netSpent = totalOutput + fee - totalExplicitInputs // We haven't provided any explicit inputs in the createAction call, so netSpent = 200 + fee // Auto-grant ephemeral for test manager.bindCallback('onSpendingAuthorizationRequested', async (req) => { await manager.grantPermission({ requestID: req.requestID, ephemeral: true, amount: 1000 }); }); await expect(manager.createAction({ description: 'Spend 200 sats with no input from user', outputs: [ { outputDescription: 'Nothing to see here', lockingScript: '1abc', satoshis: 200 } ] }, 'some-user.com')).resolves.not.toThrow(); // underlying createAction called expect(underlying.createAction).toHaveBeenCalledTimes(1); }); it('should check monthly limit usage and prompt renewal if insufficient', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com'); // Suppose we find an existing DSAP token with authorizedAmount=500 // manager.findSpendingToken() is used internally, so let's mock it const existingSpendingToken = { tx: [], txid: 'dsap-old', outputIndex: 0, outputScript: 'scriptHex', satoshis: 1, originator: 'shopper.com', authorizedAmount: 500, expiry: 0 // indefinite }; jest.spyOn(manager, 'findSpendingToken').mockResolvedValue(existingSpendingToken); // Next, manager.querySpentSince(token) sums the user’s monthly spending from labeled actions // Let’s stub that to say they've already spent 400. jest.spyOn(manager, 'querySpentSince').mockResolvedValue(400); // Attempt spending 200 => total usage would be 600 which exceeds 500 => prompt renewal // We'll auto-deny for test manager.bindCallback('onSpendingAuthorizationRequested', req => { manager.denyPermission(req.requestID); }); await expect(manager.createAction({ description: 'Buy something for 200 sats', outputs: [ { outputDescription: 'Nothing to see here', lockingScript: 'op_return', satoshis: 200 } ] }, 'shopper.com')).rejects.toThrow(/Permission denied/); // The underlying createAction call was started but the manager calls abortAction upon denial expect(underlying.abortAction).toHaveBeenCalledTimes(1); }); it('should pass if usage plus new spend is within the monthly limit', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', {}); // existing DSAP token with authorizedAmount=1000 const dsapToken = { tx: [], txid: 'dsap123', outputIndex: 0, outputScript: '9218', satoshis: 1, originator: 'shopper.com', authorizedAmount: 1000, expiry: 0 }; jest.spyOn(manager, 'findSpendingToken').mockResolvedValue(dsapToken); // Suppose they've spent 200 so far jest.spyOn(manager, 'querySpentSince').mockResolvedValue(200); // Attempt new spending of 500 => total=700 which is <= 1000 => no prompt await manager.createAction({ description: 'Spend 500 sats', outputs: [ { outputDescription: 'Nothing to see here', lockingScript: '0abc', satoshis: 500 } ] }, 'shopper.com'); // Success, no new permission requested const activeRequests = manager.activeRequests; expect(activeRequests.size).toBe(0); expect(underlying.createAction).toHaveBeenCalledTimes(1); }); }); /* ------------------------------------------------------ * 9) LABEL USAGE PERMISSION TESTS * ------------------------------------------------------ */ describe('Label Usage Permission', () => { it('should fail if label starts with "admin" and caller is not admin', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com'); // Attempt to createAction with a label "admin secret-stuff" await expect(manager.createAction({ description: 'Applying admin label?', labels: ['admin secret-stuff'] }, 'nonadmin.com')).rejects.toThrow(/admin-only/); // Underlying createAction never called expect(underlying.createAction).toHaveBeenCalledTimes(0); }); it('should skip label permission if seekPermissionWhenApplyingActionLabels=false', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', { seekPermissionWhenApplyingActionLabels: false }); // Non-admin applies label "my-app-label" await expect(manager.createAction({ description: 'Add label', labels: ['my-app-label'] }, 'some-app.com')).resolves.not.toThrow(); // No prompt const activeRequests = manager.activeRequests; expect(activeRequests.size).toBe(0); // Called underlying expect(underlying.createAction).toHaveBeenCalledTimes(1); }); it('should prompt for label usage if seekPermissionWhenApplyingActionLabels=true', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', { seekPermissionWhenApplyingActionLabels: true }); manager.bindCallback('onProtocolPermissionRequested', async (req) => { // This request will have protocolID=[1, "action label <label>"], etc. await manager.grantPermission({ requestID: req.requestID, ephemeral: true }); }); await manager.createAction({ description: 'Add label "user-label-123"', labels: ['user-label-123'] }, 'nonadmin.com'); // Underlying is called expect(underlying.createAction).toHaveBeenCalledTimes(1); }); it('should also prompt for listing actions by label if seekPermissionWhenListingActionsByLabel=true', async () => { manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.com', { seekPermissionWhenListingActionsByLabel: true }); manager.bindCallback('onProtocolPermissionRequested', async (req) => { // auto-grant ephemeral await manager.grantPermission({ requestID: req.requestID, ephemeral: true }); }); await expect(manager.listActions({ labels: ['search-this-label'] }, 'external-app.com')).resolves.not.toThrow(); expect(underlying.listActions).toHaveBeenCalledTimes(1); }); }); }); //# sourceMappingURL=WalletPermissionsManager.checks.test.js.map