morphit/docs/AUDIT-FINDINGS.md

48 KiB
Raw Permalink Blame History

Pre-launch deep audit — findings catalog

Item 17. Multi-session campaign. Each pass focuses on a subsystem, documents what was checked, what was found, what was fixed, and what was left intentionally unfixed.

Severity tiers:

  • CRITICAL — exploitable, attacker-reachable, money-loss or data-loss.
  • HIGH — exploitable in the threat model, partial mitigation exists.
  • MEDIUM — defense-in-depth gap, narrow attack surface, or theoretical.
  • LOW — style / consistency / robustness improvement.
  • INFO — note for future readers, not a finding per se.

Each finding has:

  • ID (P{part}-{N}, e.g. P1-3 = part 1 finding 3)
  • Subsystem
  • Status: OPEN / FIXED / DOCUMENTED / DEFERRED
  • Description
  • Impact
  • Resolution

Part 1 — Batch M deltas (2026-04-30)

Subsystems audited: pending-feedback-reminder helper, onion-location helper, iPhone install path, first-trade helper, install prompt, C-19 follow-on regex relaxation, relay client.ts warn-log, web feedback ops prefillSubject.

P1-1 — pendingReminders.ts XSS surface via reviewer name

Subsystem: Item 3 reminder system Status: DOCUMENTED (informational — bounded by Blurt rules) Description: received.reviewer flows into IdentityLabel and into the OS notification body template. A malicious indexer could return an account name with hostile content if it bypassed the Blurt account-name rules. Impact: Bounded — Blurt's is_valid_account_name enforces ≤16 chars, alphanumeric + dot/dash, starting with a letter. The chain itself is the trust boundary; if a malicious indexer fabricated a reviewer name not on chain, the worst case is text-only display. IdentityLabel does not pass user-derived strings to {@html} — only the operator's pre-sanitized avatarSvg, which the reminder banner never sets. Resolution: No fix needed. Note recorded for future readers.

Subsystem: Item 3 reminder system Status: FIXED (was already correct in original implementation) Description: OS notification deep-links to /my/orders#feedback=<permlink>. The hash parser at /my/orders/+page.svelte uses ^#feedback=([A-Za-z0-9-]+). Impact: None — the regex bounds the input to safe characters. Resolution: Verified.

P1-3 — onionLocation.ts non-.onion guard

Subsystem: Item 5 onion-location helper Status: FIXED (intentional defensive guard in original) Description: Operator misconfiguring alt_networks.tor with a non-.onion address would otherwise become an open-redirect surface in Tor Browser's address bar (the browser auto-prompts users to switch to the configured value). Impact: Without the guard: Tor Browser users on the clearnet site get prompted to switch to evil.com if the operator set alt_networks.tor = "evil.com". Resolution: if (!torHost.endsWith('.onion')) return null; — verified at line 51 of onionLocation.ts.

P1-4 — iPhone install meta tags

Subsystem: app.html iOS PWA hints Status: DOCUMENTED Description: Audited the new iOS-specific meta tags (apple-mobile-web-app-capable, apple-mobile-web-app-status-bar-style, apple-mobile-web-app-title, mobile-web-app-capable). Impact: None — all values are project-fixed strings, no user-controlled flow. The Permissions-Policy and referrer no-referrer headers remain tight. Resolution: No fix needed.

P1-5 — FirstTradeHelper sessionStorage quota

Subsystem: Item 16 first-trade helper Status: DOCUMENTED (no fix needed) Description: orderPermlink flows directly into dismissedSet without length validation. A 1MB string would consume sessionStorage quota. Impact: Mitigated — the only caller is ConversationView which gets orderPermlink from a route param matched by the permlink matcher (≤32 chars, lowercase-alnum-with-hyphens). Resolution: No fix needed. The route matcher is the validation boundary.

P1-6 — installPrompt event-shape trust

Subsystem: Item 16 phase 5 PWA install prompt Status: DOCUMENTED (out of threat model) Description: The beforeinstallprompt event handler stores e without runtime type-checking, then later calls e.prompt(). A hostile in-page extension could fire a custom event with an attacker-controlled prompt function that runs in our page's context. Impact: None within Morphit's threat model — browser extensions can already inject arbitrary code into the page. Resolution: Documented. Browser extensions are out-of-threat-model.

P1-7 — C-19 regex relaxation: dotted-account-name impact

Subsystem: 4 files canonicalized to [a-z][a-z0-9.-]{2,15} Status: AUDITED Description: Allowing . in account names could in theory open SQL injection, path traversal, regex-context, or HTML-context issues at downstream call sites. Impact: All audit checks passed:

  • SQL: all writes parameterized via $1, $2, ....
  • Filesystem: account names never become paths anywhere in the codebase.
  • URL paths: dot is RFC 3986 unreserved.
  • HTML output: no {@html} flow from account names.
  • Subaccount-aware logic: none — names treated as opaque strings. Resolution: Safe to relax.

P1-8 — relay client.ts warn-log raw_value forensic disclosure

Subsystem: Relay chain-fee fallback warning Status: ACCEPTED Description: console.warn of raw_value from a chain RPC response could surface attacker-controlled strings if the RPC is hostile. Subsequent log-tail-paste-into-Slack would leak them. Impact: Narrow. An attacker controlling the RPC can already inject anything into the chain-data path. Logging raw value is appropriate forensically — operator needs to see what came back to diagnose RPC issues. Resolution: Accepted. Forensic value > narrow log-poisoning risk.

P1-9 — LeaveFeedbackForm prefillSubject length cap

Subsystem: Item 3 LeaveFeedbackForm Status: DOCUMENTED (no fix needed) Description: No length validation on prefillSubject prop. Impact: Mitigated — the realistic caller (PendingFeedbackReminderBanner) sources from chain-data counterpartyAccount, bounded by Blurt account-name rules. Resolution: No fix needed.


Part 2 — Relay key handling + signing path (2026-04-30)

Subsystems audited: passphrase-at-boot unlock flow (config/unlock.ts), key envelope encrypt/decrypt (crypto/keyEnvelope.ts), relay sign path in blurt/client.ts, drainer queue defense (queue/drainer.ts).

Headline finding: P2-4 (CRITICAL) — the relay's encrypted-key envelope was unusable on stock Node. Fixed.

P2-1 — Passphrase string can't be zeroed in JS

Subsystem: unlock.ts Status: DOCUMENTED (out of practical reach) Description: passphrase = '' reassignment after use doesn't wipe the V8 string from memory. Strings are immutable in JS. Impact: A heap dump after a failed unlock could recover the original string until next major GC. Resolution: Documented. Same constraint as docs/SECURITY.md Finding F. Refactoring to Uint8Array rejected there for the same reason — closes one door in a house where every other door is open at the same privilege level.

P2-2 — Wrong-passphrase detection by error-message prefix (FIXED)

Subsystem: unlock.ts ↔ keyEnvelope.ts Status: FIXED Description: unlock.ts distinguished retryable wrong-passphrase from fatal malformed-envelope by string-prefix matching the error message: err.message.startsWith('decryption failed'). If the message is ever rephrased, wrong-passphrase becomes "fatal" → relay won't boot for a typoing operator. Impact: Brittleness, not security per se — but operator inability to recover from a typo is a real outage condition. Resolution: Added a structural code field to KeyEnvelopeError: 'decryption_failed' | 'malformed' | 'weak_params'. unlock.ts now branches on err.code rather than err.message. Smoke verifies all three codes.

P2-3 — Wrong-passphrase indistinguishable from tampered envelope

Subsystem: keyEnvelope.ts Status: ACCEPTED (correct security posture) Description: The decryption_failed code lumps wrong-passphrase and tampered-envelope together. GCM auth doesn't distinguish either, and we follow that. The unlock loop retries 3 times even if the file was tampered (because we can't tell). Impact: None — the operator gets 3 retry prompts, all fail, the service refuses to start. A tampered-file attacker gains no information advantage. Resolution: No fix. Indistinguishable failure modes is the correct property here; not leaking which mode failed is a feature.

P2-4 — CRITICAL: scrypt at N=2^17 fails on stock Node (FIXED)

Subsystem: keyEnvelope.ts encrypt + decrypt Status: FIXED Description: scrypt's memory cost is 128 × N × r bytes, which for N=2^17, r=8 is ~134 MB. OpenSSL's default maxmem is 32 MB. Without passing maxmem, every encrypt/decrypt call throws "Invalid scrypt params: error:030000AC:digital envelope routines:: memory limit exceeded". This means: any operator who used the encrypted-key envelope path could not start their relay.

Verified directly via node -e on the canonical params:

node -e "const c = require('node:crypto'); try { c.scryptSync('pass', 'salt', 32, { N: 131072, r: 8, p: 1 }); console.log('ok'); } catch (e) { console.log('FAIL:', e.message); }"
FAIL: Invalid scrypt params: error:030000AC:digital envelope routines::memory limit exceeded

Impact: Encrypted-key path completely unusable. Operators following docs/OPERATIONS.md §3 ("encrypted envelope → passphrase- at-boot") would find their service unable to boot.

Why it wasn't caught: No smoke covered the encrypt/decrypt round-trip. The path was design-correct on paper but never exercised end-to-end.

Resolution:

  1. Added SCRYPT_MAXMEM = 256 MB constant.
  2. Threaded maxmem: SCRYPT_MAXMEM through both scryptSync call sites (encrypt + decrypt).
  3. Built scripts/key-envelope-smoke.ts with 16 scenarios covering happy path + wrong passphrase + tampered ciphertext / iv / tag + weak params + every malformed-shape rejection.
  4. Registered in scripts/run-smokes.sh.

Smoke now passes 16/16. Without the fix it would have failed at the first scenario.

P2-5 — PrivateKey object survives until GC

Subsystem: blurt/client.ts broadcast methods Status: DOCUMENTED (V8 limitation) Description: PrivateKey.fromString(args.creatorActiveWif) is local but the secret is held in a Buffer that survives until GC. Impact: Same window as P2-1. Resolution: Documented.

P2-6 — WIF strings on args objects not zeroed

Subsystem: blurt/client.ts call sites Status: DOCUMENTED (codebase posture) Description: Callers pass creatorActiveWif / fromActiveWif as plain strings on the args object. After use, no args.creatorActiveWif = ''. Impact: Same window as P2-1. Resolution: Documented.

P2-7 — Memo passes through unchecked in broadcastTransfer (mitigated upstream)

Subsystem: blurt/client.ts → drainer.ts Status: MITIGATED (defense-in-depth in caller) Description: broadcastTransfer accepts arbitrary memo string that goes on chain plaintext. If a caller passed user-controlled data, it would be public. Impact: Mitigated — drainer.ts validates row.reason against /^[a-z0-9_:-]{1,64}$/ before constructing morphit:${reason}, and api/create.ts uses a fixed string 'morphit:signup_dust'. No path lets user-controlled data reach the memo. Resolution: Already fine. Documented for future call-site authors.

P2-8 — Drainer recipient regex was non-canonical (FIXED)

Subsystem: queue/drainer.ts Status: FIXED Description: Drainer's defense-in-depth ACCOUNT_NAME_RE was the OLD [a-z][a-z0-9-]{2,15} (no dot). For dotted-account-name users (e.g. alice.alpha), the relay would reject the welcome- bonus row as "invalid recipient" and refuse to deliver it. Impact: Welcome bonus + loyalty milestones silently fail to deliver for any user with a dotted account name. Resolution: Canonicalized to /^[a-z][a-z0-9.-]{2,15}$/ matching the indexer's apps/indexer/src/api/shared.ts isAccountName. Same regex relaxation as Part 1's C-19 follow-on consistency pass — drainer was a 5th file that should have been included.

P2-9 — convertBpToVests Number→BigInt scale boundary

Subsystem: blurt/client.ts BP-to-VESTS converter Status: ACCEPTED (out of operating range) Description: Math.round(bp * 10^scale) could exceed Number.MAX_SAFE_INTEGER for very large bp. With BLURT scale=3, threshold is bp > 9e12. Realistic milestone tier maxes at 1000. Impact: None at any realistic operating point. Resolution: Accepted.

Subsystem-level conclusions

Relay key handling subsystem:

Encrypt/decrypt envelope NOW works (P2-4 fix). Code-level error categorization is structural, not string-based (P2-2). AES-256-GCM with fresh IV + salt per encrypt — sound construction. scrypt N=2^17 enforced at decrypt — can't be downgraded by attacker. Bounded passphrase-attempt budget. Tampered envelope detected via GCM auth tag. Smoke coverage: 16 scenarios, runs in ~10s.

Sign path:

PrivateKey constructed per call, no long-lived key object. Self-delegation refused. Amount validation + caps in drainer (defense-in-depth). Endpoint rotation only on transport failure; RPC errors bubble. Recipient regex now canonical with rest of codebase (P2-8 fix).

Drainer:

FOR UPDATE SKIP LOCKED — concurrent-safe. Bounded retry count. 10-min hold-off prevents double-broadcast (post-N23 mitigation). Conditional UPDATE WHERE broadcast_at IS NULL — paranoid but correct defense in depth.

No HIGH or CRITICAL issues remain in this subsystem after the Part 2 fixes.


Part 3 — Indexer chain-op handlers (2026-04-30)

Subsystems audited: dispatcher (indexer/dispatcher.ts), signature verifier (blurt/verify.ts), block.ts (user-block handler — same op space, different name), handlers/order.ts (largest handler, highest financial impact), handlers/feeAttest.ts (recently-touched in C-19 follow-on). Skim review of handlers/orderReplace.ts to verify validation parity with order.ts.

Headline findings:

  • P3-2 (MEDIUM, FIXED) — savepoint name interpolation hardened with explicit integer assertion (defense-in-depth).
  • P3-5 (MEDIUM, FIXED) — duplicate payment_methods entries now rejected on insert AND replace; fix covers NFC-normalized collisions.
  • P3-11 (LOW, FIXED) — feeAttest length-check moved before regex-test to avoid wasting CPU on multi-MB inputs.
  • Two regression smoke scenarios added (duplicate + NFC-equiv duplicate).

P3-1 — Malformed-JSON _raw payload size

Subsystem: dispatcher event log Status: DOCUMENTED Description: Dispatcher writes payload: { _raw: op.json } on malformed_json rejection. If op.json is huge, this consumes DB row space proportionally. Impact: Bounded by Blurt's 50KB custom_json size cap upstream. Resolution: Documented.

P3-2 — Savepoint name SQL identifier injection (FIXED)

Subsystem: dispatcher per-op savepoint Status: FIXED (defense-in-depth) Description: SAVEPOINT op_${trxInBlock}_${opInTrx} interpolates two values directly into the query string. They come from JS array indices upstream and are integers in normal flow, but a future refactor could let a string slip through. Impact: None today — guaranteed integers. Hardening defends against future regressions. Resolution: Added Number.isInteger() && >= 0 guard before the string interpolation. Throws if violated, which the dispatcher's outer try/catch turns into a handler_threw event-log entry.

P3-3 — op.json size before JSON.parse

Subsystem: verify.ts parseJsonPayload Status: DOCUMENTED Description: No size cap before JSON.parse. Blurt enforces 50KB upstream, but if that ever changed an adversarial op could consume parser memory. Impact: Defense-in-depth gap; chain enforcement is the right defense layer. Resolution: Documented.

P3-4 — Order amount as JS Number (precision >2^53)

Subsystem: order.ts validator Status: ACCEPTED (out of operating range) Description: amount_min/max are IEEE 754 doubles. Loses precision above 2^53. Impact: No realistic order falls in this range. Resolution: Accepted.

P3-5 — Duplicate payment_methods entries (FIXED)

Subsystem: order.ts + orderReplace.ts validator Status: FIXED Description: payment_methods array allowed duplicates. A user could submit ["paypal", "paypal", ..., "paypal"] 12 times to inflate their order's payment-method tags or game any dedup- unaware filter. Visually noisy in the orderbook. Impact: Not a security issue per se, but a real misuse vector. Resolution: Added a seenPm: Set<string> after NFC normalization; returns payment_method_item_duplicate on collision. Fix applied to both order.ts (insert) and orderReplace.ts (replace). Two regression scenarios added to order-handler-smoke.ts covering the basic case and the NFC-normalized-collision case.

P3-6 — Waiver atomic-claim TOCTOU (verified safe)

Subsystem: order.ts waived_first_buy path Status: AUDITED Description: Prior-order count and waiver claim run as separate queries. Same-block races could in principle let two waiver-orders both pass priorCount = 0. Impact: Mitigated by the atomic claim INSERT ... ON CONFLICT (name) DO UPDATE ... WHERE first_buy_waived_at IS NULL RETURNING ... — the predicate makes the second op's rowCount === 0. Resolution: Already correct.

P3-7 — Waiver claim DO UPDATE branch correctness (verified safe)

Subsystem: order.ts ON CONFLICT semantics Status: AUDITED Description: ON CONFLICT triggers DO UPDATE only on row existence. The WHERE clause then filters to NULL rows. Both branches are correct. Resolution: Already correct.

P3-8 — fee-reuse query style

Subsystem: order.ts BTC/XMR path Status: ACCEPTED Description: Reuse-detection uses NOT (account = $3 AND permlink = $4) rather than the more idiomatic (account, permlink) <> ($3, $4). Same semantics. Resolution: Accepted (style preference only).

P3-9 — Reuse-rejection doesn't surface prior claimer to user

Subsystem: order.ts BTC/XMR reuse path Status: DOCUMENTED Description: When a tx-id is reused, the prior claimer's account is logged for operator inspection but not surfaced to the user via the rejection path. Impact: UX gap, not security. A user who finds their tx-id was front-run by another account would need operator support to investigate. Resolution: Documented as a future support-path enhancement.

P3-10 — feeAttest count from PG bigint

Subsystem: feeAttest.ts attestor counting Status: ACCEPTED (out of operating range) Description: Number(counts.rows[0]!.total_attestors) parses a PG bigint as JS Number. Lossy above 2^53. Impact: No order will have 2^53+ attestors. Resolution: Accepted.

P3-11 — feeAttest length check after regex (FIXED)

Subsystem: feeAttest.ts payload validation Status: FIXED (defense-in-depth) Description: PERMLINK_RE.test(...) ran before the length check. The regex pattern matches arbitrary-length strings; running it on a multi-MB input wastes CPU. Impact: Bounded — chain caps custom_json upstream. Hardening removes the wasted CPU path. Resolution: Reordered — length-check now precedes regex-test.

Subsystem-level conclusions

Dispatcher: Per-op savepoint isolates handler failures. try/catch around handler invocation + rollback on throw. Stable sort with admission/consumer priority class (Finding A9). Pending-change buffers cleared on rollback (no phantom-event leak). markFirstActivity only fires on success. ON CONFLICT DO NOTHING in writeEventLog handles poller retry. Savepoint name now hardened with integer assertion (P3-2 fix).

Signature/payload extraction: Active-key custom_json rejected (Morphit is posting-only). Multi-sig posting auths rejected (out-of-scope for v1). JSON.parse wrapped in try/catch.

order.ts: Strict per-field validation: type → length → charset → semantic. Forbidden-char filter blocks control + bidi + ZWJ across user-text fields. NFC normalization applied before storage and dup-check. amount_min/max validated as finite + non-negative + min ≤ max. price_model size-bounded. expires_at strict ISO-8601 (rejects native-Date informal strings). Atomic waiver claim with WHERE-NULL predicate. Waiver requires BUY + BLURT + min 500 BLURT. Fee-tx reuse detected before verifier hit. External txid hex-validated and lowercased. Duplicate payment_methods entries now rejected (P3-5 fix).

feeAttest.ts: Account-name regex now canonical (C-19 already-applied). Permlink length check precedes regex-test (P3-11 fix). Order existence check before insert. Attestor eligibility gate (Finding I). UNIQUE constraint catches dup attestations. ≥2 distinct + non-poster rule encoded in single COUNT FILTER query. Atomic UPDATE WHERE fee_status='pending_external' for promotion.

No HIGH or CRITICAL issues remain in this subsystem after the Part 3 fixes.

The indexer's input-layer trust boundary is solid. Validation at intake is consistent across handlers, errors are categorized structurally, and per-op savepoints contain failure damage. The two finds in this part — duplicate-pm acceptance and savepoint- name shape-trust — were both real but neither was an exploit.

Coverage and gaps

Audited this part: dispatcher.ts, verify.ts, block.ts, order.ts, orderReplace.ts (validator only), feeAttest.ts.

NOT yet audited: chat.ts, chatIdentity.ts, chatRead.ts, feedback.ts, feedbackResponse.ts, featureBid.ts, operatorBlock.ts, operatorPaymentMethod.ts, operatorRegister.ts, profile.ts, release.ts, strangerFee.ts.

Recommended next part: chat surface (chat.ts + chatIdentity.ts + chatRead.ts + strangerFee.ts) — the chat subsystem has had the most recent change activity and is the largest concentration of S2-class findings.


Part 4 — Chat surface (2026-04-30)

Subsystems audited: handlers/chat.ts (message handler with 3-layer anti-spam triad), handlers/chatIdentity.ts (chat-pubkey publication), handlers/chatRead.ts (read-receipt acks), handlers/strangerFee.ts (first-contact admission fee), indexer/strangerFeePricing.ts (escalating-fee compute). Spot-check of handlers/feedback.ts.

Headline finding: P4-10 (HIGH) — stranger-fee escalation pricing used NOW() instead of ctx.blockTime, breaking determinism on indexer replay. Fixed; regression test added.

P4-1 — chat.ts ciphertext base64 regex too permissive (FIXED)

Subsystem: chat.ts payload validation Status: FIXED Description: Ciphertext regex /^[A-Za-z0-9+/]+=*$/ accepted non-canonical base64: any number of trailing = chars, length not multiple of 4. Demonstrably-malformed strings landed in DB (decrypt would fail loudly on recipient side, but the row existed). Impact: No exploit; cleanliness gap. Resolution: Tightened to /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ — length-divisible-by-4 + at-most-2-padding-chars per real base64 rules. Existing chat-handler-smoke (15 scenarios) still passes, confirming legitimate clients produce conformant output.

P4-2 — chat_messages.id parsed as JS Number

Subsystem: chat.ts bus emit Status: DOCUMENTED (out of operating range) Description: parseInt(inserted.id, 10) for the bus event. chat_messages.id is SERIAL (32-bit, max ~2.1B); fine today but silent truncation if migrated to BIGINT. Impact: None at current scale. Resolution: Documented. Migration to BIGINT would need to revisit this site.

P4-3 — Fan-in cap accounting (verified safe)

Subsystem: chat.ts layer-3 rate limit Status: AUDITED Description: Verified that the fan-in unique-sender count correctly excludes (a) senders the recipient has replied to and (b) blocked senders. Same-block-as-self counting verified (current sender is in candidates via SELECT $2::text, filtered appropriately by NOT EXISTS reply). Resolution: Already correct.

P4-4 — Fan-in time arithmetic uses ctx.blockTime (verified safe)

Subsystem: chat.ts layer-3 query Status: AUDITED Description: The created_at > $3 - INTERVAL '24 hours' uses ctx.blockTime (deterministic), unlike the strangerFeePricing bug. Resolution: Already correct. Used as model for the P4-10 fix.

P4-5 — Fan-in COUNT(DISTINCT) bounded by index

Subsystem: chat.ts rate-limit query Status: AUDITED Description: Verified chat_messages_recipient_idx covers (recipient, created_at DESC). The 24h fan-in scan is index-supported. Resolution: Already correct.

P4-6 — chatIdentity accepted low-order X25519 points (FIXED)

Subsystem: chatIdentity.ts pubkey validation Status: FIXED Description: Original code rejected only the all-zero (point-at-infinity) pubkey. Curve25519 has eight small-order points per RFC 7748 §6.1, all of which produce predictable / trivially-recoverable DH outputs. A buggy or malicious client publishing one of these would weaken the chat session. Impact: A self-attacked chat session — only the publishing account is affected. But the cost of blocking is trivial. Resolution: Added LOW_ORDER_X25519_POINTS table covering all eight values per RFC 7748 §6.1, plus their high-bit-set variants (X25519 masks bit 255 before scalarmult, so both forms produce the same output). Built isLowOrderX25519Point helper. Reject reason changed from chat_pub_all_zero to the broader chat_pub_low_order. Built dedicated chat-identity-handler-smoke.ts with 12 scenarios covering each rejection path and the happy-path upsert. Registered.

P4-7 — strangerFee BLURT amount precision

Subsystem: strangerFee.ts findStrangerFeeTransfer Status: ACCEPTED (out of operating range) Description: Same JS Number > 2^53 precision concern as P3-4 / P4-2. Realistic stranger-fees are 5-640 BLURT. Resolution: Accepted.

P4-8 — strangerFee overpayment captured (design)

Subsystem: strangerFee.ts amount tolerance Status: DOCUMENTED (intentional) Description: Quoted amount up to 1.5× current price is accepted; excess goes to feeRecipient with no refund. Impact: UX friction in the worst case (user paid more than needed). Not a security issue. Resolution: Documented as intentional per the handler comment.

P4-9 — strangerFee multiplier float arithmetic

Subsystem: strangerFee.ts quote bound check Status: AUDITED Description: quote.priceBlurt * 1.5 and quote.priceBlurt * (1 - feeTolerance) use floats. Realistic prices max at 640; multiplied by 1.5 gives 960. Far below MAX_SAFE_INTEGER. Resolution: Already correct.

P4-10 — HIGH: strangerFee pricing used NOW() (FIXED)

Subsystem: strangerFeePricing.getStrangerFeeQuote Status: FIXED Description: The escalation-window query was paid_at > NOW() - INTERVAL '5 minutes'. NOW() is the wall clock at query execution time, not the chain block time. During indexer replay (e.g. bootstrapping a fresh DB from Blurt history), historical fees fall outside the "5-minute" window because that window is anchored to the present, not to the block being replayed. Result: same op gets a different rejection verdict on replay than it did on the original real-time pass.

Specifically, an op that was rejected with amount_blurt_below_current_quote (because the user paid the 1× rate when the current rate was 4×, owing to recent fees in the 5-minute window) would, on replay, see those recent fees as out-of-window. Multiplier resets to 1. Same op now passes.

This is exactly the kind of non-determinism that breaks "replay produces the same database state" — a property the indexer's whole architecture depends on for safe recovery.

Impact: HIGH because:

  1. Different rejection verdict between real-time and replay means an operator bootstrapping a fresh node ends up with a different ops event-log state than the canonical one.
  2. The accept-path of the replay includes ops the real-time pass had rejected — meaning rows in stranger_fees differ too.
  3. Two operators running the same chain history could end up with different chat-admission state, depending on whether they bootstrapped or ran from genesis.

Resolution: Added optional now?: Date parameter to getStrangerFeeQuote. When passed, the query uses paid_at > $3::timestamptz - INTERVAL '...' (deterministic). Handler call site now passes ctx.blockTime. API call site (real-time UI quote) can omit now and continues using NOW() — appropriate there since the user wants the live price as of right now.

Two regression scenarios added to stranger-fee-pricing-smoke.ts:

  • "passing now Date triggers deterministic query path" (asserts 3-param branch is taken and the timestamp matches)
  • "omitting now uses NOW() (real-time) path" (asserts 2-param branch is unchanged for API callers)

Smoke now covers 14 scenarios; total smokes 1136.

Subsystem: feedback.ts validator Status: FIXED Description: Same defensive-ordering issue as P3-11 in feeAttest.ts — PERMLINK_RE.test(...) ran before the length check, leaving CPU exposed to multi-MB input. Impact: Bounded by chain custom_json cap. Resolution: Length check now precedes regex test.

P4-12 — feedback.ts welcome-bonus savepoint name

Subsystem: feedback.ts welcome-bonus path Status: AUDITED Description: 'welcome_bonus_sp' is a fixed string, safe. Resolution: Already correct.

P4-13 — feedback.ts upsert placeholder values

Subsystem: feedback.ts accounts upsert Status: DOCUMENTED (known design) Description: Upsert uses placeholder creator='', block_num=0, trx_id='' for users whose Blurt accounts predate the indexer's startBlock. The handler comment notes "future cleanup could backfill from chain history". Resolution: Documented as known.

Subsystem-level conclusions

chat.ts: Strict ciphertext base64 (P4-1 fix). Block check before admit check (layer 1 first). Admit check correctly broadens for backward compat. Fan-in cap excludes blocked senders + replied-to senders. Per-pair cap correctly compares >= cap (rejects on cap-th). INSERT parameterized. UNIQUE-violation catches duplicate-trx-id replays. Bus emit gated on actual insert success.

chatIdentity.ts: All eight RFC 7748 small-order points rejected (P4-6 fix). Strict canonical base64 round-trip check. 32-byte length enforced. Self-attested with comment explaining why lying hurts only the publisher. Upsert allows safe identity rotation.

chatRead.ts: Account regex (canonical). Strict ISO-8601 shape. Future-skew bound (60s tolerance). Past floor at 2020. Monotonic-advance via WHERE in DO UPDATE.

strangerFee.ts: Account regex (canonical). Self-fee rejected. Idempotency check before pricing/transfer-verify. Replay-deterministic pricing now (P4-10 fix). Memo binding prevents replay across recipients. Underpayment rejection. Race-on-PK translated to ok:true.

feedback.ts (spot-check): Self-review rejected. Rating range check. Comment NFC + length + forbidden-char. order_permlink length-before-regex (P4-11 fix). Order ownership check (Finding R17). Welcome bonus gated on order_permlink presence. Atomic claim via ON CONFLICT DO UPDATE WHERE NULL. Savepoint isolates bonus failure from feedback success.

No HIGH or CRITICAL issues remain in this subsystem after the Part 4 fixes.

The chat surface is well-defended at the input layer. Anti-spam triad (block / admit / rate-limit) is defense-in-depth across three independent checks. The strangerFee escalation is now replay-deterministic — same op produces the same verdict during recovery as during real-time. The chat-identity layer rejects all known X25519 weak keys.

Coverage and gaps

Audited this part: chat.ts, chatIdentity.ts, chatRead.ts, strangerFee.ts, strangerFeePricing.ts, feedback.ts (spot).

NOT yet audited: feedbackResponse.ts, featureBid.ts, operatorBlock.ts, operatorPaymentMethod.ts, operatorRegister.ts, profile.ts, release.ts.

Remaining trust-layer subsystems for future passes:

  • Web keystore unlock + sign path — mirror of Part 2 but user-side. The user's posting key is decrypted in-browser when they unlock, used to sign a chat message or order op, and re-locked. Critical surface for credential-theft via XSS or extension.
  • SSE / streaming endpoints — own threat model: the indexer streams data to anonymous browsers. Audit for amplification, slow-client DoS, information leakage.
  • Operator-trust subsystem — operatorRegister, operatorBlock, operatorPaymentMethod. First-come-first-served + immutable identity claims; reserved-name + typo-squat defenses.

Part 5 — Web keystore unlock + sign path (2026-04-30)

Subsystems audited: lib/crypto/keystore.ts (encrypt/decrypt + JIT unlock), lib/crypto/runWithActiveKey.ts (helper used by BLURT-paying sites), lib/crypto/persistentKeystore.ts (localStorage envelope persistence), lib/blurt/sign.ts (transaction signing + broadcast), lib/crypto/keystoreYubikey.ts (YubiKey enrollment / unlock orchestration), skim of lib/crypto/keygen.ts wipe paths.

User-stated importance: "this login and key handling stuff makes me incredibly nervous. think like an experienced black hat hacker."

Headline finding: P5-4 (MEDIUM, FIXED) — useActiveKey / useOwnerKey had expectedPostingPub as an OPTIONAL parameter. Future callers could silently disable M6 cross-tab-XSS protection by forgetting to pass it. Made required-by-construction with an explicit useActiveKeyForPasswordChange API for the (currently nonexistent) password-change flow.

Plus P5-5 (LOW, FIXED) — the M6 mismatch error was being swallowed as bad_password by a substring heuristic; the security signal was hidden from the user. Now propagates as a distinct identity_mismatch kind through the call chain.

P5-1 — Decrypt path uses argonParams() not envelope params

Status: DOCUMENTED (intentional). Decrypt uses libsodium's INTERACTIVE constants, not the envelope's stored opslimit/memlimit. Forward-compat improvement; assertSafeKdfParams floor catches the attacker-tampered-weak-params case.

P5-2 — blobToEnvelope didn't validate at parse (FIXED)

Status: FIXED (defense-in-depth). blobToEnvelope now calls validateLayeredEnvelope(parsed) at parse time when scheme matches 'layered-cek'.

P5-3 — useJitKey with undefined expectedPostingPub

Status: SUPERSEDED by P5-4.

P5-4 — expectedPostingPub now required (FIXED)

Status: FIXED. Made expectedPostingPub required on both useActiveKey and useOwnerKey. Added separate useActiveKeyForPasswordChange API that explicitly skips M6 check — reserved for future password-change flow operating on a freshly- supplied envelope. The _ForPasswordChange naming makes the security trade-off explicit at every call site. Tests in crypto.test.ts updated to use the new API; added regression test verifying that an attacker's envelope decrypted under the victim's password but pinned to the victim's posting pubkey throws /different identity than the live session/.

P5-5 — M6 mismatch swallowed as bad_password (FIXED)

Status: FIXED. Added 'identity_mismatch' to ActiveKeyErrKind discriminated union. Catch block checks for 'different identity than the live session' BEFORE the generic 'password'/'decrypt' bucket. Updated all 4 production call sites: FeatureBidForm.svelte, PayBlurtModal.svelte, StrangerFeeModal.svelte, routes/post/+page.svelte. Added i18n key crypto.error.identity_mismatch across all 10 locales via scripts/inject-identity-mismatch-i18n.py.

P5-6 — readEnvelope didn't validate at parse (FIXED)

Status: FIXED. readEnvelope now calls validateLayeredEnvelope(parsed) at read time for layered-CEK.

P5-7 — sign.ts activePriv buffer not zeroed inside helper

Status: DOCUMENTED (V8 limitation). dblurt PrivateKey holds Buffer-backed scalar; same constraint as Part 2 P2-5/P2-6. Caller (useActiveKey) wipes activePriv itself in finally.

P5-8 — signOrderWithFeeWithKey holds two PrivateKey objects

Status: DOCUMENTED (V8 limitation).

P5-9 — broadcastCustomJson keeps posting key in scope through network roundtrip

Status: ACCEPTED (intentional design). Posting key is session- resident; F-18 split (prepare→sign→broadcast) only applies to active-key paths.

P5-10 — unlockWithYubikey accumulates only the final error

Status: ACCEPTED (UX-only). Multi-YubiKey envelope rare; typical case is 1 key.

P5-11 — unenrollWrap recovery-path semantics

Status: AUDITED . Invariant ("at least one wrap remains") correctly enforced.

Subsystem-level conclusions

The web keystore + sign path is the highest-stakes user-side surface — if compromised, users lose money. The core JIT pattern is robust by design; M6 cross-tab defense is now structurally enforced (P5-4) and propagates correctly to the UI (P5-5); storage-boundary validation is consistent (P5-2, P5-6). Remaining LOW/DOCUMENTED items are V8-string-immutability constraints requiring a libsodium- buffer-everywhere refactor — out of scope.

No HIGH or CRITICAL issues remain in this subsystem.


Part 6 — Operator-trust subsystem (2026-04-30)

Subsystems audited: handlers/operatorRegister.ts, handlers/operatorBlock.ts, handlers/operatorPaymentMethod.ts.

Threat model focus: the operator is the social-trust anchor on the instance. If a hostile party can register a tag impersonating the project, or get a payment-method URL pointing to a phishing site approved into the instance directory, downstream users get phished. Chain federation means there's no central authority to revoke a bad registration after the fact.

Headline finding: P6-3 (LOW, FIXED) — operator-tag was not protected against project-reserved name squatting. A hostile account could register tag morphit, morphit-fees, or agorise first; tag is immutable post-registration so the canonical project couldn't reclaim. Phishing surface in the operator directory.

Plus P6-13 (MEDIUM, FIXED) — operator-payment-method URL validator was permissive: /^https:\/\/[^\s]+$/ accepted userinfo-prefixed URLs (the https://bank.com@evil.example.com phishing pattern). Upgraded to parser-based validation matching operatorRegister.ts's contact_url policy.

P6-1 — operatorRegister tag length-check ordering (verified safe)

Status: AUDITED . Length check precedes regex.

P6-2 — operatorRegister display_name pre-NFC length cap (FIXED)

Status: FIXED. Added pre-NFC cap at dn.length > DISPLAY_NAME_MAX * 4.

P6-3 — operatorRegister tag squatting on reserved names (FIXED)

Status: FIXED. Added isReservedTag() helper to indexer's confusables.ts and web's confusables.ts. Wired into operatorRegister.ts validator and frontend validateTag(). Added tag_reserved to TagValidationReason discriminated union. Added i18n key run_a_node.register.err_tag_reserved across all 10 locales via inject-tag-reserved-i18n.py. 4 regression scenarios in operator-register-handler-smoke.ts: reserved exact-match, second reserved name, third reserved name, substring-not-blocked (mymorphit accepts).

P6-4 — operatorBlock reason length unit mismatch

Status: DOCUMENTED (cosmetic). UTF-16 vs codepoint counting.

P6-5 — operatorBlock empty-after-sanitize reason

Status: DOCUMENTED (operator-account gated).

P6-6 — operatorBlock empty reason accepted (verified safe)

Status: AUDITED . UI handles empty gracefully via operator_block.banner.no_reason_provided.

P6-7 — operatorBlock self-reported ts ignored (verified safe)

Status: AUDITED . Indexer correctly uses ctx.blockTime. Frontend documents this explicitly.

P6-8 — operatorBlock state-flip ordering (verified safe)

Status: AUDITED . Per-op savepoint isolates each op.

P6-9 — operatorBlock not_operator gate before payload

Status: ACCEPTED (intentional). No real info leak — operator account is public config.

P6-10 — operatorPaymentMethod key length-before-regex (FIXED)

Status: FIXED (defense-in-depth). Length check now precedes regex test.

P6-11 — operatorPaymentMethod name/description pre-sanitize length (verified safe)

Status: AUDITED . Defensive ordering already correct.

P6-12 — operatorPaymentMethod empty-after-sanitize description

Status: DOCUMENTED (operator-account gated).

P6-13 — operatorPaymentMethod URL validator accepted userinfo (FIXED)

Status: FIXED (MEDIUM). Replaced regex with new URL(urlRaw) parser + explicit checks for HTTPS scheme and rejection of userinfo. Added new rejection code url_has_userinfo. 4 regression scenarios in operator-payment-method-handler-smoke.ts: userinfo phishing pattern, user:password userinfo, malformed URL, legitimate URL with path/query.

Subsystem-level conclusions

The operator-trust subsystem now has consistent intake-layer defenses: reserved-name protection on operator tag (P6-3) and payment-method canonical keys; URL validation uses URL parser everywhere with consistent userinfo + scheme checks; name/reason free-text fields sanitized for bidi/zero-width/control chars and rendered as text-only by the UI.

No HIGH or CRITICAL issues remain in this subsystem.


Part 7 — SSE / streaming endpoints (2026-04-30)

Subsystems audited: api/orderbookStream.ts, api/chatStream.ts, api/instancesStream.ts, helper modules, event buses (chatEventBus.ts, orderbookEventBus.ts), poller emit discipline.

Threat model focus: amplification, slow-client DoS, connection exhaustion, filter authorization, info leakage in error paths, bus emission discipline.

Headline finding: P7-1 (MEDIUM, FIXED via doc) — operator deployment gap. SSE endpoints deliberately have no in-process connection cap (per main.ts comment) — relying on reverse proxy. But OPERATIONS.md nginx example didn't include limit_conn directives. A naive operator following the doc would deploy a node vulnerable to SSE-connection exhaustion. Fixed by adding §14.5 "SSE connection caps (mandatory hardening)" to OPERATIONS.md.

Plus P7-12 (LOW, FIXED) — instancesStreamHelpers.rowSignature used pipe-separator joining of fields. Operator-supplied content (name, tagline) can legitimately contain |, producing signature collisions. Replaced with JSON.stringify of a tuple.

P7-1 — Missing reverse-proxy connection caps in OPERATIONS.md (FIXED)

Status: FIXED. Added §14.5 with limit_conn_zone + limit_conn sse_per_ip 20 + proxy_read_timeout 5m + proxy_buffering off + Caddy alternate guidance.

P7-2 — chatStream pendingDuringSnapshot unbounded (FIXED)

Status: FIXED. Added PENDING_DURING_SNAPSHOT_CAP = 1000 constant; bus listener drops events when cap hit; fallback poll picks them up via latestEmittedId watermark.

P7-3 — chatStream snapshot LEAST/GREATEST query plan

Status: DOCUMENTED (perf). Postgres planner may not rewrite LEAST/GREATEST to use per-account indexes. Production EXPLAIN ANALYZE would tell us if expression index needed.

P7-4 — sseEvent JSON.stringify circular ref

Status: DOCUMENTED (data shape controlled).

P7-5 — orderbookStream pendingDuringSnapshot Set (verified safe)

Status: AUDITED . Set deduplicates; bounded by unique orderIds.

P7-6 — orderbookStream orderId origin (verified safe)

Status: AUDITED . orderId from bus is handler-derived; fetchOrderIfMatchesFilter re-parses defensively.

P7-7 — orderbookStream fallback poll LIMIT 1000

Status: DOCUMENTED (perf).

P7-8 — instancesStream cursor unbounded growth

Status: DOCUMENTED (bounded by federation size ≤200 per design).

P7-9 — instancesStream cursor.delete during keys() iteration (verified safe)

Status: AUDITED . Per spec, deletion during Map.keys() iteration is well-defined.

P7-10 — instancesStream poll cost (verified safe)

Status: AUDITED . 5s full-scan with LEFT JOIN trivial at ≤200 rows.

P7-11 — instancesStream rowSignature field coverage (verified safe)

Status: AUDITED . Includes all user-visible fields, excludes internal consecutive_failures per design.

P7-12 — instancesStream rowSignature pipe collision (FIXED)

Status: FIXED. Replaced pipe-joined string with JSON.stringify of a tuple. Added regression scenario.

P7-13 — Bus backpressure design (verified safe)

Status: AUDITED . Synchronous fire-and-forget; SSE listener work is async fire-and-forget. Snapshot-before-iterate, per-listener try/catch, errors to stderr, custom emitter (not node:events).

P7-14 — ReadableStream backpressure not applied

Status: DOCUMENTED (mitigated by reverse proxy). enqueue() called without checking desiredSize. Bounded by: proxy_read_timeout 5m, limit_conn 20 per IP, OS-level TCP backpressure.

Bus emission discipline (verified safe)

Both buses emit POST-COMMIT in poller (poller.ts:391-398) — only after withTx resolves. No phantom events from rolled-back ops.

Authorization analysis (verified safe)

  • chatStream.ts: filter from URL path; no auth check. Chat ciphertexts are E2E-encrypted. Metadata available on chain anyway.
  • orderbookStream.ts: filter from query string; no auth. Orderbook public by design.
  • instancesStream.ts: no filter. Federation directory public.

The indexer surfaces what's public on chain.

Subsystem-level conclusions

The SSE layer's threat model is well-handled. Amplification prevented by per-connection caps + filter-applied DB queries. Slow-client DoS bounded by reverse-proxy timeouts + per-IP connection caps. Filter authorization not needed because data is public. Bus emission disciplined to post-commit only.

The most material fix was P7-1 — codebase deferred to reverse proxy but didn't document the requirement. With OPERATIONS.md §14.5 hardening, stock-config deployments now have the right defenses out of the box.

No HIGH or CRITICAL issues remain in this subsystem.


Audit campaign summary

7 parts, 80 findings. Critical/High remediations:

  • CRITICAL (Part 2 P2-4): Relay's encrypted-key envelope was unusable on stock Node — scrypt at N=2^17 r=8 needs ~134MB but OpenSSL default maxmem is 32MB. Every encrypt/decrypt threw "Invalid scrypt params...memory limit exceeded". Operators using the docs/OPERATIONS.md §3 encrypted-envelope path could NOT start their relay. Fixed by threading SCRYPT_MAXMEM = 256 * 1024 * 1024 through both scryptSync calls in keyEnvelope.ts. New smoke apps/relay/scripts/key-envelope-smoke.ts with 16 scenarios.

  • HIGH (Part 4 P4-10): strangerFeePricing.getStrangerFeeQuote used NOW() instead of ctx.blockTime. Broke determinism on indexer replay. Fixed with optional now?: Date parameter; handler passes ctx.blockTime; API endpoint still uses NOW().

  • MEDIUM: P2-2 (KeyEnvelopeError code field), P2-8 (drainer recipient regex), P3-2 (savepoint integer assertion), P3-5 (duplicate payment_methods rejection), P4-6 (RFC 7748 small-order rejection), P5-4 (expectedPostingPub required), P6-13 (operator-payment-method URL parser), P7-1 (operator deployment gap).

  • LOW: Various length-before-regex defensive ordering fixes (P3-11, P4-1, P4-11, P6-2, P6-10), envelope-validation at parse (P5-2, P5-6), identity_mismatch error kind (P5-5), reserved-tag squatting (P6-3), pendingDuringSnapshot cap (P7-2), pipe-collision signature (P7-12).

The audit campaign is complete. The codebase has consistent defense-in-depth at every input layer (chain ops, web JIT, SSE streams, operator-trust).