From 557a2ba9ab0c2cda8d19be47cd5ad975f7a28a4b Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 24 Sep 2026 18:19:53 -0400 Subject: [PATCH 01/11] feat(audit): make audit records tamper-evident and queryable Add hash-chained integrity metadata, redacted attribution fields, duplicate outcome suppression, and authorized filters so transfer investigations can detect silent tampering and correlate privileged mutations without exposing secrets. --- CHANGELOG.md | 10 + src/controllers/auditController.js | 46 +++- src/controllers/transferController.js | 8 +- src/controllers/userController.js | 2 +- src/routes/auditRoutes.js | 10 +- src/services/auditService.js | 304 +++++++++++++++++---- src/services/transferService.js | 33 ++- src/services/userService.js | 4 +- src/utils/auditCrypto.js | 139 ++++++++++ test/auditIntegrity.test.js | 375 ++++++++++++++++++++++++++ 10 files changed, 870 insertions(+), 61 deletions(-) create mode 100644 src/utils/auditCrypto.js create mode 100644 test/auditIntegrity.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a8e4e0a..144ab5d 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 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..f7b6507 100644 --- a/src/services/auditService.js +++ b/src/services/auditService.js @@ -3,68 +3,228 @@ const { newId } = require('../utils/ids'); const { OrderedIndex } = require('../utils/orderedIndex'); const config = require('../config'); +const { + GENESIS_HASH, + actorRef, + 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 + * 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 (e.g. a retried request carrying the same correlation id). + * @param {object} parts + * @returns {string|null} null when the event cannot be safely deduplicated. + */ +function eventIdentity({ action, target, correlationId, outcome }) { + if (!correlationId) return null; + return `${action}\u0000${target}\u0000${correlationId}\u0000${outcome}`; +} + +/** @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.actor] - raw token or already-fingerprinted ref + * @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, + actor, + 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 resolvedOutcome = outcome || 'success'; + const identity = eventIdentity({ + action, + target: resolvedTarget, + correlationId: resolvedCorrelation, + outcome: resolvedOutcome, + }); + + if (identity) { + const existing = eventsByIdentity.get(identity); + if (existing) return existing; + } + + // Accept either a raw token (fingerprinted here) or a precomputed `actor:…` ref. + let resolvedActor; + 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 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, + 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}`, + }; + } + + const recomputed = computeEntryHash(entry, expectedPrev); + if (recomputed !== entry.entryHash) { + return { + valid: false, + checked: i, + tipHash, + brokenAt: i, + reason: `entryHash mismatch at chainSeq ${entry.chainSeq}`, + }; + } + + 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 +239,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 +311,7 @@ function queryEntries({ afterSeq, skip, maxScan, + match: buildMatch({ action, scope, outcome, correlationId, actor }), }); } @@ -135,12 +330,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 +358,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..0decba4 100644 --- a/src/services/transferService.js +++ b/src/services/transferService.js @@ -281,6 +281,8 @@ function createTransferUnchecked(data, requestId, idempotency) { sendAmount: transfer.sendAmount, }, requestId, + actor: idempotency ? idempotency.actor : undefined, + outcome: 'success', }); if (idempotency) { @@ -319,7 +321,7 @@ 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); transition(transfer, TRANSFER_STATUS.CLAIMED); transfer.claimableBalanceId = stellarService.createClaimableBalanceId(); @@ -329,6 +331,8 @@ function claimTransfer(id, requestId) { resourceId: transfer.id, payload: { claimableBalanceId: transfer.claimableBalanceId }, requestId, + actor, + outcome: 'success', }); return transfer; @@ -340,7 +344,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 +353,8 @@ function cancelTransfer(id, requestId) { resourceId: transfer.id, payload: {}, requestId, + actor, + outcome: 'success', }); return transfer; @@ -361,12 +367,21 @@ 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 }, + requestId, + actor, + outcome: 'success', + }); } return transfer; } @@ -376,13 +391,23 @@ 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: {}, + requestId, + actor, + outcome: 'success', + }); + return transfer; } diff --git a/src/services/userService.js b/src/services/userService.js index 1c2895d..87f8772 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, + actor, + outcome: 'success', }); return user; diff --git a/src/utils/auditCrypto.js b/src/utils/auditCrypto.js new file mode 100644 index 0000000..900457e --- /dev/null +++ b/src/utils/auditCrypto.js @@ -0,0 +1,139 @@ +'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|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)) { + return `[${value.map(canonicalize).join(',')}]`; + } + const keys = Object.keys(value).filter((key) => value[key] !== undefined).sort(); + return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`).join(',')}}`; +} + +/** + * 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); + + const out = {}; + for (const [key, child] of Object.entries(value)) { + if (SENSITIVE_KEY_PATTERN.test(key.replace(/-/g, '_'))) { + out[key] = REDACTED; + } else if (child != null && typeof child === 'object') { + out[key] = redact(child); + } else { + out[key] = child; + } + } + return out; +} + +/** + * 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, + 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/auditIntegrity.test.js b/test/auditIntegrity.test.js new file mode 100644 index 0000000..4c201bc --- /dev/null +++ b/test/auditIntegrity.test.js @@ -0,0 +1,375 @@ +'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 { 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/); +}); + +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); +}); + +// ─── 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 ──────────────────────────────────────── + +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); +}); From 460f829ee2339fb95a06a0b6445dd01400820a5a Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sat, 3 Oct 2026 04:11:21 -0400 Subject: [PATCH 02/11] fix(audit): retain distinct archive outcomes within one correlation An actual HTTP archive/unarchive/archive/unarchive sequence returned four successful state changes but only two audit outcomes when the caller reused a correlation ID. The later mutations were suppressed as retries while integrity still reported a valid, incomplete trail. Add optional mutationId to audit records and derive it from the existing monotonic updatedAt for archive/unarchive. Include normalized actor, scope and state-change identity in the structured replay key, preserving caller correlation fields and legacy first-write-wins behavior. Bind the optional ID in the integrity hash; entries without it retain their prior field shape and hash calculation. Archive no-ops still add no event. Three new regressions fail on the previous source. The actual app now records all six state changes across three cycles under the same correlation, including actor changes and repeated archive no-ops. Explicit mutation replay, actor/scope isolation and mutation-ID tamper detection pass. Existing creation-replay and redaction cases remain intact. Validation: npm test passes 273/273, 0 failed, 0 skipped; audit suite 17/17; git diff --check passes. Executing the previous auditCrypto implementation against a current legacy entry confirms the original hash is preserved. Node24.19.0 with the exact existing package-lock dependency tree; no dependency changes. Operation: REMIT142-AUDIT-CYCLE-20261003-443D --- src/services/auditService.js | 42 +++++++++------ src/services/transferService.js | 2 + src/utils/auditCrypto.js | 1 + test/auditIntegrity.test.js | 94 +++++++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 15 deletions(-) diff --git a/src/services/auditService.js b/src/services/auditService.js index f7b6507..b556b34 100644 --- a/src/services/auditService.js +++ b/src/services/auditService.js @@ -29,6 +29,7 @@ const { * 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 */ @@ -42,13 +43,14 @@ let tipHash = GENESIS_HASH; /** * Event identity used to suppress duplicate outcome events for the same - * privileged mutation (e.g. a retried request carrying the same correlation id). + * 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, correlationId, outcome }) { - if (!correlationId) return null; - return `${action}\u0000${target}\u0000${correlationId}\u0000${outcome}`; +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} */ @@ -66,6 +68,7 @@ const eventsByIdentity = new Map(); * @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] - raw token or already-fingerprinted ref * @param {string} [params.scope] * @param {'success'|'failure'|string} [params.outcome] @@ -79,6 +82,7 @@ function addEntry({ changes, requestId, correlationId, + mutationId, actor, scope, outcome = 'success', @@ -94,12 +98,30 @@ function addEntry({ ? String(correlationId) : (requestId != null && requestId !== '' ? String(requestId) : null); + const resolvedMutationId = mutationId != null && mutationId !== '' + ? String(mutationId) + : undefined; + + // Accept either a raw token (fingerprinted here) or a precomputed `actor:…` ref. + let resolvedActor; + 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) { @@ -107,17 +129,6 @@ function addEntry({ if (existing) return existing; } - // Accept either a raw token (fingerprinted here) or a precomputed `actor:…` ref. - let resolvedActor; - 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 redactedChanges = redact( changes != null ? changes : (payload != null ? payload : {}) ); @@ -136,6 +147,7 @@ function addEntry({ actor: resolvedActor, correlationId: resolvedCorrelation, requestId: resolvedCorrelation, + ...(resolvedMutationId === undefined ? {} : { mutationId: resolvedMutationId }), outcome: resolvedOutcome, changes: redactedChanges, payload: redactedChanges, diff --git a/src/services/transferService.js b/src/services/transferService.js index 0decba4..a8409b0 100644 --- a/src/services/transferService.js +++ b/src/services/transferService.js @@ -378,6 +378,7 @@ function archiveTransfer(id, requestId, actor) { action: 'transfer.archived', resourceId: transfer.id, payload: { archivedAt: transfer.archivedAt }, + mutationId: transfer.updatedAt, requestId, actor, outcome: 'success', @@ -403,6 +404,7 @@ function unarchiveTransfer(id, requestId, actor) { action: 'transfer.unarchived', resourceId: transfer.id, payload: {}, + mutationId: transfer.updatedAt, requestId, actor, outcome: 'success', diff --git a/src/utils/auditCrypto.js b/src/utils/auditCrypto.js index 900457e..2b1b365 100644 --- a/src/utils/auditCrypto.js +++ b/src/utils/auditCrypto.js @@ -108,6 +108,7 @@ function computeEntryHash(entry, prevHash) { actor: entry.actor, correlationId: entry.correlationId, outcome: entry.outcome, + mutationId: entry.mutationId, // Undefined is omitted, preserving legacy hashes. changes: entry.changes, at: entry.at, }); diff --git a/test/auditIntegrity.test.js b/test/auditIntegrity.test.js index 4c201bc..060cc8e 100644 --- a/test/auditIntegrity.test.js +++ b/test/auditIntegrity.test.js @@ -256,6 +256,100 @@ test('duplicate-event: privileged transfer create emits one outcome under retry' 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 () => { From 2a87aa773a60a94fc24f46aa02e40b9203bd1eb0 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sat, 3 Oct 2026 04:50:55 -0400 Subject: [PATCH 03/11] fix(audit): detect tampering through compatibility aliases Verify resourceId, requestId, and payload against their hash-bound canonical fields without changing stored hashes. Equivalent payload copies stay valid. Three HTTP regressions fail on prior source. Full npm test: 277 passed. --- src/services/auditService.js | 16 ++++++++++++++ test/auditIntegrity.test.js | 42 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/services/auditService.js b/src/services/auditService.js index b556b34..9bb7681 100644 --- a/src/services/auditService.js +++ b/src/services/auditService.js @@ -6,6 +6,7 @@ const config = require('../config'); const { GENESIS_HASH, actorRef, + canonicalize, computeEntryHash, redact, scopeFromAction, @@ -215,6 +216,21 @@ function verifyIntegrity() { }; } + // 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}`, + }; + } + expectedPrev = entry.entryHash; } diff --git a/test/auditIntegrity.test.js b/test/auditIntegrity.test.js index 060cc8e..5561c6e 100644 --- a/test/auditIntegrity.test.js +++ b/test/auditIntegrity.test.js @@ -126,6 +126,48 @@ test('REGRESSION: silent field edit on an audit record is detectable', () => { 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', From 0044e2e72b6e5733186ff3579a6c3bf095982d38 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sat, 3 Oct 2026 09:05:52 -0400 Subject: [PATCH 04/11] fix(audit): fingerprint credentials that resemble actor references Privileged transfer and user mutations now pass raw credentials through the explicit actorToken input. Always fingerprint that input, including actor-prefixed, fingerprint-shaped and literal system tokens, while preserving legacy precomputed references, actor-scoped replay, archive-cycle identities and compatibility-alias integrity checks. Actual createApp HTTP: 44 requests across four configured synthetic-token profiles. All mutation outcomes remain successful, each profile retains seven audit events, fingerprint filtering returns those events, retries reuse the original transfer and integrity remains valid. The three lookalike profiles previously stored the raw credential and returned zero results for its fingerprint. Node 24.19.0, all 76 retained package-lock versions exact, no install. npm test -- --test-concurrency=1: 280 pass, 0 fail/skips. Focused audit suite: 24 pass; unchanged parent source with the same tests: 21 controls pass and three added regressions fail. Four changed JS files pass node --check; git diff --check passes. The HTTP exercise uses the repository's existing mock settlement adapter; no external settlement or Node 22 run is claimed. --- CHANGELOG.md | 5 +++ src/services/auditService.js | 12 +++++-- src/services/transferService.js | 10 +++--- src/services/userService.js | 2 +- test/auditIntegrity.test.js | 59 +++++++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 144ab5d..0994d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,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/src/services/auditService.js b/src/services/auditService.js index 9bb7681..c4d13aa 100644 --- a/src/services/auditService.js +++ b/src/services/auditService.js @@ -70,7 +70,8 @@ const eventsByIdentity = new Map(); * @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] - raw token or already-fingerprinted ref + * @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 @@ -85,6 +86,7 @@ function addEntry({ correlationId, mutationId, actor, + actorToken, scope, outcome = 'success', } = {}) { @@ -103,9 +105,13 @@ function addEntry({ ? String(mutationId) : undefined; - // Accept either a raw token (fingerprinted here) or a precomputed `actor:…` ref. + // 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 (actor == null || actor === '') { + if (actorToken !== undefined) { + resolvedActor = actorRef(actorToken); + } else if (actor == null || actor === '') { resolvedActor = 'system'; } else if (String(actor).startsWith('actor:') || actor === 'system') { resolvedActor = String(actor); diff --git a/src/services/transferService.js b/src/services/transferService.js index a8409b0..683df40 100644 --- a/src/services/transferService.js +++ b/src/services/transferService.js @@ -281,7 +281,7 @@ function createTransferUnchecked(data, requestId, idempotency) { sendAmount: transfer.sendAmount, }, requestId, - actor: idempotency ? idempotency.actor : undefined, + actorToken: idempotency ? idempotency.actor : undefined, outcome: 'success', }); @@ -331,7 +331,7 @@ function claimTransfer(id, requestId, actor) { resourceId: transfer.id, payload: { claimableBalanceId: transfer.claimableBalanceId }, requestId, - actor, + actorToken: actor, outcome: 'success', }); @@ -353,7 +353,7 @@ function cancelTransfer(id, requestId, actor) { resourceId: transfer.id, payload: {}, requestId, - actor, + actorToken: actor, outcome: 'success', }); @@ -380,7 +380,7 @@ function archiveTransfer(id, requestId, actor) { payload: { archivedAt: transfer.archivedAt }, mutationId: transfer.updatedAt, requestId, - actor, + actorToken: actor, outcome: 'success', }); } @@ -406,7 +406,7 @@ function unarchiveTransfer(id, requestId, actor) { payload: {}, mutationId: transfer.updatedAt, requestId, - actor, + actorToken: actor, outcome: 'success', }); diff --git a/src/services/userService.js b/src/services/userService.js index 87f8772..6c77289 100644 --- a/src/services/userService.js +++ b/src/services/userService.js @@ -60,7 +60,7 @@ function createUser(data, requestId, actor) { resourceId: user.id, payload: { name: user.name, country: user.country }, requestId, - actor, + actorToken: actor, outcome: 'success', }); diff --git a/test/auditIntegrity.test.js b/test/auditIntegrity.test.js index 5561c6e..c6ea89c 100644 --- a/test/auditIntegrity.test.js +++ b/test/auditIntegrity.test.js @@ -17,6 +17,7 @@ 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'); @@ -424,6 +425,64 @@ test('query authorization: integrity endpoint requires audit:read', async () => // ─── 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', From eed04bfe87a28d5e1a4f9e24cdb884b14faf0735 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sat, 3 Oct 2026 23:52:09 -0400 Subject: [PATCH 05/11] docs: explain audit integrity investigation and actor secret setup Document authenticated audit filters, opaque actor references, and inspecting valid even when the integrity endpoint returns HTTP 200. Explain the existing import-time secret precedence and process-local chain lifetime in the README and environment template. Source and complete documentation readback are the verification for this continuation. Runtime and maintained tests were not rerun during the execution environment outage; prior source verification remains historical. --- .env.example | 8 ++++++ README.md | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) 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/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 ``` From d2962a3e9ea3778c02b2d941b2a7553da10d5deb Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 05:14:21 -0400 Subject: [PATCH 06/11] fix(audit): preserve own JSON keys in redacted integrity evidence Assigning a parsed __proto__ key to a plain clone invokes the inherited setter. The key then disappears from serialized changes and the canonical event hash, so editing that evidence does not change the hash. Construct own data properties during recursive redaction to retain the field while still redacting secrets nested beneath it. Add three maintained node:test cases for own-key and prototype preservation, nested redaction plus JSON round trips, mutation-sensitive event hashes, and the unchanged canonical digest for ordinary JSON. On the preceding source, two regressions fail and the compatibility control passes. All three pass after this change on Node 24.19.0: node --test test/auditCrypto.test.js Both changed files also pass node --check. The current full Express/HTTP suite was not run; these tests use the actual module and Node built-ins. No dependency, hash format, actor identity, replay, or storage change. --- src/utils/auditCrypto.js | 18 ++++------ test/auditCrypto.test.js | 74 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 11 deletions(-) create mode 100644 test/auditCrypto.test.js diff --git a/src/utils/auditCrypto.js b/src/utils/auditCrypto.js index 2b1b365..f225d32 100644 --- a/src/utils/auditCrypto.js +++ b/src/utils/auditCrypto.js @@ -77,17 +77,13 @@ function redact(value) { if (value == null || typeof value !== 'object') return value; if (Array.isArray(value)) return value.map(redact); - const out = {}; - for (const [key, child] of Object.entries(value)) { - if (SENSITIVE_KEY_PATTERN.test(key.replace(/-/g, '_'))) { - out[key] = REDACTED; - } else if (child != null && typeof child === 'object') { - out[key] = redact(child); - } else { - out[key] = child; - } - } - return out; + // 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), + ])); } /** 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'); +}); From 2bee10a64b326b6fffa0d4f51cbde8151affdc7d Mon Sep 17 00:00:00 2001 From: woahwhattheheck <293286387+woahwhattheheck@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:29:58 +0000 Subject: [PATCH 07/11] Include every array slot in audit integrity hashes [skip ci] --- .../audit-array-integrity-20261004.md | 21 ++++++ src/utils/auditCrypto.js | 6 +- test/auditArrayIntegrity.test.js | 74 +++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 docs/validation/audit-array-integrity-20261004.md create mode 100644 test/auditArrayIntegrity.test.js 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/utils/auditCrypto.js b/src/utils/auditCrypto.js index f225d32..5423fc9 100644 --- a/src/utils/auditCrypto.js +++ b/src/utils/auditCrypto.js @@ -41,7 +41,11 @@ function canonicalize(value) { return JSON.stringify(value) ?? 'null'; } if (Array.isArray(value)) { - return `[${value.map(canonicalize).join(',')}]`; + // Visit every numeric slot: map() skips holes and can hash [empty] as []. + const items = Array.from( + { length: value.length }, (_, index) => canonicalize(value[index]) + ); + return `[${items.join(',')}]`; } const keys = Object.keys(value).filter((key) => value[key] !== undefined).sort(); return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`).join(',')}}`; 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/); +}); From 7192af8e4cc854abe3e0b344ce741c8a0cf097ee Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 09:46:13 +0000 Subject: [PATCH 08/11] fix(audit): preserve pending claims on adapter failure [skip ci] --- docs/CLAIM_FAILURE_AUDIT.md | 31 ++++++++++++++++ src/services/transferService.js | 26 ++++++++++++-- test/auditIntegrity.test.js | 64 +++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 docs/CLAIM_FAILURE_AUDIT.md 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/src/services/transferService.js b/src/services/transferService.js index 683df40..a080e89 100644 --- a/src/services/transferService.js +++ b/src/services/transferService.js @@ -303,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; @@ -323,8 +328,25 @@ function transition(transfer, nextStatus) { */ 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', diff --git a/test/auditIntegrity.test.js b/test/auditIntegrity.test.js index c6ea89c..a9a8c7a 100644 --- a/test/auditIntegrity.test.js +++ b/test/auditIntegrity.test.js @@ -568,3 +568,67 @@ test('privileged archive/unarchive mutations each emit one outcome event', () => 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); +}); From d4f7be31dd6790646ab9d6ccd7d89a24bbdf8b87 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 08:02:17 -0400 Subject: [PATCH 09/11] perf(audit): reduce canonical encoding allocations Preserve canonical bytes, existing integrity hashes and sparse-array handling while avoiding temporary item arrays. Include paired measurements and the reusable benchmark for the production encoder and hash function. --- benchmark/auditCrypto-results.json | 211 +++++++++++++++++++++++++++++ benchmark/auditCrypto-results.md | 68 ++++++++++ benchmark/auditCrypto.js | 124 +++++++++++++++++ src/utils/auditCrypto.js | 21 ++- 4 files changed, 418 insertions(+), 6 deletions(-) create mode 100644 benchmark/auditCrypto-results.json create mode 100644 benchmark/auditCrypto-results.md create mode 100644 benchmark/auditCrypto.js 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/src/utils/auditCrypto.js b/src/utils/auditCrypto.js index 5423fc9..0027593 100644 --- a/src/utils/auditCrypto.js +++ b/src/utils/auditCrypto.js @@ -41,14 +41,23 @@ function canonicalize(value) { return JSON.stringify(value) ?? 'null'; } if (Array.isArray(value)) { - // Visit every numeric slot: map() skips holes and can hash [empty] as []. - const items = Array.from( - { length: value.length }, (_, index) => canonicalize(value[index]) - ); - return `[${items.join(',')}]`; + // 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(); - return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`).join(',')}}`; + 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}}`; } /** From 887d5b17e294e350f5a3ba13547e297cf5920738 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 08:17:27 -0400 Subject: [PATCH 10/11] fix(audit): report malformed payloads as failed integrity checks Catch failures from hash recomputation and compatibility payload encoding. Return the broken index and a fixed reason without exposing the payload exception or changing stored hashes. Add two focused regressions covering cyclic changes and a throwing payload accessor. --- src/services/auditService.js | 48 ++++++++++++++++---------- test/auditMalformedIntegrity.test.js | 51 ++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 18 deletions(-) create mode 100644 test/auditMalformedIntegrity.test.js diff --git a/src/services/auditService.js b/src/services/auditService.js index c4d13aa..c46ecf4 100644 --- a/src/services/auditService.js +++ b/src/services/auditService.js @@ -211,29 +211,41 @@ function verifyIntegrity() { }; } - const recomputed = computeEntryHash(entry, expectedPrev); - if (recomputed !== entry.entryHash) { + 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: `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}`, + reason: 'entry contents cannot be verified', }; } 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); +}); From 93b24ba70075e9bd547997aa6ec027582e6c2de4 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 10:58:11 -0400 Subject: [PATCH 11/11] fix(audit): cover prefixed credential key --- src/utils/auditCrypto.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/auditCrypto.js b/src/utils/auditCrypto.js index 0027593..ccf045e 100644 --- a/src/utils/auditCrypto.js +++ b/src/utils/auditCrypto.js @@ -27,7 +27,7 @@ const ACTOR_SECRET = process.env.AUDIT_ACTOR_SECRET * 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|api[_-]?key|authorization|auth|bearer|private[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|ssn|cvv|pin|credential|credentials)$/i; +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]';