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
35 changes: 34 additions & 1 deletion src/config/config.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,40 @@ const sorobanRpcUrl = Joi.string()
export const configValidationSchema = Joi.object({
PORT: Joi.number().default(3000),
DATABASE_URL: Joi.string().required(),
CREDENTIAL_ENCRYPTION_KEY: Joi.string().hex().length(64).required(),
CONTACT_ENCRYPTION_KEY: Joi.string()
.hex()
.length(64)
.messages({
'string.hex':
'Config validation error: CONTACT_ENCRYPTION_KEY must be a 64-character hexadecimal string',
'string.length':
'Config validation error: CONTACT_ENCRYPTION_KEY must be exactly 64 hex characters (32 bytes)',
})
.when('NODE_ENV', {
is: 'production',
then: Joi.required().messages({
'any.required':
'Config validation error: CONTACT_ENCRYPTION_KEY is required in production',
}),
otherwise: Joi.optional(),
}),
CREDENTIAL_ENCRYPTION_KEY: Joi.string()
.hex()
.length(64)
.messages({
'string.hex':
'Config validation error: CREDENTIAL_ENCRYPTION_KEY must be a 64-character hexadecimal string',
'string.length':
'Config validation error: CREDENTIAL_ENCRYPTION_KEY must be exactly 64 hex characters (32 bytes)',
})
.when('NODE_ENV', {
is: 'production',
then: Joi.required().messages({
'any.required':
'Config validation error: CREDENTIAL_ENCRYPTION_KEY is required in production',
}),
otherwise: Joi.optional(),
}),
SEP10_JWT_SECRET: Joi.string().min(32).required(),
// Stellar system signer secret key — validated by Keypair.fromSecret for checksum
SYSTEM_SIGNER_SECRET: stellarSecretKey.required(),
Expand Down
10 changes: 10 additions & 0 deletions src/config/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { ConfigService as NestConfigService } from '@nestjs/config';
export interface Config {
PORT: number;
DATABASE_URL: string;
CONTACT_ENCRYPTION_KEY?: string;
CREDENTIAL_ENCRYPTION_KEY?: string;
DB_POOL_CONNECTION_LIMIT?: number;
DB_POOL_TIMEOUT_MS?: number;
SEP10_JWT_SECRET: string;
Expand Down Expand Up @@ -62,6 +64,14 @@ export class ConfigService {
return {
PORT: this.get('PORT'),
DATABASE_URL: this.get('DATABASE_URL'),
CONTACT_ENCRYPTION_KEY: this.nestConfigService.get(
'CONTACT_ENCRYPTION_KEY',
{ infer: true },
),
CREDENTIAL_ENCRYPTION_KEY: this.nestConfigService.get(
'CREDENTIAL_ENCRYPTION_KEY',
{ infer: true },
),
SEP10_JWT_SECRET: this.get('SEP10_JWT_SECRET'),
ADMIN_ADDRESS: this.get('ADMIN_ADDRESS'),
AUTO_RELEASE_SOURCE_ADDRESS: this.nestConfigService.get(
Expand Down
75 changes: 75 additions & 0 deletions src/config/encryption-keys-config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { configValidationSchema } from "./config.module";

const VALID_KEY_64 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const INVALID_KEY_63 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde";
const NON_HEX_KEY = "GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG";

const BASE_PROD_ENV = {
PORT: "3000",
DATABASE_URL: "postgresql://user:pass@localhost:5432/db",
SYSTEM_SIGNER_SECRET: "SAIJDXETR5B7YFPH7SUOISWVBHHSI46JLYFDCWDMEV2L46XAHASPP35C",
ADMIN_ADDRESS: "GBEFNNUJ3IRKU2JEAMWBA7YI52HF2GYPHMDXF37T75GHK5KU2Y2QSUAJ",
SEP10_JWT_SECRET: "12345678901234567890123456789012",
CONTRACT_ID: "CB6453...VALID",
NODE_ENV: "production",
STELLAR_WEBHOOK_SECRET: "secret",
SENTRY_DSN: "https://key@sentry.io/123",
SOROBAN_RPC_URL: "https://soroban-testnet.stellar.org",
CONTACT_ENCRYPTION_KEY: VALID_KEY_64,
CREDENTIAL_ENCRYPTION_KEY: VALID_KEY_64,
};

describe("Encryption Keys Config Validation (#564)", () => {
describe("production requirements", () => {
it("fails startup in production when CONTACT_ENCRYPTION_KEY is missing", () => {
const env = { ...BASE_PROD_ENV };
delete (env as Record<string, string>).CONTACT_ENCRYPTION_KEY;
const { error } = configValidationSchema.validate(env);
expect(error).toBeDefined();
expect(error?.message).toContain("CONTACT_ENCRYPTION_KEY");
});

it("fails startup in production when CREDENTIAL_ENCRYPTION_KEY is missing", () => {
const env = { ...BASE_PROD_ENV };
delete (env as Record<string, string>).CREDENTIAL_ENCRYPTION_KEY;
const { error } = configValidationSchema.validate(env);
expect(error).toBeDefined();
expect(error?.message).toContain("CREDENTIAL_ENCRYPTION_KEY");
});
});

describe("length and hex format constraints", () => {
it("rejects a 63-character CONTACT_ENCRYPTION_KEY", () => {
const env = { ...BASE_PROD_ENV, CONTACT_ENCRYPTION_KEY: INVALID_KEY_63 };
const { error } = configValidationSchema.validate(env);
expect(error).toBeDefined();
expect(error?.message).toContain("CONTACT_ENCRYPTION_KEY");
});

it("rejects a 63-character CREDENTIAL_ENCRYPTION_KEY", () => {
const env = { ...BASE_PROD_ENV, CREDENTIAL_ENCRYPTION_KEY: INVALID_KEY_63 };
const { error } = configValidationSchema.validate(env);
expect(error).toBeDefined();
expect(error?.message).toContain("CREDENTIAL_ENCRYPTION_KEY");
});

it("rejects a non-hex CONTACT_ENCRYPTION_KEY", () => {
const env = { ...BASE_PROD_ENV, CONTACT_ENCRYPTION_KEY: NON_HEX_KEY };
const { error } = configValidationSchema.validate(env);
expect(error).toBeDefined();
expect(error?.message).toContain("CONTACT_ENCRYPTION_KEY");
});

it("rejects a non-hex CREDENTIAL_ENCRYPTION_KEY", () => {
const env = { ...BASE_PROD_ENV, CREDENTIAL_ENCRYPTION_KEY: NON_HEX_KEY };
const { error } = configValidationSchema.validate(env);
expect(error).toBeDefined();
expect(error?.message).toContain("CREDENTIAL_ENCRYPTION_KEY");
});

it("accepts valid 64-character hex keys in production", () => {
const { error } = configValidationSchema.validate(BASE_PROD_ENV);
expect(error).toBeUndefined();
});
});
});
Loading