feat(#284): Reduce timestamp validation window to 60s with replay attack prevention
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>
This commit is contained in:
@@ -9,10 +9,12 @@ 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", {
|
||||
@@ -37,6 +39,12 @@ describe("SignatureService", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
mockRedis = {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
set: vi.fn().mockResolvedValue("OK"),
|
||||
setex: vi.fn().mockResolvedValue("OK"),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SignatureService,
|
||||
@@ -44,6 +52,10 @@ describe("SignatureService", () => {
|
||||
provide: FederationService,
|
||||
useValue: mockFederationService,
|
||||
},
|
||||
{
|
||||
provide: "REDIS_CLIENT",
|
||||
useValue: mockRedis,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -168,16 +180,16 @@ describe("SignatureService", () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept timestamps within 5 minutes", () => {
|
||||
const fourMinutesAgo = Date.now() - 4 * 60 * 1000;
|
||||
const result = service.validateTimestamp(fourMinutesAgo);
|
||||
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 5 minutes", () => {
|
||||
const sixMinutesAgo = Date.now() - 6 * 60 * 1000;
|
||||
const result = service.validateTimestamp(sixMinutesAgo);
|
||||
it("should reject timestamps older than 60 seconds", () => {
|
||||
const twoMinutesAgo = Date.now() - 2 * 60 * 1000;
|
||||
const result = service.validateTimestamp(twoMinutesAgo);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
@@ -226,7 +238,7 @@ describe("SignatureService", () => {
|
||||
});
|
||||
|
||||
describe("verifyConnectionRequest", () => {
|
||||
it("should verify a valid connection request", () => {
|
||||
it("should verify a valid connection request", async () => {
|
||||
const timestamp = Date.now();
|
||||
const request = {
|
||||
instanceId: "instance-123",
|
||||
@@ -239,13 +251,14 @@ describe("SignatureService", () => {
|
||||
const signature = service.sign(request, privateKey);
|
||||
const signedRequest = { ...request, signature };
|
||||
|
||||
const result = service.verifyConnectionRequest(signedRequest);
|
||||
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", () => {
|
||||
it("should reject request with invalid signature", async () => {
|
||||
const request = {
|
||||
instanceId: "instance-123",
|
||||
instanceUrl: "https://test.example.com",
|
||||
@@ -255,13 +268,13 @@ describe("SignatureService", () => {
|
||||
signature: "invalid-signature",
|
||||
};
|
||||
|
||||
const result = service.verifyConnectionRequest(request);
|
||||
const result = await service.verifyConnectionRequest(request);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("signature");
|
||||
});
|
||||
|
||||
it("should reject request with expired timestamp", () => {
|
||||
it("should reject request with expired timestamp", async () => {
|
||||
const expiredTimestamp = Date.now() - 10 * 60 * 1000; // 10 minutes ago
|
||||
const request = {
|
||||
instanceId: "instance-123",
|
||||
@@ -274,10 +287,92 @@ describe("SignatureService", () => {
|
||||
const signature = service.sign(request, privateKey);
|
||||
const signedRequest = { ...request, signature };
|
||||
|
||||
const result = service.verifyConnectionRequest(signedRequest);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user