{
  "verdict_summary": "Forumer's cryptographic core is sound: signed canonical posts with PoW/Ed25519/id re-verification, XChaCha20-Poly1305 for private wire/at-rest data, Argon2id vaults, encrypted account state/outbox, parameterised SQL and a bounded sync parser. The serious weaknesses are resource control and metadata privacy. Any author can attach four 96 KiB signed previews per post to an uncapped, never-runtime-pruned SQLite table (remote disk fill), and unsigned mprev/mhere messages grow in-memory maps without a budget. Private-forum posts are sealed at rest but their forum id, persona public key, timestamps and thread graph are stored in clear, so a local file reader learns who posts in which private forum. Holder hints also let a peer make the victim dial loopback/LAN/arbitrary addresses, and CI/release workflows use mutable action refs with a write token. Several smaller findings include unbounded vault KDF parameters, a wrong Markdown backslash assumption, log scrubbing gaps, weak forum-name Unicode validation, and store-page/digest/range work limits. 163/163 core tests passed under ASan/UBSan; the published .lgx QML matches the reviewed source, but the module itself could not be built without Nix/logos-module-builder.",
  "findings": [
    {
      "id": "FR-1",
      "severity": "High",
      "title": "Signed posts allow uncapped preview storage; previews are only pruned at startup",
      "files": "src/forumer_backend.cpp:2532-2593; lib/forumer_core/src/post_store.cpp:44-53,344-348; src/forumer_backend.cpp:450-457; src/forumer_backend.h:355; lib/forumer_core/include/forumer_core/media.h",
      "exploit": "One 16-bit-work post can declare four media refs. acceptPreview() only checks media::usable, the author's signature over previewMessage(postId,index,blob) and openPreview(); it then stores up to 96 KiB per blob via putPreview(). pruneTo(50000) runs only in openStore(), deletes only previews of pruned posts, and there is no per-author/per-day or total-byte budget. Four previews per new-key post at the 60/min new-key budget is ~23MB/min; 50000 retained posts can hold ~19GB. Our local private post stored a 711-byte preview row in the live DB; the 96 KiB remote case is code-read.",
      "fix": "Cap previews by total bytes and per author/day; refuse previews for posts outside the live window; run periodic post+preview pruning with a hard DB-size budget; take a token bucket before any mprev SQL/map work; sanity-check preview dimensions against the signed image.",
      "confirmed": "read"
    },
    {
      "id": "FR-2",
      "severity": "Medium",
      "title": "Private-forum post metadata (forum id, persona key, timestamps, reply graph) is stored in clear",
      "files": "lib/forumer_core/src/post_store.cpp:24-42,248-260,44-53; lib/forumer_core/src/forums.cpp (sealStored seals only envelope)",
      "exploit": "Inspecting the live posts.sqlite3 after posting in private forum 02fd62cff093523a showed id 9b33d73daf929882f0242210b87fdeeb, forum 02fd62cff093523a, author B30E5F485C9792355CCFBE9BD732C062E7DA2B68A86691C309A6022C908F5CDE, ts 1793134782875, received 1793134783008, envelope pf1:... (sealed). A local file reader or device thief gets membership, the persona public key (linkable to public posts), timestamps and root/parent without any password or invite key; previews additionally keep post_id and cid in clear.",
      "fix": "Store keyed hashes (or encrypted per-forum indexes) instead of raw author/root/parent/forum for private posts; omit persona keys and timestamps from clear columns; document the remaining metadata exposure.",
      "confirmed": "run"
    },
    {
      "id": "FR-3",
      "severity": "Medium",
      "title": "Unsigned mprev/mhere messages grow in-memory maps without a budget",
      "files": "src/forumer_backend.h:526-550; src/forumer_backend.cpp:2317-2350,2378,2536",
      "exploit": "mhere needs only a syntactically valid cid/peer/addrs; mprev needs a 32-hex post id and one small base64 piece. m_hintSeenAt[hint.cid] and m_previewSeenAt[postId:index] insert attacker-chosen keys that are never erased (except m_previewSeenAt no pruning at all), and handlePreviewPart does a SQLite preview() lookup per piece before any budget. A peer can stream them to grow the ui-host heap without posting anything.",
      "fix": "Take a token bucket before any map/SQL work; cap and LRU/age-out the maps; drop mprev for post ids not held before allocating state.",
      "confirmed": "read"
    },
    {
      "id": "FR-4",
      "severity": "Medium",
      "title": "Holder hints make the storage node dial attacker-chosen addresses, including loopback/LAN/UDP",
      "files": "lib/forumer_core/src/sync.cpp:83-105; src/forumer_backend.cpp:2377-2400,2398",
      "exploit": "addrShape accepts /ip4|ip6|dns4|dns6 with /tcp or /udp and does not reject 127.0.0.1, RFC1918, link-local, multicast or .local. A peer sends mhere for a CID the victim is fetching with hints such as /ip4/127.0.0.1/tcp/22 or /ip4/192.168.1.1/tcp/80; handleMediaHere stores up to three and dialHolder calls storage_module.connect(). Existing test_media.cpp round-trips /ip4/192.168.1.175/tcp/30123.",
      "fix": "Allow only public routable unicast addresses/DNS resolving to them; reject loopback/private/link-local/multicast; verify peer id/address; cap dials per fetch and time; consider dropping /udp unless required.",
      "confirmed": "read"
    },
    {
      "id": "FR-5",
      "severity": "Medium",
      "title": "CI/release workflows use mutable action refs and a contents:write release job",
      "files": ".github/workflows/ci.yml:15-16,39-44; .github/workflows/release.yml:31,36,67; .github/workflows/e3-ui.yml:28,41; .github/workflows/e3-two-node-manual.yml:25,33; .github/workflows/windows-build.yml:17,25",
      "exploit": "DeterminateSystems/nix-installer-action@main, actions/checkout@v4, actions/upload-artifact@v4 and softprops/action-gh-release@v2 are tags/branches. A compromised or repointed action runs in the release job, which has permissions: contents: write and uploads .lgx artifacts. Semgrep flagged the mutable-tag pattern.",
      "fix": "Pin all actions to full 40-character SHAs; keep build jobs read-only; isolate release upload in a minimal job; audit flake.lock inputs.",
      "confirmed": "read"
    },
    {
      "id": "FR-6",
      "severity": "Low",
      "title": "Vault accepts unbounded Argon2id parameters from the vault file",
      "files": "lib/forumer_core/src/vault.cpp:73-84",
      "exploit": "ops and mem are read from vault.json and passed straight to deriveKeyFromPassword. Our probe: 8 GiB mem blocked unlock for >30s (timeout 124); 64 GiB and 1 TiB returned quickly (allocation failure); 1 MiB/3 ops lowered the KDF cost. A local file writer can cause an unlock DoS or weaken offline guessing on their copy.",
      "fix": "Clamp ops/mem to a small allow-list of presets on open, reject out-of-range values, and bind the parameters into the AEAD associated data if the format is revised.",
      "confirmed": "run"
    },
    {
      "id": "FR-7",
      "severity": "Low",
      "title": "Markdown sanitiser backslash assumption is wrong for Qt; limited raw HTML injection/text spoofing",
      "files": "src/qml/markdown.js:21-51,111-130; src/qml/MdText.qml:12-13",
      "exploit": "For [</span><img/src=x>](https://example.com) the sanitizer emits <a href=...><span ...>\\</span>\\<img/src=x></span></a>. Qt 6.4.2 QTextDocument::setMarkdown parses </span> and <img> as tags (anchor body becomes empty), so a post can escape the generated anchor and inject a limited HTML subset (text hiding/spoofing). <img>, <script> and entity-encoded tags were dropped/left as text; no script execution or network fetch observed.",
      "fix": "Escape every < and > in non-code text regardless of preceding backslashes (use Qt-safe entities or literal text); remove raw HTML before generating anchors; add a QTextDocument-based regression test.",
      "confirmed": "run"
    },
    {
      "id": "FR-8",
      "severity": "Low",
      "title": "Log scrubbing misses full local paths, 16-char private forum ids and 10-char fingerprints",
      "files": "src/forumer_backend.cpp:433,458,3136,3189,3213,3236,819; lib/forumer_core/include/forumer_core/redact.h:22-40; src/forumer_backend.cpp:74-100",
      "exploit": "The produced backend.log contains 'post log open at /root/.local/share/Logos/LogosBasecamp/module_data/forumer/posts/posts.sqlite3', 'joined private forum 02fd62cff093523a', 'created private forum 02fd62cff093523a' and a 10-char 'fr:VFZ1-...' fingerprint. scrubIds only shortens runs of 20+ alphanumerics; check-logs.sh is not in CI. Debug logs attached to reports expose username/home layout and private forum ids.",
      "fix": "Log paths relative to the data dir; scrub by field type (forum ids, fr: fingerprints); open the log file 0600; run check-logs.sh in CI.",
      "confirmed": "run"
    },
    {
      "id": "FR-9",
      "severity": "Low",
      "title": "Private-forum names accept bidi/invisible/overlong UTF-8; display spoofing and a serialisation throw",
      "files": "lib/forumer_core/src/forums.cpp:40-60; lib/forumer_core/src/identity.cpp (AccountState::toJson); src/qml/links.js:66-73; src/qml/Sidebar.qml",
      "exploit": "validName accepts U+202E/U+202C, U+200B, U+2066, overlong-encoded NUL (C0 80) and UTF-8-encoded lone surrogates; forums::create accepted the U+202E name. An invite's attacker-controlled name can reorder/impersonate sidebar text. A name with invalid UTF-8 made AccountState::toJson throw json.exception.type_error.316 out of saveAccount (direct core probe; not reachable from the present network path).",
      "fix": "Reuse post::aliasProblem's strict UTF-8/invisible-character validator for forum names; wrap state serialization in try/catch and report unusable names.",
      "confirmed": "run"
    },
    {
      "id": "FR-10",
      "severity": "Low",
      "title": "Store-page parser has no message/size/cursor limits and wraps statusCode",
      "files": "src/delivery_module_transport.cpp:112-155; src/forumer_backend.cpp:3793",
      "exploit": "A store node can return an arbitrary number of messages, arbitrarily large base64 payloads and a long cursor, all decoded before size checks. statusCode is get<int>() on an unsigned JSON number: 4294967496 (2^32+200) wraps to 200 and is accepted as success; our compiled copy reproduced the wrap without a crash. Store peers are hard-coded but FORUMER_STORE_PEERS overrides them.",
      "fix": "Cap messages per page, payload bytes and cursor length; reject statusCode outside int range; use bounded JSON reading.",
      "confirmed": "read"
    },
    {
      "id": "FR-11",
      "severity": "Low",
      "title": "Preview answers cross forum boundaries and leak private post ids",
      "files": "src/forumer_backend.cpp:2317-2370,2440-2465; lib/forumer_core/src/post_store.cpp:356-364",
      "exploit": "previewsOf(cid) selects by CID only, across public and private forums, and sendPreview publishes them on the forum named in an unsigned mask. A member of forum A can ask for a CID belonging to a private forum B post and cause another member's node to emit B's post id and sealed preview on A's topic. Content stays sealed but the private post id/existence leaks.",
      "fix": "Join previews to posts and filter by the requesting forum; only answer with previews for posts in that forum.",
      "confirmed": "read"
    },
    {
      "id": "FR-12",
      "severity": "Low",
      "title": "Post log rows are trusted on read and not integrity-protected",
      "files": "lib/forumer_core/src/post_store.cpp:24-42,248-275,286-300; src/forumer_backend.cpp (loadPost, emitPost, sendOwn)",
      "exploit": "Posts are verified only on arrival; get/all/page/loadPost parse envelopes without re-verification and the clear author column drives display, OP matching and the inbox. Any same-user writer can inject posts/authors, create fake replies-to-you, or alter a queued post before sendOwn re-sends it (peer signature check still fails).",
      "fix": "Re-verify envelopes on read or HMAC the DB rows with the account key; do not trust the author column without envelope verification.",
      "confirmed": "read"
    },
    {
      "id": "FR-13",
      "severity": "Low",
      "title": "fetchAround multiplies an unchecked day before validating it (signed overflow)",
      "files": "src/forumer_backend.cpp (fetchAround, ~3567-3590)",
      "exploit": "since = day * 86400000 is computed before the since <= 0 check, so a huge qint64 day is signed-overflow UB. QML validates day < 1e6, so an ordinary link cannot reach it, but the backend slot is the trust boundary.",
      "fix": "Range-check day (e.g. 1..100000) before multiplying and use checked arithmetic.",
      "confirmed": "read"
    },
    {
      "id": "FR-14",
      "severity": "Low",
      "title": "Display fingerprints are only 40 bits and grindable",
      "files": "lib/forumer_core/src/identity.cpp:52-68",
      "exploit": "A key pair whose 40-bit BLAKE2b fingerprint matches a victim's requires ~2^40 work, feasible for a determined attacker; a post can then display the victim's fr:XXXX-XXXX while signed by the attacker's key. The docs call it non-security, but the UI presents it as identity at a glance.",
      "fix": "Use 64-80 bits for displayed fingerprints and/or expose full-key verification for important contacts.",
      "confirmed": "read"
    },
    {
      "id": "FR-15",
      "severity": "Low",
      "title": "Repository hygiene: committed development log and fixture-looking key",
      "files": "git history commit 3737669 file '-a'; scripts/test-links.mjs:36",
      "exploit": "gitleaks reports a 43-char fixture in test-links.mjs (not a real secret) and a key:002508... record from a committed dev log '-a' that also contains /Users/bristinborah/forumer-test/a and libp2p peer records. No private key material was established (the record looks like a peer/public-key value), but local paths/usernames and peer identifiers remain in history.",
      "fix": "Purge '-a' from history if public; add a gitleaks baseline/allow-list for the fixture; forbid committing debug logs in CI.",
      "confirmed": "read"
    },
    {
      "id": "FR-16",
      "severity": "Low",
      "title": "Storage upload completion can be attributed to another app's upload",
      "files": "src/forumer_backend.cpp (storageUploadDone handler ~965-1010; pumpUploads ~1960-2010)",
      "exploit": "While m_mediaUploadSession is empty and the probe is not sharing, the shared storage module's storageUploadDone is treated as ours. Another Basecamp app's concurrent upload completing in that window can set our attachment's CID to the other app's content; the post then fails to decrypt/display and the image is lost.",
      "fix": "Accept completion only when the session id is known and matches; use a per-upload marker; ignore unknown sessions.",
      "confirmed": "read"
    },
    {
      "id": "FR-17",
      "severity": "Low",
      "title": "Digest scans and empty range requests still perform per-message work",
      "files": "lib/forumer_core/src/digest_plan.cpp:10-45,56-75; src/forumer_backend.cpp (handleDigest, handleRange)",
      "exploit": "Each admitted digest causes PostStore::recent() over up to 96h (60/min allowed); with 50000 posts each scan loads tens of thousands of rows. A valid range with nothing missing performs a range() query and does not charge the cooldown (by design), allowing repeated DB work from unsigned requests.",
      "fix": "Cap rows scanned per digest or pre-check counts; charge a small budget for range queries even when the answer is empty; aggregate/prune old posts.",
      "confirmed": "read"
    },
    {
      "id": "FR-18",
      "severity": "Low",
      "title": "readIndex/vault::open are not exception-safe on malformed local JSON",
      "files": "lib/forumer_core/src/account_store.cpp (readIndex ~112-132); lib/forumer_core/src/vault.cpp:60-85",
      "exploit": "A local file with a wrong JSON type (e.g. accounts.json {\"v\":2,\"selected\":123}) makes doc.value(\"selected\", std::string()) throw json::type_error outside any try, crashing startup; vault::open's version->get<int>() has the same shape.",
      "fix": "Check types explicitly and catch json::exception around all local-file parsing; treat malformed files as unknown accounts.",
      "confirmed": "read"
    },
    {
      "id": "FR-19",
      "severity": "Low",
      "title": "A peer can forge outbox Sent confirmation",
      "files": "src/forumer_backend.cpp (confirmHeldByPeers, handleDigest)",
      "exploit": "Any peer that has seen a 32-hex post id can send a digest listing its 16-hex short id; confirmHeldByPeers marks the post Sent without a delivery-module confirmation. This is delivery-state/UI integrity, not content forgery.",
      "fix": "Settle to Sent only on a delivery success or agreement from more than one peer/our own echo.",
      "confirmed": "read"
    }
  ],
  "ran": [
    "Installed libsodium-dev, libsqlite3-dev, nlohmann-json3-dev and qt6-base-dev from the Ubuntu mirror",
    "Built forumer_core with cmake/ninja using -fsanitize=address,undefined -g -O1",
    "Ran forumer_core_tests: 163/163 passed under ASan/UBSan",
    "Ran node scripts/test-markdown.mjs: all passed",
    "Ran node scripts/test-links.mjs: all passed",
    "Probe deep_json.cpp: 20k/30k/32k nested arrays in a 64KiB mprev payload; no stack overflow, decode rejected",
    "Probe vault_params.cpp: 8GiB Argon2id mem blocked >30s (timeout 124); 1TiB/64GiB returned quickly; 1MiB/3ops KDF accepted file",
    "Probe forum_name.cpp: validName accepted U+202E/U+202C, U+200B, U+2066; create() accepted a bidi name",
    "Probe forum_name2.cpp: validName accepted overlong NUL and UTF-8 lone surrogate, rejected truncated sequence",
    "Probe utf8_state.cpp: AccountState::toJson threw json.exception.type_error.316 on an overlong forum name",
    "Probe storepage.cpp: parseStorePage statusCode 4394967296 wrapped to 100000000, 1e20 ignored, no crash; no message/size/cursor limits",
    "Probes md/mdtest.cpp and md/mdtest2.cpp: Qt 6.4.2 QTextDocument Markdown rendering; <a>/<span> honoured, <img>/<script> dropped, but backslash-prefixed tags escape the sanitizer anchor",
    "desk start; desk install /run/lgx/forumer-0.2.5-linux-x86_64.lgx (delivery_module 0.4.0 installed, storage_module bundled)",
    "Opened Forumer in Basecamp and created account carol/password12345; confirmed the 24-word phrase screen",
    "Synced 189 public posts from live logos.dev store nodes; sent digests on /forumer/3/public/proto",
    "Created private forum 02fd62cff093523a (audit room) with a U+202E attempt in a later field test",
    "Posted private topic 9b33d73daf929882f0242210b87fdeeb and private image post fe563e16f82e8cf3b657199b2734dcb7",
    "Attached /tmp/audit-red.png; backend re-encoded it to a 691-byte JPEG, uploaded CID zDvZRwzm..., and sent a 711-byte preview",
    "Opened the private thread and observed the Basecamp host show 'this Basecamp can't display images yet (it may need an update)'",
    "Attempted alias fr:ABCD-EFGH (screen rejected: An alias can't look like a fingerprint) and U+202E via desk key",
    "Inspected posts.sqlite3 with python3 sqlite3: private posts store forum id, author persona key, ts, received and pf1: sealed envelope; preview row stores post_id/index/cid/blob/signature",
    "Took screenshots 01..33 in /run/media (named evidence: 09 phrase, 11 feed, 17 private forum, 20 private thread, 22 settings, 23 alias rejected, 24 alias bidi, 32 image ready, 33 image thread) and session.mp4"
  ],
  "checked_clean": [
    "Post envelope canonicalisation, proof-of-work, Ed25519 verification and id recomputation (post.cpp), including size/UTF-8/disclosure/alias checks",
    "Re-verification before storage in handlePost/checkPost, including duplicate posts (seen-on-wire only after successful verify)",
    "libsodium-only crypto wrappers, fixed sizes, crypto_kdf contexts, random AEAD nonces, SecretBytes wiping",
    "Vault Argon2id MODERATE preset with random salt and associated data (except unbounded params in FR-6)",
    "Private-forum wire sealing with forum-id AD and store-at-rest sealing of the envelope column (metadata finding FR-2 remains)",
    "Account ids validated as 16 lowercase hex before path use; private file names restricted to [a-z]+; 0600/0700 files and atomic writes",
    "Password re-checks for revealPhrase/deleteAccount; minimum password length; encrypted state/outbox",
    "sync::decode bounds (24KiB/64KiB, have<=512, short-id hex, hint shapes, negative since rejection, version checks)",
    "RangeGate span/clock-skew clamps and digest scan budget; MediaAsk budget before DB work",
    "Parameterised SQLite statements throughout PostStore/AccountStore; no SQL injection found",
    "QML PlainText usage; http(s)-only link activation; no XMLHttpRequest/eval/Qt.createQmlObject from post data; no remote image loading in Qt Text",
    "No remote crash from nested JSON or store statusCode overflow (tested)",
    "flake.lock pins Nix inputs; published .lgx QML diff-identical to src/qml"
  ],
  "not_tested": [
    "Building the module/plugin from source (no Nix, no logos-module-builder, no generated logos_sdk.h); only forumer_core was built",
    "Two-instance/two-peer private-forum sync, invite join on a second device and non-member isolation",
    "Reply send through the UI (text typed but the inline reply button was not hit), nested replies, mute, unread/missed flow",
    "Join/invalid-invite flow, lock/unlock, restore, change-password, delete and clipboard clearing after 60s",
    "Network switch/restart, logos.test/RLN, Windows/macOS builds and native Windows Basecamp",
    "Image display/decoding with a Basecamp build that has the image-provider fix",
    "A hostile network peer harness: mprev/mhere/digest/range floods, forged/oversized messages, poisoned store pages, and driving FR-1 to disk exhaustion",
    "Vendor/dependency source beyond interfaces and the repo's own test reports"
  ],
  "repo_tried_to_instruct_reviewer": false
}
