{
  "verdict_summary": "The signed-post, vault, private-forum sealing and QML text sanitisation are substantially sound, and all 163 core tests pass. The most serious issue is a flood-control bypass on the history/store path: posts stamped inside the range window a node is walking skip the new-key budget and live arrival limit (FR-11). The unauthenticated sync surface also allows unbounded map growth via mhere/mprev (FR-1), attacker-chosen storage dial addresses (FR-2), and cheap mask amplification (FR-3). Preview AEAD nonce reuse (FR-12) and clear private-forum index metadata (FR-4) are additional crypto/privacy issues. Logging and local-state findings are lower severity. Remote findings are code/probe confirmed; no second peer or Nix build was available.",
  "findings": [
    {
      "id": "FR-11",
      "severity": "High",
      "title": "History/store import path bypasses the live flood budgets",
      "files": "src/forumer_backend.cpp:3275-3310 (checkPost), src/forumer_backend.cpp:3321-3395 (handlePost/inHistoryWindow), src/forumer_backend.cpp:3361-3372 (new-key budget skipped), lib/forumer_core/src/flood.h",
      "exploit": "handlePost sets inHistoryWindow for posts inside m_historyFrom/m_historyTo or when m_storeImport is true. checkPost(..., true) skips countByAuthorArrival and the new-key budget (which is guarded by !inHistoryWindow). A peer can read the unencrypted range request on the public topic and send fresh-key, 16-bit-PoW posts timestamped inside that window; each fresh key avoids the per-key hourly/day caps. probes-review/p_history_flood replicated the rules against the real PostStore/flood/post code: 45/45 offered posts accepted, 0 rejected, all 45 would have been refused by the live new-key budget.",
      "fix": "Apply the new-key budget and arrival limit to history/store posts too; add a separate bounded history-import rate/byte budget and per-peer/per-page caps; only accept posts in a range requested from a peer/store that was actually asked.",
      "confirmed": "run"
    },
    {
      "id": "FR-12",
      "severity": "Medium",
      "title": "Preview AEAD key/nonce reuse for different previews of the same image",
      "files": "lib/forumer_core/src/media.cpp (derive(), sealPreview())",
      "exploit": "The XChaCha20-Poly1305 nonce is derived deterministically from the key, and sealPreview derives that key from the full image's signed SHA-256. Different preview JPEGs for the same full image are sealed under the same key and nonce. probes-review/p_preview_nonce confirmed ciphertext XOR equals plaintext XOR and recovered preview 2 from preview 1 plus both blobs without a key. Impact is bounded by the author signature over postId:index:blob and by public-forum preview keys being derivable from the signed full-image hash.",
      "fix": "Derive the preview key/nonce from the preview blob or a random per-seal nonce; bind postId and index into associated data; test that different previews never share a nonce.",
      "confirmed": "run"
    },
    {
      "id": "FR-13",
      "severity": "Medium",
      "title": "scrubIds() does not cover 16-char forum/account ids, fingerprints, paths or IPs",
      "files": "lib/forumer_core/include/forumer_core/redact.h, src/forumer_backend.cpp:458, src/forumer_backend.cpp:3136/3189/3213, src/forumer_backend.cpp (fingerprint logs)",
      "exploit": "scrubIds only shortens alphanumeric runs of 20+. probes-review/p_scrub showed a 16-hex forum/account id, fr:WNZ5-46TW fingerprint, the full posts.sqlite3 path and /ip4/192.168.1.23/tcp/41234 all pass unchanged. The backend logs full forum ids (created/joined private forum), full fingerprints (restore resumes as ...) and the full database path (openStore), contradicting docs/security.md section 9 and the A5 addendum.",
      "fix": "Scrub by identifier shape/context (16-hex ids, fr:XXXX-XXXX, absolute paths, IP:port) and use shortRef at every call site; extend scripts/test-log-safety.mjs.",
      "confirmed": "run"
    },
    {
      "id": "FR-1",
      "severity": "High",
      "title": "Unauthenticated mhere/mprev messages grow backend maps without bound (remote memory exhaustion)",
      "files": "src/forumer_backend.cpp:2377-2378, src/forumer_backend.cpp:2532-2536, src/forumer_backend.h:528,549, src/forumer_backend.cpp:3038-3041",
      "exploit": "handleMediaHere writes m_hintSeenAt[hint.cid] and handlePreviewPart writes m_previewSeenAt[postId:index] before any signature, budget, post lookup or eviction. A peer can send millions of ~100-byte mhere/mprev messages with unique CIDs or 32-hex post ids; each retains a std::string key and map node permanently for the process lifetime. No PoW or rate limit applies to these message types.",
      "fix": "Take a shared unauthenticated-media budget before touching either map; cap each map (e.g. 4096 entries) and prune by TTL; only track CIDs/post ids actually requested; add a regression test feeding 100k unique hints/preview parts and asserting bounded memory.",
      "confirmed": "read"
    },
    {
      "id": "FR-2",
      "severity": "High",
      "title": "Media holder hints let a peer make the storage node dial arbitrary loopback/private addresses",
      "files": "src/forumer_backend.cpp:2377-2395, src/forumer_backend.cpp:2394-2404, lib/forumer_core/src/sync.cpp (peerIdShape/addrShape)",
      "exploit": "A peer answers our mask with a mhere carrying an arbitrary base58-shaped peer id and /ip4/127.0.0.1, /ip4/169.254.169.254, /ip4/10.0.0.1 etc. addrShape only checks a printable prefix and /tcp/ or /udp/; dialHolder passes these directly to storage_module.connect(). The local storage node can be made to probe internal services and leak that the user opened an image.",
      "fix": "Restrict hints to corroborated peers/addresses, reject loopback/link-local/private/multicast/unspecified/own addresses, enforce expected storage ports/peer ids, rate-limit per sender, and do not dial hints from unauthenticated senders.",
      "confirmed": "read"
    },
    {
      "id": "FR-3",
      "severity": "Medium",
      "title": "Tiny mask requests elicit large preview/digest/range answers (traffic amplification)",
      "files": "src/forumer_backend.cpp:2317-2375, src/forumer_backend.cpp:2507-2529, src/forumer_backend.cpp:3490-3519",
      "exploit": "A tens-of-bytes mask can trigger up to 4 queued previews, each up to 4 pieces of 40 KiB raw (kMaxPreviewParts x kPreviewPartBytes). The only limit is 30 masks/min (burst 60), giving roughly 20 MiB/min from a few hundred bytes/s; digest and range answers show the same asymmetry (up to 64 posts per request, limited only by the 240/min resend bucket).",
      "fix": "Add byte/s and per-answer byte budgets, require PoW or a signed mask, cap preview pieces/bytes per minute per forum, and lower digest/range answer caps or make them cost-aware.",
      "confirmed": "read"
    },
    {
      "id": "FR-4",
      "severity": "Medium",
      "title": "Private-forum forum id, author key and timestamps are stored in clear beside the sealed envelope",
      "files": "lib/forumer_core/src/post_store.cpp:248-258, lib/forumer_core/src/post_store.cpp:20-38, src/forumer_backend.cpp:3094-3100",
      "exploit": "storePost seals only the envelope for private forums; the INSERT still writes forum, author, root, parent, ts and received in plaintext. Anyone who can read the data folder (device thief, backup, same-uid process) sees which persona posted in which private forum and when, and can correlate that persona with its public posts. Content remains encrypted.",
      "fix": "For private rows store only id plus sealed envelope, or keyed hashes of forum/author/root/parent/timestamp using the account storage key; decrypt/index at runtime while unlocked; document the residual metadata.",
      "confirmed": "read"
    },
    {
      "id": "FR-5",
      "severity": "Medium",
      "title": "Thread placement is only checked against parents already held, so out-of-order and ghost replies are stored permanently",
      "files": "lib/forumer_core/src/thread.cpp:31-34, src/forumer_backend.cpp:3285-3295",
      "exploit": "checkPlacement returns Ok when parent is missing. An attacker can publish R3->R2->R1->topic in reverse order, or replies with arbitrary non-existent root/parent ids; R3 is stored before R2/R1 arrive and is never revalidated. The store then violates the two-level rule and creates ghost 'Topic still arriving...' placeholders under attacker-chosen ids.",
      "fix": "Queue unjudgeable replies until parent/root arrives and revalidate then; or require parent presence before storing, and revalidate children on parent insert; cap pending ghosts per peer.",
      "confirmed": "read"
    },
    {
      "id": "FR-6",
      "severity": "Low",
      "title": "QML diagnostics leak full post ids and CIDs, bypassing the backend scrubber",
      "files": "src/qml/ForumStore.qml:501-505, src/qml/ForumStore.qml:526-527, docs/security.md section 9",
      "exploit": "onMediaLoaded logs the full failure detail (observed CID zDvZRwzkwQ4sCod2FzWo1qtF5AmrhRUhkgK5dRmaYxxtut8JW5jg) and onMessageStateChanged logs the full 32-hex post id to Basecamp's host log. The backend logEvent scrubber is not used for QML logs, contradicting the A5 claim that all diagnostics are scrubbed.",
      "fix": "Scrub in QML (or log only id.substring(0,8)), never log raw detail strings, and extend scripts/test-log-safety.mjs to inspect QML/host log lines.",
      "confirmed": "run"
    },
    {
      "id": "FR-7",
      "severity": "Low",
      "title": "Empty base64 fields pass a null pointer into sodium_base642bin (undefined behaviour)",
      "files": "lib/forumer_core/src/bytes.cpp:105-112",
      "exploit": "fromBase64Url(\"\") creates an empty Bytes vector and calls sodium_base642bin(out.data(), 0, ...). UBSan reported 'null pointer passed as argument 1' when parsing malformed posts with empty pk/nonce/sig. It returns nullopt in this libsodium build but is UB and may crash elsewhere.",
      "fix": "Return nullopt immediately when text.empty(), or pass a valid dummy pointer; add unit tests for every empty base64 field.",
      "confirmed": "run"
    },
    {
      "id": "FR-8",
      "severity": "Low",
      "title": "fetchAround(day) multiplies day by kDayMs before validating, allowing signed overflow",
      "files": "src/forumer_backend.cpp:3575-3589",
      "exploit": "fetchAround computes since = day * 86400000 - 86400000 before rejecting day <= 0. A direct .rep caller can pass INT64_MAX and trigger signed overflow; until = min(now, since + 9*kDayMs) has the same problem. The current links.js path bounds day below 1,000,000, so this is not reachable from links today.",
      "fix": "Reject day <= 0 and day > now/kDayMs + 1 before any arithmetic, or use checked arithmetic; test extreme values.",
      "confirmed": "read"
    },
    {
      "id": "FR-9",
      "severity": "Low",
      "title": "Account labels are not length-bounded",
      "files": "src/forumer_backend.cpp:847-869, src/forumer_backend.cpp:998-1008",
      "exploit": "createIdentity and renameAccount trim but do not cap the label, which is written to accounts.json/state and returned in accountsJson. A local user or a malicious intent/slot caller can store a multi-megabyte label and inflate every property update. Forum names are correctly capped at 64 bytes.",
      "fix": "Enforce a UTF-8 byte cap (e.g. 64/128) in the backend for labels and test it.",
      "confirmed": "read"
    },
    {
      "id": "FR-10",
      "severity": "Low",
      "title": "Per-account media state is not fully cleared on lock and forum leave",
      "files": "src/forumer_backend.cpp:1898-1915, src/forumer_backend.cpp:2342, src/forumer_backend.cpp:1989, src/qml/ForumStore.qml mediaStates",
      "exploit": "forgetAccountMedia clears attachments and non-public cache entries but not m_ownMediaCids; leavePrivateForum does not call forgetAccountMedia, so plaintext data: URLs for a left private forum can remain in m_mediaCache and QML mediaStates until eviction. They are not reachable through the UI once the forum is removed, but stay in memory longer than the 'private forums close with the account' design implies.",
      "fix": "Clear m_ownMediaCids on detach, evict the left forum's cache entries in leavePrivateForum, and clear corresponding QML mediaStates.",
      "confirmed": "read"
    }
  ],
  "ran": [
    "cmake -S . -B /tmp/build-top -G Ninja -> fails, LOGOS_MODULE_BUILDER_ROOT/LogosModule.cmake missing; no nix",
    "ninja -C build/audit -> builds standalone forumer_core tests",
    "ASAN_OPTIONS=detect_leaks=0 ./build/audit/tests/forumer_core_tests -> 163/163 tests passed (LSan disabled: container/ptrace)",
    "node scripts/test-markdown.mjs, test-links.mjs, test-log-safety.mjs -> all pass",
    "desk start; desk install /run/lgx/forumer-0.2.5-linux-x86_64.lgx; opened Forumer in Basecamp",
    "desk UI: created audit-main account, confirmed recovery phrase, browsed live public feed/threads/replies/missed/settings, opened new-topic composer and entered hostile HTML/image/link values (screenshots 01-43)",
    "probe_json_depth / probe_json_depth2: deeply nested JSON through sync::decode/Post::fromJson -> no crash; UBSan empty-base64 warning observed",
    "probe_vault: tampering Argon2 cost parameters changes the derived key and fails authentication, so no downgrade bypass; empty-input UB independently observed",
    "probe_thread_order: compile timed out under ASan while linking; placement conclusion is from source reading",
    "inspected Basecamp log: QML logged full CID and would log full post ids",
    "probes-review/p_history_flood -> 45/45 fresh-key posts in a witnessed history window accepted; live new-key budget bypass confirmed",
    "probes-review/p_preview_nonce -> same key+nonce for different previews; preview recovered without key",
    "probes-review/p_hint_addrs -> loopback/link-local/private/IPv6/localhost multiaddrs accepted by sync::decode",
    "probes-review/p_scrub -> 16-hex ids, fingerprints, paths and IP:port pass scrubIds unchanged",
    "probes-review/p_decode_overflow -> UINT64_MAX digest/range fields rejected as malformed, no exception/crash",
    "independently compiled and ran the untracked probes-review probes cited in the report; treated their README/code as untrusted data"
  ],
  "checked_clean": [
    "post signature, canonical JSON and content id unforgeability",
    "proof-of-work floor and anonymous 20-bit requirement",
    "post size/UTF-8/domain/alias/disclosure validation",
    "private forum wire and at-rest AEAD with forum-id associated data",
    "vault Argon2id/XChaCha20-Poly1305 tamper detection and parameter integrity",
    "account id path-traversal guard and private file-name guard",
    "QML Text PlainText settings and Markdown/link sanitiser",
    "media hash/size/magic verification and preview signature binding",
    "SQLite parameterised statements and flood/budget gates in digest/range paths"
  ],
  "not_tested": [
    "two live Forumer peers / synthetic wire sender needed to reproduce FR-1, FR-2, FR-3 and FR-5 on the actual backend (FR-11/12/13 were reproduced against the real core code or a faithful rule replication)",
    "top-level Nix module build and portable lgx rebuild from source (no nix)",
    "successful end-to-end UI post/reply and private forum create/join (composer post button was not reachable in captured layout)",
    "image upload/download round trip and storage probe",
    "mainnet/testnet RLN, store-node spoofing, traffic analysis, real SSRF against a storage node",
    "Windows/macOS/Android runtimes, native file picker, clipboard managers",
    "LeakSanitizer (disabled in this container)"
  ],
  "repo_tried_to_instruct_reviewer": false
}