# Forumer 0.2.5 — independent security review @ b9907523bffc975723762f1766f483d576b677e3

## 1. Bottom line

The cryptographic core is competently built: posts are canonicalised, proof-of-worked, Ed25519-signed, content-addressed and fully re-verified before storage; private-forum wire traffic and private posts at rest use XChaCha20-Poly1305 with the forum id bound as associated data; account state and outbox/read marks are sealed with a key derived from the master secret; the vault uses Argon2id with a random salt. The 163 core unit tests pass under ASan/UBSan, the QML Markdown/link tests pass, and the network, posting and image-upload paths work in a real Basecamp against the live `logos.dev` fleet. SQL is parameterised throughout, and the link/Markdown boundary does not execute scripts or fetch remote images in the Qt version tested.

The weak points are resource control and metadata privacy, not signature verification. **The most serious is unbounded, unauthenticated preview/holder-hint state**: an ordinary signed post grants an attacker four signed 96 KiB preview blobs, and these are written to SQLite with no total budget, no per-author daily cap and no runtime pruning (the only prune runs at startup). That is a practical remote disk-fill attack on every reader. Related unsigned messages (`mprev`, `mhere`) also grow in-memory maps without any budget. **Private-forum posts are sealed at rest but their metadata is not**: the unencrypted post-log SQLite file stores the forum id, the signing persona public key, author/received timestamps and the reply graph in clear, so a local file reader or device thief learns who posts in which private forum even without the forum key. A peer can also make the victim's storage node dial arbitrary multiaddrs, including loopback/LAN addresses.

The published `.lgx` was used: its QML is byte-identical to the reviewed source. The module itself could not be compiled from source here (no Nix and no `logos-module-builder`); only `forumer_core` was built and tested locally. Several documented claims are correct; a few are overstated (preview storage, private-forum metadata, the Markdown escape assumption, and full-path/short-id log scrubbing).

## 2. What I ran

**Build and core tests**
- `apt-get install libsodium-dev libsqlite3-dev nlohmann-json3-dev qt6-base-dev` — sandbox had no development headers; mirror install succeeded. [run]
- `cmake -S lib/forumer_core -B /run/build-core -G Ninja -DFORUMER_CORE_TESTS=ON -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -g -O1"` then `ninja -C /run/build-core -j6` — succeeded. [run]
- `./tests/forumer_core_tests` — **163/163 tests passed** under ASan/UBSan. [run]
- `node scripts/test-markdown.mjs` — all passed. [run]
- `node scripts/test-links.mjs` — all checks passed. [run]
- Did **not** run `./scripts/test-core.sh` (it execs Nix) and did **not** build the module with the top-level CMake: it requires `LOGOS_MODULE_BUILDER_ROOT`/`LogosModule.cmake`, generated `logos_sdk.h`, Qt and the Logos SDK, none present and no Nix in the sandbox. [not tested]

**Probes (all in `/run/probes/`)**
- `deep_json.cpp` — 20k, 30k and 32k nested arrays inside a 64 KiB `mprev`-tagged payload fed to `sync::decode`. **No stack overflow; decode rejected them.** [run, refuted]
- `vault_params.cpp` — vault file with attacker-chosen Argon2id params. 1 TiB rejected in 8 ms, 64 GiB rejected in 32 ms, **8 GiB blocked for >30 s (timeout 124)**; 1 MiB/3 ops opened with "wrong password" in 23 ms. [run]
- `forum_name.cpp` — `forums::validName` accepts a name containing U+202E/U+202C, U+200B and U+2066. [run]
- `forum_name2.cpp` — accepts overlong-encoded NUL (C0 80), a UTF-8-encoded lone surrogate (ED A0 80), rejects a truncated sequence. [run]
- `utf8_state.cpp` — a forum with an overlong name is accepted by `create()`/`joinForum()`; `AccountState::toJson()` then throws `json.exception.type_error.316` (not caught by `saveAccount`). [run]
- `storepage.cpp` — `DeliveryModuleTransport::parseStorePage` compiled against stub SDK headers. A statusCode of 4394967296 wraps to 100000000; 1e20 is ignored; **no exception/crash**, but there is no message-count, payload-size or cursor-size limit. [run + read]
- `md/mdtest.cpp`, `md/mdtest2.cpp` — Qt 6.4.2 `QTextDocument::setMarkdown` rendering of `markdown.js` output. Raw `<a>`/`<span>` are honoured; `<img>` and `<script>` are dropped; entity-encoded tags remain text; but `\</span>\<img/src=x>` is parsed as real tags and can empty/escape the sanitizer's anchor. [run]

**Runtime in Basecamp (published package)**
- `desk start`; `desk install /run/lgx/forumer-0.2.5-linux-x86_64.lgx` (delivery_module 0.4.0 installed; storage_module bundled) — installed. [run]
- Opened Forumer, created account `carol` / `password12345`, confirmed the 24-word phrase screen. [run]
- The node synced **189 public posts** from the live `logos.dev` store nodes; digests were published on `/forumer/3/public/proto`. [run]
- Created private forum **`02fd62cff093523a`** ("audit room"), copied its invite flow, posted topic `9b33d73daf929882f0242210b87fdeeb`, attached a 64×64 PNG (re-encoded to 691-byte JPEG), uploaded it as CID `zDvZRwzm…`, posted `fe563e16f82e8cf3b657199b2734dcb7` with a sealed 711-byte preview, and viewed the thread. [run]
- Basecamp could not display the image ("this Basecamp can't display images yet (it may need an update)") — the documented mobile/Basecamp image-provider limitation, not a module error. [run]
- Attempted hostile values: `<img src=x>` and malformed comma lists in domains (normalised and posted); `fr:ABCD-EFGH` as alias (rejected on screen: "An alias can't look like a fingerprint"); U+202E in the alias field; oversized/garbled Markdown body text. [run, partial]
- Inspected the live post log with `python3 sqlite3` (below). [run]
- Screenshots in `/run/media` (recording: `session.mp4`):
  `01-installed`, `02-forumer-open`, `03-identity-created`, `04-phrase`, `05-phrase-screen`, `06-filled`, `07-phrase`, `08-phrase-screen`, `09-09-phrase-24-words` (24-word phrase), `10-home`, `11-home-with-feed`, `12-profile`, `13-composer` (home after cancelling), `14-new-forum-sheet`, `15-new-forum-sheet`, `16-new-forum-named`, `17-private-forum`, `18-composer-private`, `19-posted-private`, `20-private-thread`, `21-settings` (composer, not settings), `22-settings`, `23-alias-rejected`, `24-alias-bidi`, `25-after-alias`, `26-recover`, `27-backup`, `28-backup`, `29-wrong-password` (backup, no visible error), `30-phrase-shown` (backup; reveal not completed), `31-phrase-shown` (same), `32-image-ready`, `33-image-thread` (image card host limitation). Some early shots are duplicates/mislabelled; the named evidence is `09`, `11`, `17`, `20`, `22`, `23`, `24`, `32`, `33`. [run]

**Not run:** two-instance private-forum sync, invite join in a second instance, reply send (text entered but the inline "reply" button was not hit), lock/unlock/restore, change-password, clipboard clearing after 60 s, network switch/restart, store-node fetch in a second node, image display with a fixed Basecamp, `logos.test`/RLN, Windows/macOS, and a hostile peer harness. [not tested]

## 3. Findings (most severe first)

### FR-1 — High — Signed posts let any author fill peers' disks: previews have no global budget and are never pruned during a session
**Files:** `src/forumer_backend.cpp:2532-2593` (`handlePreviewPart`, `acceptPreview`), `lib/forumer_core/src/post_store.cpp:44-53` (previews table), `lib/forumer_core/src/post_store.cpp:344-348` (`putPreview`), `src/forumer_backend.cpp:450-457` (the only prune), `src/forumer_backend.h:355` (`kMaxStoredPosts`), `lib/forumer_core/include/forumer_core/media.h` (`kMaxPreviewBytes = 96 KiB`).

**Exploit:** one 16-bit-work post may declare four media refs (`kMaxMedia = 4`). `acceptPreview` only requires that each ref be `media::usable()` and that the preview blob decrypts with the key derived from the author-chosen `sha256`; it then calls `putPreview` with up to 96 KiB per blob. `m_posts->pruneTo(50 000)` runs once in `openStore()` and only deletes previews of pruned posts; there is no per-author daily cap, total byte cap or periodic prune. Four previews per new-key post at the 60-posts/minute new-key budget is ~23 MB/minute (~1.3 GB/hour), and 50 000 retained posts can hold ~19 GB of previews. Our local post already produced a 711-byte preview row in `previews` (below); the 96 KiB case is code-read. [read; storage path confirmed run]

```
previews: [('fe563e16f82e8cf3b657199b2734dcb7', 0,
            'zDvZRwzm3schzbv4Txqn6rBA135LtYZaR2Zg2qjWcGt687BXWs9p', 711, 64)]
```

**Fix:** cap previews by total bytes and per author/day; refuse previews for posts older than the live window; run a periodic prune of posts *and* previews with a hard DB-size budget; budget `mprev` pieces with a token bucket before any SQL/map work; and sanity-check the preview's decoded dimensions against the image's signed size.

### FR-2 — Medium — Private-forum posts are sealed, but their forum id, persona key, timestamps and thread graph are stored in clear
**Files:** `lib/forumer_core/src/post_store.cpp:24-42` (posts schema), `lib/forumer_core/src/post_store.cpp:248-260` (`insert` writes `forum`, `root`, `parent`, `author`, `ts`, `received`), `lib/forumer_core/src/post_store.cpp:44-53` (previews keep `post_id` and `cid` in clear), `lib/forumer_core/src/forums.cpp` (`sealStored` only seals the `envelope` column).

**Exploit:** a device thief or another process that can read `posts/posts.sqlite3` (threat actor "curious local user" in `docs/security.md`) gets, without any password or forum invite:

```
('9b33d73daf929882f0242210b87fdeeb', 0, '02fd62cff093523a',
 'B30E5F485C9792355CCFBE9BD732C062E7DA2B68A86691C309A6022C908F5CDE',
 1793134782875, 1793134783008, 'pf1:znXaeCIxWAiYsDfDiSU_bU_VzgVOzyxjgvRJlaAlPGt4GKnyMBirHeIr')
```

i.e. the private forum id, the signing persona's full public key, author and receipt times, and `root`/`parent`, while the envelope is `pf1:` sealed. The persona key can be matched against public posts, linking a public persona to membership/activity in a private forum; the preview table also reveals each private image caption's post id and CID. [run]

**Fix:** do not index private posts by raw `author`/`root`/`parent`; store keyed hashes (or an encrypted per-forum index) for flood/inbox/thread queries; seal `forum` too, or at least omit persona keys and timestamps from clear columns; document this metadata exposure explicitly.

### FR-3 — Medium — Unsigned `mprev`/`mhere` messages grow in-memory maps without a budget (remote memory exhaustion)
**Files:** `src/forumer_backend.h:526-550` (`m_hintSeenAt`, `m_previewSeenAt`, `m_previewParts`, `m_previewsWaiting`), `src/forumer_backend.cpp:2378` (`m_hintSeenAt[hint.cid] = nowMs()`), `src/forumer_backend.cpp:2536` (`m_previewSeenAt[key] = nowMs()`), `src/forumer_backend.cpp:2317-2350` (the only place a budget is taken is MediaAsk).

**Exploit:** `mhere` needs only a syntactically valid CID, peer id and 1–8 addresses; `mprev` needs a 32-hex post id and one small base64 piece. Every distinct id inserts a QString key permanently (except `m_previewSeenAt`/`m_hintSeenAt` entries, which are never erased). Neither path is PoW-bound or rate-budgeted before the map insert (and `handlePreviewPart` also performs a SQLite `preview()` lookup per piece). A peer can stream these messages and grow the ui-host heap without posting anything. [read]

**Fix:** take a token bucket (like `m_mediaAskBudget`) before any map/SQL work; cap the maps and evict LRU/older-than-window entries; drop `mprev` for post ids we do not hold before allocating state.

### FR-4 — Medium — Holder hints make the storage node dial attacker-chosen addresses, including loopback, LAN and UDP
**Files:** `lib/forumer_core/src/sync.cpp:83-105` (`peerIdShape`, `addrShape`), `src/forumer_backend.cpp:2377-2400` (`handleMediaHere`, `dialHolder`), `src/forumer_backend.cpp:2398` (`storage_module.connect`).

**Exploit:** `addrShape` accepts `/ip4/…`, `/ip6/…`, `/dns4/…`, `/dns6/…` followed by `/tcp/` or `/udp/`; it does not reject `127.0.0.1`, `169.254/16`, RFC1918, link-local, multicast or `.local`. A peer who knows the CID a reader is fetching (the CID is in the public post) sends `mhere` for that CID 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 calls `storage_module.connect()`. The existing test even round-trips `/ip4/192.168.1.175/tcp/30123` (`lib/forumer_core/tests/test_media.cpp:149-155`). This gives a remote port-reachability/SSRF primitive from the victim's node. [read]

**Fix:** allow only public, globally routable unicast addresses (and DNS names resolving to them), reject loopback/private/link-local/multicast, require the peer id to match the advertised address, cap dials per fetch/time, and consider dropping `/udp/` unless the storage transport needs QUIC.

### FR-5 — Medium — CI/release workflows use mutable action refs and a write-token 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, not commit SHAs. A compromised action owner (or a moved `main`) executes in the release job, which has `permissions: contents: write` and uploads `.lgx` artifacts. Semgrep already flags the mutable-tag pattern. [read]

**Fix:** pin every action to a full 40-character SHA, keep `GITHUB_TOKEN` read-only in build jobs, put release upload in a separate minimal job, and review the `flake.lock` inputs separately.

### FR-6 — Low — Vault accepts unbounded Argon2id parameters from the vault file
**Files:** `lib/forumer_core/src/vault.cpp:73-84` (`ops`/`mem` read from the file and passed straight to `deriveKeyFromPassword`).

**Exploit:** an attacker who can modify `vault.json` (or a backup/restore path in a future version) can force `unlock()` to attempt an 8 GiB Argon2id allocation; our probe blocked for more than 30 s (timeout) on 8 GiB and returned quickly only at 64 GiB because allocation failed. Conversely, `ops=1, mem=8192` lowers the offline-guessing cost. [run]

**Fix:** clamp `ops`/`mem` to a small allow-list of presets on open, reject out-of-range values with a clear error, and bind the parameters into the associated data/auth tag.

### FR-7 — Low — Markdown sanitiser's backslash assumption is wrong for Qt; a post can inject limited raw HTML and spoof/hide text
**Files:** `src/qml/markdown.js:21-51` (`escapeLt`, `safeText`), `src/qml/markdown.js:111-130` (`safe`), `src/qml/MdText.qml:12-13`.

**Exploit:** `escapeLt` adds a backslash before `<` when the preceding backslash run is even, assuming Qt will read `\<` as an escaped literal. Qt 6.4.2 does not in this context: for input `[</span><img/src=x>](https://example.com)` the sanitizer emits

```
<a href="https://example.com"><span style="color:#60a5fa;">\</span>\<img/src=x></span></a>
```

and `QTextDocument::setMarkdown` parses `</span>` and `<img>` as tags (the anchor body becomes empty). Raw `<a>`/`<span>` injection is therefore possible; `<img>` and `<script>` are dropped by Qt, and no network fetch or script execution was observed. Impact: display/text spoofing inside a post, and a post can make text disappear; not code execution. [run]

**Fix:** escape every `<`/`>` in non-code text (use `&lt;`/`&gt;` or a Qt-safe literal) regardless of preceding backslashes, and add a Qt-based regression test (the current JS tests exercise a mock, not `QTextDocument`). Drop raw HTML from the input before generating the sanitizer's own anchors.

### FR-8 — Low — Log scrubbing misses full local paths, private forum ids (16 chars) and fingerprints (10 chars)
**Files:** `src/forumer_backend.cpp:433,458,3136,3189,3213,3236,819`, `lib/forumer_core/include/forumer_core/redact.h:22-40` (only runs of 20+ alphanumerics), `src/forumer_backend.cpp:74-100` (`logEvent`).

**Exploit/observation:** the debug log we produced contains

```
post log open at /root/.local/share/Logos/LogosBasecamp/module_data/forumer/posts/posts.sqlite3
joined private forum 02fd62cff093523a
created private forum 02fd62cff093523a
restore: history has synced; posting resumes as fr:VFZ1-...
```

The full path exposes the OS username/home layout; private forum ids are 16 hex characters (below the 20-character scrub threshold) and are logged in full; persona fingerprints are 10 characters and are logged in full. `scripts/check-logs.sh` is not in CI (docs admit this). [run]

**Fix:** log paths relative to the data directory (or a redacted form), scrub by field type rather than run length (forum ids, `fr:` fingerprints), open the optional log file 0600, and run `check-logs.sh` in CI.

### FR-9 — Low — Private-forum names from invites accept bidi/invisible/overlong UTF-8; display spoofing and a serialisation throw
**Files:** `lib/forumer_core/src/forums.cpp:40-60` (`validName`), `lib/forumer_core/src/identity.cpp` (`AccountState::toJson`), `src/qml/links.js:66-73` (`cleanForumName`), `src/qml/Sidebar.qml` (name shown as PlainText).

**Exploit:** `validName` only rejects ASCII controls and malformed continuation bytes. It accepts U+202E (RTL override), U+200B, U+2066, overlong-encoded NUL and UTF-8-encoded lone surrogates; `forums::create()` accepted the U+202E name. An invite link's `name` is attacker-controlled, so a forum name can reorder or visually impersonate other UI text in the sidebar/home header. A name with invalid UTF-8 also makes `AccountState::toJson()` throw (`json.exception.type_error.316`) out of `saveAccount()`; this was reproduced with a direct core probe and is reachable only from a non-network caller today. [run]

**Fix:** reuse the strict UTF-8/invisible-character validator from `post::aliasProblem` for forum names (reject overlong encodings, surrogates, bidi controls and zero-width characters); wrap state serialisation in a `try/catch` and report "unusable name" instead of terminating.

### FR-10 — Low — Store-page parser has no limits and wraps status codes
**Files:** `src/delivery_module_transport.cpp:112-155` (`parseStorePage`), `src/forumer_backend.cpp:3793` (`storePageArrived`).

**Exploit:** a malicious or faulty store node can return an arbitrary number of `messages`, arbitrarily large base64 payloads and an arbitrarily long `paginationCursor`; every message is decoded into memory before any length check (the sync decoder later rejects large payloads). `statusCode` is `get<int>()` on an unsigned JSON number, so 4294967496 (2^32+200) wraps to 200 and is treated as success; we reproduced the wrap with a compiled copy of `parseStorePage` (no crash, but a wrong-status acceptance). Store peers are hard-coded but can be overridden with `FORUMER_STORE_PEERS`. [run for wrap; read for limits]

**Fix:** cap messages per page, bytes per payload and cursor length; reject `statusCode` outside `int` range instead of wrapping; parse with a bounded JSON reader.

### FR-11 — Low — Preview/hint answers cross forum boundaries
**Files:** `src/forumer_backend.cpp:2317-2370` (`handleMediaAsk`), `src/forumer_backend.cpp:2440-2465` (`sendPreview`), `lib/forumer_core/src/post_store.cpp:356-364` (`previewsOf` selects by CID only).

**Exploit:** `previewsOf(cid)` returns previews for every stored post with that CID, regardless of forum, and `sendPreview` publishes them on the forum named in the ask. A member of private forum A can ask for a CID belonging to a post in private forum B (or a public post) and cause another member's node to emit that post id and sealed preview on A's topic. The sealed content is not readable without B's key, but the private post id and its existence leak. [read]

**Fix:** join previews to posts, filter by the requesting forum, and only answer with previews for posts in that forum.

### FR-12 — Low — Local post log is trusted on read and is not integrity-protected
**Files:** `lib/forumer_core/src/post_store.cpp:24-42,248-275,286-300` (`insert`, `get`, `all`), `src/forumer_backend.cpp` (`loadPost`, `emitPost`, `sendOwn`).

**Exploit:** rows in `posts` are verified only when they arrive. `get`/`all`/`page`/`loadPost` parse envelopes without re-verifying, and the clear `author` column is what the UI and inbox use. Any process with write access to the SQLite file can inject posts and authors, create fake "replies to you", or alter a queued post before `sendOwn` re-sends it (the signature check then fails at peers, so no remote forgery). The DB is owner-only but unauthenticated. [read]

**Fix:** re-verify envelopes (signature, id, PoW, forum) on read, or HMAC the DB/preview rows with the account key; at minimum do not use the `author` column for display/OP/inbox decisions without checking it against the envelope.

### FR-13 — Low — `fetchAround` multiplies an unchecked `day` before validating it
**Files:** `src/forumer_backend.cpp` (`fetchAround`, ~3567-3590).

**Exploit:** `since = day * 86400000` is evaluated before the `since <= 0` guard; a caller passing a huge `qint64` triggers signed overflow (UB). The QML path validates `day < 1 000 000`, so this is not reachable from an ordinary link today, but the backend slot is the trust boundary. [read]

**Fix:** range-check `day` (e.g. `1..100000`) before multiplying and use checked arithmetic.

### FR-14 — Low — Display fingerprints are only 40 bits and are grindable
**Files:** `lib/forumer_core/src/identity.cpp:52-68` (`fingerprint`), `src/qml/*` (display/OP badges compare full keys, but users read fingerprints).

**Exploit:** generating a key pair whose fingerprint matches a victim's needs ~2^40 Ed25519/BLAKE2b work, feasible for a determined attacker. A post can then display the victim's `fr:XXXX-XXXX` while being signed by the attacker's key. The docs call the fingerprint "not a security identifier", but the UI presents it as the identity at a glance. [read]

**Fix:** use at least 64–80 bits for the displayed fingerprint, and offer/derive a name/trust-on-first-use check of the full key for important contacts.

### FR-15 — Low — Repository hygiene: a committed development log and fixture-looking "key"
**Files:** git history commit `3737669` file `-a`; `scripts/test-links.mjs:36`.

**Observation:** 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`. The log also contains `/Users/bristinborah/forumer-test/a` and libp2p peer records. No private key material was established from the record (it appears to be a peer-record/public-key value). [read; gitleaks output]

**Fix:** purge `-a` from history if the repo is public, add a gitleaks baseline/allow-list for the fixture, and add "never commit local debug logs" to CI.

### FR-16 — Low — Storage upload completion can be attributed to the wrong app's upload
**Files:** `src/forumer_backend.cpp` (`wireStorage` `storageUploadDone` handler, ~965-1010; `pumpUploads` ~1960-2010).

**Exploit:** while `m_mediaUploadSession` is still empty and the probe is not sharing, the handler treats **any** `storageUploadDone` event as ours (`m_mediaUploadSession.isEmpty() && !m_storageSharing`). Because `storage_module` is shared by all Basecamp apps, another app's concurrent upload completing in that window can set our attachment's CID to the other app's content. The post still fails to display (AEAD/size mismatch), but the image is lost and we may reference someone else's CID. [read]

**Fix:** only accept a completion whose session id is known and matches; use a per-upload marker in the file/topic; ignore completions for unknown sessions.

### FR-17 — Low — Digest scans and empty range requests still do work per unsigned message
**Files:** `lib/forumer_core/src/digest_plan.cpp:10-45`, `lib/forumer_core/src/digest_plan.cpp:56-75`; `src/forumer_backend.cpp` `handleDigest`/`handleRange`.

**Exploit:** each admitted digest causes `PostStore::recent()` over up to 96 h (60 scans/min allowed); a fill of 50 000 posts makes each scan load tens of thousands of rows. A valid range with nothing missing performs a `range()` query and does not charge the cooldown (by design, so junk cannot silence honest ranges), allowing repeated DB work. [read]

**Fix:** cap the rows scanned per digest (or use a bounded index/count first), charge a small per-request budget for range queries even when the answer is empty, and prune/aggregate old posts.

### FR-18 — Low — `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` (`open`, ~60-85).

**Exploit:** a local file with a wrong JSON type (e.g. `accounts.json` `{"v":2,"selected":123}`) can make `doc.value("selected", std::string())` throw `json::type_error` outside any `try`, crashing startup. `vault::open`'s `version->get<int>()` has the same shape. [read]

**Fix:** validate types explicitly (`is_string`, range-check numbers) and catch `json::exception` around all local-file parsing; treat malformed files as "unknown account".

### FR-19 — Low — A peer can forge outbox "sent" confirmation
**Files:** `src/forumer_backend.cpp` `confirmHeldByPeers` and `handleDigest`.

**Exploit:** any peer that has seen a 32-hex post id can send a digest listing its 16-hex short id; `confirmHeldByPeers` then marks the post `Sent` without a delivery-module confirmation. This is a UI/delivery-state integrity issue, not content forgery. [read]

**Fix:** only settle to `Sent` on a delivery success or on agreement from more than one peer/our own echo.

## 4. What is solid

- **Post envelope integrity.** `post::sign`/`verify` (`lib/forumer_core/src/post.cpp`) canonicalise every signed field with sorted JSON, mine and re-check BLAKE2b proof-of-work, verify Ed25519 over `canonical||nonce`, and recompute the BLAKE2b-128 id; size/shape/UTF-8/disclosure/alias checks are complete. Tampering, re-attribution, wrong work and wrong ids are refused. `verify()` is called before any storage in `handlePost`/`checkPost`; the `hasAuthor`-based seen-on-wire shortcut only trusts a successfully re-verified copy (F8 fix). 163 core tests pass under ASan/UBSan.
- **Crypto wrappers.** `crypto.cpp` uses libsodium only; sizes are fixed and static-asserted; AEAD nonces are random per encryption; `SecretBytes` wipes on copy/destruction/move; vault uses Argon2id (MODERATE ~256 MiB) with a random 16-byte salt and binds `forumer-vault-v1` as associated data; `sealed`/`forums`/`media` bind account/forum/purpose context into AD.
- **Private-forum crypto.** `forums::sealWire`/`openWire` check the message type, version and forum id, derive independent wire/store subkeys from the 32-byte forum key, and use AEAD; `sealStored`/`openStored` keep private text out of the SQLite envelope. Leaving a forum drops its rows. Tests cover sealing, binding and non-member isolation.
- **Account storage.** Account ids are derived and validated as 16 lowercase hex before use in paths; no traversal. Passwords are re-checked for `revealPhrase`/`deleteAccount`; `create`/`restore`/`changePassword` refuse short passwords; state/outbox are encrypted with the master-derived storage key; files/dirs are 0600/0700; writes are temp+rename. `AccountState::fromJson` rejects mismatched ids/counters.
- **Network parser.** `sync::decode` enforces a 24 KiB normal / 64 KiB preview cap, rejects negative `since`, validates short ids, caps `have` at 512 and hint addresses at 8, validates CIDs/peer ids/address shapes, and checks versions. `RangeGate` clamps the future end and trims oversized spans with overflow-safe arithmetic; `planDigest` reads the held set once and only under the scan budget; `MessageAsk` takes its bucket before DB work. The store-page crash hypothesis from nested/large JSON was tested and refuted.
- **SQL and filesystem.** Every query is parameterised (`PostStore`, `AccountStore`); forum/account/private-file names are validated before path construction; fetched/upload paths use UUIDs or right-truncated alphanumeric CIDs; media temp dirs are removed at startup.
- **Content display.** Almost every QML `Text` is `PlainText`; the only Markdown sink is `MdText`, and link activation is restricted to `https?://` (`Qt.openUrlExternally`). `safe()` neutralises inline images and raw `<` (the bypass in FR-7 affects tag parsing, not script/image loading in Qt 6.4.2). No `XMLHttpRequest`, remote `Image.source`, `eval`, or `Qt.createQmlObject` from post data was found.
- **Build/package.** `flake.lock` pins all Nix inputs; the published `.lgx` QML is byte-identical to `src/qml` (diff clean); bundled `libsodium`/`sqlite3` are included and the plugin loads in Basecamp.

## 5. What I did not test

- A source build of the **module/plugin** (only `forumer_core` was compiled); no Nix, no `logos-module-builder`, no generated `logos_sdk.h`. The published `.lgx` was used, so the binary plugin itself is not reproduced from the reviewed commit.
- Two-instance or two-peer private-forum sync, invite join on a second device, non-member isolation, and the private store archive.
- Reply sending through the UI (text was typed, but the inline "reply" button was not successfully clicked); nested replies; mute; missed/unread; join/invalid-invite flow; restore, lock/unlock, change-password and delete; clipboard clearing after 60 s; network switch and restart.
- Live `logos.test`/RLN, Windows/macOS builds, native Windows Basecamp, Basecamp's image-provider build (images showed "can't display" here), and actual image decoding/rendering.
- A hostile **network peer harness**: digest/range/mprev/mhere floods, forged/oversized messages, duplicate/poisoned store pages, and the unbounded-map/disk growth were assessed from code, not by injecting them into the live node.
- The full disk-fill budget (FR-1) was not driven to exhaustion; only one 711-byte preview row was produced by our own post.
- Vendor/dependency source (delivery_module 0.4.0, storage_module 3.0.2, Logos SDK, Qt) beyond the interfaces and the published test reports.

## 6. Suggested order

1. **FR-1** — bound preview storage with a total/per-author/per-day budget and a periodic prune; this is the easiest remote denial of service.
2. **FR-2** — remove clear private-forum persona/forum/timestamp metadata from SQLite (hash or seal the index columns) and document the remaining exposure.
3. **FR-3 / FR-4** — budget unsigned `mprev`/`mhere` before map/SQL work, cap/evict the maps, and restrict `mhere` addresses to public routable unicast with a dial budget.
4. **FR-5** — pin all GitHub Actions by SHA and split release permissions.
5. **FR-6 / FR-18** — clamp vault KDF parameters and make all local-file JSON parsing exception-safe.
6. **FR-7 / FR-9** — fix the Markdown HTML escape and forum-name Unicode validation; add Qt/`QTextDocument` and bidi regression tests.
7. **FR-8 / FR-10 / FR-11 / FR-13 / FR-16 / FR-17 / FR-19** — hardening: log redaction and CI log check, store-page limits, per-forum preview filtering, checked arithmetic, upload-session matching, and tighter digest/range budgets.
8. **FR-12 / FR-14 / FR-15** — longer-term: authenticated post log, wider display fingerprints, repository history clean-up.

---
Triage, not approval — this never certifies a module as safe. The sandbox is a container; its kernel is the host's and could be detected.
