morphit/docs/AUDIT-FINDINGS.md

1106 lines
48 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:**
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.
### 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 `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).