diff --git a/.env.example b/.env.example index dcba576..677a5ba 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,14 @@ PAGINATION_MAX_SCAN=10000 # or cursors minted by one will be rejected by another. # PAGINATION_CURSOR_SECRET= +# Audit actor references (README: Audit integrity and attribution) +# Optional actor fingerprinting secret, read once when the module loads. +# Falls back to PAGINATION_CURSOR_SECRET, then a random secret per process. +# Set before startup; restart after changing it. A fixed secret stabilizes +# references across restarts/instances but does not persist or share the +# process-local audit history. +# AUDIT_ACTOR_SECRET= + # API Token authentication # JSON map of token → array of scopes. If omitted, hardcoded demo tokens are used. # Valid scopes: transfers:read, transfers:write, users:read, users:write, audit:read diff --git a/CHANGELOG.md b/CHANGELOG.md index a8e4e0a..0994d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,16 @@ When preparing a new release: ### Added +- Tamper-evident audit log: hash-chained `prevHash` / `entryHash` metadata, + `GET /api/audit/integrity` for authorized operators, and attribution fields + (`actor`, `scope`, `target`, `correlationId`, `outcome`, redacted `changes`). + Duplicate outcome events for the same privileged mutation (same action, + target, correlation id, and outcome) are suppressed. `GET /api/audit` + accepts `action`, `scope`, `outcome`, `correlationId`, and `actor` filters. + Secrets in changes are redacted at write time. Archive/unarchive now emit + outcome events. Optional `AUDIT_ACTOR_SECRET` (falls back to + `PAGINATION_CURSOR_SECRET`) fingerprints actors without storing raw tokens. + - Cursor pagination for `GET /api/transfers` and `GET /api/audit`. Pass `?cursor=` (with optional `?order=asc|desc`) to page by an indexed position instead of a row offset; responses carry a `pageInfo` block with @@ -55,6 +65,11 @@ When preparing a new release: ### Fixed +- Privileged mutation audit actors now fingerprint raw credentials even when a + configured token starts with `actor:` or equals `system`. Transfer and user + services use the explicit `actorToken` audit input; legacy precomputed actor + references and token-scoped idempotent transfer replay remain supported. + - Offset pagination over transfer and audit history repeated or skipped rows when records were written while a client was paging, because the window was defined by a row count rather than a position. Cursor pagination anchors to diff --git a/README.md b/README.md index 4791414..f02dc7b 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ The application is configured using environment variables (typically defined in | `PAGINATION_MAX_LIMIT` | Largest accepted `limit`; bigger requests are rejected | `200` | | `PAGINATION_MAX_SCAN` | Max records a single history query may examine | `10000` | | `PAGINATION_CURSOR_SECRET` | HMAC key used to sign pagination cursors | *(random per process)* | +| `AUDIT_ACTOR_SECRET` | Secret used to fingerprint audit actors; read once at startup | `PAGINATION_CURSOR_SECRET`, then random per process | | `API_TOKENS` | JSON object mapping API tokens to their allowed scopes (see [Authentication](#authentication)) | *(demo tokens)* | @@ -69,7 +70,7 @@ Authorization: Bearer | `transfers:write` | `POST /api/transfers`, `POST /api/transfers/:id/claim`, `POST /api/transfers/:id/cancel`, `POST /api/transfers/:id/archive`, `POST /api/transfers/:id/unarchive` | | `users:read` | `GET /api/users`, `GET /api/users/:id` | | `users:write` | `POST /api/users` | -| `audit:read` | `GET /api/audit` | +| `audit:read` | `GET /api/audit`, `GET /api/audit/integrity` | ### Public endpoints (no token required) @@ -294,6 +295,75 @@ exists. - `GET /api/users/:id` — fetch one user. - `POST /api/users` — create a user. Body: `{ name, email, country? }` +### Audit integrity and attribution + +`GET /api/audit` and `GET /api/audit/integrity` both require a Bearer API token +with the `audit:read` scope. Set `TOKEN` to one of your configured tokens with +that scope, then inspect the current chain: + +```bash +curl -H "Authorization: Bearer $TOKEN" \ + "http://localhost:3000/api/audit/integrity" +``` + +The integrity endpoint returns HTTP `200` for both intact and invalid chains. +Inspect the JSON `valid` field; a successful HTTP request alone does not mean +that verification passed. + +| Field | Meaning | +|-------|---------| +| `valid` | Whether the current chain and compatibility aliases pass verification. | +| `checked` | Number of entries verified before the report was returned. | +| `tipHash` | The process's stored hash for the end of its chain. | +| `brokenAt` | Zero-based chain position of a reported problem; `null` when valid. | +| `reason` | Description of the reported problem; `null` when valid. | + +Verification walks the whole current process-local chain. Listing filters +do not narrow the integrity check. + +Use `GET /api/audit` to investigate individual entries. It supports the +[pagination parameters](#pagination) and these exact-match filters, combined +when more than one is supplied. Empty filter strings are treated as absent. + +| Filter | Match against a returned entry | +|--------|-------------------------------| +| `resourceId` | Resource ID (the compatibility alias for `target`). | +| `action` | Action such as `transfer.created` or `user.created`. | +| `scope` | Event scope such as `transfers` or `users`. | +| `outcome` | Recorded outcome such as `success`. | +| `correlationId` | Correlation ID (also returned as `requestId`). | +| `actor` | The exact opaque actor reference returned in `actor`. | + +First list entries, then copy a returned actor value into `ACTOR_REF`: + +```bash +curl -H "Authorization: Bearer $TOKEN" "http://localhost:3000/api/audit?limit=50" +# Set ACTOR_REF to an entry's returned actor value. +curl --get -H "Authorization: Bearer $TOKEN" \ + --data-urlencode "actor=$ACTOR_REF" \ + "http://localhost:3000/api/audit" +``` + +The actor filter does not fingerprint its query value. Keep the raw API token +in the Authorization header and use the returned reference in the query. + +#### Actor secret and storage lifetime + +[Actor fingerprinting](src/utils/auditCrypto.js) reads its secret once when +the module loads: nonempty `AUDIT_ACTOR_SECRET` takes precedence, then nonempty +`PAGINATION_CURSOR_SECRET`, then a random secret generated for that process. +Set the chosen value in the environment or `.env` before starting the server; +restart the server after changing it. A fixed secret keeps token-derived actor +references stable across restarts and instances; rotating it changes those +references. + +The [audit service](src/services/auditService.js) keeps entries, duplicate-event +identities and the chain tip in memory. Restarting or resetting the store +clears that history. Each server process has its own chain: sharing the actor +secret does not share or persist records. The integrity report checks this +in-memory chain's internal consistency; it does not establish an externally +anchored history or provide a persistence guarantee. + ## Transfer lifecycle ``` diff --git a/benchmark/auditCrypto-results.json b/benchmark/auditCrypto-results.json new file mode 100644 index 0000000..68417f0 --- /dev/null +++ b/benchmark/auditCrypto-results.json @@ -0,0 +1,211 @@ +{ + "node": "v24.19.0", + "cpu": "AMD EPYC 9V74 80-Core Processor", + "baselineSourceSha256": "2f4ed3632d507b155840ab31be3f6a220b0141a0beb7e9657fe70ba6eb178c3a", + "currentSourceSha256": "ffbcbc5f4815d423879d3b16d30c5315c65c7891bfcd9e96381369356a4c2966", + "samples": 9, + "checksum": 4785642, + "results": [ + { + "workload": "transfer", + "operation": "canonicalize", + "iterations": 25622, + "samples": 9, + "baselineOpsPerSecond": 231872, + "currentOpsPerSecond": 254396, + "throughputGainPercent": 9.7, + "milliseconds": { + "baseline": [ + 108.14750799999999, + 110.50075000000004, + 114.085827, + 120.19784000000004, + 433.98151299999995, + 118.08017599999994, + 108.58598699999993, + 107.03499200000033, + 104.13203199999998 + ], + "current": [ + 100.71716899999998, + 96.60119300000002, + 93.150758, + 105.88667299999997, + 110.9722999999999, + 109.8460540000001, + 96.07561699999997, + 96.66931999999997, + 116.32044300000007 + ] + } + }, + { + "workload": "transfer", + "operation": "computeEntryHash", + "iterations": 20913, + "samples": 9, + "baselineOpsPerSecond": 154730, + "currentOpsPerSecond": 168089, + "throughputGainPercent": 8.6, + "milliseconds": { + "baseline": [ + 139.94221199999993, + 128.48196199999984, + 135.15780799999993, + 163.80414900000005, + 135.65932299999986, + 127.02398100000028, + 122.57160699999986, + 121.9742380000007, + 138.5978610000002 + ], + "current": [ + 112.19437000000016, + 176.93305099999998, + 146.36374899999964, + 363.42496900000015, + 124.41626499999984, + 118.41780299999982, + 138.92307100000016, + 120.26536099999976, + 112.68107099999997 + ] + } + }, + { + "workload": "nested", + "operation": "canonicalize", + "iterations": 13635, + "samples": 9, + "baselineOpsPerSecond": 93662, + "currentOpsPerSecond": 109206, + "throughputGainPercent": 16.6, + "milliseconds": { + "baseline": [ + 161.79588400000011, + 513.342294, + 167.7878469999996, + 136.8664800000006, + 169.69064600000002, + 129.1553560000002, + 145.57622300000003, + 141.5409579999996, + 135.3788199999999 + ], + "current": [ + 140.92494100000022, + 140.3922789999997, + 130.87958700000036, + 133.18306399999983, + 124.85618700000032, + 107.7370120000005, + 83.3952220000001, + 88.12642499999947, + 87.29651599999943 + ] + } + }, + { + "workload": "nested", + "operation": "computeEntryHash", + "iterations": 10425, + "samples": 9, + "baselineOpsPerSecond": 78433, + "currentOpsPerSecond": 91882, + "throughputGainPercent": 17.1, + "milliseconds": { + "baseline": [ + 140.76221500000065, + 132.91666199999963, + 177.81992100000025, + 112.24446799999896, + 151.19165799999973, + 110.88364899999942, + 110.13825799999904, + 374.8971299999994, + 130.50535200000013 + ], + "current": [ + 109.00271799999973, + 90.57954900000004, + 120.02028500000051, + 138.31343799999922, + 139.6425650000001, + 113.46040299999913, + 90.29416000000128, + 105.93533899999966, + 119.5507439999983 + ] + } + }, + { + "workload": "bulk", + "operation": "canonicalize", + "iterations": 1018, + "samples": 9, + "baselineOpsPerSecond": 8012, + "currentOpsPerSecond": 16196, + "throughputGainPercent": 102.1, + "milliseconds": { + "baseline": [ + 119.26919499999894, + 125.01002899999912, + 121.21669199999997, + 100.20210100000077, + 334.13020099999994, + 127.05269299999964, + 164.37312499999825, + 153.95353699999941, + 157.6018699999986 + ], + "current": [ + 63.9687529999992, + 61.7488110000013, + 62.85602699999981, + 60.481588000000556, + 62.75086899999951, + 59.93035299999974, + 98.35994399999981, + 97.32818900000166, + 92.10636800000066 + ] + } + }, + { + "workload": "bulk", + "operation": "computeEntryHash", + "iterations": 1221, + "samples": 9, + "baselineOpsPerSecond": 9863, + "currentOpsPerSecond": 16103, + "throughputGainPercent": 63.3, + "milliseconds": { + "baseline": [ + 127.3327360000003, + 112.31843000000117, + 123.13689300000078, + 129.09518000000025, + 123.80094899999858, + 133.62701300000117, + 123.57619999999952, + 123.3772399999998, + 272.7938579999991 + ], + "current": [ + 69.38326500000039, + 69.1379160000015, + 79.74982400000044, + 79.03531900000053, + 75.65462900000057, + 77.22689999999966, + 75.8228909999998, + 74.59852799999862, + 134.89016499999707 + ] + } + } + ], + "baseline_repository_blob": "5423fc9652af26952821d872bcd034582c522968", + "baseline_repository_sha256": "063c5ddcbace868618de6b211212521d1fabff3ba524976fd2af2f00461aeb41", + "baseline_materialization": "Pinned source plus one trailing newline; executable source is unchanged." +} diff --git a/benchmark/auditCrypto-results.md b/benchmark/auditCrypto-results.md new file mode 100644 index 0000000..97fb17e --- /dev/null +++ b/benchmark/auditCrypto-results.md @@ -0,0 +1,68 @@ +# Audit encoding throughput + +The encoder now appends serialized items directly, avoiding the temporary +arrays previously produced by `Array.from()` and `map()` before `join()`. +Object keys are still filtered and sorted in the original order. Arrays still +capture their length and visit every index, so holes encode as `null`. +`computeEntryHash`, redaction, field selection, and primitive JSON encoding +are unchanged. + +## Measured result + +Baseline: original PR 142 head `7192af8e4cc854abe3e0b344ce741c8a0cf097ee`, +using its actual `src/utils/auditCrypto.js` implementation. The baseline and +candidate source SHA-256 values are recorded in `auditCrypto-results.json`. +The measured baseline materialization added one trailing newline; that file's +hash and the exact repository source hash are both retained. + +Environment: Node.js `v24.19.0`, AMD EPYC 9V74 80-Core Processor, shared Linux +cloud environment. All numbers below are operations per second calculated +from the median elapsed time of nine batches per implementation. + +| Workload | Operation | Baseline ops/s | Changed ops/s | Throughput gain | +| --- | --- | ---: | ---: | ---: | +| Transfer event | `canonicalize` | 231,872 | 254,396 | 9.7% | +| Transfer event | `computeEntryHash` | 154,730 | 168,089 | 8.6% | +| Nested configuration | `canonicalize` | 93,662 | 109,206 | 16.6% | +| Nested configuration | `computeEntryHash` | 78,433 | 91,882 | 17.1% | +| Bulk changes | `canonicalize` | 8,012 | 16,196 | 102.1% | +| Bulk changes | `computeEntryHash` | 9,863 | 16,103 | 63.3% | + +## Method and compatibility + +The benchmark calls the exported production functions from both files. Each +workload cycles through 32 distinct events: ordinary transfer changes, nested +configuration changes with arrays and escaped text, or an event containing 32 +transfer records. Half of the events omit `mutationId`. + +Before timing, the benchmark compares the exact canonical string and entry +hash produced by both implementations for every event. Each operation has +4,000 warmup calls per implementation, followed by a baseline calibration +targeting 120 ms batches. Both implementations then use the same iteration +count. Execution order alternates between batches. Raw elapsed times, iteration +counts, and source hashes are retained in `auditCrypto-results.json`. + +The existing focused regression command passed all three cases: + +```sh +node --test test/auditCrypto.test.js +``` + +Those cases cover preserving own JSON keys through redaction, binding that +evidence into the integrity hash, and retaining the previous ordinary event +digest. + +## Reproduction + +Materialize the original source from the pinned head, then pass its path: + +```sh +git show 7192af8e4cc854abe3e0b344ce741c8a0cf097ee:src/utils/auditCrypto.js > /tmp/auditCrypto-baseline.js +node benchmark/auditCrypto.js /tmp/auditCrypto-baseline.js +``` + +These are in-process encoding and SHA-256 measurements. They do not measure +HTTP requests, authorization, persistence, or total endpoint latency. The +shared environment produced timing outliers; the raw batches are included +so readers can assess the spread. The percentages describe this measured +workload and environment, not a guaranteed application-wide improvement. diff --git a/benchmark/auditCrypto.js b/benchmark/auditCrypto.js new file mode 100644 index 0000000..e7d3d9b --- /dev/null +++ b/benchmark/auditCrypto.js @@ -0,0 +1,124 @@ +'use strict'; + +// Usage: node benchmark/auditCrypto.js /absolute/path/to/baseline/auditCrypto.js +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { performance } = require('node:perf_hooks'); + +if (!process.argv[2]) { + throw new Error('Pass the original auditCrypto.js path to compare real implementations.'); +} +const baselinePath = path.resolve(process.argv[2]); +const currentPath = path.resolve(__dirname, '../src/utils/auditCrypto.js'); +const baseline = require(baselinePath); +const current = require(currentPath); +const prevHash = 'ab'.repeat(32); +const samples = 9; +let checksum = 0; + +function fixture(index, changes) { + const entry = { + id: `audit-${index}`, + chainSeq: index, + action: 'transfer.updated', + scope: 'transfers', + target: `transfer-${index % 8}`, + actor: `actor:${index.toString(16).padStart(16, '0')}`, + correlationId: `request-${index}`, + outcome: index % 3 ? 'success' : 'denied', + mutationId: index % 2 ? `mutation-${index}` : undefined, + changes, + at: '2026-10-04T11:00:00.000Z', + }; + return { entry, value: { ...entry, prevHash } }; +} + +const workloads = { + transfer: Array.from({ length: 32 }, (_, index) => fixture(index, { + amount: 1250 + index, currency: 'USD', status: { from: 'pending', to: 'approved' }, + })), + nested: Array.from({ length: 32 }, (_, index) => fixture(index, { + config: { + limits: { daily: 50000 + index, perTransfer: 5000, enabled: true }, + destinations: ['treasury', 'settlement', 'reserve'], + policy: { roles: ['operator', 'auditor'], token: '[REDACTED]', reason: 'Updated "daily" limit\n€' }, + }, + previous: { daily: 25000, notes: null }, + ignored: undefined, + })), + bulk: Array.from({ length: 32 }, (_, index) => fixture(index, { + transfers: Array.from({ length: 32 }, (_, offset) => ({ + id: `transfer-${index}-${offset}`, amount: offset * 100, approved: offset % 2 === 0, + tags: ['settlement', `batch-${index}`], metadata: { currency: 'USD', attempt: 1 }, + })), + })), +}; + +for (const fixtures of Object.values(workloads)) { + for (const item of fixtures) { + assert.equal(current.canonicalize(item.value), baseline.canonicalize(item.value)); + assert.equal(current.computeEntryHash(item.entry, prevHash), baseline.computeEntryHash(item.entry, prevHash)); + } +} + +function measure(operation, implementation, fixtures, iterations) { + let total = 0; + const start = performance.now(); + for (let index = 0; index < iterations; index += 1) { + const item = fixtures[index % fixtures.length]; + const result = operation === 'canonicalize' + ? implementation.canonicalize(item.value) + : implementation.computeEntryHash(item.entry, prevHash); + total = (total + result.length + result.charCodeAt(0)) | 0; + } + const elapsed = performance.now() - start; + checksum ^= total; + return elapsed; +} + +function median(values) { + const ordered = [...values].sort((left, right) => left - right); + return ordered[Math.floor(ordered.length / 2)]; +} + +const results = []; +for (const [workload, fixtures] of Object.entries(workloads)) { + for (const operation of ['canonicalize', 'computeEntryHash']) { + measure(operation, baseline, fixtures, 4000); + measure(operation, current, fixtures, 4000); + const calibration = measure(operation, baseline, fixtures, 1000); + const iterations = Math.max(1000, Math.ceil(120000 / calibration)); + const times = { baseline: [], current: [] }; + for (let sample = 0; sample < samples; sample += 1) { + // Alternate order so one implementation does not always get a warmer CPU. + const order = sample % 2 ? ['current', 'baseline'] : ['baseline', 'current']; + for (const name of order) { + times[name].push(measure(operation, name === 'baseline' ? baseline : current, fixtures, iterations)); + } + } + const baselineMs = median(times.baseline); + const currentMs = median(times.current); + results.push({ + workload, operation, iterations, samples, + baselineOpsPerSecond: Math.round(iterations * 1000 / baselineMs), + currentOpsPerSecond: Math.round(iterations * 1000 / currentMs), + throughputGainPercent: Number(((baselineMs / currentMs - 1) * 100).toFixed(1)), + milliseconds: times, + }); + } +} + +function sourceHash(filename) { + return crypto.createHash('sha256').update(fs.readFileSync(filename)).digest('hex'); +} + +console.log(JSON.stringify({ + node: process.version, + cpu: os.cpus()[0].model, + baselineSourceSha256: sourceHash(baselinePath), + currentSourceSha256: sourceHash(currentPath), + samples, checksum, results, +}, null, 2)); diff --git a/docs/CLAIM_FAILURE_AUDIT.md b/docs/CLAIM_FAILURE_AUDIT.md new file mode 100644 index 0000000..b98479b --- /dev/null +++ b/docs/CLAIM_FAILURE_AUDIT.md @@ -0,0 +1,31 @@ +# Claim failure state and audit outcome + +An adapter failure now leaves the pending transfer, timestamp and claimable-balance fields unchanged. It records a hash-chained failure outcome with actor fingerprint, target, scope and request correlation, then propagates the original exception. Raw error messages, stacks and credentials are not copied into audit storage. A successful retry records its separate success outcome; repeated failure with the same event identity retains the existing deduplication behavior. Missing or terminal targets never call the adapter. + +The Stellar adapter in this repository is an explicit in-memory simulation. The regression injects a thrown error at that adapter boundary; it is not a live Stellar outage, blockchain transaction or durable settlement test. Other source changes, including JSON-key and sparse-array integrity repairs, are preserved. + +## Focused execution + +Node22.16.0, unchanged npm-ci lock, run 37193202657. + +Before, only new failure cases: +``` +node --test --test-name-pattern="claim failure:" test/auditIntegrity.test.js +# tests 3 +# pass 1 +# fail 2 +# skipped 0 +``` + +After, the maintained audit-integrity file: +``` +node --test test/auditIntegrity.test.js +# tests 27 +# pass 27 +# fail 0 +# skipped 0 +``` + +Source blobs: {'src/services/transferService.js': 'a080e89302a8b04eb6a759fddaf5a742895fc7cf', 'test/auditIntegrity.test.js': 'a9a8c7adb7ddea9ec90ca3ee5eeb66a2fbc8b276'} + +No broad suite, deployment, live provider call or dependency change. A failed local dependency installation could not reach the registry; the recorded execution above used the cloud runner and original locked dependencies. diff --git a/docs/validation/audit-array-integrity-20261004.md b/docs/validation/audit-array-integrity-20261004.md new file mode 100644 index 0000000..4c8698d --- /dev/null +++ b/docs/validation/audit-array-integrity-20261004.md @@ -0,0 +1,21 @@ +# Array positions in audit integrity hashes + +Canonical array encoding now visits every numeric slot, including holes, and encodes absent values as JSON null. Previously map() skipped holes: changing an empty stored array to length one changed its JSON output from [] to [null] but left the canonical hash input as []. The existing verifier and HTTP integrity endpoint could therefore miss the edit. + +The repair also makes sparse arrays agree with their JSON round trip. Ordinary dense JSON keeps the same bytes and digest. No record hash is rewritten. Existing pre-repair sparse-array records may fail verification under the corrected encoding; investigate them rather than silently rehashing or accepting the old ambiguous representation. The documented store is in-memory; any separate durable-store adopter must handle this compatibility boundary explicitly. + +The previous own-JSON-key redaction repair is retained unchanged, as are actor fingerprints, compatibility-alias checks and the set of hashed event fields. + +## Executed evidence + +Base `d2962a3e9ea3778c02b2d941b2a7553da10d5deb`; source before `f225d3244c5dde9d2a1a085929410d04ad2cfa77`, after `5423fc9652af26952821d872bcd034582c522968`; new test `89e403bc43372fb706fcec21e755c1dd744143f2`. Runtime `v22.23.3`. + +Original source with new regressions: 1 pass / 4 fail, exit 1. The actual service and authenticated integrity endpoint accepted an array-length edit before the repair. + +Repaired source with maintained auditIntegrity and auditCrypto suites plus new regressions: 32 pass / 0 fail / 0 cancelled / 0 skipped, exit 0. Existing tests unchanged. + +```sh +NODE_ENV=test node --test --test-reporter=tap test/auditIntegrity.test.js test/auditCrypto.test.js test/auditArrayIntegrity.test.js +``` + +Actual in-memory audit service, canonical hash implementation and authenticated loopback Express integrity endpoint. Synthetic records only; no live accounts, external provider, durable database or full-suite claim. diff --git a/src/controllers/auditController.js b/src/controllers/auditController.js index 799b428..45b9fae 100644 --- a/src/controllers/auditController.js +++ b/src/controllers/auditController.js @@ -7,33 +7,69 @@ const { buildHistoryPage } = require('../utils/historyPage'); * Audit log controllers. */ +/** + * Collect optional attribution / outcome filters from the query string. + * Empty strings are treated as absent so `?action=` does not over-filter. + * @param {import('express').Request} req + * @returns {{ action: string|null, scope: string|null, outcome: string|null, + * correlationId: string|null, actor: string|null }} + */ +function readFilters(req) { + const pick = (name) => { + const raw = req.query[name]; + if (raw == null || raw === '') return null; + return String(raw); + }; + + return { + action: pick('action'), + scope: pick('scope'), + outcome: pick('outcome'), + correlationId: pick('correlationId'), + actor: pick('actor'), + }; +} + /** * GET /api/audit * Return audit log entries, newest first by default. * - * Supports ?resourceId= to filter by resource, ?order=asc|desc, ?limit=, and - * either ?cursor= (stable under concurrent writes) or the legacy ?offset=. + * Supports ?resourceId=, ?action=, ?scope=, ?outcome=, ?correlationId=, + * ?actor=, ?order=asc|desc, ?limit=, and either ?cursor= or legacy ?offset=. + * Responses only ever contain redacted changes — secrets are stripped at write. */ function listAuditEntries(req, res) { const resourceId = req.query.resourceId == null || req.query.resourceId === '' ? null : String(req.query.resourceId); - const filters = { resourceId }; + const attribution = readFilters(req); + const filters = { resourceId, ...attribution }; const { items, envelope } = buildHistoryPage({ req, collection: 'audit', filters, defaultOrder: 'desc', - query: (args) => auditService.queryEntries({ resourceId, ...args }), - countTotal: () => auditService.countEntries(resourceId), + query: (args) => auditService.queryEntries({ resourceId, ...attribution, ...args }), + countTotal: () => auditService.countEntries(resourceId, attribution), resolvePosition: (seq) => auditService.positionKeyAt(seq, resourceId), }); res.json({ ...envelope, entries: items }); } +/** + * GET /api/audit/integrity + * Report whether the hash chain is intact. Authorized operators use this to + * detect silent tampering without reading every entry's payload. + */ +function getIntegrity(req, res) { + const report = auditService.verifyIntegrity(); + res.json(report); +} + module.exports = { + getIntegrity, listAuditEntries, }; diff --git a/src/controllers/transferController.js b/src/controllers/transferController.js index 4fb2e72..313afbe 100644 --- a/src/controllers/transferController.js +++ b/src/controllers/transferController.js @@ -135,7 +135,7 @@ function getTransfer(req, res) { * Mark a transfer as claimed by the recipient. */ function claimTransfer(req, res) { - const transfer = transferService.claimTransfer(req.params.id, req.id); + const transfer = transferService.claimTransfer(req.params.id, req.id, req.token); res.json(transfer); } @@ -144,7 +144,7 @@ function claimTransfer(req, res) { * Cancel a pending transfer. */ function cancelTransfer(req, res) { - const transfer = transferService.cancelTransfer(req.params.id, req.id); + const transfer = transferService.cancelTransfer(req.params.id, req.id, req.token); res.json(transfer); } @@ -153,7 +153,7 @@ function cancelTransfer(req, res) { * Archive a transfer, hiding it from default list results. */ function archiveTransfer(req, res) { - const transfer = transferService.archiveTransfer(req.params.id); + const transfer = transferService.archiveTransfer(req.params.id, req.id, req.token); res.json(transfer); } @@ -162,7 +162,7 @@ function archiveTransfer(req, res) { * Unarchive a transfer, restoring it to default list results. */ function unarchiveTransfer(req, res) { - const transfer = transferService.unarchiveTransfer(req.params.id); + const transfer = transferService.unarchiveTransfer(req.params.id, req.id, req.token); res.json(transfer); } diff --git a/src/controllers/userController.js b/src/controllers/userController.js index fa0153a..fcd096f 100644 --- a/src/controllers/userController.js +++ b/src/controllers/userController.js @@ -32,7 +32,7 @@ function getUser(req, res) { * Create a new user. */ function createUser(req, res) { - const user = userService.createUser(req.body, req.id); + const user = userService.createUser(req.body, req.id, req.token); res.status(201).json(user); } diff --git a/src/routes/auditRoutes.js b/src/routes/auditRoutes.js index 30fb2a1..12cf445 100644 --- a/src/routes/auditRoutes.js +++ b/src/routes/auditRoutes.js @@ -7,8 +7,16 @@ const auditController = require('../controllers/auditController'); const router = express.Router(); +// GET /api/audit/integrity +// Hash-chain verification for authorized operators (requires audit:read). +router.get( + '/integrity', + requireScope(['audit:read']), + asyncHandler(auditController.getIntegrity) +); + // GET /api/audit -// Lists audit log entries (newest first). Supports ?resourceId= and ?limit=/?offset=. +// Lists audit log entries (newest first). Supports resource and attribution filters. router.get('/', requireScope(['audit:read']), asyncHandler(auditController.listAuditEntries)); module.exports = router; diff --git a/src/services/auditService.js b/src/services/auditService.js index 4e51403..c46ecf4 100644 --- a/src/services/auditService.js +++ b/src/services/auditService.js @@ -3,68 +3,274 @@ const { newId } = require('../utils/ids'); const { OrderedIndex } = require('../utils/orderedIndex'); const config = require('../config'); +const { + GENESIS_HASH, + actorRef, + canonicalize, + computeEntryHash, + redact, + scopeFromAction, +} = require('../utils/auditCrypto'); /** * Audit log service. * - * Records an immutable, append-only log of write operations performed - * against the API. Entries are stored in memory (consistent with the - * rest of the in-memory store) and are therefore cleared when the - * process restarts. + * Records an append-only, hash-chained log of privileged mutations. Each entry + * binds to its predecessor via `prevHash` / `entryHash`, so a silent edit, + * deletion, or reorder is detectable by walking the chain. Sensitive fields in + * `changes` are redacted before storage so authorized operators can filter + * without ever being shown secrets. * - * Supported actions (non-exhaustive, extend as needed): + * Supported actions (non-exhaustive): * transfer.created transfer.claimed transfer.cancelled + * transfer.archived transfer.unarchived * user.created * * Each entry captures: - * id — unique audit entry id (aud_ prefix) - * action — dot-namespaced action string (e.g. "transfer.created") - * resourceId — id of the created / mutated resource - * payload — arbitrary context snapshot (sanitised by the caller) - * requestId — correlation id from the originating HTTP request (optional) - * at — ISO-8601 timestamp of when the entry was recorded + * id, action, scope, target, actor, correlationId, outcome, changes, + * resourceId / requestId / payload — compat aliases + * chainSeq, prevHash, entryHash — integrity metadata + * mutationId — optional state-change identity + * at — ISO-8601 timestamp */ -/** - * Entries live in an append-only ordered index rather than a plain array. - * - * The index adds two things a plain array cannot: a dense sequence number per - * entry, which gives cursor pagination a deterministic tie-breaker when several - * entries share a millisecond, and a secondary index by `resourceId`, so - * filtering by resource no longer scans the whole log. - */ const auditIndex = new OrderedIndex({ sortKeyOf: (entry) => entry.at, groupKeyOf: (entry) => entry.resourceId, }); +/** Tip of the integrity chain (hash of the most recently appended entry). */ +let tipHash = GENESIS_HASH; + +/** + * Event identity used to suppress duplicate outcome events for the same + * privileged mutation. A state-change id distinguishes later mutations that + * share a workflow correlation id; callers without one retain replay behavior. + * @param {object} parts + * @returns {string|null} null when the event cannot be safely deduplicated. + */ +function eventIdentity({ action, target, actor, scope, correlationId, outcome, mutationId }) { + if (!correlationId && !mutationId) return null; + return JSON.stringify([action, target, actor, scope, correlationId, outcome, mutationId]); +} + +/** @type {Map} */ +const eventsByIdentity = new Map(); + /** - * Append a new entry to the audit log. + * Append a new entry to the audit log, or return the existing one when the + * same privileged mutation is recorded again under the same correlation id. * * @param {object} params - * @param {string} params.action - action identifier (e.g. "transfer.created") - * @param {string} params.resourceId - id of the affected resource - * @param {object} [params.payload] - additional context to record - * @param {string} [params.requestId]- request correlation id - * @returns {object} the newly created audit entry + * @param {string} params.action + * @param {string} [params.resourceId] - legacy alias for target + * @param {string} [params.target] + * @param {object} [params.payload] - legacy alias for changes (redacted) + * @param {object} [params.changes] + * @param {string} [params.requestId] - legacy alias for correlationId + * @param {string} [params.correlationId] + * @param {string} [params.mutationId] - stable id of the recorded state change + * @param {string} [params.actor] - legacy token or already-fingerprinted ref + * @param {string} [params.actorToken] - raw credential; always fingerprinted + * @param {string} [params.scope] + * @param {'success'|'failure'|string} [params.outcome] + * @returns {object} the newly created (or previously recorded) audit entry */ -function addEntry({ action, resourceId, payload = {}, requestId } = {}) { +function addEntry({ + action, + resourceId, + target, + payload, + changes, + requestId, + correlationId, + mutationId, + actor, + actorToken, + scope, + outcome = 'success', +} = {}) { if (!action) throw new Error('audit.addEntry: action is required'); - if (!resourceId) throw new Error('audit.addEntry: resourceId is required'); + + const resolvedTarget = target != null && target !== '' + ? String(target) + : (resourceId != null && resourceId !== '' ? String(resourceId) : null); + if (!resolvedTarget) throw new Error('audit.addEntry: resourceId is required'); + + const resolvedCorrelation = correlationId != null && correlationId !== '' + ? String(correlationId) + : (requestId != null && requestId !== '' ? String(requestId) : null); + + const resolvedMutationId = mutationId != null && mutationId !== '' + ? String(mutationId) + : undefined; + + // Credentials must use the explicit input: their contents can resemble an + // existing actor reference (or the system sentinel). Keep legacy references + // working without treating a credential's spelling as proof of redaction. + let resolvedActor; + if (actorToken !== undefined) { + resolvedActor = actorRef(actorToken); + } else if (actor == null || actor === '') { + resolvedActor = 'system'; + } else if (String(actor).startsWith('actor:') || actor === 'system') { + resolvedActor = String(actor); + } else { + resolvedActor = actorRef(actor); + } + + const resolvedScope = scope || scopeFromAction(action); + const resolvedOutcome = outcome || 'success'; + const identity = eventIdentity({ + action, + target: resolvedTarget, + actor: resolvedActor, + scope: resolvedScope, + correlationId: resolvedCorrelation, + outcome: resolvedOutcome, + mutationId: resolvedMutationId, + }); + + if (identity) { + const existing = eventsByIdentity.get(identity); + if (existing) return existing; + } + + const redactedChanges = redact( + changes != null ? changes : (payload != null ? payload : {}) + ); + + const chainSeq = auditIndex.size; + const prevHash = tipHash; + const at = new Date().toISOString(); const entry = { id: newId(), action, - resourceId, - payload, - requestId: requestId || null, - at: new Date().toISOString(), + scope: resolvedScope, + target: resolvedTarget, + // Compat aliases kept so existing consumers and tests keep working. + resourceId: resolvedTarget, + actor: resolvedActor, + correlationId: resolvedCorrelation, + requestId: resolvedCorrelation, + ...(resolvedMutationId === undefined ? {} : { mutationId: resolvedMutationId }), + outcome: resolvedOutcome, + changes: redactedChanges, + payload: redactedChanges, + chainSeq, + prevHash, + at, }; + entry.entryHash = computeEntryHash(entry, prevHash); auditIndex.append(entry); + tipHash = entry.entryHash; + + if (identity) { + eventsByIdentity.set(identity, entry); + } + return entry; } +/** + * Walk the chain and recompute every hash. + * + * Detects in-place field edits, broken predecessor links, and gaps. Used by + * operators and by regression tests for the original failure mode (silent + * mutation of an audit record). + * + * @returns {{ valid: boolean, checked: number, tipHash: string, + * brokenAt: number|null, reason: string|null }} + */ +function verifyIntegrity() { + let expectedPrev = GENESIS_HASH; + const records = auditIndex.records; + + for (let i = 0; i < records.length; i += 1) { + const entry = records[i].item; + + if (entry.chainSeq !== i) { + return { + valid: false, + checked: i, + tipHash, + brokenAt: i, + reason: `chainSeq mismatch at index ${i}: expected ${i}, got ${entry.chainSeq}`, + }; + } + + if (entry.prevHash !== expectedPrev) { + return { + valid: false, + checked: i, + tipHash, + brokenAt: i, + reason: `prevHash mismatch at chainSeq ${entry.chainSeq}`, + }; + } + + try { + const recomputed = computeEntryHash(entry, expectedPrev); + if (recomputed !== entry.entryHash) { + return { + valid: false, + checked: i, + tipHash, + brokenAt: i, + reason: `entryHash mismatch at chainSeq ${entry.chainSeq}`, + }; + } + + // Legacy consumers read these aliases, while the hash binds the canonical + // fields. Verify their values without changing the stored hash format. + if (entry.resourceId !== entry.target + || entry.requestId !== entry.correlationId + || (entry.payload !== entry.changes + && canonicalize(entry.payload) !== canonicalize(entry.changes))) { + return { + valid: false, + checked: i, + tipHash, + brokenAt: i, + reason: `compatibility alias mismatch at chainSeq ${entry.chainSeq}`, + }; + } + } catch { + // Tampered payloads may be cyclic or contain throwing accessors. Do not + // return their contents or exception messages to the integrity caller. + return { + valid: false, + checked: i, + tipHash, + brokenAt: i, + reason: 'entry contents cannot be verified', + }; + } + + expectedPrev = entry.entryHash; + } + + if (expectedPrev !== tipHash) { + return { + valid: false, + checked: records.length, + tipHash, + brokenAt: records.length, + reason: 'tipHash does not match the final entry hash', + }; + } + + return { + valid: true, + checked: records.length, + tipHash, + brokenAt: null, + reason: null, + }; +} + /** * Return all audit entries, newest first. * @returns {Array} @@ -79,31 +285,65 @@ function getEntries() { * @returns {Array} */ function getEntriesForResource(resourceId) { - // A nullish id matches no resource. Guarded explicitly because the index - // treats a null group key as "the whole index". if (resourceId == null || resourceId === '') return []; return auditIndex.recordsFor(String(resourceId)).map((record) => record.item).reverse(); } +/** + * Residual filter over integrity / attribution fields that are not covered by + * the secondary index. Secrets never participate — only redacted changes and + * actor fingerprints are visible to callers. + * @param {object} filters + * @returns {(entry: object) => boolean} + */ +function buildMatch(filters = {}) { + const { + action, + scope, + outcome, + correlationId, + actor, + } = filters; + + const hasResidual = action != null || scope != null || outcome != null + || correlationId != null || actor != null; + if (!hasResidual) return null; + + return (entry) => { + if (action != null && entry.action !== String(action)) return false; + if (scope != null && entry.scope !== String(scope)) return false; + if (outcome != null && entry.outcome !== String(outcome)) return false; + if (correlationId != null && entry.correlationId !== String(correlationId)) return false; + if (actor != null && entry.actor !== String(actor)) return false; + return true; + }; +} + /** * Page through the audit log using the ordered index. * - * Entries are immutable once written, so the sort position of an entry never - * changes. That is what makes a cursor into this log stable: a page boundary - * recorded now still means the same thing after any number of later appends. - * * @param {object} [options] - * @param {string} [options.resourceId] - restrict to one resource via the secondary index. - * @param {'asc'|'desc'} [options.order] - defaults to newest first. + * @param {string} [options.resourceId] + * @param {string} [options.action] + * @param {string} [options.scope] + * @param {string} [options.outcome] + * @param {string} [options.correlationId] + * @param {string} [options.actor] + * @param {'asc'|'desc'} [options.order] * @param {number} [options.limit] - * @param {number|null} [options.afterSeq] - exclusive start position from a cursor. - * @param {number} [options.skip] - legacy offset support. - * @param {number} [options.maxScan] - per-request work budget. + * @param {number|null} [options.afterSeq] + * @param {number} [options.skip] + * @param {number} [options.maxScan] * @returns {{ items: object[], last: object|null, hasMore: boolean, scanned: number, * scanTruncated: boolean, skipped: number }} */ function queryEntries({ resourceId, + action, + scope, + outcome, + correlationId, + actor, order = 'desc', limit = config.pagination.defaultLimit, afterSeq = null, @@ -117,6 +357,7 @@ function queryEntries({ afterSeq, skip, maxScan, + match: buildMatch({ action, scope, outcome, correlationId, actor }), }); } @@ -135,12 +376,27 @@ function positionKeyAt(seq, resourceId) { /** * Number of entries recorded for a resource, or in the whole log. + * When residual filters are supplied the count walks matching entries so + * page envelopes stay accurate for authorized filtered queries. * @param {string} [resourceId] + * @param {object} [filters] * @returns {number} */ -function countEntries(resourceId) { - if (resourceId == null || resourceId === '') return auditIndex.size; - return auditIndex.recordsFor(String(resourceId)).length; +function countEntries(resourceId, filters = {}) { + const match = buildMatch(filters); + if (!match) { + if (resourceId == null || resourceId === '') return auditIndex.size; + return auditIndex.recordsFor(String(resourceId)).length; + } + + const records = resourceId == null || resourceId === '' + ? auditIndex.records + : auditIndex.recordsFor(String(resourceId)); + let count = 0; + for (const record of records) { + if (match(record.item)) count += 1; + } + return count; } /** @@ -148,14 +404,18 @@ function countEntries(resourceId) { */ function reset() { auditIndex.reset(); + tipHash = GENESIS_HASH; + eventsByIdentity.clear(); } module.exports = { addEntry, + actorRef, countEntries, getEntries, getEntriesForResource, positionKeyAt, queryEntries, reset, + verifyIntegrity, }; diff --git a/src/services/transferService.js b/src/services/transferService.js index 1517f62..a080e89 100644 --- a/src/services/transferService.js +++ b/src/services/transferService.js @@ -281,6 +281,8 @@ function createTransferUnchecked(data, requestId, idempotency) { sendAmount: transfer.sendAmount, }, requestId, + actorToken: idempotency ? idempotency.actor : undefined, + outcome: 'success', }); if (idempotency) { @@ -301,13 +303,18 @@ function createTransferUnchecked(data, requestId, idempotency) { * @param {string} nextStatus * @returns {object} */ -function transition(transfer, nextStatus) { +function assertTransition(transfer, nextStatus) { const allowed = TRANSFER_TRANSITIONS[transfer.status] || []; if (!allowed.includes(nextStatus)) { throw ApiError.conflict( `Cannot change transfer from ${transfer.status} to ${nextStatus}` ); } + return transfer; +} + +function transition(transfer, nextStatus) { + assertTransition(transfer, nextStatus); transfer.status = nextStatus; transfer.updatedAt = nextTimestamp(transfer.updatedAt); return transfer; @@ -319,16 +326,35 @@ function transition(transfer, nextStatus) { * @param {string} [requestId] - optional correlation id for audit logging * @returns {object} */ -function claimTransfer(id, requestId) { +function claimTransfer(id, requestId, actor) { const transfer = getTransferOrThrow(id); + assertTransition(transfer, TRANSFER_STATUS.CLAIMED); + let claimableBalanceId; + try { + claimableBalanceId = stellarService.createClaimableBalanceId(); + } catch (err) { + // Leave the pending transfer unchanged when the adapter fails. Retain an + // attributable outcome without copying exception text or credentials. + auditService.addEntry({ + action: 'transfer.claimed', + resourceId: transfer.id, + payload: { stage: 'claim', code: 'CLAIM_ADAPTER_FAILED' }, + requestId, + actorToken: actor, + outcome: 'failure', + }); + throw err; + } transition(transfer, TRANSFER_STATUS.CLAIMED); - transfer.claimableBalanceId = stellarService.createClaimableBalanceId(); + transfer.claimableBalanceId = claimableBalanceId; auditService.addEntry({ action: 'transfer.claimed', resourceId: transfer.id, payload: { claimableBalanceId: transfer.claimableBalanceId }, requestId, + actorToken: actor, + outcome: 'success', }); return transfer; @@ -340,7 +366,7 @@ function claimTransfer(id, requestId) { * @param {string} [requestId] - optional correlation id for audit logging * @returns {object} */ -function cancelTransfer(id, requestId) { +function cancelTransfer(id, requestId, actor) { const transfer = getTransferOrThrow(id); transition(transfer, TRANSFER_STATUS.CANCELLED); @@ -349,6 +375,8 @@ function cancelTransfer(id, requestId) { resourceId: transfer.id, payload: {}, requestId, + actorToken: actor, + outcome: 'success', }); return transfer; @@ -361,12 +389,22 @@ function cancelTransfer(id, requestId) { * @param {string} id * @returns {object} */ -function archiveTransfer(id) { +function archiveTransfer(id, requestId, actor) { const transfer = getTransferOrThrow(id); if (!transfer.archivedAt) { const timestamp = nextTimestamp(transfer.updatedAt); transfer.archivedAt = timestamp; transfer.updatedAt = timestamp; + + auditService.addEntry({ + action: 'transfer.archived', + resourceId: transfer.id, + payload: { archivedAt: transfer.archivedAt }, + mutationId: transfer.updatedAt, + requestId, + actorToken: actor, + outcome: 'success', + }); } return transfer; } @@ -376,13 +414,24 @@ function archiveTransfer(id) { * @param {string} id * @returns {object} */ -function unarchiveTransfer(id) { +function unarchiveTransfer(id, requestId, actor) { const transfer = getTransferOrThrow(id); if (!transfer.archivedAt) { throw ApiError.conflict(`Transfer is not archived: ${id}`); } transfer.archivedAt = null; transfer.updatedAt = nextTimestamp(transfer.updatedAt); + + auditService.addEntry({ + action: 'transfer.unarchived', + resourceId: transfer.id, + payload: {}, + mutationId: transfer.updatedAt, + requestId, + actorToken: actor, + outcome: 'success', + }); + return transfer; } diff --git a/src/services/userService.js b/src/services/userService.js index 1c2895d..6c77289 100644 --- a/src/services/userService.js +++ b/src/services/userService.js @@ -45,7 +45,7 @@ function getUserOrThrow(id) { * @param {string} [requestId] - optional correlation id for audit logging * @returns {object} */ -function createUser(data, requestId) { +function createUser(data, requestId, actor) { const user = { id: prefixedId('usr'), name: data.name, @@ -60,6 +60,8 @@ function createUser(data, requestId) { resourceId: user.id, payload: { name: user.name, country: user.country }, requestId, + actorToken: actor, + outcome: 'success', }); return user; diff --git a/src/utils/auditCrypto.js b/src/utils/auditCrypto.js new file mode 100644 index 0000000..ccf045e --- /dev/null +++ b/src/utils/auditCrypto.js @@ -0,0 +1,149 @@ +'use strict'; + +const crypto = require('crypto'); + +/** + * Cryptographic helpers for tamper-evident audit records. + * + * The hash chain binds each entry to its predecessor so a silent edit, insert, + * or reorder is detectable by recomputing the chain. Actor references are + * keyed digests of the API token so operators can filter by who acted without + * ever storing or returning the raw secret. + */ + +/** Genesis predecessor for the first entry in a chain. */ +const GENESIS_HASH = '0'.repeat(64); + +/** + * Secret used to fingerprint actors. Falls back to the pagination cursor + * secret so a single ops configuration covers both, then to a per-process + * random value (correct for the in-memory demo store). + */ +const ACTOR_SECRET = process.env.AUDIT_ACTOR_SECRET + || process.env.PAGINATION_CURSOR_SECRET + || crypto.randomBytes(32).toString('hex'); + +/** + * Field names (case-insensitive, ignoring `_` / `-`) that must never appear + * in stored or returned audit changes. Matched on every nested object key. + */ +const SENSITIVE_KEY_PATTERN = /^(password|passwd|secret|token|(?:x[_-]?)?api[_-]?key|authorization|auth|bearer|private[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|ssn|cvv|pin|credential|credentials)$/i; + +const REDACTED = '[REDACTED]'; + +/** + * Deterministic JSON encoding so hash inputs do not depend on key order. + * @param {*} value + * @returns {string} + */ +function canonicalize(value) { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value) ?? 'null'; + } + if (Array.isArray(value)) { + // Capture the length and visit every slot, including holes as null. + const length = value.length; + let encoded = '['; + for (let index = 0; index < length; index += 1) { + if (index > 0) encoded += ','; + encoded += canonicalize(value[index]); + } + return `${encoded}]`; + } + const keys = Object.keys(value).filter((key) => value[key] !== undefined).sort(); + let encoded = '{'; + for (let index = 0; index < keys.length; index += 1) { + if (index > 0) encoded += ','; + const key = keys[index]; + encoded += `${JSON.stringify(key)}:${canonicalize(value[key])}`; + } + return `${encoded}}`; +} + +/** + * SHA-256 hex digest of a canonicalized value. + * @param {*} value + * @returns {string} + */ +function sha256(value) { + return crypto.createHash('sha256').update(canonicalize(value), 'utf8').digest('hex'); +} + +/** + * Stable, non-reversible actor reference derived from an API token. + * Anonymous / missing actors collapse to the literal `"system"`. + * @param {string|null|undefined} token + * @returns {string} + */ +function actorRef(token) { + if (token == null || token === '') return 'system'; + return `actor:${crypto.createHmac('sha256', ACTOR_SECRET).update(String(token)).digest('hex').slice(0, 16)}`; +} + +/** + * Deep-clone `value`, replacing sensitive keys with `[REDACTED]`. + * Non-objects are returned as-is. Arrays are walked element-wise. + * @param {*} value + * @returns {*} + */ +function redact(value) { + if (value == null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(redact); + + // Create own data properties even for JSON keys such as "__proto__". + // Assignment to {} would invoke the inherited setter, dropping evidence + // from both JSON responses and the canonical hash's own-key traversal. + return Object.fromEntries(Object.entries(value).map(([key, child]) => [ + key, + SENSITIVE_KEY_PATTERN.test(key.replace(/-/g, '_')) ? REDACTED : redact(child), + ])); +} + +/** + * Compute the integrity hash for one audit entry given its predecessor hash. + * Only fields that define the event participate; `entryHash` itself is excluded. + * @param {object} entry + * @param {string} prevHash + * @returns {string} + */ +function computeEntryHash(entry, prevHash) { + return sha256({ + prevHash, + chainSeq: entry.chainSeq, + id: entry.id, + action: entry.action, + scope: entry.scope, + target: entry.target, + actor: entry.actor, + correlationId: entry.correlationId, + outcome: entry.outcome, + mutationId: entry.mutationId, // Undefined is omitted, preserving legacy hashes. + changes: entry.changes, + at: entry.at, + }); +} + +/** + * Derive a coarse scope from a dotted action (`transfer.created` → `transfers`). + * @param {string} action + * @returns {string} + */ +function scopeFromAction(action) { + const head = String(action || '').split('.')[0] || 'unknown'; + if (head === 'transfer') return 'transfers'; + if (head === 'user') return 'users'; + if (head === 'admin') return 'admin'; + return `${head}s`; +} + +module.exports = { + GENESIS_HASH, + REDACTED, + SENSITIVE_KEY_PATTERN, + actorRef, + canonicalize, + computeEntryHash, + redact, + scopeFromAction, + sha256, +}; diff --git a/test/auditArrayIntegrity.test.js b/test/auditArrayIntegrity.test.js new file mode 100644 index 0000000..89e403b --- /dev/null +++ b/test/auditArrayIntegrity.test.js @@ -0,0 +1,74 @@ +'use strict'; + +const { test, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +process.env.NODE_ENV = 'test'; +const createApp = require('../src/app'); +const audit = require('../src/services/auditService'); +const { canonicalize, sha256 } = require('../src/utils/auditCrypto'); +let server; +let origin; + +before(() => new Promise(resolve => { + server = createApp().listen(0, '127.0.0.1', () => { + origin = `http://127.0.0.1:${server.address().port}`; + resolve(); + }); +})); +after(() => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))); +beforeEach(() => audit.reset()); + +function record(changes) { + return audit.addEntry({ action: 'transfer.updated', target: 'test-transfer', changes }); +} + +test('sparse array slots retain their JSON null positions', () => { + const sparse = new Array(3); + sparse[1] = 7; + assert.equal(canonicalize(sparse), '[null,7,null]'); + assert.equal(canonicalize(new Array(1)), '[null]'); + assert.notEqual(sha256([]), sha256(new Array(1))); + assert.deepEqual(JSON.parse(canonicalize(sparse)), JSON.parse(JSON.stringify(sparse))); +}); + +test('extending an empty stored array cannot keep a valid audit hash', () => { + const entry = record({ recipients: [] }); + assert.equal(audit.verifyIntegrity().valid, true); + entry.changes.recipients.length = 1; + assert.equal(JSON.stringify(entry.changes), '{"recipients":[null]}'); + const result = audit.verifyIntegrity(); + assert.equal(result.valid, false); + assert.equal(result.brokenAt, 0); + assert.match(result.reason, /entryHash mismatch/); +}); + +test('a sparse stored array and its JSON round trip have the same digest', () => { + const entry = record({ recipients: new Array(2) }); + assert.equal(audit.verifyIntegrity().valid, true); + const originalHash = entry.entryHash; + entry.changes.recipients = JSON.parse(JSON.stringify(entry.changes.recipients)); + assert.deepEqual(entry.changes.recipients, [null, null]); + assert.equal(audit.verifyIntegrity().valid, true); + assert.equal(entry.entryHash, originalHash); +}); + +test('ordinary dense JSON keeps the existing canonical bytes and digest', () => { + const value = { z: [1, null, undefined, { y: 2, x: 1 }], a: 'dense' }; + const expected = '{"a":"dense","z":[1,null,null,{"x":1,"y":2}]}'; + assert.equal(canonicalize(value), expected); + assert.equal(sha256(value), crypto.createHash('sha256').update(expected).digest('hex')); +}); + +test('authenticated integrity endpoint reports array-length tampering', async () => { + const entry = record({ amounts: [] }); + entry.changes.amounts.length = 1; + const response = await fetch(origin + '/api/audit/integrity', { + headers: { Authorization: 'Bearer test-token-admin' }, + }); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.valid, false); + assert.equal(body.brokenAt, 0); + assert.match(body.reason, /entryHash mismatch/); +}); diff --git a/test/auditCrypto.test.js b/test/auditCrypto.test.js new file mode 100644 index 0000000..5534c46 --- /dev/null +++ b/test/auditCrypto.test.js @@ -0,0 +1,74 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { + GENESIS_HASH, + REDACTED, + computeEntryHash, + redact, +} = require('../src/utils/auditCrypto'); + +function entryWith(changes) { + return { + id: 'audit-json-fixture', + chainSeq: 0, + action: 'admin.config_changed', + scope: 'admin', + target: 'cfg-json', + actor: 'system', + correlationId: 'corr-json', + outcome: 'success', + changes, + at: '2026-10-04T00:00:00.000Z', + }; +} + +test('redaction retains every own JSON key without changing object prototypes', () => { + const input = JSON.parse('{"__proto__":{"amount":100,"token":"top-secret"},"constructor":{"prototype":{"safe":true,"password":"nested-secret"}},"items":[{"__proto__":{"label":"nested","api-key":"array-secret"}}]}'); + const result = redact(input); + + assert.equal(Object.getPrototypeOf(result), Object.prototype); + assert.equal(Object.hasOwn(result, '__proto__'), true); + assert.equal(result.amount, undefined, 'payload fields must not become inherited properties'); + assert.deepEqual(result.__proto__, { amount: 100, token: REDACTED }); + assert.deepEqual(result.constructor, { prototype: { safe: true, password: REDACTED } }); + assert.equal(Object.getPrototypeOf(result.items[0]), Object.prototype); + assert.equal(Object.hasOwn(result.items[0], '__proto__'), true); + assert.deepEqual(result.items[0].__proto__, { label: 'nested', 'api-key': REDACTED }); + assert.equal(Object.getPrototypeOf(result.__proto__), Object.prototype); + + const roundTrip = JSON.parse(JSON.stringify(result)); + assert.deepEqual(roundTrip, result, 'the stored view must survive the JSON response boundary'); + for (const secret of ['top-secret', 'nested-secret', 'array-secret']) { + assert.equal(JSON.stringify(result).includes(secret), false); + } + assert.equal(input.__proto__.token, 'top-secret', 'redaction must not mutate the caller'); + assert.equal(input.items[0].__proto__['api-key'], 'array-secret'); +}); + +test('integrity hashes bind preserved JSON __proto__ evidence and detect its mutation', () => { + const changes = redact(JSON.parse('{"__proto__":{"amount":100,"token":"private"},"reason":"corrected fee"}')); + const entry = entryWith(changes); + const originalHash = computeEntryHash(entry, GENESIS_HASH); + const omittedHash = computeEntryHash(entryWith({ reason: 'corrected fee' }), GENESIS_HASH); + assert.notEqual(originalHash, omittedHash, 'an own JSON key must participate in the event hash'); + + changes.__proto__.amount = 999; + assert.notEqual(computeEntryHash(entry, GENESIS_HASH), originalHash, 'editing evidence must change the hash'); + assert.equal(changes.__proto__.token, REDACTED); +}); + +test('ordinary JSON redaction retains the previous canonical event hash', () => { + const input = { nested: { z: [1, true, 'text'], token: 'private', a: null }, amount: 100 }; + const changes = redact(input); + assert.deepEqual(changes, { + nested: { z: [1, true, 'text'], token: REDACTED, a: null }, + amount: 100, + }); + assert.equal( + computeEntryHash(entryWith(changes), GENESIS_HASH), + 'c4ffe45ff0cd8bb84ea96f32162fce02114de42f358ec6fa1180fca0914ba97b', + ); + assert.equal(input.nested.token, 'private'); +}); diff --git a/test/auditIntegrity.test.js b/test/auditIntegrity.test.js new file mode 100644 index 0000000..a9a8c7a --- /dev/null +++ b/test/auditIntegrity.test.js @@ -0,0 +1,634 @@ +'use strict'; + +/** + * Tamper-evident, queryable audit storage (issue #127). + * + * Covers: + * - integrity-chain verification and tamper detection (original failure mode) + * - redaction of secrets from stored / returned changes + * - duplicate-event suppression for the same privileged mutation + * - query authorization (audit:read scope) + * - correlation / attribution filters without exposing secrets + */ + +const { test, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); + +process.env.NODE_ENV = 'test'; + +const createApp = require('../src/app'); +const config = require('../src/config'); +const { reset } = require('../src/store'); +const auditService = require('../src/services/auditService'); +const transferService = require('../src/services/transferService'); +const { REDACTED, computeEntryHash, GENESIS_HASH } = require('../src/utils/auditCrypto'); + +let server; +let baseUrl; + +before(() => { + const app = createApp(); + return new Promise((resolve) => { + server = app.listen(0, () => { + const { port } = server.address(); + baseUrl = `http://127.0.0.1:${port}`; + resolve(); + }); + }); +}); + +after(() => { + if (server) server.close(); +}); + +beforeEach(() => { + reset(); +}); + +function auth(token) { + return { Authorization: `Bearer ${token}` }; +} + +async function fetchJson(path, options = {}) { + const res = await fetch(`${baseUrl}${path}`, options); + let body; + try { + body = await res.json(); + } catch { + body = null; + } + return { status: res.status, body }; +} + +const TRANSFER = { + senderName: 'Alice', + recipientName: 'Bob', + amount: 50, + from: 'USD', + to: 'INR', +}; + +// ─── Integrity chain ────────────────────────────────────────────────────────── + +test('integrity-chain: successive entries link via prevHash and verify clean', () => { + const a = auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-1', + requestId: 'req-1', + actor: 'test-token-admin', + }); + const b = auditService.addEntry({ + action: 'transfer.claimed', + resourceId: 'txn-1', + requestId: 'req-2', + actor: 'test-token-admin', + }); + + assert.equal(a.chainSeq, 0); + assert.equal(a.prevHash, GENESIS_HASH); + assert.equal(b.chainSeq, 1); + assert.equal(b.prevHash, a.entryHash); + assert.notEqual(a.entryHash, b.entryHash); + assert.equal(a.entryHash, computeEntryHash(a, GENESIS_HASH)); + + const report = auditService.verifyIntegrity(); + assert.equal(report.valid, true); + assert.equal(report.checked, 2); + assert.equal(report.tipHash, b.entryHash); + assert.equal(report.brokenAt, null); +}); + +test('REGRESSION: silent field edit on an audit record is detectable', () => { + // Original failure mode: an investigator cannot trust an audit trail if a + // record can be changed without leaving evidence. Mutating a stored field + // must break the hash chain. + auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-tamper', + payload: { sendAmount: 10 }, + requestId: 'req-tamper', + actor: 'test-token-admin', + }); + auditService.addEntry({ + action: 'transfer.cancelled', + resourceId: 'txn-tamper', + requestId: 'req-tamper-2', + actor: 'test-token-admin', + }); + + assert.equal(auditService.verifyIntegrity().valid, true); + + const [newest] = auditService.getEntries(); + newest.action = 'transfer.claimed'; // silent rewrite + + const report = auditService.verifyIntegrity(); + assert.equal(report.valid, false); + assert.equal(report.brokenAt, newest.chainSeq); + assert.match(report.reason, /entryHash mismatch/); +}); + +for (const [field, value] of [ + ['resourceId', 'txn-reassigned'], + ['requestId', 'req-reassigned'], + ['payload', { sendAmount: 999999 }], +]) { + test(`integrity endpoint detects a reassigned ${field} compatibility alias`, async () => { + const entry = auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-alias', + requestId: 'req-alias', + payload: { sendAmount: 10 }, + }); + const originalHash = entry.entryHash; + entry[field] = value; + + const listing = await fetchJson('/api/audit', { headers: auth('test-token-admin') }); + assert.equal(listing.status, 200); + assert.deepEqual(listing.body.entries[0][field], value); + + const report = await fetchJson('/api/audit/integrity', { headers: auth('test-token-admin') }); + assert.equal(report.status, 200); + assert.equal(report.body.valid, false); + assert.equal(report.body.brokenAt, entry.chainSeq); + assert.match(report.body.reason, /compatibility alias mismatch/); + assert.equal(entry.entryHash, originalHash); + }); +} + +test('equivalent payload copies retain the existing hash and valid integrity', () => { + const entry = auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-alias-copy', + payload: { sendAmount: 10, currency: 'USD' }, + }); + const originalHash = entry.entryHash; + entry.payload = { currency: 'USD', sendAmount: 10 }; + + assert.notEqual(entry.payload, entry.changes); + assert.equal(computeEntryHash(entry, entry.prevHash), originalHash); + assert.equal(auditService.verifyIntegrity().valid, true); +}); + +test('REGRESSION: broken predecessor link is detectable', () => { + auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-a', + requestId: 'req-a', + }); + const second = auditService.addEntry({ + action: 'transfer.claimed', + resourceId: 'txn-a', + requestId: 'req-b', + }); + + second.prevHash = GENESIS_HASH; // pretend the first entry never happened + + const report = auditService.verifyIntegrity(); + assert.equal(report.valid, false); + assert.match(report.reason, /prevHash mismatch/); +}); + +// ─── Redaction ──────────────────────────────────────────────────────────────── + +test('redaction: sensitive keys are stripped from stored changes', () => { + const entry = auditService.addEntry({ + action: 'admin.config_changed', + resourceId: 'cfg-1', + requestId: 'req-redact', + actor: 'test-token-admin', + changes: { + feePercent: 1.5, + apiKey: 'super-secret-key', + nested: { token: 'abc', safe: true }, + authorization: 'Bearer leaked', + }, + }); + + assert.equal(entry.changes.feePercent, 1.5); + assert.equal(entry.changes.apiKey, REDACTED); + assert.equal(entry.changes.nested.token, REDACTED); + assert.equal(entry.changes.nested.safe, true); + assert.equal(entry.changes.authorization, REDACTED); + // Compat alias stays in sync with the redacted view. + assert.deepEqual(entry.payload, entry.changes); + // Raw secret must not survive anywhere on the entry. + assert.equal(JSON.stringify(entry).includes('super-secret-key'), false); +}); + +test('redaction: HTTP list responses never expose secrets', async () => { + auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-secret', + requestId: 'req-secret', + actor: 'test-token-admin', + changes: { sendAmount: 42, secret: 'should-not-leak', password: 'nope' }, + }); + + const { status, body } = await fetchJson('/api/audit?resourceId=txn-secret', { + headers: auth('test-token-admin'), + }); + + assert.equal(status, 200); + assert.equal(body.entries.length, 1); + assert.equal(body.entries[0].changes.secret, REDACTED); + assert.equal(body.entries[0].changes.password, REDACTED); + assert.equal(body.entries[0].changes.sendAmount, 42); + assert.equal(JSON.stringify(body).includes('should-not-leak'), false); +}); + +// ─── Duplicate-event suppression ────────────────────────────────────────────── + +test('duplicate-event: same action/target/correlation/outcome records once', () => { + const first = auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-dup', + requestId: 'corr-dup', + actor: 'test-token-admin', + outcome: 'success', + payload: { sendAmount: 1 }, + }); + const second = auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-dup', + requestId: 'corr-dup', + actor: 'test-token-admin', + outcome: 'success', + payload: { sendAmount: 99 }, + }); + + assert.equal(second.id, first.id); + assert.equal(auditService.getEntries().length, 1); + assert.equal(first.changes.sendAmount, 1, 'first-write wins; retry must not rewrite'); + assert.equal(auditService.verifyIntegrity().valid, true); +}); + +test('duplicate-event: a different correlation id still appends', () => { + auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-dup-2', + requestId: 'corr-1', + outcome: 'success', + }); + auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-dup-2', + requestId: 'corr-2', + outcome: 'success', + }); + assert.equal(auditService.getEntries().length, 2); +}); + +test('duplicate-event: privileged transfer create emits one outcome under retry', () => { + const first = transferService.createTransfer(TRANSFER, 'corr-idem', { + actor: 'test-token-admin', + key: 'idem-audit-1', + fingerprint: 'fp-audit-1', + }); + // Force a second audit attempt with the same correlation id (simulates a + // client that retried past the idempotency layer and hit addEntry again). + const replayed = auditService.addEntry({ + action: 'transfer.created', + resourceId: first.id, + requestId: 'corr-idem', + actor: 'test-token-admin', + outcome: 'success', + }); + + const created = auditService.getEntries().filter((e) => e.action === 'transfer.created'); + assert.equal(created.length, 1); + assert.equal(replayed.id, created[0].id); +}); + +test('duplicate-event: repeated archive cycles retain every outcome under one correlation id', async () => { + const created = await fetchJson('/api/transfers', { + method: 'POST', + headers: { + ...auth('test-token-admin'), + 'Content-Type': 'application/json', + 'Idempotency-Key': 'archive-cycle-audit', + 'X-Request-Id': 'archive-cycle-create', + }, + body: JSON.stringify(TRANSFER), + }); + assert.equal(created.status, 201); + const id = created.body.id; + const correlationId = 'shared-archive-workflow'; + const mutations = []; + + for (const actor of ['test-token-admin', 'test-token-transfers', 'test-token-admin']) { + for (const action of ['archive', 'unarchive']) { + const path = `/api/transfers/${id}/${action}`; + const options = { + method: 'POST', + headers: { ...auth(actor), 'X-Request-Id': correlationId }, + }; + const changed = await fetchJson(path, options); + assert.equal(changed.status, 200); + mutations.push({ + action: `transfer.${action}d`, + actor: auditService.actorRef(actor), + mutationId: changed.body.updatedAt, + }); + if (action === 'archive') { + const noop = await fetchJson(path, options); + assert.equal(noop.status, 200); + assert.equal(noop.body.updatedAt, changed.body.updatedAt); + } + } + } + + const query = new URLSearchParams({ resourceId: id, correlationId, order: 'asc' }); + const listed = await fetchJson(`/api/audit?${query}`, { headers: auth('test-token-admin') }); + assert.equal(listed.status, 200); + assert.equal(listed.body.entries.length, 6, 'every state change needs its own outcome'); + assert.deepEqual(listed.body.entries.map(({ action, actor, mutationId }) => ({ action, actor, mutationId })), mutations); + assert.equal(new Set(listed.body.entries.map((entry) => entry.mutationId)).size, 6); + assert.ok(listed.body.entries.every((entry) => entry.correlationId === correlationId && entry.requestId === correlationId)); + assert.equal(JSON.stringify(listed.body).includes('test-token-'), false); + + const integrity = await fetchJson('/api/audit/integrity', { headers: auth('test-token-admin') }); + assert.equal(integrity.status, 200); + assert.equal(integrity.body.valid, true); + assert.equal(integrity.body.checked, 7, 'one creation and six state changes; archive no-ops add nothing'); +}); + +test('mutation identity preserves first-write wins and isolates actor, scope, and state change', () => { + const command = { + action: 'transfer.archived', + resourceId: 'txn-mutation-identity', + requestId: 'shared-correlation', + actor: 'test-token-admin', + mutationId: 'version-1', + }; + const first = auditService.addEntry({ ...command, changes: { archivedAt: 'first' } }); + const replay = auditService.addEntry({ + ...command, + actor: auditService.actorRef(command.actor), + scope: 'transfers', + changes: { archivedAt: 'must-not-rewrite' }, + }); + assert.equal(replay.id, first.id); + assert.equal(replay.changes.archivedAt, 'first'); + + const otherActor = auditService.addEntry({ ...command, actor: 'test-token-transfers' }); + const otherScope = auditService.addEntry({ ...command, scope: 'admin' }); + const laterMutation = auditService.addEntry({ ...command, mutationId: 'version-2' }); + assert.equal(new Set([first.id, otherActor.id, otherScope.id, laterMutation.id]).size, 4); + assert.equal(auditService.countEntries(), 4); + assert.equal(auditService.verifyIntegrity().valid, true); +}); + +test('integrity-chain: mutation ID tampering is detected', () => { + const entry = auditService.addEntry({ + action: 'transfer.unarchived', + resourceId: 'txn-mutation-tamper', + requestId: 'corr-mutation-tamper', + mutationId: 'version-1', + }); + assert.equal(auditService.verifyIntegrity().valid, true); + entry.mutationId = 'version-2'; + const report = auditService.verifyIntegrity(); + assert.equal(report.valid, false); + assert.equal(report.brokenAt, 0); + assert.match(report.reason, /entryHash mismatch/); +}); + +// ─── Query authorization ────────────────────────────────────────────────────── + +test('query authorization: missing token is 401', async () => { + const { status, body } = await fetchJson('/api/audit'); + assert.equal(status, 401); + assert.equal(body.error.status, 401); +}); + +test('query authorization: token without audit:read is 403', async () => { + const { status, body } = await fetchJson('/api/audit', { + headers: auth('test-token-transfers'), + }); + assert.equal(status, 403); + assert.equal(body.error.status, 403); +}); + +test('query authorization: integrity endpoint requires audit:read', async () => { + const denied = await fetchJson('/api/audit/integrity', { + headers: auth('test-token-transfers'), + }); + assert.equal(denied.status, 403); + + const allowed = await fetchJson('/api/audit/integrity', { + headers: auth('test-token-readonly'), + }); + assert.equal(allowed.status, 200); + assert.equal(allowed.body.valid, true); + assert.equal(typeof allowed.body.tipHash, 'string'); +}); + +// ─── Correlation / attribution filters ──────────────────────────────────────── + +for (const token of ['actor:test-only-secret', 'actor:0123456789abcdef', 'system']) { + test(`raw credentials resembling actor references are fingerprinted: ${token}`, async (t) => { + const previous = config.apiTokens[token]; + const hadToken = Object.hasOwn(config.apiTokens, token); + config.apiTokens[token] = ['transfers:read', 'transfers:write', 'users:read', 'users:write', 'audit:read']; + t.after(() => { + if (hadToken) config.apiTokens[token] = previous; + else delete config.apiTokens[token]; + }); + + const request = (path, method = 'GET', body, key) => fetchJson(path, { + method, + headers: { + ...auth(token), + 'Content-Type': 'application/json', + 'X-Request-Id': 'actor-credential-workflow', + ...(key ? { 'Idempotency-Key': key } : {}), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + + const first = await request('/api/transfers', 'POST', TRANSFER, 'actor-first'); + assert.equal(first.status, 201); + const id = first.body.id; + for (const action of ['archive', 'unarchive', 'cancel']) { + const response = await request(`/api/transfers/${id}/${action}`, 'POST'); + assert.equal(response.status, 200); + } + const second = await request('/api/transfers', 'POST', TRANSFER, 'actor-second'); + assert.equal(second.status, 201); + assert.equal((await request(`/api/transfers/${second.body.id}/claim`, 'POST')).status, 200); + const user = await request('/api/users', 'POST', { + name: 'Audit Profile', email: 'audit-profile@example.invalid', country: 'US', + }); + assert.equal(user.status, 201); + + const replay = await request('/api/transfers', 'POST', TRANSFER, 'actor-first'); + assert.equal(replay.status, 201); + assert.equal(replay.body.id, id); + + const expectedActor = auditService.actorRef(token); + const stored = auditService.getEntries(); + assert.equal(stored.length, 7, 'replay must not append another outcome'); + assert.ok(stored.every((entry) => entry.actor === expectedActor)); + assert.equal(JSON.stringify(stored).includes(token), false); + + const listed = await request('/api/audit?order=asc'); + assert.equal(listed.status, 200); + assert.equal(listed.body.entries.length, 7); + assert.ok(listed.body.entries.every((entry) => entry.actor === expectedActor)); + assert.equal(JSON.stringify(listed.body.entries).includes(token), false); + const filtered = await request(`/api/audit?actor=${encodeURIComponent(expectedActor)}`); + assert.equal(filtered.status, 200); + assert.equal(filtered.body.entries.length, 7); + assert.equal((await request('/api/audit/integrity')).body.valid, true); + }); +} + +test('correlation: entries carry actor fingerprint, scope, outcome, correlationId', () => { + const entry = auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-corr', + requestId: 'corr-xyz', + actor: 'test-token-admin', + outcome: 'success', + payload: { sendAmount: 5 }, + }); + + assert.equal(entry.correlationId, 'corr-xyz'); + assert.equal(entry.requestId, 'corr-xyz'); + assert.equal(entry.target, 'txn-corr'); + assert.equal(entry.resourceId, 'txn-corr'); + assert.equal(entry.scope, 'transfers'); + assert.equal(entry.outcome, 'success'); + assert.match(entry.actor, /^actor:[0-9a-f]{16}$/); + assert.equal(entry.actor.includes('test-token-admin'), false); +}); + +test('correlation: operators can filter by correlationId and scope', async () => { + auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-f1', + requestId: 'corr-filter', + actor: 'test-token-admin', + }); + auditService.addEntry({ + action: 'user.created', + resourceId: 'usr-f1', + requestId: 'corr-other', + actor: 'test-token-admin', + }); + auditService.addEntry({ + action: 'transfer.claimed', + resourceId: 'txn-f1', + requestId: 'corr-filter', + actor: 'test-token-readonly', + }); + + const byCorr = await fetchJson('/api/audit?correlationId=corr-filter', { + headers: auth('test-token-admin'), + }); + assert.equal(byCorr.status, 200); + assert.equal(byCorr.body.entries.length, 2); + assert.ok(byCorr.body.entries.every((e) => e.correlationId === 'corr-filter')); + + const byScope = await fetchJson('/api/audit?scope=users', { + headers: auth('test-token-admin'), + }); + assert.equal(byScope.status, 200); + assert.equal(byScope.body.entries.length, 1); + assert.equal(byScope.body.entries[0].action, 'user.created'); + + // Actor fingerprints differ across tokens; filtering by one excludes the other. + const adminActor = auditService.getEntries().find((e) => e.action === 'transfer.created').actor; + const byActor = await fetchJson(`/api/audit?actor=${encodeURIComponent(adminActor)}`, { + headers: auth('test-token-admin'), + }); + assert.equal(byActor.status, 200); + assert.ok(byActor.body.entries.length >= 1); + assert.ok(byActor.body.entries.every((e) => e.actor === adminActor)); + assert.equal(JSON.stringify(byActor.body).includes('test-token-admin'), false); +}); + +test('privileged archive/unarchive mutations each emit one outcome event', () => { + const transfer = transferService.createTransfer(TRANSFER, 'corr-arch-create', { + actor: 'test-token-admin', + key: 'idem-arch', + fingerprint: 'fp-arch', + }); + + transferService.archiveTransfer(transfer.id, 'corr-arch', 'test-token-admin'); + transferService.archiveTransfer(transfer.id, 'corr-arch', 'test-token-admin'); // idempotent no-op + + const archived = auditService.getEntries().filter((e) => e.action === 'transfer.archived'); + assert.equal(archived.length, 1); + assert.equal(archived[0].outcome, 'success'); + assert.equal(archived[0].correlationId, 'corr-arch'); + + transferService.unarchiveTransfer(transfer.id, 'corr-unarch', 'test-token-admin'); + const unarchived = auditService.getEntries().filter((e) => e.action === 'transfer.unarchived'); + assert.equal(unarchived.length, 1); + assert.equal(auditService.verifyIntegrity().valid, true); +}); + +// A failed adapter call must not strand a pending transfer as already claimed. +test('claim failure: preserves state and records a redacted attributable outcome', (t) => { + const stellar = require('../src/services/stellarService'); + const row = transferService.createTransfer(TRANSFER, 'failure-create'); + const snapshot = JSON.parse(JSON.stringify(row)); + const failure = new Error('private provider token=do-not-retain'); + t.mock.method(stellar, 'createClaimableBalanceId', () => { throw failure; }); + + assert.throws(() => transferService.claimTransfer(row.id, 'failed-claim', 'test-token-admin'), + (err) => err === failure); + assert.deepEqual(row, snapshot); + const entries = auditService.getEntries().filter((e) => e.action === 'transfer.claimed'); + assert.equal(entries.length, 1); + assert.equal(entries[0].outcome, 'failure'); + assert.equal(entries[0].correlationId, 'failed-claim'); + assert.equal(entries[0].target, row.id); + assert.equal(entries[0].scope, 'transfers'); + assert.equal(entries[0].actor, auditService.actorRef('test-token-admin')); + assert.deepEqual(entries[0].changes, { stage: 'claim', code: 'CLAIM_ADAPTER_FAILED' }); + const encoded = JSON.stringify(entries); + assert.equal(encoded.includes('test-token-admin'), false); + assert.equal(encoded.includes('do-not-retain'), false); + assert.equal(auditService.verifyIntegrity().valid, true); +}); + +test('claim failure: same-correlation recovery has one failure and one success', (t) => { + const stellar = require('../src/services/stellarService'); + const row = transferService.createTransfer(TRANSFER, 'retry-create'); + const failure = new Error('temporary adapter failure'); + let fail = true; + let calls = 0; + t.mock.method(stellar, 'createClaimableBalanceId', () => { + calls += 1; + if (fail) throw failure; + return 'cb-recovered'; + }); + for (let i = 0; i < 2; i += 1) { + assert.throws(() => transferService.claimTransfer(row.id, 'retry-claim', 'test-token-admin'), + (err) => err === failure); + } + fail = false; + const recovered = transferService.claimTransfer(row.id, 'retry-claim', 'test-token-admin'); + assert.equal(recovered.status, 'claimed'); + assert.equal(recovered.claimableBalanceId, 'cb-recovered'); + assert.equal(calls, 3); + const entries = auditService.getEntries().filter((e) => e.action === 'transfer.claimed'); + assert.equal(entries.length, 2); + assert.deepEqual(entries.map((e) => e.outcome), ['success', 'failure']); + assert.equal(auditService.verifyIntegrity().valid, true); +}); + +test('claim failure: terminal and missing targets never call the adapter', (t) => { + const stellar = require('../src/services/stellarService'); + const row = transferService.createTransfer(TRANSFER, 'terminal-create'); + transferService.cancelTransfer(row.id, 'cancel-before-claim', 'test-token-admin'); + const adapter = t.mock.method(stellar, 'createClaimableBalanceId', () => 'unexpected'); + assert.throws(() => transferService.claimTransfer(row.id, 'terminal-claim', 'test-token-admin')); + assert.throws(() => transferService.claimTransfer('missing', 'missing-claim', 'test-token-admin')); + assert.equal(adapter.mock.callCount(), 0); + assert.equal(row.status, 'cancelled'); + assert.equal(auditService.getEntries().filter((e) => e.action === 'transfer.claimed').length, 0); + assert.equal(auditService.verifyIntegrity().valid, true); +}); diff --git a/test/auditMalformedIntegrity.test.js b/test/auditMalformedIntegrity.test.js new file mode 100644 index 0000000..0dacf4e --- /dev/null +++ b/test/auditMalformedIntegrity.test.js @@ -0,0 +1,51 @@ +'use strict'; + +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const auditService = require('../src/services/auditService'); + +beforeEach(() => auditService.reset()); +afterEach(() => auditService.reset()); + +function validChain() { + auditService.addEntry({ action: 'transfer.created', target: 'first', changes: { amount: 10 } }); + const entry = auditService.addEntry({ action: 'transfer.created', target: 'second', changes: { amount: 20 } }); + const original = auditService.verifyIntegrity(); + assert.equal(original.valid, true); + return { entry, original }; +} + +function assertUnverifiable(original) { + assert.deepEqual(auditService.verifyIntegrity(), { + valid: false, + checked: 1, + tipHash: original.tipHash, + brokenAt: 1, + reason: 'entry contents cannot be verified', + }); +} + +test('a cyclic tampered changes object returns the broken index without changing hashes', () => { + const { entry, original } = validChain(); + const originalHash = entry.entryHash; + entry.changes.self = entry.changes; + + assertUnverifiable(original); + assert.equal(entry.entryHash, originalHash); + + delete entry.changes.self; + assert.deepEqual(auditService.verifyIntegrity(), original); +}); + +test('a throwing compatibility payload returns a fixed reason without leaking the error', () => { + const { entry, original } = validChain(); + entry.payload = Object.defineProperty({}, 'privateField', { + enumerable: true, + get() { throw new Error('SYNTHETIC_PRIVATE_PAYLOAD_MARKER'); }, + }); + + assertUnverifiable(original); + + entry.payload = entry.changes; + assert.deepEqual(auditService.verifyIntegrity(), original); +});