Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ DATABASE_URL="postgresql://parashield:secret@localhost:5432/parashield_db?schema
# Secret used to sign JWT tokens — REQUIRED. In production, this MUST be a cryptographically secure random string of at least 32 bytes (256 bits).
JWT_SECRET=

# ─── Webhook secret encryption (#606) ─────────────────────────────────────────
# When set, webhook signing secrets are encrypted at rest with AES-256-GCM
# before being stored in the database. Strongly recommended in production.
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# WEBHOOK_SECRET_KEY=

# Operator key for admin-only oracle fetch endpoints — REQUIRED.
# Send as x-api-key header when calling POST /api/v1/oracle/fetch/*.
# Use a cryptographically random string of at least 32 bytes.
Expand Down
63 changes: 61 additions & 2 deletions src/common/events/webhooks.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ import { BadRequestException } from '@nestjs/common';
import { WebhooksService } from './webhooks.service';

describe('WebhooksService', () => {
function build(rows: any[] = []) {
/** Build a service instance with optional WEBHOOK_SECRET_KEY configured. */
function build(rows: any[] = [], webhookSecretKey?: string) {
const prisma = {
webhookRegistration: {
create: jest.fn(async ({ data }: any) => ({ id: 'wh-1', createdAt: new Date(), isActive: true, ...data })),
findMany: jest.fn().mockResolvedValue(rows),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
};
return { service: new WebhooksService(prisma as any), prisma };
const config = { get: jest.fn((key: string) => key === 'WEBHOOK_SECRET_KEY' ? webhookSecretKey : undefined) };
return { service: new WebhooksService(prisma as any, config as any), prisma, config };
}

afterEach(() => jest.restoreAllMocks());
Expand All @@ -22,6 +24,7 @@ describe('WebhooksService', () => {
const result = await service.registerWebhook({ url: 'https://example.com/hook', events: ['claim.status.change'], secret: 's' });

expect(result).toEqual({ id: 'wh-1', status: 'registered' });
// Without WEBHOOK_SECRET_KEY the secret is stored as plain text.
expect(prisma.webhookRegistration.create).toHaveBeenCalledWith({
data: { url: 'https://example.com/hook', secret: 's', events: ['claim.status.change'] },
});
Expand Down Expand Up @@ -70,4 +73,60 @@ describe('WebhooksService', () => {
service.notifyPolicyStatusChange({ policyId: 'p', fromStatus: 'ACTIVE', toStatus: 'CLAIMED', timestamp: 1 }),
).resolves.toBeUndefined();
});

describe('#606 — webhook secret encryption at rest', () => {
const SECRET_KEY = 'test-encryption-key-32-bytes-long!';

it('stores the secret encrypted (enc: prefix) when WEBHOOK_SECRET_KEY is set', async () => {
const { service, prisma } = build([], SECRET_KEY);

await service.registerWebhook({ url: 'https://example.com/hook', events: ['policy.status.change'], secret: 'mysecret' });

const stored = prisma.webhookRegistration.create.mock.calls[0][0].data.secret as string;
expect(stored).toMatch(/^enc:/);
});

it('round-trips: encrypted secret decrypts back to original value on read', async () => {
const { service, prisma } = build([], SECRET_KEY);

// Register to get the encrypted value.
await service.registerWebhook({ url: 'https://example.com/hook', events: ['policy.status.change'], secret: 'mysecret' });
const encryptedSecret = prisma.webhookRegistration.create.mock.calls[0][0].data.secret as string;

// Simulate a DB row with the encrypted secret and read it back.
prisma.webhookRegistration.findMany.mockResolvedValue([
{ id: 'wh-1', url: 'https://example.com/hook', secret: encryptedSecret, events: ['policy.status.change'], isActive: true, createdAt: new Date() },
]);

const registrations = await service.getRegistrations();
expect(registrations[0].secret).toBe('mysecret');
});

it('reads plain-text secrets without WEBHOOK_SECRET_KEY (backward compatibility)', async () => {
const { service, prisma } = build([
{ id: 'wh-1', url: 'https://example.com/hook', secret: 'plaintext', events: ['policy.status.change'], isActive: true, createdAt: new Date() },
]);

const registrations = await service.getRegistrations();
expect(registrations[0].secret).toBe('plaintext');
});

it('#607 — signPayload uses the top-level crypto import (not require)', async () => {
// Verify the HMAC signature is produced correctly via the module-level
// crypto import; if signPayload used require('crypto') at runtime it
// would still work but would be inconsistent — this test confirms the
// correct output regardless of import mechanism.
const { service } = build([
{ id: 'a', url: 'https://a.test', secret: 'sig-secret', events: ['claim.status.change'], isActive: true, createdAt: new Date() },
]);
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, status: 200 } as Response);

const event = { claimId: 'x', fromStatus: 'PROCESSING', toStatus: 'PAID', timestamp: 99 };
await service.notifyClaimStatusChange(event);

const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
const expected = crypto.createHmac('sha256', 'sig-secret').update(JSON.stringify(event)).digest('base64');
expect((init.headers as Record<string, string>)['X-Webhook-Signature']).toBe(expected);
});
});
});
70 changes: 67 additions & 3 deletions src/common/events/webhooks.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import * as crypto from 'crypto';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../prisma/prisma.service';

type WebhookEvent = 'policy.status.change' | 'claim.status.change';
Expand All @@ -20,17 +21,79 @@ export interface WebhookRegistration {
const MAX_RETRY_ATTEMPTS = 3;
const RETRY_BASE_DELAY_MS = 1_000;

// #606 — AES-256-GCM parameters for encrypting webhook secrets at rest.
// SHA-256 of WEBHOOK_SECRET_KEY always yields the 32-byte key AES-256 requires.
const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;

@Injectable()
export class WebhooksService {
private readonly logger = new Logger(WebhooksService.name);
private readonly encryptionKey: Buffer | null;

constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
) {
// #606 — Derive a 32-byte key from WEBHOOK_SECRET_KEY when configured.
// When the env var is absent the service still works; secrets are stored
// as-is (plain text) and a warning is emitted once at startup.
const raw = this.config.get<string>('WEBHOOK_SECRET_KEY');
if (raw) {
// SHA-256 always yields exactly 32 bytes — matches the AES-256 key size
// without forcing operators to supply a precise-length value.
this.encryptionKey = crypto.createHash('sha256').update(raw).digest();
} else {
this.logger.warn(
'WEBHOOK_SECRET_KEY is not set — webhook secrets will be stored as plain text. ' +
'Set WEBHOOK_SECRET_KEY to enable AES-256-GCM encryption at rest.',
);
this.encryptionKey = null;
}
}

/**
* #606 — Encrypt a webhook signing secret with AES-256-GCM before
* persisting to the database. Returns a colon-delimited string of
* hex-encoded iv:authTag:ciphertext so the three components travel
* together and can be decoded without extra columns.
* When WEBHOOK_SECRET_KEY is not configured the value is stored unchanged.
*/
private encryptSecret(secret: string): string {
if (!this.encryptionKey) return secret;
const iv = crypto.randomBytes(IV_BYTES);
const cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, this.encryptionKey, iv);
const ciphertext = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return `enc:${iv.toString('hex')}:${authTag.toString('hex')}:${ciphertext.toString('hex')}`;
}

constructor(private readonly prisma: PrismaService) {}
/**
* #606 — Decrypt a secret encrypted by encryptSecret. Handles both
* encrypted (enc: prefix) and legacy plain-text values so existing rows
* stored before WEBHOOK_SECRET_KEY was configured remain readable.
*/
private decryptSecret(stored: string): string {
if (!this.encryptionKey || !stored.startsWith('enc:')) return stored;
const parts = stored.split(':');
if (parts.length !== 4) return stored; // malformed — return as-is
const [, ivHex, authTagHex, ciphertextHex] = parts;
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const ciphertext = Buffer.from(ciphertextHex, 'hex');
const decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, this.encryptionKey, iv);
decipher.setAuthTag(authTag);
return decipher.update(ciphertext).toString('utf8') + decipher.final('utf8');
}

// #482 — registrations are persisted via Prisma (previously an in-memory
// Map) so they survive restarts and are visible to every instance.
// #606 — signing secrets are encrypted at rest with AES-256-GCM when
// WEBHOOK_SECRET_KEY is configured.
async registerWebhook(dto: { url: string; events: WebhookEvent[]; secret?: string }) {
const storedSecret = dto.secret ? this.encryptSecret(dto.secret) : dto.secret;
const registration = await this.prisma.webhookRegistration.create({
data: { url: dto.url, secret: dto.secret, events: dto.events },
data: { url: dto.url, secret: storedSecret, events: dto.events },
});
this.logger.log(`Webhook registered: ${registration.id} → ${dto.url} for events: ${dto.events.join(', ')}`);
return { id: registration.id, status: 'registered' };
Expand All @@ -56,7 +119,8 @@ export class WebhooksService {
return rows.map((r) => ({
id: r.id,
url: r.url,
secret: r.secret ?? undefined,
// #606 — decrypt the stored secret before returning it for delivery signing.
secret: r.secret ? this.decryptSecret(r.secret) : undefined,
events: r.events as WebhookEvent[],
createdAt: r.createdAt,
isActive: r.isActive,
Expand Down