-
Notifications
You must be signed in to change notification settings - Fork 0
Implement Zero Trust Mandate Enforcement for A2A Transfers #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Contextual mandate errors mislabeledMedium Severity The inner catch around Reviewed by Cursor Bugbot for commit 1f0e424. Configure here. |
||
| */ | ||
| async executeTransfer({ | ||
| fromAgentId, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Object>} 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}`, | ||
| ); | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Amount zero skips mandate checksMedium Severity Contextual amount validation is gated on Reviewed by Cursor Bugbot for commit 1f0e424. Configure here. |
||
| } | ||
|
|
||
| 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`, | ||
| ); | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Recipient uses merchant DIDsMedium Severity Recipient authorization compares Reviewed by Cursor Bugbot for commit 1f0e424. Configure here. |
||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cart recipient not validatedMedium Severity New contextual recipient checks only consult Reviewed by Cursor Bugbot for commit 1f0e424. Configure here. |
||
|
|
||
| return decoded; | ||
| } | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
| }); | ||
| }); | ||
| }); |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mandate context not bound A2A
High Severity
It looks like
executeTransfercallsverifyMandatewithout passing theamountand recipient. This means the new budget and recipient scope checks inverifyMandatewon't run for A2A transfers, potentially allowing transfers that exceed authorized spend or go to unauthorized recipients.Reviewed by Cursor Bugbot for commit 1f0e424. Configure here.