Security improvements: - Reduce timestamp tolerance from 5 minutes to 60 seconds - Add nonce-based replay attack prevention using Redis - Store signature nonce with 60s TTL matching tolerance window - Reject replayed messages with same signature Changes: - Update SignatureService.TIMESTAMP_TOLERANCE_MS to 60s - Add Redis client injection to SignatureService - Make verifyConnectionRequest async for nonce checking - Create RedisProvider for shared Redis client - Update ConnectionService to await signature verification - Add comprehensive test coverage for replay prevention Part of M7.1 Remediation Sprint P1 security fixes. Fixes #284 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
379 lines
11 KiB
TypeScript
379 lines
11 KiB
TypeScript
/**
|
|
* 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";
|
|
import type Redis from "ioredis";
|
|
|
|
describe("SignatureService", () => {
|
|
let service: SignatureService;
|
|
let mockFederationService: Partial<FederationService>;
|
|
let mockRedis: Partial<Redis>;
|
|
|
|
// 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(),
|
|
}),
|
|
};
|
|
|
|
mockRedis = {
|
|
get: vi.fn().mockResolvedValue(null),
|
|
set: vi.fn().mockResolvedValue("OK"),
|
|
setex: vi.fn().mockResolvedValue("OK"),
|
|
};
|
|
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
SignatureService,
|
|
{
|
|
provide: FederationService,
|
|
useValue: mockFederationService,
|
|
},
|
|
{
|
|
provide: "REDIS_CLIENT",
|
|
useValue: mockRedis,
|
|
},
|
|
],
|
|
}).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 60 seconds", () => {
|
|
const fiftySecondsAgo = Date.now() - 50 * 1000;
|
|
const result = service.validateTimestamp(fiftySecondsAgo);
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should reject timestamps older than 60 seconds", () => {
|
|
const twoMinutesAgo = Date.now() - 2 * 60 * 1000;
|
|
const result = service.validateTimestamp(twoMinutesAgo);
|
|
|
|
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", async () => {
|
|
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 = await service.verifyConnectionRequest(signedRequest);
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(result.error).toBeUndefined();
|
|
expect(mockRedis.setex).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should reject request with invalid signature", async () => {
|
|
const request = {
|
|
instanceId: "instance-123",
|
|
instanceUrl: "https://test.example.com",
|
|
publicKey,
|
|
capabilities: {},
|
|
timestamp: Date.now(),
|
|
signature: "invalid-signature",
|
|
};
|
|
|
|
const result = await service.verifyConnectionRequest(request);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toContain("signature");
|
|
});
|
|
|
|
it("should reject request with expired timestamp", async () => {
|
|
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 = await service.verifyConnectionRequest(signedRequest);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toContain("timestamp");
|
|
});
|
|
});
|
|
|
|
describe("replay attack prevention", () => {
|
|
it("should reject replayed message with same signature", async () => {
|
|
const timestamp = Date.now();
|
|
const request = {
|
|
instanceId: "instance-123",
|
|
instanceUrl: "https://test.example.com",
|
|
publicKey,
|
|
capabilities: {},
|
|
timestamp,
|
|
};
|
|
|
|
const signature = service.sign(request, privateKey);
|
|
const signedRequest = { ...request, signature };
|
|
|
|
// First request should succeed
|
|
const result1 = await service.verifyConnectionRequest(signedRequest);
|
|
expect(result1.valid).toBe(true);
|
|
|
|
// Mock Redis to indicate nonce was already used
|
|
mockRedis.get = vi.fn().mockResolvedValue("1");
|
|
|
|
// Second request with same signature should be rejected
|
|
const result2 = await service.verifyConnectionRequest(signedRequest);
|
|
expect(result2.valid).toBe(false);
|
|
expect(result2.error).toContain("replay");
|
|
});
|
|
|
|
it("should store nonce with 60 second TTL", async () => {
|
|
const timestamp = Date.now();
|
|
const request = {
|
|
instanceId: "instance-123",
|
|
instanceUrl: "https://test.example.com",
|
|
publicKey,
|
|
capabilities: {},
|
|
timestamp,
|
|
};
|
|
|
|
const signature = service.sign(request, privateKey);
|
|
const signedRequest = { ...request, signature };
|
|
|
|
await service.verifyConnectionRequest(signedRequest);
|
|
|
|
expect(mockRedis.setex).toHaveBeenCalledWith(expect.stringContaining("nonce:"), 60, "1");
|
|
});
|
|
|
|
it("should allow different messages with different signatures", async () => {
|
|
const timestamp1 = Date.now();
|
|
const request1 = {
|
|
instanceId: "instance-123",
|
|
instanceUrl: "https://test.example.com",
|
|
publicKey,
|
|
capabilities: {},
|
|
timestamp: timestamp1,
|
|
};
|
|
|
|
const signature1 = service.sign(request1, privateKey);
|
|
const signedRequest1 = { ...request1, signature: signature1 };
|
|
|
|
const result1 = await service.verifyConnectionRequest(signedRequest1);
|
|
expect(result1.valid).toBe(true);
|
|
|
|
// Different timestamp creates different signature
|
|
const timestamp2 = Date.now() + 1;
|
|
const request2 = {
|
|
instanceId: "instance-123",
|
|
instanceUrl: "https://test.example.com",
|
|
publicKey,
|
|
capabilities: {},
|
|
timestamp: timestamp2,
|
|
};
|
|
|
|
const signature2 = service.sign(request2, privateKey);
|
|
const signedRequest2 = { ...request2, signature: signature2 };
|
|
|
|
// Reset mock to simulate nonce not found
|
|
mockRedis.get = vi.fn().mockResolvedValue(null);
|
|
|
|
const result2 = await service.verifyConnectionRequest(signedRequest2);
|
|
expect(result2.valid).toBe(true);
|
|
});
|
|
});
|
|
});
|