diff --git a/src/index.js b/src/index.js index 924626f..a4a3e10 100644 --- a/src/index.js +++ b/src/index.js @@ -34,7 +34,7 @@ const mobilePaymentService = new MobilePaymentService( walletService, ); const agentService = new AgentService(db); -const a2aService = new A2AService(walletService, db); +const a2aService = new A2AService(walletService, db, config); const ucpService = new UCPService(a2aService); // Initialize Express app diff --git a/src/services/a2aService.js b/src/services/a2aService.js index 73c4443..3c5c9d9 100644 --- a/src/services/a2aService.js +++ b/src/services/a2aService.js @@ -27,6 +27,7 @@ class A2AService { * @param {number} params.amount - Amount to transfer * @param {string} params.mandate - Optional signed Mandate (AP2) for Zero Trust validation * @param {Object} params.ucpPayload - The original UCP intent/payload + * @param {string} params.mandate - Optional signed Mandate (AP2) for Zero Trust validation */ async executeTransfer({ fromAgentId, diff --git a/src/services/agent.js b/src/services/agent.js index c1be719..63a4a71 100644 --- a/src/services/agent.js +++ b/src/services/agent.js @@ -177,15 +177,27 @@ class AgentService { // In a real system, this would involve interaction with the WalletService, // and policy checks for both agents. const fromAgent = await this.getAgent(fromAgentId); - const toAgent = await this.getAgent(toAgentId); + await this.getAgent(toAgentId); // Verify toAgent exists - // Basic policy checks (more complex logic would be here) - if (amount > fromAgent.policy.spendingLimit) { - throw new Error(`Zero Trust Validation Failed: Transfer amount exceeds spending limit for agent ${fromAgentId}`); + // Basic policy checks + const config = fromAgent.config || {}; + const limits = config.limits || {}; + const perTransactionLimit = limits.perTransaction || 0; + + if (perTransactionLimit > 0 && amount > perTransactionLimit) { + throw new Error( + `Zero Trust Validation Failed: Transfer amount ${amount} exceeds per-transaction limit of ${perTransactionLimit} for agent ${fromAgentId}`, + ); } - if (!fromAgent.policy.authorizedCounterparties.includes(toAgentId) && - fromAgent.policy.authorizedCounterparties.length > 0) { - throw new Error(`Zero Trust Validation Failed: Agent ${toAgentId} is not an authorized counterparty for ${fromAgentId}`); + + const authorizedCounterparties = config.authorizedCounterparties || []; + if ( + authorizedCounterparties.length > 0 && + !authorizedCounterparties.includes(toAgentId) + ) { + throw new Error( + `Zero Trust Validation Failed: Agent ${toAgentId} is not an authorized counterparty for ${fromAgentId}`, + ); } // Simulate transfer success diff --git a/src/services/mandate.js b/src/services/mandate.js index 20feda1..df7e827 100644 --- a/src/services/mandate.js +++ b/src/services/mandate.js @@ -105,14 +105,42 @@ class MandateService { /** * Verify a Mandate (Intent or Cart) * @param {string} token - Signed JWT Mandate + * @param {Object} context - Optional context for validation (amount, recipient) * @returns {Promise} Decoded mandate payload */ - async verifyMandate(token) { + async verifyMandate(token, context = {}) { + let decoded; try { - return jwt.verify(token, this.signingKey, { algorithms: ["HS256"] }); + decoded = jwt.verify(token, this.signingKey, { algorithms: ["HS256"] }); } catch (error) { - throw new Error(`Zero Trust Validation Failed: Mandate verification failed: ${error.message}`); + throw new Error( + `Zero Trust Validation Failed: Mandate verification failed: ${error.message}`, + ); } + + // Contextual Validation + if (context.amount) { + if (decoded.max_budget && context.amount > decoded.max_budget.value) { + throw new Error( + `Zero Trust Validation Failed: Amount ${context.amount} exceeds mandate budget of ${decoded.max_budget.value}`, + ); + } + if (decoded.total_price && context.amount !== decoded.total_price) { + throw new Error( + `Zero Trust Validation Failed: Amount ${context.amount} does not match cart mandate total of ${decoded.total_price}`, + ); + } + } + + if (context.recipient && decoded.allowed_merchants?.length > 0) { + if (!decoded.allowed_merchants.includes(context.recipient)) { + throw new Error( + `Zero Trust Validation Failed: Recipient ${context.recipient} not authorized by mandate`, + ); + } + } + + return decoded; } /** diff --git a/tests/unit/a2a.spec.js b/tests/unit/a2a.spec.js new file mode 100644 index 0000000..bd89434 --- /dev/null +++ b/tests/unit/a2a.spec.js @@ -0,0 +1,94 @@ +const A2AService = require('../../src/services/a2aService'); +const MandateService = require('../../src/services/mandate'); + +describe('A2AService', () => { + let a2aService; + let mockWalletService; + let mockDb; + let mandateService; + let validMandate; + + beforeAll(async () => { + mandateService = new MandateService({ signingKey: 'test-secret' }); + validMandate = await mandateService.issueIntentMandate({ + userDid: 'did:key:user', + agentDid: 'did:key:agent1', + maxBudget: 1000 + }); + }); + + beforeEach(() => { + mockWalletService = { + transfer: jest.fn().mockResolvedValue({ success: true, transferId: 'tx123' }) + }; + mockDb = { + findAgentById: jest.fn() + }; + a2aService = new A2AService(mockWalletService, mockDb, { + mandateConfig: { signingKey: 'test-secret' }, + strictMandateMode: true + }); + }); + + describe('executeTransfer', () => { + it('should throw error if mandate is missing in strict mode', async () => { + await expect(a2aService.executeTransfer({ + fromAgentId: 'agent1', + toAgentId: 'agent2', + amount: 100 + })).rejects.toThrow('Zero Trust Validation Failed: Mandate required for A2A transfer in strict mode'); + }); + + it('should throw error if mandate is invalid', async () => { + await expect(a2aService.executeTransfer({ + fromAgentId: 'agent1', + toAgentId: 'agent2', + amount: 100, + mandate: 'invalid-token' + })).rejects.toThrow(/Zero Trust Validation Failed: Mandate verification failed/); + }); + + it('should execute transfer when mandate is valid and agents exist', async () => { + const fromAgent = { + id: 'agent1', + name: 'Agent 1', + status: 'active', + walletId: 'wallet1', + config: { limits: { perTransaction: 500 } } + }; + const toAgent = { + id: 'agent2', + name: 'Agent 2', + status: 'active', + walletId: 'wallet2' + }; + + mockDb.findAgentById.mockImplementation((id) => { + if (id === 'agent1') return fromAgent; + if (id === 'agent2') return toAgent; + return null; + }); + + const result = await a2aService.executeTransfer({ + fromAgentId: 'agent1', + toAgentId: 'agent2', + amount: 100, + mandate: validMandate + }); + + expect(result.success).toBe(true); + expect(mockWalletService.transfer).toHaveBeenCalled(); + }); + + it('should throw error if agent is not found', async () => { + mockDb.findAgentById.mockResolvedValue(null); + + await expect(a2aService.executeTransfer({ + fromAgentId: 'agent1', + toAgentId: 'agent2', + amount: 100, + mandate: validMandate + })).rejects.toThrow('Zero Trust Validation Failed: Sender agent agent1 not found or inactive'); + }); + }); +}); diff --git a/tests/unit/agent.spec.js b/tests/unit/agent.spec.js index d4ea2da..49a8df6 100644 --- a/tests/unit/agent.spec.js +++ b/tests/unit/agent.spec.js @@ -38,4 +38,51 @@ describe('AgentService', () => { await expect(agentService.getAgent('nonexistent')).rejects.toThrow('Zero Trust Validation Failed: Agent not found'); }); }); + + describe('performA2ATransfer', () => { + it('should throw if amount exceeds perTransaction limit', async () => { + mockDb.findAgentById.mockResolvedValue({ + id: 'agent1', + config: { limits: { perTransaction: 50 } }, + status: 'active', + }); + + await expect(agentService.performA2ATransfer({ + fromAgentId: 'agent1', + toAgentId: 'agent2', + amount: 100 + })).rejects.toThrow(/Zero Trust Validation Failed: Transfer amount 100 exceeds per-transaction limit of 50/); + }); + + it('should throw if counterparty is not authorized', async () => { + mockDb.findAgentById.mockImplementation((id) => { + if (id === 'agent1') return { + id: 'agent1', + config: { authorizedCounterparties: ['agent3'] }, + status: 'active', + }; + return { id: id, status: 'active' }; + }); + + await expect(agentService.performA2ATransfer({ + fromAgentId: 'agent1', + toAgentId: 'agent2', + amount: 10 + })).rejects.toThrow(/Zero Trust Validation Failed: Agent agent2 is not an authorized counterparty/); + }); + + it('should not throw if config is missing (graceful handling)', async () => { + mockDb.findAgentById.mockImplementation((id) => { + return { id: id, status: 'active' }; // No config + }); + + const result = await agentService.performA2ATransfer({ + fromAgentId: 'agent1', + toAgentId: 'agent2', + amount: 10 + }); + + expect(result.success).toBe(true); + }); + }); });