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
37 changes: 37 additions & 0 deletions backend/SEP12_KYC_SECURITY_AUDIT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# SEP-12 KYC Integration — Security Audit

Scope: `src/lib/sep12-kyc.js`, `src/routes/sep12.js`, and the
`sep12_kyc_customers` table (migration `20260527000000`).

## Threat model & controls

| Threat | Control | Where |
| --- | --- | --- |
| **Unauthorized writes** (anyone updating another account's KYC) | Every `PUT` must carry a signature from the account's own Stellar key, verified with `Keypair.verify` | `verifyCustomerSignature` (#590) |
| **Replay** of a captured request | Signature binds a unix `timestamp`; requests older than `SIGNATURE_MAX_AGE_SECONDS` (300s) are rejected | `verifyCustomerSignature` |
| **Signature reuse against different data** | The signed payload includes a SHA-256 of the canonical (sorted-key) field set, so a signature is valid only for the exact fields submitted | `buildSignaturePayload` |
| **SQL injection** | All queries are parameterised (`$1..$n`); no string interpolation of user input | `putCustomer` / `getCustomer` / `deleteCustomer` |
| **Mass-assignment / junk data** | `zod` schema with `.strict()` rejects unknown keys and validates types/lengths/email/date | `fieldsSchema` |
| **Invalid account identifiers** | `Keypair.fromPublicKey` validates the account before any DB access | `assertValidAccount` |
| **PII leakage via logs** | Field values are never logged; only operation labels and error codes are recorded | `withRecovery`, route `handleError` |
| **Information leakage on errors** | Internal errors are surfaced as a generic 500/`INTERNAL_ERROR`; structured `KycError` codes are deliberate and non-sensitive | `handleError` |
| **Availability under DB stress** | Transient pool failures are retried, then surfaced as a retryable `503` so clients back off rather than hammering | `withRecovery` (#592), `queryWithRetry` |

## Residual risks / recommendations

- **Rate limiting:** the SEP-12 routes should be placed behind the existing
API rate-limit middleware in production to blunt brute-force/enumeration of
account KYC status. (Mounting is left to the deployment configuration.)
- **PII at rest:** `fields` is stored as `jsonb` in plaintext. For regulated
deployments, consider column-level encryption or a dedicated KYC vault.
- **Right to erasure:** `DELETE /sep12/customer/:account` supports hard
deletion; retention policy should be defined by compliance.
- **Signature algorithm:** verification is Ed25519 over a SHA-256 digest of the
canonical payload, matching Stellar account keys.

## Test coverage

`src/lib/sep12-kyc.test.js` covers: valid/forged/stale/wrong-key/missing
signatures, parameterised upsert shape, field validation, invalid account,
status derivation, get/delete hit & miss, and both error-recovery branches
(retryable 503 vs non-leaky 500).
41 changes: 41 additions & 0 deletions backend/migrations/20260527000000_create_sep12_kyc_customers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Migration: SEP-12 KYC customers
*
* Backing store for the SEP-12 KYC integration. Schema and indexes are tuned
* for the two hot query paths (issue #591):
* - upsert/lookup by (stellar_account, memo) -> unique composite index
* - status filtering for compliance dashboards -> partial-friendly index
*
* `memo` defaults to '' (rather than NULL) so the composite uniqueness used by
* the PUT upsert's ON CONFLICT target is well-defined.
*/

export async function up(knex) {
await knex.raw(`
CREATE TABLE IF NOT EXISTS sep12_kyc_customers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
stellar_account text NOT NULL,
memo text NOT NULL DEFAULT '',
fields jsonb NOT NULL DEFAULT '{}'::jsonb,
status text NOT NULL DEFAULT 'NEEDS_INFO',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);

// Hot path: PUT upsert + GET lookup. Doubles as the ON CONFLICT target.
await knex.raw(`
CREATE UNIQUE INDEX IF NOT EXISTS sep12_kyc_account_memo_uidx
ON sep12_kyc_customers (stellar_account, memo)
`);

// Compliance dashboards filter by status.
await knex.raw(`
CREATE INDEX IF NOT EXISTS sep12_kyc_status_idx
ON sep12_kyc_customers (status)
`);
}

export async function down(knex) {
await knex.raw(`DROP TABLE IF EXISTS sep12_kyc_customers`);
}
4 changes: 4 additions & 0 deletions backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import metricsRouter from "./routes/metrics.js";
import webhooksRouter from "./routes/webhooks.js";
import prometheusRouter from "./routes/prometheus.js";
import sep0001Router from "./routes/sep0001.js";
import createSep12Router from "./routes/sep12.js";
import paymentDetailsRouter from "./routes/paymentDetails.js";
import x402Router from "./routes/x402.js";
import authRouter from "./routes/auth.js";
Expand Down Expand Up @@ -293,6 +294,9 @@ export async function createApp({ redisClient }) {
// SEP-0001 stellar.toml endpoint (public, no auth required)
app.use("/", sep0001Router);

// SEP-12 KYC endpoints (signature-gated; auth enforced per-request)
app.use("/", createSep12Router());

// Prometheus Metrics endpoint
app.use("/", prometheusRouter);

Expand Down
286 changes: 286 additions & 0 deletions backend/src/lib/sep12-kyc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
/**
* SEP-12 KYC integration.
*
* Stores and retrieves customer KYC records keyed by Stellar account (+ memo),
* gated by a cryptographic signature from the account holder (issue #590).
* Queries are single-round-trip, parameterised, and index-aligned (#591) and
* every database interaction goes through a structured error-recovery wrapper
* (#592). See SEP12_KYC_SECURITY_AUDIT.md for the threat model (#593).
*/

import { createHash } from "node:crypto";
import * as StellarSdk from "stellar-sdk";
import { z } from "zod";
import { queryWithRetry, isRetryablePoolError } from "./db.js";
import { logger } from "./logger.js";

/** Max age (seconds) accepted for a request signature — replay protection. */
export const SIGNATURE_MAX_AGE_SECONDS = 300;

/** KYC lifecycle statuses (SEP-12 §Status). */
export const KYC_STATUSES = ["ACCEPTED", "PROCESSING", "NEEDS_INFO", "REJECTED"];

/**
* Accepted KYC fields. `.strict()` rejects unknown keys so callers cannot
* smuggle arbitrary JSON into the store (security hardening, #593).
*/
const fieldsSchema = z
.object({
first_name: z.string().min(1).max(100).optional(),
last_name: z.string().min(1).max(100).optional(),
email_address: z.string().email().max(254).optional(),
birth_date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "birth_date must be YYYY-MM-DD")
.optional(),
address: z.string().max(500).optional(),
id_number: z.string().min(1).max(100).optional(),
})
.strict();

/**
* Structured error type so the route layer can map failures to the right HTTP
* status and signal retryability to clients (#592).
*/
export class KycError extends Error {
constructor(code, message, httpStatus = 400, { retryable = false, cause } = {}) {
super(message);
this.name = "KycError";
this.code = code;
this.httpStatus = httpStatus;
this.retryable = retryable;
if (cause) this.cause = cause;
}
}

// ---------------------------------------------------------------------------
// Signature verification (#590)
// ---------------------------------------------------------------------------

/** Deterministic JSON with sorted keys so client and server hash identically. */
function canonicalJson(obj) {
const sorted = {};
for (const key of Object.keys(obj || {}).sort()) {
sorted[key] = obj[key];
}
return JSON.stringify(sorted);
}

/**
* The canonical message a client signs to authorise a KYC write. Binds the
* account, memo, a unix `timestamp` (replay window), and a hash of the field
* payload so a captured signature cannot be replayed against different data.
*/
export function buildSignaturePayload({ account, memo = "", timestamp, fields }) {
const fieldsHash = createHash("sha256").update(canonicalJson(fields)).digest("hex");
return `${account}:${memo}:${timestamp}:${fieldsHash}`;
}

/**
* Verify a customer's signature over {@link buildSignaturePayload}.
* Returns `{ valid: true }` or `{ valid: false, reason }` — never throws.
*/
export function verifyCustomerSignature(
{ account, memo = "", timestamp, fields, signature },
{ maxAgeSeconds = SIGNATURE_MAX_AGE_SECONDS, now = Date.now() } = {},
) {
if (!account || typeof signature !== "string" || signature.length === 0 || !timestamp) {
return { valid: false, reason: "missing_signature_fields" };
}

const ts = Number(timestamp);
const ageSeconds = Math.abs(Math.floor(now / 1000) - ts);
if (!Number.isFinite(ts) || ageSeconds > maxAgeSeconds) {
return { valid: false, reason: "stale_or_invalid_timestamp" };
}

let keypair;
try {
keypair = StellarSdk.Keypair.fromPublicKey(account);
} catch {
return { valid: false, reason: "invalid_account" };
}

let signatureBuffer;
try {
signatureBuffer = Buffer.from(signature, "base64");
} catch {
return { valid: false, reason: "invalid_signature_encoding" };
}
if (signatureBuffer.length === 0) {
return { valid: false, reason: "invalid_signature_encoding" };
}

const payload = buildSignaturePayload({ account, memo, timestamp: ts, fields });
const hash = createHash("sha256").update(payload).digest();

let ok = false;
try {
ok = keypair.verify(hash, signatureBuffer);
} catch {
ok = false;
}
return ok ? { valid: true } : { valid: false, reason: "signature_mismatch" };
}

// ---------------------------------------------------------------------------
// Error recovery wrapper (#592)
// ---------------------------------------------------------------------------

/**
* Run a DB operation, translating low-level failures into {@link KycError}.
* `queryWithRetry` already retries transient pool errors; once retries are
* exhausted we surface a retryable 503 so the client can back off, and map
* everything else to a non-leaky 500. Field values are never logged (#593).
*/
async function withRecovery(fn, label) {
try {
return await fn();
} catch (err) {
if (err instanceof KycError) throw err;
if (isRetryablePoolError(err)) {
logger.warn({ label, code: err.code }, "sep12 kyc store temporarily unavailable");
throw new KycError(
"SERVICE_UNAVAILABLE",
"KYC store temporarily unavailable, please retry",
503,
{ retryable: true, cause: err },
);
}
logger.error({ label, code: err.code }, "sep12 kyc store error");
throw new KycError("DB_ERROR", "KYC store error", 500, { cause: err });
}
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function assertValidAccount(account) {
try {
StellarSdk.Keypair.fromPublicKey(account);
} catch {
throw new KycError("INVALID_ACCOUNT", "A valid Stellar account is required", 400);
}
}

/** Derive a KYC status from the supplied fields. */
function deriveStatus(fields) {
const hasCore = fields.first_name && fields.last_name && fields.email_address;
return hasCore ? "ACCEPTED" : "NEEDS_INFO";
}

function mapRow(row) {
return {
id: row.id,
account: row.stellar_account,
memo: row.memo,
fields: row.fields,
status: row.status,
created_at: row.created_at,
updated_at: row.updated_at,
};
}

// ---------------------------------------------------------------------------
// Operations
// ---------------------------------------------------------------------------

/**
* Create or update a customer's KYC record (SEP-12 `PUT /customer`).
*
* Steps: validate account → verify signature (#590) → validate fields → upsert
* in a single parameterised round trip (#591) under error recovery (#592).
*
* `deps` allows injecting `query` / `verifySignature` for testing.
*/
export async function putCustomer(input, deps = {}) {
const query = deps.query || queryWithRetry;
const verifySignature = deps.verifySignature || verifyCustomerSignature;
const now = deps.now;

assertValidAccount(input.account);

const signatureResult = verifySignature(input, now ? { now } : undefined);
if (!signatureResult.valid) {
throw new KycError(
"SIGNATURE_INVALID",
`Signature verification failed: ${signatureResult.reason}`,
401,
);
}

const parsed = fieldsSchema.safeParse(input.fields ?? {});
if (!parsed.success) {
throw new KycError("VALIDATION_ERROR", "Invalid KYC fields", 400, {
cause: parsed.error,
});
}
const fields = parsed.data;
const memo = input.memo ?? "";
const status = deriveStatus(fields);

// Single-round-trip parameterised upsert (#591, #593). The ON CONFLICT
// target matches the sep12_kyc_account_memo_uidx unique index.
const sql = `
INSERT INTO sep12_kyc_customers (stellar_account, memo, fields, status, updated_at)
VALUES ($1, $2, $3::jsonb, $4, now())
ON CONFLICT (stellar_account, memo)
DO UPDATE SET fields = EXCLUDED.fields, status = EXCLUDED.status, updated_at = now()
RETURNING id, status`;

const result = await withRecovery(
() => query(sql, [input.account, memo, JSON.stringify(fields), status], { label: "sep12_put" }),
"sep12_put",
);

return { id: result.rows[0].id, status: result.rows[0].status };
}

/**
* Fetch a customer's KYC record (SEP-12 `GET /customer`). Uses the unique
* (stellar_account, memo) index and selects only the needed columns (#591).
*/
export async function getCustomer({ account, memo = "" }, deps = {}) {
const query = deps.query || queryWithRetry;
assertValidAccount(account);

const sql = `
SELECT id, stellar_account, memo, fields, status, created_at, updated_at
FROM sep12_kyc_customers
WHERE stellar_account = $1 AND memo = $2
LIMIT 1`;

const result = await withRecovery(
() => query(sql, [account, memo], { label: "sep12_get" }),
"sep12_get",
);

if (result.rows.length === 0) {
throw new KycError("NOT_FOUND", "Customer not found", 404);
}
return mapRow(result.rows[0]);
}

/**
* Delete a customer's KYC record (SEP-12 `DELETE /customer`).
*/
export async function deleteCustomer({ account, memo = "" }, deps = {}) {
const query = deps.query || queryWithRetry;
assertValidAccount(account);

const sql = `
DELETE FROM sep12_kyc_customers
WHERE stellar_account = $1 AND memo = $2
RETURNING id`;

const result = await withRecovery(
() => query(sql, [account, memo], { label: "sep12_delete" }),
"sep12_delete",
);

if (result.rows.length === 0) {
throw new KycError("NOT_FOUND", "Customer not found", 404);
}
return { id: result.rows[0].id, deleted: true };
}
Loading
Loading