48 KiB
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.
P1-2 — pendingReminders.ts hash deep-link
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:
- Added
SCRYPT_MAXMEM = 256 MBconstant. - Threaded
maxmem: SCRYPT_MAXMEMthrough bothscryptSynccall sites (encrypt + decrypt). - Built
scripts/key-envelope-smoke.tswith 16 scenarios covering happy path + wrong passphrase + tampered ciphertext / iv / tag + weak params + every malformed-shape rejection. - 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_methodsentries 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:
- Different rejection verdict between real-time and replay
means an operator bootstrapping a fresh node ends up with
a different
opsevent-log state than the canonical one. - The accept-path of the replay includes ops the real-time
pass had rejected — meaning rows in
stranger_feesdiffer too. - 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.
P4-11 — feedback.ts order_permlink length-before-regex (FIXED)
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
maxmemis 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 threadingSCRYPT_MAXMEM = 256 * 1024 * 1024through both scryptSync calls inkeyEnvelope.ts. New smokeapps/relay/scripts/key-envelope-smoke.tswith 16 scenarios. -
HIGH (Part 4 P4-10):
strangerFeePricing.getStrangerFeeQuoteusedNOW()instead of ctx.blockTime. Broke determinism on indexer replay. Fixed with optionalnow?: Dateparameter; 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).