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 inTECHSPEC.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|malleabacrosscontext/*.md).
- severity: high
Суть / Substance
Section titled “Суть / Substance”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 withReadStateor 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 lowerid⇒ the client believes the user has read messages they have not;read_inbox_max_idgets pushed upward and the read state is silently lost on every other device. - Rewrite an outbox
PendingtoSent(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
timestampbackwards ⇒ 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).
Предлагаемое решение
Section titled “Предлагаемое решение”(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:
- 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). - Specify the sealed layout:
[tier_id ‖ nonce ‖ ciphertext‖tag], nonce derived deterministically as above; AAD =(chat_id, record_key, schema_version). - 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.
- 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.