Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

Metadata tier is unauthenticated and unbound: ReadState / outbox / timestamps are forgeable

Источник: dev/fera-research/docs/oc1/006-metadata-tier-unauthenticated-no-aad.md (read-only; сгенерировано scripts/sync-docs.mjs)

Metadata tier is unauthenticated and unbound: ReadState / outbox / timestamps are forgeable

Section titled “Metadata tier is unauthenticated and unbound: ReadState / outbox / timestamps are forgeable”
  • kind: risk
  • area: trust / storage
  • evidence:
    • /home/nikki/dev/fera/project-design/context/ADR.md:620-623 — ADR-016: “Metadata requiring fast search across hundreds of chats … lightweight, hardware-accelerable cipher — ChaCha20 or AES-GCM (AES-NI-accelerated).” The choice is left open inside an Accepted ADR, and repeated verbatim in TECHSPEC.md:160.
    • /home/nikki/dev/fera/project-design/context/ADR.md:648-652 — ADR-031 puts into metadata tier: “chat/message IDs, timestamps, counters, ReadState, outbox status + retry info, chat-list entries (titles, chat type, pinned/muted, unread counts), storage indexes, predictor event log.”
    • /home/nikki/dev/fera/project-design/context/ADR.md:629-635 — “Two distinct encryption code paths must be maintained”; no AAD, no nonce, no record-binding is specified anywhere for this tier.
    • TECHSPEC.md:155-159 — Encrypted<T> { data: Vec<u8>, .. }: no nonce, no tag, no AAD field.
    • No ADR/OQ in the corpus covers AEAD nonce strategy or AAD design (verified by grep for nonce|AAD|associated data|malleab across context/*.md).
  • severity: high

Everything the app needs to be consistent (message ids, timestamps, read state, outbox status, unread counters) is in the cheap tier, and the cheap tier is specified as a cipher choice that is still open between two options with opposite authentication properties:

  • ChaCha20 (as literally written at ADR.md:622) is a stream cipher with no authentication at all. Bit-flips in the ciphertext produce chosen plaintext/ciphertext. Tampering with ReadState or outbox status in a ChaCha20-tier record is a one-bit operation with no detection.
  • AES-GCM is authenticated, but only if implemented with a unique nonce per encryption. With no nonce policy in the spec, the natural implementation for “metadata records” (encrypt many small fields, often re-encrypted on update) risks nonce reuse — which for GCM is catastrophic (auth key + plaintext recovery), not merely weak.

Even with AEAD, there is no AAD binding: nothing ties a ciphertext to (record type, chat_id, message_id, schema version). Consequences for the exact fields ADR-031 assigns here:

  • Swap a Synced(id) for a lower id ⇒ the client believes the user has read messages they have not; read_inbox_max_id gets pushed upward and the read state is silently lost on every other device.
  • Rewrite an outbox Pending to Sent(fake_id) ⇒ an unsent message appears sent forever, with no retry — a silent data-loss bug reachable by anyone who can write to the store.
  • Roll a timestamp backwards ⇒ defeat message ordering, TTL logic, and the predictor’s time-of-day histograms (which are themselves in this tier), degrading ADR-019 Tier 1.

Realistically the attacker here is not a network MITM — it is anyone with write access to the sanakirja files: another local user on the machine, a malicious or merely buggy module loaded through ADR-024’s developer mode, a sync/backup tool, or a future cloud backup. The corpus’s threat model (ADR.md:100-114, ADR-003/004) is entirely about module supply chain and never about at-rest tamper, so this class is currently unowned.

Варианты решения (tradeoffs)

Section titled “Варианты решения (tradeoffs)”
  • (A) Full AEAD + AAD binding per record. Metadata tier becomes AES-GCM (hardware AES-NI on x86; ChaCha20-Poly1305-AEAD on ARM/older, which is the right pairing — the bare “ChaCha20” in the ADR is the actual error). Deterministic nonce derived per record: nonce = trunc( HMAC(key_meta, chat_id ‖ record_key ‖ schema_version)) — safe for random access and re-encryption. AAD = chat_id ‖ record_key ‖ schema_version. Cost: one HMAC per record (cheap), storage of derivation context per record.
  • (B) MAC-only (encrypt-then-MAC) with a separate fast MAC key. Simplest to retrofit onto existing per-field code; two primitives instead of one; more room for implementation error.
  • (C) Leave metadata plaintext and protect only content (drop metadata encryption, keep XChaCha20-Poly1305 for bodies). Cheapest, but then chat titles, presence, read state and the whole predictor log are readable in a stolen store — probably not acceptable, and it would contradict ADR-031’s table wholesale.
  • (D) Encrypt whole metadata blocks rather than per field. One AEAD per chat-list page; natural AAD (chat id, page); but it forces whole-block rewrite on any counter update (unread counts change constantly).

(A), and treat the bare “ChaCha20” in ADR-016 as a defect to fix in the same amendment. Deterministic-per-record nonces are the key insight that makes the fast tier viable: it keeps random access (no state needed to decrypt field X) while preventing nonce reuse across re-encryptions.

Amendment checklist for ADR-016:

  1. Replace “ChaCha20 or AES-GCM” with the actual pairing: AES-256-GCM where AES-NI is present, ChaCha20-Poly1305-AEAD elsewhere, selected by the same CPU-feature detect the manifest already does (ADR.md:838-840, TECHSPEC.md:397-398).
  2. Specify the sealed layout: [tier_id ‖ nonce ‖ ciphertext‖tag], nonce derived deterministically as above; AAD = (chat_id, record_key, schema_version).
  3. State the tamper model in one line: at-rest writes are assumed possible; detection is via AEAD failure, not via hash mismatch — and note this is a different guarantee from ADR-004’s hash warning, so the two must not be confused in QA.
  4. New OQ (nobody has asked): what is the behaviour on AEAD failure — drop the record, fall back to re-fetch from network, or quarantine the whole chat? Silent drop loses read state; re-fetch is the offline-first-correct answer but needs a “store is untrusted” mode flag.