Skip to content

Commit 6f27a3c

Browse files
authored
Merge pull request #20 from dcplatforms/feat/a2a-zero-trust-refactor-17556814814901887395
Implement Zero Trust Mandate Enforcement for A2A Transfers
2 parents c078632 + 1f0e424 commit 6f27a3c

6 files changed

Lines changed: 193 additions & 11 deletions

File tree

src/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const mobilePaymentService = new MobilePaymentService(
3434
walletService,
3535
);
3636
const agentService = new AgentService(db);
37-
const a2aService = new A2AService(walletService, db);
37+
const a2aService = new A2AService(walletService, db, config);
3838
const ucpService = new UCPService(a2aService);
3939

4040
// Initialize Express app

src/services/a2aService.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class A2AService {
2727
* @param {number} params.amount - Amount to transfer
2828
* @param {string} params.mandate - Optional signed Mandate (AP2) for Zero Trust validation
2929
* @param {Object} params.ucpPayload - The original UCP intent/payload
30+
* @param {string} params.mandate - Optional signed Mandate (AP2) for Zero Trust validation
3031
*/
3132
async executeTransfer({
3233
fromAgentId,

src/services/agent.js

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -177,15 +177,27 @@ class AgentService {
177177
// In a real system, this would involve interaction with the WalletService,
178178
// and policy checks for both agents.
179179
const fromAgent = await this.getAgent(fromAgentId);
180-
const toAgent = await this.getAgent(toAgentId);
180+
await this.getAgent(toAgentId); // Verify toAgent exists
181181

182-
// Basic policy checks (more complex logic would be here)
183-
if (amount > fromAgent.policy.spendingLimit) {
184-
throw new Error(`Zero Trust Validation Failed: Transfer amount exceeds spending limit for agent ${fromAgentId}`);
182+
// Basic policy checks
183+
const config = fromAgent.config || {};
184+
const limits = config.limits || {};
185+
const perTransactionLimit = limits.perTransaction || 0;
186+
187+
if (perTransactionLimit > 0 && amount > perTransactionLimit) {
188+
throw new Error(
189+
`Zero Trust Validation Failed: Transfer amount ${amount} exceeds per-transaction limit of ${perTransactionLimit} for agent ${fromAgentId}`,
190+
);
185191
}
186-
if (!fromAgent.policy.authorizedCounterparties.includes(toAgentId) &&
187-
fromAgent.policy.authorizedCounterparties.length > 0) {
188-
throw new Error(`Zero Trust Validation Failed: Agent ${toAgentId} is not an authorized counterparty for ${fromAgentId}`);
192+
193+
const authorizedCounterparties = config.authorizedCounterparties || [];
194+
if (
195+
authorizedCounterparties.length > 0 &&
196+
!authorizedCounterparties.includes(toAgentId)
197+
) {
198+
throw new Error(
199+
`Zero Trust Validation Failed: Agent ${toAgentId} is not an authorized counterparty for ${fromAgentId}`,
200+
);
189201
}
190202

191203
// Simulate transfer success

src/services/mandate.js

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,42 @@ class MandateService {
105105
/**
106106
* Verify a Mandate (Intent or Cart)
107107
* @param {string} token - Signed JWT Mandate
108+
* @param {Object} context - Optional context for validation (amount, recipient)
108109
* @returns {Promise<Object>} Decoded mandate payload
109110
*/
110-
async verifyMandate(token) {
111+
async verifyMandate(token, context = {}) {
112+
let decoded;
111113
try {
112-
return jwt.verify(token, this.signingKey, { algorithms: ["HS256"] });
114+
decoded = jwt.verify(token, this.signingKey, { algorithms: ["HS256"] });
113115
} catch (error) {
114-
throw new Error(`Zero Trust Validation Failed: Mandate verification failed: ${error.message}`);
116+
throw new Error(
117+
`Zero Trust Validation Failed: Mandate verification failed: ${error.message}`,
118+
);
115119
}
120+
121+
// Contextual Validation
122+
if (context.amount) {
123+
if (decoded.max_budget && context.amount > decoded.max_budget.value) {
124+
throw new Error(
125+
`Zero Trust Validation Failed: Amount ${context.amount} exceeds mandate budget of ${decoded.max_budget.value}`,
126+
);
127+
}
128+
if (decoded.total_price && context.amount !== decoded.total_price) {
129+
throw new Error(
130+
`Zero Trust Validation Failed: Amount ${context.amount} does not match cart mandate total of ${decoded.total_price}`,
131+
);
132+
}
133+
}
134+
135+
if (context.recipient && decoded.allowed_merchants?.length > 0) {
136+
if (!decoded.allowed_merchants.includes(context.recipient)) {
137+
throw new Error(
138+
`Zero Trust Validation Failed: Recipient ${context.recipient} not authorized by mandate`,
139+
);
140+
}
141+
}
142+
143+
return decoded;
116144
}
117145

118146
/**

tests/unit/a2a.spec.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
const A2AService = require('../../src/services/a2aService');
2+
const MandateService = require('../../src/services/mandate');
3+
4+
describe('A2AService', () => {
5+
let a2aService;
6+
let mockWalletService;
7+
let mockDb;
8+
let mandateService;
9+
let validMandate;
10+
11+
beforeAll(async () => {
12+
mandateService = new MandateService({ signingKey: 'test-secret' });
13+
validMandate = await mandateService.issueIntentMandate({
14+
userDid: 'did:key:user',
15+
agentDid: 'did:key:agent1',
16+
maxBudget: 1000
17+
});
18+
});
19+
20+
beforeEach(() => {
21+
mockWalletService = {
22+
transfer: jest.fn().mockResolvedValue({ success: true, transferId: 'tx123' })
23+
};
24+
mockDb = {
25+
findAgentById: jest.fn()
26+
};
27+
a2aService = new A2AService(mockWalletService, mockDb, {
28+
mandateConfig: { signingKey: 'test-secret' },
29+
strictMandateMode: true
30+
});
31+
});
32+
33+
describe('executeTransfer', () => {
34+
it('should throw error if mandate is missing in strict mode', async () => {
35+
await expect(a2aService.executeTransfer({
36+
fromAgentId: 'agent1',
37+
toAgentId: 'agent2',
38+
amount: 100
39+
})).rejects.toThrow('Zero Trust Validation Failed: Mandate required for A2A transfer in strict mode');
40+
});
41+
42+
it('should throw error if mandate is invalid', async () => {
43+
await expect(a2aService.executeTransfer({
44+
fromAgentId: 'agent1',
45+
toAgentId: 'agent2',
46+
amount: 100,
47+
mandate: 'invalid-token'
48+
})).rejects.toThrow(/Zero Trust Validation Failed: Mandate verification failed/);
49+
});
50+
51+
it('should execute transfer when mandate is valid and agents exist', async () => {
52+
const fromAgent = {
53+
id: 'agent1',
54+
name: 'Agent 1',
55+
status: 'active',
56+
walletId: 'wallet1',
57+
config: { limits: { perTransaction: 500 } }
58+
};
59+
const toAgent = {
60+
id: 'agent2',
61+
name: 'Agent 2',
62+
status: 'active',
63+
walletId: 'wallet2'
64+
};
65+
66+
mockDb.findAgentById.mockImplementation((id) => {
67+
if (id === 'agent1') return fromAgent;
68+
if (id === 'agent2') return toAgent;
69+
return null;
70+
});
71+
72+
const result = await a2aService.executeTransfer({
73+
fromAgentId: 'agent1',
74+
toAgentId: 'agent2',
75+
amount: 100,
76+
mandate: validMandate
77+
});
78+
79+
expect(result.success).toBe(true);
80+
expect(mockWalletService.transfer).toHaveBeenCalled();
81+
});
82+
83+
it('should throw error if agent is not found', async () => {
84+
mockDb.findAgentById.mockResolvedValue(null);
85+
86+
await expect(a2aService.executeTransfer({
87+
fromAgentId: 'agent1',
88+
toAgentId: 'agent2',
89+
amount: 100,
90+
mandate: validMandate
91+
})).rejects.toThrow('Zero Trust Validation Failed: Sender agent agent1 not found or inactive');
92+
});
93+
});
94+
});

tests/unit/agent.spec.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,51 @@ describe('AgentService', () => {
3838
await expect(agentService.getAgent('nonexistent')).rejects.toThrow('Zero Trust Validation Failed: Agent not found');
3939
});
4040
});
41+
42+
describe('performA2ATransfer', () => {
43+
it('should throw if amount exceeds perTransaction limit', async () => {
44+
mockDb.findAgentById.mockResolvedValue({
45+
id: 'agent1',
46+
config: { limits: { perTransaction: 50 } },
47+
status: 'active',
48+
});
49+
50+
await expect(agentService.performA2ATransfer({
51+
fromAgentId: 'agent1',
52+
toAgentId: 'agent2',
53+
amount: 100
54+
})).rejects.toThrow(/Zero Trust Validation Failed: Transfer amount 100 exceeds per-transaction limit of 50/);
55+
});
56+
57+
it('should throw if counterparty is not authorized', async () => {
58+
mockDb.findAgentById.mockImplementation((id) => {
59+
if (id === 'agent1') return {
60+
id: 'agent1',
61+
config: { authorizedCounterparties: ['agent3'] },
62+
status: 'active',
63+
};
64+
return { id: id, status: 'active' };
65+
});
66+
67+
await expect(agentService.performA2ATransfer({
68+
fromAgentId: 'agent1',
69+
toAgentId: 'agent2',
70+
amount: 10
71+
})).rejects.toThrow(/Zero Trust Validation Failed: Agent agent2 is not an authorized counterparty/);
72+
});
73+
74+
it('should not throw if config is missing (graceful handling)', async () => {
75+
mockDb.findAgentById.mockImplementation((id) => {
76+
return { id: id, status: 'active' }; // No config
77+
});
78+
79+
const result = await agentService.performA2ATransfer({
80+
fromAgentId: 'agent1',
81+
toAgentId: 'agent2',
82+
amount: 10
83+
});
84+
85+
expect(result.success).toBe(true);
86+
});
87+
});
4188
});

0 commit comments

Comments
 (0)