# Forumer 0.2.5 — independent security review @ b9907523bffc975723762f1766f483d576b677e3

## 1. Bottom line

The cryptographic core is the strongest part of this module. Posts are Ed25519-signed, content-addressed, proof-of-work checked, size-bounded and rejected on any canonical-field change; private-forum payloads are XChaCha20-Poly1305 sealed on the wire and at rest; vaults use Argon2id + XChaCha20-Poly1305; and all 163 unit tests pass, including tampering, replay/placement, media-ownership and flood-limit tests. I did not find a way to forge, alter or splice a post, nor to read a private forum without its key.

The most serious weakness is a flood-control bypass on the history/store path: posts timestamped in the window the node is currently walking skip both the new-key budget and the live arrival limit, so a peer that sees a range request can inject fresh-key spam at proof-of-work speed (`FR-11`). The unauthenticated sync surface is the next risk: two maps used to suppress duplicate media hints and previews are written from every `mhere` / `mprev` message before any signature or budget check, and are never pruned, so a peer can make the backend allocate memory without paying proof-of-work (`FR-1`). The same unauthenticated media-hint path passes attacker-chosen peer ids and multiaddrs to `storage_module.connect()`, so the local storage node can be made to dial loopback/private addresses (`FR-2`). Media-ask replies are also a cheap amplification channel (`FR-3`). Separately, the private-forum index columns in the local SQLite log stay in clear while the envelope is sealed, leaking forum membership/persona/timestamp metadata to anyone who can read the unlocked device's data folder (`FR-4`).

The QML side is generally careful: every `Text` element sets `PlainText`, `MdText` escapes HTML/images, links are restricted to http(s), and no QML path loads remote images directly. The exceptions are diagnostic logs that print full post ids/CIDs (`FR-6`) and backend logs that still contain 16-character forum ids, fingerprints and filesystem paths despite the A5 claim (`FR-13`), plus an empty-input nullptr passed to libsodium (`FR-7`), all confirmed here. Preview sealing also reuses an AEAD nonce when two clients seal different previews of the same image (`FR-12`). The top-level build could not be reproduced: it requires the Nix `logos-module-builder`; the core library was built and probed standalone.

This is triage, not approval. It does not certify the module as safe.

## 2. What I ran

Environment: Debian-like container, Qt 6, libsodium, SQLite, nlohmann-json present; `nix` absent. `/run/lgx/forumer-0.2.5-linux-x86_64.lgx` installed into Basecamp by `desk install`, which also installed delivery_module 0.4.0 and used Basecamp's bundled storage_module.

Build / test:
- `cmake -S . -B /tmp/build-top -G Ninja` → fails at `CMakeLists.txt:7`: `LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.` No Nix in the sandbox, so the shipped module build was not reproduced. **[run]**
- Pre-existing ASan/UBSan core build `ninja -C build/audit` → linked `forumer_core_tests`; `ASAN_OPTIONS=detect_leaks=0 ./build/audit/tests/forumer_core_tests` → `163/163 tests passed`. LeakSanitizer cannot run under the container's ptrace setup; leak detection was disabled for the test run only. **[run]**
- `node scripts/test-markdown.mjs` → all tests passed. `node scripts/test-links.mjs` → all checks passed. `node scripts/test-log-safety.mjs` → PASS. **[run]**
- `gitleaks.json`: two generic-api-key hits. One is the deliberate 43-character fixture `q3Xv0b5…` in `scripts/test-links.mjs`; the other is a removed historical log file (`-a`, commit `3737669`) containing a libp2p peer record. Neither is a live credential for this module. `osv.json` is empty. **[read]**

Basecamp / UI (`desk`):
- `desk start`, `desk install /run/lgx/forumer-0.2.5-linux-x86_64.lgx`, opened the module at the left-bar icon. Shots `01-onboarding-create`, `02/04-onboarding-filled`, `06-recovery-phrase`, `08-phrase2`, `23-home-feed`, `34-home-feed2`, `36-composer-open`, `37-composer-hostile-input`, `40-search-hostile`, `42-composer3`, `43-after-post2`. **[run]**
- Created a local account (`audit-main` + password), saw the generated 24-word phrase, confirmed it, and reached the public feed. The live `logos.dev` feed and threads from other peers loaded (153–160 posts). **[run]**
- Evidence set: every screenshot in `/run/media/*.png` (`01`–`43`, including onboarding, recovery phrase, home feed, thread, missed screen, new-forum sheet, composer with hostile text, and the search/filter result); full screen recording at `/run/media/session.mp4`. **[run]**
- Opened a thread (e.g. “E3 topic while peer offline…”), the replies screen, the missed/history screen, and the new-topic composer. Entered HTML/image/link payloads into the composer title/body; the UIs stayed up, no markup executed, and the preview/sanitiser behaved as claimed. **[run]**
- The composer's `post` button was not reachable in the captured layout at 1920×1200 (footer showed `cancel` but not `post`); the attempted click closed/left the sheet and the local SQLite log showed no new row. I did not confirm a successful UI post or reply end-to-end. **[run]**
- The live feed exposed a storage CID in a media error, which QML logged in full (`basecamp.log:2026`, `qml: [forumer qml] media … failed … zDvZRwzkwQ4sCod2FzWo1qtF5AmrhRUhkgK5dRmaYxxtut8JW5jg …`). **[run]**

Probes (`/run/probes/`, linked against `build/audit/libforumer_core.a`). The worktree also contained an untracked `probes-review/` directory (not part of commit b990752); I treated its README/code as untrusted data, then independently compiled and ran the probes cited below:
- `probe_json_depth` / `probe_json_depth2` (deeply nested JSON up to 30k levels through `sync::decode` and `Post::fromJson`): no stack overflow; parser rejects. **[run]**
- `probes-review/p_history_flood`: replicated `inHistoryWindow`/`checkPost` against real `PostStore`/flood/post; 45/45 fresh-key posts timestamped in a witnessed history window were accepted, 0 rejected; all 45 would have hit the new-key budget on the live path. **[run]**
- `probes-review/p_preview_nonce`: two different previews of the same full image seal under the same key/nonce; one preview plus both blobs recovers the other. **[run]**
- `probes-review/p_hint_addrs`: loopback, link-local, private, IPv6 loopback and `localhost` multiaddrs all accepted by `sync::decode`. **[run]**
- `probes-review/p_scrub`: 16-hex forum/account ids, `fr:XXXX-XXXX` fingerprints, full storage paths and IP:port pass `scrubIds()` unchanged. **[run]**
- `probes-review/p_decode_overflow`: numbers at/above `UINT64_MAX` in digest/range fields are rejected with `DecodeError::Malformed`, not exceptions; no crash. **[run]**
- `probe_vault`: confirms the stored Argon2 cost parameters are used as-is (changing them changes the derived key, so a tampered vault fails authentication); not a downgrade bypass. **[run]**
- `probe_thread_order` (compile timed out under ASan while linking; source completed but binary was left without execute permission) — placement logic was then checked by reading `thread.cpp` and `checkPost`; reported as `[read]`, not `[run]`.
- Empty-base64 UBSan observation from `probe_json_depth`: `bytes.cpp:109` “null pointer passed as argument 1”. **[run]**

Not run: no second Forumer peer/instance, no crafted wire sender, no official testnet, no native Windows/macOS. Attacks that require publishing to `/forumer/3/public/proto` (FR-1/2/3/5) are therefore code-confirmed only; they are marked `[read]` rather than overclaimed as reproduced. FR-11 was reproduced by faithfully replicating the backend’s `inHistoryWindow` decision against the real `PostStore`/flood/post code, not by a live peer.

## 3. Findings

### FR-11 — High — 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 when `inHistoryWindow`), `lib/forumer_core/src/flood.h` layers 2-3.

**Exploit:** `handlePost` uses one predicate for both the arrival limit and the new-key budget:

```cpp
const bool inHistoryWindow =
    m_storeImport || (m_historyActive && ts >= m_historyFrom && ts < m_historyTo);
const std::string problem = checkPost(post, inHistoryWindow);
...
if (!inHistoryWindow && !m_posts->hasAuthor(post.publicKey)) {
    // new-key / priority token buckets
}
```

With `fromHistory=true`, `checkPost` skips `countByAuthorArrival` (the live anti-backdating limit) and only checks the per-key daily cap, counted over the author's own `[ts-24h, ts]`. The new-key budget is also skipped. A peer can read the unencrypted range request (`{"v":1,"t":"range","since":…,"until":…}`) that the victim publishes on the public topic, then send posts timestamped inside that `[since,until)` window. Each post is signed by a fresh key, so the per-key caps never bind; only 16-bit PoW per post is paid. The probe `probes-review/p_history_flood.cpp` (rules replicated against the real `PostStore`/flood/post libraries) offers 45 such posts spaced 30 h apart in a witnessed 90-day window:

```
posts offered in the witnessed history window: 45
accepted by the history-path rules: 45
rejected: 0
of the accepted, refused three-per-minute-ish by the new-key budget
if they had come in live: 45 (skipped on this path)
stored rows: 45
```

The same predicate makes any message returned by a store node (`m_storeImport`) bypass both budgets; the store fleet is remote and can be compromised or spoofed on-path.

**Fix:** Do not skip the new-key budget or per-arrival limit for history/store posts. Add a separate bounded history-import rate (e.g. tokens per minute and a byte budget) and apply the same arrival clock; cap the number of posts accepted per range/store page per peer; validate that history posts are actually in a range we requested from a peer/store we asked.

### FR-1 — High — Unauthenticated peers can grow two backend maps without bound (remote memory exhaustion)
**Files:** `src/forumer_backend.cpp:2377-2378` (`handleMediaHere` writes `m_hintSeenAt[hint.cid]`), `src/forumer_backend.cpp:2532-2536` (`handlePreviewPart` writes `m_previewSeenAt[key]`), declarations `src/forumer_backend.h:528,549`; dispatch `src/forumer_backend.cpp:3038-3041`.

**Exploit:** `mhere` and `mprev` are unsigned sync messages. `handlePayload` dispatches them straight to `handleMediaHere` / `handlePreviewPart`; the first statements insert into a `std::map`, before any signature check, `m_mediaAskBudget`, post lookup, or size/age eviction:

```cpp
void ForumerBackend::handleMediaHere(const fc::sync::MediaHint &hint) {
  m_hintSeenAt[hint.cid] = nowMs();          // key from the wire, never pruned
  auto it = m_mediaFetches.find(hint.cid);
  ...
}
void ForumerBackend::handlePreviewPart(fc::sync::PreviewPart part, const std::string &forum) {
  ...
  const std::string key = part.postId + ":" + std::to_string(part.index);
  m_previewSeenAt[key] = nowMs();            // arbitrary postId; never pruned
```

A peer sends `{"v":1,"t":"mhere","cid":"<20-128 alnum>","peer":"<40-64 base58>","addrs":["/ip4/1.2.3.4/tcp/1"]}` with a fresh CID each time (or `mprev` with fresh 32-hex post ids). Each ~100-byte message permanently retains a `std::string` key plus map node (roughly 100–200 bytes); no proof-of-work, signature, rate limit or eviction applies. A few million messages exhaust a laptop's memory; after restart the map is empty again, but the DoS repeats. This is exactly the class of unsigned-traffic work the flood-layer docs claim is bounded.

**Fix:** Before touching any map keyed by wire data, take an unauthenticated-media budget shared by `mhere` and `mprev`; cap both maps (e.g. 4096 entries) and prune entries older than the relevant suppression window (`kSeenWindowMs`/120 s); ideally only track CIDs/post ids we have actually asked for, and ignore hints for unknown CIDs. Add a regression test that feeds 100k unique `mhere`/`mprev` messages and asserts the map stays bounded.

### FR-2 — High — Media hints let a peer redirect the storage node to arbitrary loopback/private addresses
**Files:** `src/forumer_backend.cpp:2377-2395` (`handleMediaHere`), `src/forumer_backend.cpp:2394-2404` (`dialHolder` → `modules().storage_module.connect(qs(hint.peer), addrs)`), validators in `lib/forumer_core/src/sync.cpp` (`peerIdShape`, `addrShape`).

**Exploit:** A peer answers any `mask` we send while fetching an image with a `mhere` whose `peer` is an arbitrary base58-shaped id and whose `addrs` are arbitrary `/ip4/127.0.0.1/tcp/1`, `/ip4/169.254.169.254/tcp/80`, `/ip4/10.0.0.1/tcp/22`, etc. `addrShape` only checks a printable prefix and a `/tcp/` or `/udp/` substring; it does not reject loopback, link-local, private, multicast or the local machine's own port. `probes-review/p_hint_addrs.cpp` feeds these hints through `sync::decode`: `/ip4/127.0.0.1/tcp/6379`, `/ip4/169.254.169.254/tcp/80`, `/ip4/10.0.0.5/tcp/22`, `/ip6/::1/tcp/5432`, `/dns4/localhost/tcp/2375`, `/dns4/internal.corp.example/tcp/443` and a public UDP address are all `ACCEPTED`. `dialHolder` then passes the strings directly to `storage_module.connect()`, so the node attempts libp2p connections to the chosen host:port: an SSRF/port-scan primitive and a way to leak that the user opened an image to local services. Only CIDs we are actually fetching are affected, but the attacker controls the reply to our public `mask` and can pre-answer for every image-heavy thread.

**Fix:** Accept holder hints only for peers/addresses that can be corroborated (e.g. the peer id of a known forum peer, or an address returned by the storage DHT); reject loopback, link-local, private, multicast, unspecified and own addresses; allow only the expected storage port range; rate-limit by peer; do not dial hints from a forum whose sender identity you cannot check. At minimum add an explicit address policy comparison against `QNetworkInterface` local/private ranges.

### FR-12 — Medium — Preview AEAD key/nonce reuse for different previews of the same image
**Files:** `lib/forumer_core/src/media.cpp` (`derive()` computes `nonce = BLAKE2b(kNonceLabel || key)`; `sealPreview()` passes `hexBytes(full.sha256)` as the derivation input).

**Exploit:** The XChaCha20-Poly1305 nonce is a deterministic function of the **full image's** signed SHA-256, not of the preview bytes. Two clients (or two encoders) sealing different preview JPEGs for the same full image use the same key and the same nonce. `probes-review/p_preview_nonce.cpp` confirms keystream equality and recovers preview 2 from preview 1 plus both blobs without any key:

```
sealed preview 1: 320 bytes, preview 2: 320 bytes
ciphertext XOR == plaintext XOR (same key+nonce keystream): YES -> nonce reuse
both open: 1 1
p2 recovered from p1 and both sealed blobs: YES
```

Impact is bounded because the blob is also signed together with `postId:index` in `acceptPreview`, and public-forum preview keys are derivable from the signed full-image hash; the practical break is cross-preview confidentiality/forgery inside a key domain where the attacker often already knows one plaintext. It is still nonce reuse in an AEAD and should be fixed before previews are treated as a security boundary.

**Fix:** derive the preview key/nonce from the preview blob (or a random per-seal nonce stored with the blob), not from the full image alone; bind `postId`/`index` into the associated data. Add a test that different previews of the same image never share a nonce.

### FR-3 — Medium — Image "mask" request amplification
**Files:** `src/forumer_backend.cpp:2317-2375` (`handleMediaAsk`), `src/forumer_backend.cpp:2507-2529` (`sendPreview`), `src/forumer_backend.cpp:3038-3041` (`handlePayload` dispatch).

**Exploit:** One tiny `mask` message (`{"v":1,"t":"mask","cid":"…"}`, tens of bytes) is answered by up to four queued previews (`m_posts->previewsOf(cid)` has `LIMIT 4`), each split into up to four `mprev` pieces of up to 40 KiB raw (`sync::kMaxPreviewParts × kPreviewPartBytes`), i.e. hundreds of KiB per request. The only limiter is `m_mediaAskBudget` (30/min, burst 60), so one peer can elicit ~20 MiB/min from a few hundred bytes/s. The same pattern exists for digest answers (64 posts per digest, capped by the 240/min resend bucket) and range answers (64 posts per 3 s). This is not a correctness break, but it is an asymmetric traffic amplifier against a node that did not author those posts.

**Fix:** Apply a byte/second budget to answers, not only a message count; per-answer caps; require a PoW or authenticated (signed) mask from the asker; suppress previews for CIDs the asker has not proven interest in; cap `sendPreview` pieces per minute per forum.

### FR-4 — Medium — Private-forum metadata is stored in clear beside the sealed envelope
**Files:** `lib/forumer_core/src/post_store.cpp:248-258` (`INSERT … forum, root, parent, author, ts, received, envelope`), `src/forumer_backend.cpp:3094-3100` (`storePost` seals only `envelope`), schema `lib/forumer_core/src/post_store.cpp:20-38`.

**Exploit:** For a private forum, `storePost()` correctly passes a `pf1:` sealed envelope, but the indexed copy columns remain plaintext: `forum` (the forum id, a hash of its secret key), `author` (the persona public key), `root`, `parent`, `ts` and `received`. An attacker with read access to the locked/unlocked data folder (device thief, backup, other local process with the same uid) can therefore see who posted in which private forum and when, and can correlate a persona used in a private forum with the same persona used publicly. The account link stays encrypted (good), but the claim that the log “never says who wrote what” is too strong for private-forum rows: it says exactly which signing key wrote each row and which forum it belongs to. Content remains protected.

**Fix:** For `forum != "public"` rows, do not store a clear `author`/`forum`/`root`/`parent`/`ts`; store keyed hashes (e.g. BLAKE2b with a per-account key) or only the `id` + sealed envelope, and decrypt/index at runtime while unlocked. At least HMAC the forum id and author with the account storage key. Document the residual metadata precisely.

### FR-5 — Medium — Thread placement is judged only against messages already held, so out-of-order/ghost replies are accepted permanently
**Files:** `lib/forumer_core/src/thread.cpp:31-34` (`if (!parent) return Placement::Ok;`), `src/forumer_backend.cpp:3285-3295` (backend passes `loadPost(parent)` and trusts `Ok`), `src/qml/ForumStore.qml` `subthreadOf`/thread derivation.

**Exploit:** A reply whose `parent` is not yet stored is accepted with `Placement::Ok`. An attacker can publish a reply ordering that would be a third level if the parent were present (R3 → R2 → R1 → topic), or a reply to a completely non-existent `root`/`parent` pair. If R3 arrives first it is stored; R2 and R1 arrive later and are also accepted; nothing re-checks R3 when its parent appears. The QML `subthreadOf` loop then folds R3 into the R1 sub-thread, so the UI does not render arbitrary depth, but the store now contains a reply chain that violates `thread.h`'s two-level rule, and ghost replies create “Topic still arriving…” placeholders under attacker-chosen 32-hex ids. This is an integrity/feed-pollution issue, not a signature break; it lets a peer manufacture threads that no honest client would have produced.

**Fix:** Keep unjudgeable replies in a pending table until their parent/root arrives, then run `checkPlacement` and drop/repair them; or require `parent` to be present (queuing the reply, not storing it) and revalidate all children when a parent is inserted. For roots, require the topic or a previously seen root id; cap pending ghosts per peer.

### FR-6 — Low — QML diagnostics print full identifiers and CIDs, bypassing the backend scrubber
**Files:** `src/qml/ForumStore.qml:501-505` (`onMediaLoaded` logs `detail`), `src/qml/ForumStore.qml:526-527` (`onMessageStateChanged` logs the full post `id`), `src/forumer_backend.cpp` `logEvent` scrubber; docs/security.md §9/A5 claims all logging is scrubbed.

**Exploit:** Any user who attaches `basecamp.log` to a bug report (or any host log reader) gets full 32-hex post ids and full CIDs from QML `console.log`, even though backend `logEvent` shortens 20+ character runs. Reproduced in the running Basecamp log: `qml: [forumer qml] media … zDvZRwzkwQ4sCod2FzWo1qtF5AmrhRUhkgK5dRmaYxxtut8JW5jg …` and `messageStateChanged -> <32-hex id>`. These are content hashes (not secrets), but they are exactly the identifiers the A5 control says are removed; they link a device's activity and the exact media it fetched. The docs' “all logging goes through logEvent” is false for QML.

**Fix:** Scrub in QML too (use a JS equivalent, or log only `id.substring(0,8)`), remove `detail` verbatim from logs, and add a host-log regression check that includes QML lines. The current `scripts/test-log-safety.mjs` does not catch this path.

### FR-7 — Low — Empty base64 fields pass a null pointer into libsodium (UB)
**Files:** `lib/forumer_core/src/bytes.cpp:105-112`.

**Exploit:** `fromBase64Url("")` constructs `Bytes out(0)` and calls `sodium_base642bin(out.data(), 0, text.data(), 0, …)`; `std::vector::data()` is null for an empty vector, which UBSan reports as “null pointer passed as argument 1”. Any malformed post with `"pk":""`, `"nonce":""`, `"sig":""`, empty forum key text, etc. reaches this path before returning `nullopt`. The probe output:

```
/run/src/lib/forumer_core/src/bytes.cpp:109:37: runtime error: null pointer passed as argument 1, which is declared to never be null
```

In this libsodium build it fails cleanly, but it is undefined behaviour in the C++ contract and could crash with a different libc/libsodium. **Fix:** return `nullopt` for empty input, or pass a valid dummy pointer when `text.empty()`; add a unit test for every empty base64 field.

### FR-8 — Low — `fetchAround(day)` has signed integer overflow before validation
**Files:** `src/forumer_backend.cpp:3575-3589`.

**Exploit:** The slot computes `const int64_t since = static_cast<int64_t>(day) * kDayMs - kDayMs;` before checking `day <= 0 || since <= 0 || since >= now`. A direct `.rep` caller can pass `day = INT64_MAX`, causing signed overflow (UB) in the multiplication/subtraction; `until = min(now, since + 9*kDayMs)` has the same issue. The current QML path bounds `day < 1,000,000` in `links.js`, so this is not reachable from a link today, but the public slot has no such bound. **Fix:** reject `day <= 0` and `day > (now/kDayMs) + 1` (or use checked arithmetic) before any multiplication; unit-test extreme values.

### FR-9 — Low — Unbounded account labels and many local strings
**Files:** `src/forumer_backend.cpp:847-869` (`createIdentity`), `src/forumer_backend.cpp:998-1008` (`renameAccount`); `createPrivateForum`/`joinPrivateForum` do validate names (`forums::validName`, 64 bytes).

**Exploit:** `createIdentity(label, …)` and `renameAccount(label)` trim but do not bound `label`; it is written to `accounts.json` and the encrypted state. A local user (or a malicious intent caller that reaches the identity slots) can store a multi-megabyte label, inflating `accounts.json` and every `accountsJson` PROP update. Not remote. **Fix:** cap at 64/128 bytes UTF-8, enforce in the backend (not only the QML hint).

### FR-10 — Low — Per-account media state survives lock/leave in places
**Files:** `src/forumer_backend.cpp:1898-1915` (`forgetAccountMedia`), `src/forumer_backend.cpp:2342` (`m_ownMediaCids`), `m_mediaCache`/`m_attachment` handling; `src/qml/ForumStore.qml` `mediaStates` map.

**Exploit:** `forgetAccountMedia()` clears attachments and non-public cache entries, but `m_ownMediaCids` is not cleared on lock or account switch, and `leavePrivateForum()` does not call `forgetAccountMedia()`, so the plaintext `data:` URLs of a left private forum's images can stay in `m_mediaCache` (and in QML `mediaStates`) until eviction. They are not reachable through the UI after the forum is removed from the list, but they remain in memory longer than the “private forums close with the account” comment implies. **Fix:** clear `m_ownMediaCids` on detach and clear `m_mediaCache` entries for a forum in `leavePrivateForum`; clear QML `mediaStates` for removed forums.

## 4. What is solid

- **Signed post envelopes** (`lib/forumer_core/src/post.cpp`): canonical JSON, Ed25519 over canonical||nonce, BLAKE2b-128 id, exact size caps, strict UTF-8, domain/alias/disclosure validation, PoW check before signature check. Fuzzing with deep nested JSON up to 30k levels did not crash the parser.
- **Vault and account store** (`crypto.cpp`, `vault.cpp`, `account_store.cpp`): Argon2id (MODERATE), XChaCha20-Poly1305, fresh salt/nonce, atomic writes, owner-only permissions, `isValidId` path guard (no `../` traversal), private-name guard. Tampering with vault ciphertext fails; tampering with KDF parameters also fails because they feed the key, so this is not a downgrade bypass.
- **Private forums** (`forums.cpp`): 32-byte key → id/topic/wire key/store key via domain-separated KDF; AEAD with forum id as associated data; `openWire`/`openStored` reject wrong forum and altered ciphertext. Tests cover member/non-member and at-rest sealing.
- **Sync framing** (`sync.cpp`): 24 KiB normal / 64 KiB preview caps, integer/version/shape checks, 512-id digest cap, range span clamping, `acceptTimestamp` future cap, overflow-safe span check. `probe_json_depth2` found no parser stack issue.
- **Malicious media** (`media.cpp`, backend): image refs are signed; decoded image is checked against signed SHA-256, size and magic; convergent encryption; private-forum key mixed in; preview signed with the post key; manifest size checked before download; storage download size/decryption checked before display.
- **QML presentation**: every `Text`/`TextEdit` in `src/qml/*.qml` sets `textFormat: Text.PlainText` (checked with a source scan); `MdText` neutralises HTML and images, allows only `http(s)` links, and `scripts/test-markdown.mjs` passes; `MediaCards`/`MediaStrip` display only backend `data:` URLs or `SandboxImages`, never remote URLs; `links.js` validation passes.
- **Data-at-rest basics**: post log, accounts, media and temp dirs are created owner-only (0700/0600); `PeerId` is random and unrelated to identity; SQLite uses bound parameters throughout; `pruneTo`, `removeForum`, `markRead` are parameterised.
- **Core tests**: 163/163 passed, covering signatures, tamper detection, persona derivation/rotation/restore, vault tamper, owner-only files, flood limits, digest/range gates, thread placement, media ownership, private forums, and log scrubbing.

## 5. What I did not test

- **No second peer / crafted wire sender.** The sandbox has one Basecamp and no Nix `nix run`; I could not publish `mhere`/`mprev`/digest/range/post frames from an attacker node. FR-1/2/3/5 are therefore `[read]`-confirmed only, with exact code paths; they need a two-instance or synthetic-wire harness.
- **Top-level build / portable module build** failed: `CMakeLists.txt` requires `LOGOS_MODULE_BUILDER_ROOT` and the sandbox has no Nix. I built and linked only `lib/forumer_core` from the pre-existing ASan build directory. The installed `.lgx` was tested, but I did not rebuild it from source.
- **Complete UI flow.** I created an account, saw the phrase, browsed live public threads, opened replies/missed/settings/backup, and entered hostile composer content. The composer's `post` button was not reachable in the 1920×1200 layout (likely a clipped/disabled footer action); no UI-created topic or reply was confirmed, so end-to-end posting, private-forum creation/join, image attach/upload, and restore are only covered by unit tests and code reading.
- **Storage network / images.** The live feed had a media fetch that failed (“Key doesn't exist”); no successful image upload/download round trip. No dev-mode storage probe (`FORUMER_DEV=1`) was run.
- **Network-level attacks**: no mainnet/testnet peer, no RLN, no store-node spoofing, no traffic analysis, no DNS/IPv6 SSRF verification against a real storage node.
- **Platforms**: Linux only; no Windows/macOS Basecamp, no Android, no clipboard-manager behaviour, no native file picker.
- **FR-5 probe** compiled too slowly under ASan/Klein; its conclusion is from `thread.cpp:31-34` and `checkPost` code reading.
- LeakSanitizer was disabled for the test run (container/ptrace limitation), so memory leaks are not part of this review.

## 6. Suggested order

1. **FR-11** — close the history/store flood bypass first: apply the new-key and arrival budgets to history imports, add a bounded import rate and per-page/peer caps.
2. **FR-1, FR-2, FR-3** — bound and authenticate the unauthenticated media/sync surface; these are the remaining remotely reachable resource/SSRF issues.
3. **FR-12, FR-4** — fix preview nonce reuse and remove clear private-forum metadata from the local index; correct the security docs.
4. **FR-13, FR-5, FR-8, FR-9** — logging scrub coverage and structural/robustness fixes.
5. **FR-6, FR-7, FR-10** — hygiene: scrub QML logs, guard empty base64, clear media state on leave/lock.
6. Re-run the core tests and add regression tests for each fix; add the two-instance wire harness for FR-1–FR-3, which the current test matrix explicitly does not cover.

---
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.
