feat(#360): Add federation credential isolation
Implement explicit deny-lists in QueryService and CommandService to prevent user credentials from leaking across federation boundaries. ## Changes ### Core Implementation - QueryService: Block all credential-related queries with keyword detection - CommandService: Block all credential operations (create/update/delete/read) - Case-insensitive keyword matching for both queries and commands ### Security Features - Deny-list includes: credential, api_key, secret, token, password, oauth - Errors returned for blocked operations - No impact on existing allowed operations (tasks, events, projects, agent commands) ### Testing - Added 2 unit tests to query.service.spec.ts - Added 3 unit tests to command.service.spec.ts - Added 8 integration tests in credential-isolation.integration.spec.ts - All 377 federation tests passing ### Documentation - Created comprehensive security doc at docs/security/federation-credential-isolation.md - Documents 4 security guarantees (G1-G4) - Includes testing strategy and incident response procedures ## Security Guarantees 1. G1: Credential Confidentiality - Credentials never leave instance in plaintext 2. G2: Cross-Instance Isolation - Compromised key on one instance doesn't affect others 3. G3: Query/Command Isolation - Federated instances cannot query/modify credentials 4. G4: Accidental Exposure Prevention - Credentials cannot leak via messages ## Defense-in-Depth This implementation adds application-layer protection on top of existing: - Transit key separation (mosaic-credentials vs mosaic-federation) - Per-instance OpenBao servers - Workspace-scoped credential access Fixes #360 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -756,4 +756,145 @@ describe("CommandService", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleIncomingCommand - Credential Isolation", () => {
|
||||
it("should reject credential.create commands", async () => {
|
||||
const commandMessage: CommandMessage = {
|
||||
messageId: "cmd-123",
|
||||
instanceId: "remote-instance-1",
|
||||
commandType: "credential.create",
|
||||
payload: {
|
||||
name: "test-credential",
|
||||
value: "secret-value",
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
signature: "signature-123",
|
||||
};
|
||||
|
||||
const mockConnection = {
|
||||
id: "connection-1",
|
||||
workspaceId: mockWorkspaceId,
|
||||
remoteInstanceId: "remote-instance-1",
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
};
|
||||
|
||||
const mockIdentity = {
|
||||
instanceId: "local-instance-1",
|
||||
};
|
||||
|
||||
vi.spyOn(signatureService, "validateTimestamp").mockReturnValue(true);
|
||||
vi.spyOn(prisma.federationConnection, "findFirst").mockResolvedValue(mockConnection as never);
|
||||
vi.spyOn(signatureService, "verifyMessage").mockResolvedValue({
|
||||
valid: true,
|
||||
error: null,
|
||||
} as never);
|
||||
vi.spyOn(federationService, "getInstanceIdentity").mockResolvedValue(mockIdentity as never);
|
||||
vi.spyOn(signatureService, "signMessage").mockResolvedValue("response-signature");
|
||||
|
||||
const result = await service.handleIncomingCommand(commandMessage);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Credential operations are not allowed");
|
||||
});
|
||||
|
||||
it("should reject all credential operations", async () => {
|
||||
const credentialCommands = [
|
||||
"credential.create",
|
||||
"credential.update",
|
||||
"credential.delete",
|
||||
"credential.read",
|
||||
"credential.list",
|
||||
"credentials.sync",
|
||||
];
|
||||
|
||||
const mockConnection = {
|
||||
id: "connection-1",
|
||||
workspaceId: mockWorkspaceId,
|
||||
remoteInstanceId: "remote-instance-1",
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
};
|
||||
|
||||
const mockIdentity = {
|
||||
instanceId: "local-instance-1",
|
||||
};
|
||||
|
||||
vi.spyOn(signatureService, "validateTimestamp").mockReturnValue(true);
|
||||
vi.spyOn(prisma.federationConnection, "findFirst").mockResolvedValue(mockConnection as never);
|
||||
vi.spyOn(signatureService, "verifyMessage").mockResolvedValue({
|
||||
valid: true,
|
||||
error: null,
|
||||
} as never);
|
||||
vi.spyOn(federationService, "getInstanceIdentity").mockResolvedValue(mockIdentity as never);
|
||||
vi.spyOn(signatureService, "signMessage").mockResolvedValue("response-signature");
|
||||
|
||||
for (const commandType of credentialCommands) {
|
||||
const commandMessage: CommandMessage = {
|
||||
messageId: `cmd-${Math.random()}`,
|
||||
instanceId: "remote-instance-1",
|
||||
commandType,
|
||||
payload: {},
|
||||
timestamp: Date.now(),
|
||||
signature: "signature-123",
|
||||
};
|
||||
|
||||
const result = await service.handleIncomingCommand(commandMessage);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Credential operations are not allowed");
|
||||
}
|
||||
});
|
||||
|
||||
it("should allow agent commands (existing functionality)", async () => {
|
||||
const commandMessage: CommandMessage = {
|
||||
messageId: "cmd-123",
|
||||
instanceId: "remote-instance-1",
|
||||
commandType: "agent.spawn",
|
||||
payload: {
|
||||
agentType: "task-executor",
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
signature: "signature-123",
|
||||
};
|
||||
|
||||
const mockConnection = {
|
||||
id: "connection-1",
|
||||
workspaceId: mockWorkspaceId,
|
||||
remoteInstanceId: "remote-instance-1",
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
};
|
||||
|
||||
const mockIdentity = {
|
||||
instanceId: "local-instance-1",
|
||||
};
|
||||
|
||||
vi.spyOn(signatureService, "validateTimestamp").mockReturnValue(true);
|
||||
vi.spyOn(prisma.federationConnection, "findFirst").mockResolvedValue(mockConnection as never);
|
||||
vi.spyOn(signatureService, "verifyMessage").mockResolvedValue({
|
||||
valid: true,
|
||||
error: null,
|
||||
} as never);
|
||||
vi.spyOn(federationService, "getInstanceIdentity").mockResolvedValue(mockIdentity as never);
|
||||
vi.spyOn(signatureService, "signMessage").mockResolvedValue("response-signature");
|
||||
|
||||
// Mock FederationAgentService
|
||||
const mockAgentService = {
|
||||
handleAgentCommand: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { agentId: "agent-123" },
|
||||
}),
|
||||
};
|
||||
|
||||
const moduleRef = {
|
||||
get: vi.fn().mockReturnValue(mockAgentService),
|
||||
};
|
||||
|
||||
// Inject moduleRef into service
|
||||
(service as never)["moduleRef"] = moduleRef;
|
||||
|
||||
const result = await service.handleIncomingCommand(commandMessage);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ agentId: "agent-123" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user