Skip to content
Open
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
2 changes: 2 additions & 0 deletions backend/src/lib/auth.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import bcrypt from "bcryptjs";
import { decryptSecret } from "./secret-crypto.js";

const SALT_ROUNDS = 12;

Expand Down Expand Up @@ -49,6 +50,7 @@ export function createApiKeyAuth({ supabaseClient = null } = {}) {
return res.status(401).json({ error: "Invalid API key" });
}

merchant.webhook_secret = decryptSecret(merchant.webhook_secret);
req.merchant = merchant;
next();
} catch (err) {
Expand Down
12 changes: 9 additions & 3 deletions backend/src/lib/auth.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createApiKeyAuth, hashPassword, verifyPassword } from "./auth.js";
import { encryptSecret } from "./secret-crypto.js";

function createResponse() {
return {
Expand Down Expand Up @@ -51,6 +52,7 @@ describe("createApiKeyAuth", () => {
let next;

beforeEach(() => {
process.env.WEBHOOK_SECRET_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
maybeSingle = vi.fn();
eq = vi.fn(() => ({ maybeSingle }));
select = vi.fn(() => ({ eq }));
Expand Down Expand Up @@ -81,7 +83,7 @@ describe("createApiKeyAuth", () => {

expect(from).toHaveBeenCalledWith("merchants");
expect(select).toHaveBeenCalledWith(
"id, email, business_name, notification_email, branding_config, merchant_settings",
"id, email, business_name, notification_email, branding_config, merchant_settings, webhook_secret, payment_limits",
);
expect(eq).toHaveBeenCalledWith("api_key", "invalid-key");
expect(res.status).toHaveBeenCalledWith(401);
Expand All @@ -94,15 +96,19 @@ describe("createApiKeyAuth", () => {
id: "merchant-123",
email: "merchant@example.com",
business_name: "Merchant Co",
notification_email: "ops@example.com"
notification_email: "ops@example.com",
webhook_secret: encryptSecret("whsec_plain"),
};
maybeSingle.mockResolvedValue({ data: merchant, error: null });
const req = createRequest({ "x-api-key": " valid-key " });

await middleware(req, res, next);

expect(eq).toHaveBeenCalledWith("api_key", "valid-key");
expect(req.merchant).toEqual(merchant);
expect(req.merchant).toEqual({
...merchant,
webhook_secret: "whsec_plain",
});
expect(next).toHaveBeenCalledWith();
expect(res.status).not.toHaveBeenCalled();
});
Expand Down
10 changes: 10 additions & 0 deletions backend/src/lib/env-validation.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { getWebhookSecretsEncryptionKey } from "./secret-crypto.js";

function validateEnvironmentVariables() {
const required = [
'SUPABASE_URL',
'SUPABASE_SERVICE_ROLE_KEY',
'STELLAR_NETWORK',
'DATABASE_URL',
'REDIS_URL',
'WEBHOOK_SECRET_ENCRYPTION_KEY',
];

const missing = required.filter(key => !process.env[key]);
Expand Down Expand Up @@ -48,6 +51,13 @@ function validateEnvironmentVariables() {
}
}

try {
getWebhookSecretsEncryptionKey();
} catch (error) {
console.error(`❌ ${error.message}`);
process.exit(1);
}

console.log('✅ Environment variables validated');
}

Expand Down
75 changes: 75 additions & 0 deletions backend/src/lib/secret-crypto.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";

const ENCRYPTED_PREFIX = "enc:v1:";
const KEY_BYTES = 32;
const IV_BYTES = 12;

let cachedKey = null;

function decodeKey(rawKey) {
if (!rawKey || typeof rawKey !== "string") {
throw new Error(
"WEBHOOK_SECRET_ENCRYPTION_KEY is required and must be a base64-encoded 32-byte key",
);
}

const decoded = Buffer.from(rawKey, "base64");
if (decoded.length !== KEY_BYTES) {
throw new Error(
"WEBHOOK_SECRET_ENCRYPTION_KEY must decode to exactly 32 bytes",
);
}

return decoded;
}

export function getWebhookSecretsEncryptionKey() {
if (cachedKey) return cachedKey;
cachedKey = decodeKey(process.env.WEBHOOK_SECRET_ENCRYPTION_KEY);
return cachedKey;
}

export function isEncryptedSecret(value) {
return typeof value === "string" && value.startsWith(ENCRYPTED_PREFIX);
}

export function encryptSecret(plaintext) {
if (!plaintext) return plaintext;

const key = getWebhookSecretsEncryptionKey();
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const ciphertext = Buffer.concat([
cipher.update(String(plaintext), "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
const payload = Buffer.concat([iv, authTag, ciphertext]).toString("base64url");

return `${ENCRYPTED_PREFIX}${payload}`;
}

export function decryptSecret(value) {
if (!value) return value;
if (!isEncryptedSecret(value)) return value;

const key = getWebhookSecretsEncryptionKey();
const raw = value.slice(ENCRYPTED_PREFIX.length);
const decoded = Buffer.from(raw, "base64url");

if (decoded.length <= IV_BYTES + 16) {
throw new Error("Encrypted webhook secret payload is malformed");
}

const iv = decoded.subarray(0, IV_BYTES);
const authTag = decoded.subarray(IV_BYTES, IV_BYTES + 16);
const ciphertext = decoded.subarray(IV_BYTES + 16);

const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(authTag);

return Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]).toString("utf8");
}
30 changes: 30 additions & 0 deletions backend/src/lib/secret-crypto.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
decryptSecret,
encryptSecret,
isEncryptedSecret,
} from "./secret-crypto.js";

describe("secret-crypto", () => {
beforeEach(() => {
process.env.WEBHOOK_SECRET_ENCRYPTION_KEY = Buffer.alloc(32, 9).toString(
"base64",
);
});

it("encrypts with non-deterministic ciphertext and decrypts round-trip", () => {
const plaintext = "whsec_merchant_secret";
const encryptedA = encryptSecret(plaintext);
const encryptedB = encryptSecret(plaintext);

expect(encryptedA).not.toBe(plaintext);
expect(encryptedA).not.toBe(encryptedB);
expect(isEncryptedSecret(encryptedA)).toBe(true);
expect(decryptSecret(encryptedA)).toBe(plaintext);
expect(decryptSecret(encryptedB)).toBe(plaintext);
});

it("passes through plain values for backward compatibility", () => {
expect(decryptSecret("legacy_plain_secret")).toBe("legacy_plain_secret");
});
});
10 changes: 8 additions & 2 deletions backend/src/routes/merchants.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import {
registerMerchantZodSchema,
sessionBrandingSchema,
} from "../lib/request-schemas.js";
import { generateSessionToken } from "../lib/sep10-auth.js";
import { resolveBrandingConfig } from "../lib/branding.js";
import { resolveMerchantSettings } from "../lib/merchant-settings.js";
import { sendWebhook } from "../lib/webhooks.js";
import { encryptSecret } from "../lib/secret-crypto.js";

const router = express.Router();

Expand Down Expand Up @@ -80,7 +82,7 @@ router.post("/register-merchant", async (req, res, next) => {
business_name,
notification_email,
api_key: apiKey,
webhook_secret: webhookSecret,
webhook_secret: encryptSecret(webhookSecret),
merchant_settings: resolveMerchantSettings(body.merchant_settings),
created_at: new Date().toISOString()
};
Expand All @@ -96,16 +98,20 @@ router.post("/register-merchant", async (req, res, next) => {
throw insertError;
}

// Issue a session token so the frontend can seamlessly continue to dashboard.
const token = generateSessionToken(merchant.id, merchant.email);

res.status(201).json({
message: "Merchant registered successfully",
token,
merchant: {
id: merchant.id,
email: merchant.email,
business_name: merchant.business_name,
notification_email: merchant.notification_email,
merchant_settings: resolveMerchantSettings(merchant.merchant_settings),
api_key: merchant.api_key,
webhook_secret: merchant.webhook_secret,
webhook_secret: webhookSecret,
created_at: merchant.created_at
}
});
Expand Down
100 changes: 100 additions & 0 deletions backend/src/routes/payments.dry-run.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("../lib/supabase.js", () => ({
supabase: {
from: vi.fn(),
},
}));

import createPaymentsRouter from "./payments.js";
import { supabase } from "../lib/supabase.js";

function createResponse() {
return {
status: vi.fn(),
json: vi.fn(),
};
}

function getCreatePaymentHandler() {
const router = createPaymentsRouter({
verifyPaymentRateLimit: (req, res, next) => next(),
});
const createPaymentLayer = router.stack.find(
(layer) => layer.route?.path === "/create-payment",
);
return createPaymentLayer.route.stack.at(-1).handle;
}

describe("create-payment dry_run", () => {
let handler;
let res;
let next;
let insert;
let from;

beforeEach(() => {
handler = getCreatePaymentHandler();
res = createResponse();
res.status.mockReturnValue(res);
next = vi.fn();

insert = vi.fn().mockResolvedValue({ error: null });
from = vi.fn(() => ({ insert }));
supabase.from.mockImplementation(from);
});

it("returns a preview and does not persist when dry_run=true", async () => {
const req = {
query: { dry_run: "true" },
merchant: { id: "merchant-1" },
body: {
amount: 12.5,
asset: "XLM",
recipient: "GRECIPIENTADDRESS",
description: "Preview payment",
},
get: vi.fn(() => undefined),
};

await handler(req, res, next);

expect(from).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
dry_run: true,
status: "pending",
payment_link: expect.stringContaining("/pay/"),
payment: expect.objectContaining({
merchant_id: "merchant-1",
amount: 12.5,
asset: "XLM",
description: "Preview payment",
status: "pending",
}),
}),
);
expect(next).not.toHaveBeenCalled();
});

it("persists normally when dry_run is not enabled", async () => {
const req = {
query: {},
merchant: { id: "merchant-1" },
body: {
amount: 12.5,
asset: "XLM",
recipient: "GRECIPIENTADDRESS",
},
get: vi.fn(() => undefined),
};

await handler(req, res, next);

expect(from).toHaveBeenCalledWith("payments");
expect(insert).toHaveBeenCalledOnce();
expect(res.status).toHaveBeenCalledWith(201);
expect(next).not.toHaveBeenCalled();
});
});
17 changes: 15 additions & 2 deletions backend/src/routes/payments.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { createCreatePaymentRateLimit } from "../lib/create-payment-rate-limit.js";
import { sendWebhook } from "../lib/webhooks.js";
import { resolveBrandingConfig } from "../lib/branding.js";
import { decryptSecret } from "../lib/secret-crypto.js";

const createPaymentRateLimit = createCreatePaymentRateLimit();

Expand Down Expand Up @@ -103,7 +104,7 @@ function createPaymentsRouter({
* branding_config:
* type: object
* 200:
* description: Duplicate request — cached response returned from idempotency key
* description: Dry-run preview (when dry_run=true) or duplicate request response from idempotency cache
* content:
* application/json:
* schema:
Expand All @@ -123,6 +124,7 @@ function createPaymentsRouter({
async function createSession(req, res, next) {
try {
const body = parseVersionedPaymentBody(req);
const isDryRun = String(req.query?.dry_run || "").toLowerCase() === "true";

// Per-asset payment limit validation (#153)
const limits = req.merchant.payment_limits;
Expand Down Expand Up @@ -179,6 +181,17 @@ function createPaymentsRouter({
created_at: now,
};

if (isDryRun) {
return res.status(200).json({
dry_run: true,
payment_id: paymentId,
payment_link: paymentLink,
status: "pending",
branding_config: resolvedBrandingConfig,
payment: payload,
});
}

const { error: insertError } = await supabase
.from("payments")
.insert(payload);
Expand Down Expand Up @@ -352,7 +365,7 @@ function createPaymentsRouter({
throw updateError;
}

const merchantSecret = data.merchants?.webhook_secret;
const merchantSecret = decryptSecret(data.merchants?.webhook_secret);

const webhookResult = await sendWebhook(
data.webhook_url,
Expand Down
Loading