---
last_verified: 2026-06-02
verified_version: 0.1.48
freshness_days: 180
---

# AEAD Nonce Audit (2026-06-02)

Audit of **every** `ChaCha20-Poly1305` `seal()` call site in `core/`, checking the project rule:
*nonces are managed centrally (crash-safe counter or synthetic) — never caller-random; reuse is
catastrophic*. Triggered by the hardening track (SR-2026-05-30-007/010).

## Message / transport nonces — synthetic, unique by construction ✅

| Site | Nonce source | Why unique |
|---|---|---|
| `sendchain.rs` (async msg) | `HMAC(MK, "async/nonce")` | `MK` is unique per chain index |
| `offline.rs` (envelope) | `HMAC(env_key, "env-nonce" ‖ seq)` | `seq` = monotonic chain index |
| `offline.rs` (prekey seal) | `HMAC(seal_key, "prekey-nonce" ‖ id)` | `id` random + one-time per prekey |
| `directory.rs` (record) | `dir_nonce(dkey, epoch, seq)` | unique per (epoch, seq) |
| `media.rs` (chunk) | `HMAC(FileKey, media_id ‖ index)` | `FileKey`/`media_id` random per file; index unique |
| `ratchet.rs` (live DR) | Double-Ratchet counter | central per-chain counter |
| `handshake.rs` | per-handshake ephemeral key | fresh key per session |

All derive the nonce deterministically from a unique (key-context, counter/id). No `getrandom`/`OsRng`
feeds any of these nonces.

## At-rest nonces — **FINDING (fixed)** ⚠️

`contacts.rs`, `store.rs`, and `identity.rs` sealed re-writable blobs with a **fixed** nonce
(`[0u8; 12]`) under a **stable** per-device storage key. The comments claimed "one blob per key → safe",
but these blobs are **re-sealed on every mutation**:

- the **contacts list** is re-encrypted on every add / remove / verify / identity-rotation;
- the **message log** is re-encrypted on **every appended message**;
- the **identity** is re-encrypted after a key rotation.

Re-sealing different plaintext under the same `(key, nonce)` is **nonce reuse** — for ChaCha20-Poly1305
this leaks the XOR of the plaintexts (keystream reuse) **and** allows Poly1305 tag forgery. **Severity:
high** (at-rest confidentiality + integrity of contacts and message history).

### Fix

New `crypto::seal_at_rest` / `open_at_rest`: each seal draws a fresh 16-byte random **salt**, derives a
per-write subkey `HKDF(storage_key, salt, "pvtcoms/v1/at-rest")`, and seals under that unique subkey
with a fixed nonce. The randomness lives in the **KDF salt, not the nonce** (so the "no caller-random
nonce" rule holds), the subkey is unique per write (no `(key, nonce)` reuse), and it is **rollback-safe**
(unlike a persisted counter) with a 128-bit salt (no collisions). Blob format gains a 16-byte salt
prefix. All three call sites migrated; regression test
`crypto::tests::at_rest_reseal_uses_fresh_salt_so_no_nonce_reuse`.

## Follow-ups

- When the SQLCipher-backed store lands, it supersedes the file `store.rs`; carry the same per-write
  subkey discipline (or rely on SQLCipher's own page nonces).
- Consider an `enforce_coding_standards.py` checker that flags a literal `[0u8; 12]` / fixed nonce
  passed to `seal()` outside the audited at-rest helper.
