Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/services/a2aService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

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 executeTransfer calls verifyMandate without passing the amount and recipient. This means the new budget and recipient scope checks in verifyMandate won't run for A2A transfers, potentially allowing transfers that exceed authorized spend or go to unauthorized recipients.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1f0e424. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Contextual mandate errors mislabeled

Medium Severity

The inner catch around verifyMandate rewrites every failure as “Mandate verification failed,” including new contextual budget and recipient errors from mandate.js. Policy violations can be reported as cryptographic verification failures once context is passed.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1f0e424. Configure here.

*/
async executeTransfer({
fromAgentId,
Expand Down
26 changes: 19 additions & 7 deletions src/services/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 31 additions & 3 deletions src/services/mandate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amount zero skips mandate checks

Medium Severity

Contextual amount validation is gated on if (context.amount), so a transfer amount of 0 skips both max_budget and cart total_price checks even when those fields are present on the decoded mandate.

Fix in Cursor Fix in Web

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`,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recipient uses merchant DIDs

Medium Severity

Recipient authorization compares context.recipient to allowed_merchants, which intent mandates populate with merchant DIDs, while A2A transfers identify payees by agent ID. Passing toAgentId as recipient will reject valid transfers whenever the whitelist is non-empty.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1f0e424. Configure here.

}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cart recipient not validated

Medium Severity

New contextual recipient checks only consult allowed_merchants on the decoded mandate. Cart mandates carry merchant_did instead, so when context.recipient is supplied, cart mandates never bind the transfer to the mandated merchant.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1f0e424. Configure here.


return decoded;
}

/**
Expand Down
94 changes: 94 additions & 0 deletions tests/unit/a2a.spec.js
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');
});
});
});
47 changes: 47 additions & 0 deletions tests/unit/agent.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading