feat(#85): implement CONNECT/DISCONNECT protocol

Implemented connection handshake protocol for federation building on
the Instance Identity Model from issue #84.

**Services:**
- SignatureService: Message signing/verification with RSA-SHA256
- ConnectionService: Federation connection management

**API Endpoints:**
- POST /api/v1/federation/connections/initiate
- POST /api/v1/federation/connections/:id/accept
- POST /api/v1/federation/connections/:id/reject
- POST /api/v1/federation/connections/:id/disconnect
- GET /api/v1/federation/connections
- GET /api/v1/federation/connections/:id
- POST /api/v1/federation/incoming/connect

**Tests:** 70 tests pass (18 Signature + 20 Connection + 13 Controller + 19 existing)
**Coverage:** 100% on new code
**TDD Approach:** Tests written before implementation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-03 11:41:07 -06:00
parent b336d9c1f7
commit fc3919012f
13 changed files with 2063 additions and 19 deletions

View File

@@ -0,0 +1,283 @@
/**
* Signature Service Tests
*
* Tests for message signing and verification.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { SignatureService } from "./signature.service";
import { FederationService } from "./federation.service";
import { generateKeyPairSync } from "crypto";
describe("SignatureService", () => {
let service: SignatureService;
let mockFederationService: Partial<FederationService>;
// Test keypair
const { publicKey, privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
beforeEach(async () => {
mockFederationService = {
getInstanceIdentity: vi.fn().mockResolvedValue({
id: "test-id",
instanceId: "instance-123",
name: "Test Instance",
url: "https://test.example.com",
publicKey,
privateKey,
capabilities: {},
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
}),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
SignatureService,
{
provide: FederationService,
useValue: mockFederationService,
},
],
}).compile();
service = module.get<SignatureService>(SignatureService);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("sign", () => {
it("should create a valid signature for a message", () => {
const message = {
instanceId: "instance-123",
timestamp: Date.now(),
data: "test data",
};
const signature = service.sign(message, privateKey);
expect(signature).toBeDefined();
expect(typeof signature).toBe("string");
expect(signature.length).toBeGreaterThan(0);
});
it("should create different signatures for different messages", () => {
const message1 = { data: "message 1", timestamp: 1 };
const message2 = { data: "message 2", timestamp: 2 };
const signature1 = service.sign(message1, privateKey);
const signature2 = service.sign(message2, privateKey);
expect(signature1).not.toBe(signature2);
});
it("should create consistent signatures for the same message", () => {
const message = { data: "test", timestamp: 12345 };
const signature1 = service.sign(message, privateKey);
const signature2 = service.sign(message, privateKey);
// RSA signatures are deterministic for the same input
expect(signature1).toBe(signature2);
});
});
describe("verify", () => {
it("should verify a valid signature", () => {
const message = {
instanceId: "instance-123",
timestamp: Date.now(),
data: "test data",
};
const signature = service.sign(message, privateKey);
const result = service.verify(message, signature, publicKey);
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
it("should reject an invalid signature", () => {
const message = {
instanceId: "instance-123",
timestamp: Date.now(),
data: "test data",
};
const invalidSignature = "invalid-signature-data";
const result = service.verify(message, invalidSignature, publicKey);
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
it("should reject a tampered message", () => {
const originalMessage = {
instanceId: "instance-123",
timestamp: Date.now(),
data: "original data",
};
const signature = service.sign(originalMessage, privateKey);
const tamperedMessage = {
...originalMessage,
data: "tampered data",
};
const result = service.verify(tamperedMessage, signature, publicKey);
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
it("should reject a signature from wrong key", () => {
const message = { data: "test" };
// Generate a different keypair
const { publicKey: wrongPublicKey, privateKey: wrongPrivateKey } = generateKeyPairSync(
"rsa",
{
modulusLength: 2048,
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
}
);
const signature = service.sign(message, wrongPrivateKey);
const result = service.verify(message, signature, publicKey);
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
});
describe("validateTimestamp", () => {
it("should accept recent timestamps", () => {
const recentTimestamp = Date.now();
const result = service.validateTimestamp(recentTimestamp);
expect(result).toBe(true);
});
it("should accept timestamps within 5 minutes", () => {
const fourMinutesAgo = Date.now() - 4 * 60 * 1000;
const result = service.validateTimestamp(fourMinutesAgo);
expect(result).toBe(true);
});
it("should reject timestamps older than 5 minutes", () => {
const sixMinutesAgo = Date.now() - 6 * 60 * 1000;
const result = service.validateTimestamp(sixMinutesAgo);
expect(result).toBe(false);
});
it("should reject future timestamps beyond tolerance", () => {
const farFuture = Date.now() + 10 * 60 * 1000;
const result = service.validateTimestamp(farFuture);
expect(result).toBe(false);
});
it("should accept slightly future timestamps (clock skew tolerance)", () => {
const slightlyFuture = Date.now() + 30 * 1000; // 30 seconds
const result = service.validateTimestamp(slightlyFuture);
expect(result).toBe(true);
});
});
describe("signMessage", () => {
it("should sign a message with instance private key", async () => {
const message = {
instanceId: "instance-123",
timestamp: Date.now(),
data: "test",
};
const signature = await service.signMessage(message);
expect(signature).toBeDefined();
expect(typeof signature).toBe("string");
expect(signature.length).toBeGreaterThan(0);
});
it("should create verifiable signatures with instance keys", async () => {
const message = {
instanceId: "instance-123",
timestamp: Date.now(),
};
const signature = await service.signMessage(message);
const result = service.verify(message, signature, publicKey);
expect(result.valid).toBe(true);
});
});
describe("verifyConnectionRequest", () => {
it("should verify a valid connection request", () => {
const timestamp = Date.now();
const request = {
instanceId: "instance-123",
instanceUrl: "https://test.example.com",
publicKey,
capabilities: { supportsQuery: true },
timestamp,
};
const signature = service.sign(request, privateKey);
const signedRequest = { ...request, signature };
const result = service.verifyConnectionRequest(signedRequest);
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
it("should reject request with invalid signature", () => {
const request = {
instanceId: "instance-123",
instanceUrl: "https://test.example.com",
publicKey,
capabilities: {},
timestamp: Date.now(),
signature: "invalid-signature",
};
const result = service.verifyConnectionRequest(request);
expect(result.valid).toBe(false);
expect(result.error).toContain("signature");
});
it("should reject request with expired timestamp", () => {
const expiredTimestamp = Date.now() - 10 * 60 * 1000; // 10 minutes ago
const request = {
instanceId: "instance-123",
instanceUrl: "https://test.example.com",
publicKey,
capabilities: {},
timestamp: expiredTimestamp,
};
const signature = service.sign(request, privateKey);
const signedRequest = { ...request, signature };
const result = service.verifyConnectionRequest(signedRequest);
expect(result.valid).toBe(false);
expect(result.error).toContain("timestamp");
});
});
});