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
5 changes: 5 additions & 0 deletions backend/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ module.exports = {
},
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
// @pact-foundation/pact@16 requires ESM-only https-proxy-agent@9 at
// module load; Jest's CJS runtime cannot parse it. The agent is never
// constructed unless a proxy env var is set.
"^https-proxy-agent$":
"<rootDir>/src/__tests__/stubs/httpsProxyAgent.ts",
},
modulePaths: ["<rootDir>"],
setupFiles: ["<rootDir>/src/__tests__/setup.ts"],
Expand Down
3 changes: 2 additions & 1 deletion backend/scripts/verify-pii-encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const prisma = new PrismaClient();
const CIPHERTEXT_PATTERN = /^v\d+:[0-9a-f]+:[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/i;

interface PiiColumnSpec {
model: "deliveryManifest";
model: "deliveryManifest" | "tradeNote";
table: string;
idColumn: string;
column: string;
Expand All @@ -35,6 +35,7 @@ const PII_COLUMNS: PiiColumnSpec[] = [
{ model: "deliveryManifest", table: "DeliveryManifest", idColumn: "tradeId", column: "driverIdNumber" },
{ model: "deliveryManifest", table: "DeliveryManifest", idColumn: "tradeId", column: "vehicleRegistration" },
{ model: "deliveryManifest", table: "DeliveryManifest", idColumn: "tradeId", column: "routeDescription" },
{ model: "tradeNote", table: "TradeNote", idColumn: "id", column: "content" },
];

async function verify(): Promise<number> {
Expand Down
11 changes: 11 additions & 0 deletions backend/src/__tests__/stubs/httpsProxyAgent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Minimal CommonJS-compatible stand-in for `https-proxy-agent`.
*
* `@pact-foundation/pact@16` requires the ESM-only v9 package at module load,
* which Jest cannot load in its CJS runtime. The agent is only constructed
* when HTTPS_PROXY/HTTP_PROXY is set, which is not the case in CI or local
* provider verification.
*/
export class HttpsProxyAgent {
constructor(_proxy?: string | URL) {}
}
55 changes: 38 additions & 17 deletions backend/src/__tests__/trades.pact.verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Trade, TradeStatus } from '@prisma/client';
import { createTradeRouter } from '../routes/trade.routes';
import { errorHandler } from '../middleware/errorHandler';
import { AuthService } from '../services/auth.service';
import * as contractServiceModule from '../services/contract.service';
import { ContractService } from '../services/contract.service';
import { TradeService } from '../services/trade.service';

Expand All @@ -20,12 +21,18 @@ jest.mock('../services/auth.service', () => ({
}));

const JWT_SECRET = 'pact-provider-verify-secret-key-at-least-32-chars';
const pactFile = path.resolve(
__dirname,
'../../../frontend/tests/pact/pacts/AmanaFrontend-AmanaBackend.json',
);
const pactTest = existsSync(pactFile) ? it : it.skip;
const pactDescribe = existsSync(pactFile) ? describe : describe.skip;
const pactFiles = [
path.resolve(
__dirname,
'../../../frontend/tests/pact/pacts/AmanaFrontend-AmanaBackend.json',
),
path.resolve(
__dirname,
'../../../mobile/tests/pact/pacts/AmanaMobile-AmanaBackend.json',
),
].filter((file) => existsSync(file));
const pactTest = pactFiles.length > 0 ? it : it.skip;
const pactDescribe = pactFiles.length > 0 ? describe : describe.skip;

function createMockTradeService() {
return {
Expand Down Expand Up @@ -71,6 +78,14 @@ function makeTrade(overrides: Partial<Trade> = {}): Trade {

const mockTradeService = createMockTradeService();
const mockContractService = createMockContractService();
let mockedTradeStatus: TradeStatus = TradeStatus.CREATED;

jest
.spyOn(contractServiceModule, 'buildConfirmDeliveryTx')
.mockResolvedValue('AAAAAXNvbWUtY29uZmlybS14ZHI=');
jest
.spyOn(contractServiceModule, 'buildReleaseFundsTx')
.mockResolvedValue('AAAAAXNvbWUtcmVsZWFzZS14ZHI=');

function createTestApp(): express.Application {
const app = express();
Expand All @@ -82,6 +97,7 @@ function createTestApp(): express.Application {

const tradeRouter = createTradeRouter();
app.use('/trades', tradeRouter);
app.use('/api/v1/trades', tradeRouter);
app.use(errorHandler);

return app;
Expand All @@ -95,7 +111,7 @@ pactDescribe('Pact Provider Verification - Trades API', () => {
app = createTestApp();

const buyerAddress = 'GDNM7WSJ7VIUVK2TSZ2OQES5XR2663TZEIBFXRDT56B5IRLHERVWSXMU';
const sellerAddress = 'GA4T33YK6H6D5E7ZQY5W3J2L7F8K9B0N1M2P3Q4R5S6T7U8V9W0X1Y2Z3';
const sellerAddress = 'GBQ5NYMF2UBXZPH7PCPCUNTONIIIVRRQ24ZDUVAOD4GXLMI5SAQUJDFK';

mockContractService.buildCreateTradeTx.mockResolvedValue({
tradeId: '4294967297',
Expand All @@ -113,7 +129,7 @@ pactDescribe('Pact Provider Verification - Trades API', () => {
buyerAddress,
sellerAddress,
amountUsdc: '100.00',
status: TradeStatus.CREATED,
status: mockedTradeStatus,
});
}
return null;
Expand Down Expand Up @@ -166,23 +182,28 @@ pactDescribe('Pact Provider Verification - Trades API', () => {
jest.restoreAllMocks();
});

pactTest('verifies the provider against the consumer pact', async () => {
const pactDir = path.resolve(__dirname, '../../../frontend/tests/pact/pacts');
pactTest('verifies the provider against the consumer pacts', async () => {
const port = process.env.PACT_PROVIDER_PORT || '3001';

const { Verifier } = await import('@pact-foundation/pact');
const output = await new Verifier({
provider: 'AmanaBackend',
providerBaseUrl: `http://localhost:${port}`,
pactUrls: [
path.resolve(pactDir, 'AmanaFrontend-AmanaBackend.json'),
],
pactUrls: pactFiles,
stateHandlers: {
'a buyer is authenticated': async () => Promise.resolve(),
'a trade exists in CREATED status': async () => Promise.resolve(),
'a trade exists in FUNDED status': async () => Promise.resolve(),
'a trade exists in DELIVERED status': async () => Promise.resolve(),
'a trade exists with id 4294967297': async () => Promise.resolve(),
'a trade exists in CREATED status': async () => {
mockedTradeStatus = TradeStatus.CREATED;
},
'a trade exists in FUNDED status': async () => {
mockedTradeStatus = TradeStatus.FUNDED;
},
'a trade exists in DELIVERED status': async () => {
mockedTradeStatus = TradeStatus.DELIVERED;
},
'a trade exists with id 4294967297': async () => {
mockedTradeStatus = TradeStatus.CREATED;
},
'the user has trades': async () => Promise.resolve(),
'the user has trade statistics': async () => Promise.resolve(),
},
Expand Down
5 changes: 3 additions & 2 deletions backend/src/config/alertRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ export const ALERT_REGISTRY: Record<AlertType, AlertRegistryEntry> = {
},
redis_connection_failure: {
routing: "page",
runbookUrl: "docs/runbooks/incident-response.md#severity-levels",
description: "Redis is unreachable, degrading caching, sessions, and rate limiting.",
runbookUrl: "docs/runbooks/redis-outage-active-settlement.md",
description:
"Redis is unreachable, failing auth/settlement paths closed and degrading caching, queues, and rate limiting.",
},
cache_unavailable: {
routing: "ticket",
Expand Down
49 changes: 45 additions & 4 deletions docs/pii-encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,55 @@

## 1. Classified PII Inventory

**Last audited against `backend/prisma/schema.prisma`: 2026-10-19.**

| Column | Table | Classification | Encrypted? | Search need |
|---|---|---|---|---|
| `driverName` | `DeliveryManifest` | Direct identifier (name) | ✅ App-layer AES-256-GCM | No — mediator/seller view only |
| `driverIdNumber` | `DeliveryManifest` | Direct identifier (gov't ID) | ✅ App-layer AES-256-GCM | No |
| `vehicleRegistration` | `DeliveryManifest` | Indirect identifier | ✅ App-layer AES-256-GCM | No |
| `routeDescription` | `DeliveryManifest` | Free-text, may contain address fragments | ✅ App-layer AES-256-GCM | No |
| `driverNameHash` / `driverIdHash` | `DeliveryManifest` | SHA-256 blind index of the above | N/A (already a one-way hash) | Yes — equality match, see §3 |
| `content` | `TradeNote` | Private free-text note (may contain names/addresses/contact details) | ✅ App-layer AES-256-GCM (`TradeNotesService`) | No — decrypt only for author/admin |
| `displayName` | `User` | Direct identifier (name) | ❌ **Not encrypted — gap F2** | No |
| `phoneNumber` | `NotificationPreference` | Direct identifier (E.164 phone number) | ❌ **Not encrypted — gap F1** | No today; add blind index only if lookup is ever required |
| `reason` | `Dispute` | Free-text, may contain direct identifiers or contact details | ❌ **Not encrypted — gap F3** | No |
| `filename` | `TradeEvidence` | Free-text metadata, may contain names/addresses | ❌ **Not encrypted — gap F3** | No |
| `walletAddress` | `User`, `Trade.buyerAddress/sellerAddress`, etc. | Pseudonymous identifier (Stellar public key) | Not encrypted — public by design, required for on-chain joins and is not classified as PII under NDPR guidance for public blockchain addresses | Yes — primary key lookups |

There is currently **no phone/contact-number column** in `backend/prisma/schema.prisma`. If one is added (e.g. a `phone` field for delivery contact), it must be added to this table and to `PII_COLUMNS` in `backend/scripts/verify-pii-encryption.ts` before it ships, following the pattern in §2–§3 below.
### Operational free-text reviewed (not classified, may still capture PII)

| Column | Table | Why it matters | Status |
|---|---|---|---|
| `title`, `message`, `metadata` | `InAppNotification` | Generated from trade events; may echo names, addresses, or contact details | Review notification builders; mask/minimise — gap F4 |
| `responseBody` | `WebhookDeliveryAttempt` | Third-party response bodies can echo user data | Redact/retain — gap F7 |
| `note` | `AdminActionAudit` | Operator-supplied free text | Redact/retain — gap F7 |
| `terminationReason`, `lockReason` | `Stream` | Operator notes | Redact/retain — gap F7 |
| `reason` | `ContractPauseLog` | Operator notes | Redact/retain — gap F7 |
| `lastError`, `notes` | `JobHeartbeat` | Error strings may contain user data | Redact/retain — gap F7 |
| `extra` | `EscrowAudit` | Event metadata; should stay public-chain-derived | Confirm no off-chain PII is copied into it |

`NotificationPreference.phoneNumber` was added after the original inventory was
written. It is currently persisted and returned in plaintext by
`backend/src/routes/notifications.preferences.routes.ts`; treat this as the
highest-priority gap below.

## 1a. Audit findings and follow-ups (2026-10-19)

| ID | Priority | Finding | Follow-up |
|---|---|---|---|
| F1 | High | `NotificationPreference.phoneNumber` is a direct identifier stored and returned in plaintext. | Encrypt on write/decrypt on read using `EncryptionService` (record id = user wallet address), backfill existing rows, add the column to `PII_COLUMNS`, and add route tests asserting ciphertext at rest. No search path exists today; add a blind index only if lookup is later required. |
| F2 | High | `User.displayName` is a direct identifier stored in plaintext. | Decide product requirement: encrypt with the same envelope scheme, or replace with a pseudonymous display value. If equality search is needed, add a blind index (see §3). |
| F3 | Medium | `Dispute.reason` and `TradeEvidence.filename` are free-text fields that can carry names/addresses/contact details; neither is encrypted. | Add input constraints + PII-safe copy in UI, then encrypt at rest or apply documented retention/access controls. Add both to the inventory/verifier once encrypted. |
| F4 | Medium | `InAppNotification.title`/`message`/`metadata` can duplicate PII from trade/manifest events. | Audit notification builders to use masked fields; encrypt or minimise notification payloads. |
| F5 | Medium | `TradeNote.content` is encrypted but was missing from this inventory and from the verifier. | **Fixed in this change:** added to the inventory and to `PII_COLUMNS`. |
| F6 | Medium | `TradeNotesService.listNotes` decrypts note content without the `logPiiAccess()` audit trail used by manifests. | Add decrypt access logging consistent with §5 (resource `trade_note`, recordId = trade ID, actor = caller). |
| F7 | Low | Operator free-text and webhook response bodies can capture PII. | Apply log redaction/retention rules and document them in the relevant ops docs; do not store request bodies or PII in notes/errors. |
| F8 | Low | The KMS-backed envelope roadmap is still outstanding; `JWT_SECRET` remains the root key. | Track via the roadmap in §2. |

Follow-ups should be filed as dedicated security issues before implementation;
this table is the source of truth until then. Re-run the audit whenever a Prisma
model gains a name, contact, address, identifier, or free-text field.

## 2. Encryption Design

Expand All @@ -37,9 +76,9 @@ Direct equality search on `driverName` / `driverIdNumber` is supported without d

## 4. Migration & Rollback

Ciphertext columns (`driverName`, `driverIdNumber`, `vehicleRegistration`, `routeDescription`) are plain `VARCHAR`/`TEXT` columns — encryption is transparent at the application layer (`ManifestService.submitManifest` always encrypts before `create`). No separate backfill migration was required because these columns were introduced encrypted-by-default (migration `20260326000001_add_manifest_evidence` plus `feat(backend): encrypt sensitive trade data at rest`).
Ciphertext columns (`driverName`, `driverIdNumber`, `vehicleRegistration`, `routeDescription`, `TradeNote.content`) are plain `VARCHAR`/`TEXT` columns — encryption is transparent at the application layer (`ManifestService.submitManifest` and `TradeNotesService.addNote` always encrypt before `create`). No separate backfill migration was required because these columns were introduced encrypted-by-default (migration `20260326000001_add_manifest_evidence` plus `feat(backend): encrypt sensitive trade data at rest`).

Run `backend/scripts/verify-pii-encryption.ts` after any migration or backfill touching these tables to assert **zero plaintext remnants**:
Run `backend/scripts/verify-pii-encryption.ts` after any migration or backfill touching these tables to assert **zero plaintext remnants** in every column listed as encrypted in §1:

```bash
npx ts-node backend/scripts/verify-pii-encryption.ts
Expand All @@ -51,11 +90,13 @@ It walks every column in the inventory table above and fails (exit code 1) if an

Every decrypt of manifest PII (`ManifestService.getManifestByTradeId`) writes a structured audit event via `logPiiAccess()` (`backend/src/lib/piiAudit.ts`), following the same `audit: true` convention as escrow audit logs (`backend/src/lib/escrowAudit.ts`). Each entry records `resource`, `recordId` (trade ID), `fields` decrypted, `actor` (caller wallet address), and `action`. Ship these logs to the same aggregator as other audit events and alert on unexpected `actor` values relative to trade participants/mediator allowlist.

`TradeNotesService.listNotes` currently decrypts `TradeNote.content` without a `logPiiAccess()` event — tracked as follow-up F6 in §1a.

## 6. Key Rotation Procedure & Drill

1. Introduce a new `keyVersion` (e.g. `v2`) constant.
2. Deploy `EncryptionService` accepting decrypts of both `v1` and `v2` payloads (already supported — `decrypt()` reads `keyVersion` from the payload itself, so old and new ciphertexts co-exist with no code change needed).
3. Run a batch job calling `rotateCiphertext(ciphertext, tradeId, "v2")` over all `DeliveryManifest` rows, writing the result back.
3. Run a batch job calling `rotateCiphertext(ciphertext, tradeId, "v2")` over all `DeliveryManifest` and `TradeNote` rows, writing the result back.
4. Run `verify-pii-encryption.ts` to confirm every row is re-encrypted and well-formed.
5. Once 100% migrated, retire the old master secret.

Expand Down
9 changes: 6 additions & 3 deletions docs/redis-resilience.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ Redis backs queues, locks, cache, and rate limiting. This document defines the *
| **Cache** (`cacheGet/Set`, `streamCache`, `quoteCache`, `dailyReconciliation`, `feature-flags`) | `GET/SET EX` | **Fail-open graceful** | `cacheGet` → `null` (miss), `cacheSet` → no-op, callers fall back to DB/Horizon. Warn + `cache_unavailable`. | `cache_unavailable` |
| **Rate limiting** | `express-rate-limit` MemoryStore (wallet/IP keying) | **Fail-open graceful (documented choice)** | **Allow request** with per-pod counters (lenient). Do NOT deny traffic. Rationale: routine Redis upgrade must not cause global deadlock. Alternatives considered: fail-closed (deny all when Redis down) rejected — unacceptable for trade flows on mobile networks. | `redis_connection_failure` (page, informational) |
| **Queues** (BullMQ: `webhooks`, `notifications`, `exports`, `trade-expiry`, `reconciliation` + DLQs) | BullMQ + `createQueueConnection()` per queue/worker | **Degrade-queue, resume on reconnect** | Producers throw transiently, workers pause. Connections use `retryStrategy` exponential backoff (200ms*attempt capped 5s, 20 retries). Jobs are durable in Redis (AOF + RDB) and remain until processed. DLQs capture failed jobs after 3 attempts. On reconnect, workers re-attach and drain. | `redis_connection_failure` + Grafana `redis_key_length_total{key=~"bull:.*:wait"}` |
| **Auth challenge / revocation** | `challenge:{addr}`, `revoked_jti:{jti}`, `auth:challenge-failures:{id}` | **Fail-closed (503)** | Challenge generation/verification and `isTokenRevoked` deny auth when Redis unavailable. Prevents use of revoked tokens during split-brain. | `redis_connection_failure` |
| **Auth challenge / revocation / token version** | `challenge:{addr}`, `revoked_jti:{jti}`, `token_version:{addr}`, `auth:challenge-failures:{id}` | **Fail-closed (503)** | Challenge generation/verification, `isTokenRevoked`, and `getTokenVersion` deny auth when Redis unavailable. Because every authenticated request re-checks revocation/version, **all authenticated trade and settlement routes can return 503 during a Redis outage**. Prevents use of revoked tokens during split-brain. | `redis_connection_failure` (page) |
| **Admin session registry** | `admin_session:{jti}`, `admin_sessions:{wallet}` | **Fail-closed (401/503)** | Device-bound admin session lookup/registration fails closed; admin actions (including clawback/terminate) are unavailable until Redis recovers. | `redis_connection_failure` (page) |
| **Feature flags** | `feature:<name>` | **Fail-open graceful** | Fall back to DB/default rollout. | `cache_unavailable` |

## Fail-closed verification (payout-safety-critical)
Expand Down Expand Up @@ -54,9 +55,10 @@ Coverage:

See `backend/src/__tests__/redis.resilience.test.ts`.

## Maintenance runbook
## Runbooks

See `docs/runbooks/redis-maintenance.md` for the maintenance window procedure that guarantees no payout risk.
- `docs/runbooks/redis-maintenance.md` — planned maintenance/failover with no payout risk.
- `docs/runbooks/redis-outage-active-settlement.md` — unplanned outage during an active trade settlement window, including impact matrix, recovery ordering, and payout-intent reconciliation.

## References

Expand All @@ -66,3 +68,4 @@ See `docs/runbooks/redis-maintenance.md` for the maintenance window procedure th
- `backend/src/jobs/queue.ts`, `deadLetter.ts`, workers
- `infra/terraform/modules/redis` (ElastiCache replication_group multi-AZ, auto-failover)
- `docs/runbooks/redis-maintenance.md`
- `docs/runbooks/redis-outage-active-settlement.md`
Loading
Loading