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:
2026-02-07 16:55:49 -06:00
parent 33dc746714
commit 73074932f6
7 changed files with 959 additions and 0 deletions

View File

@@ -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" });
});
});
});