# Changelog

All notable changes to this project are documented here.
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

---

## [Unreleased]

---

## [0.1.34] - 2026-06-06

### Fixed

**Antivirus false positive (Wacatac!ml): ship lyrebird as a separate file, not embedded** _[SR-2026-06-06-024]_

- Embedding `lyrebird.exe` inside `pvtcoms.exe` and extracting it to disk at runtime tripped Defender's
  ML heuristic (`Trojan:Win32/Wacatac.C!ml`) — "an exe that drops and runs another exe" is a classic
  false-positive trigger for unsigned binaries. Stopped embedding: `lyrebird.exe` now ships as a
  **separate file** beside `pvtcoms.exe` (it's the official Tor Project obfs4 client, widely whitelisted —
  the same binary inside Tor Browser). The app finds it next to itself (`pt_binary_path`).
- Distribution is now a **`pvtcoms-windows.zip`** (pvtcoms.exe + lyrebird.exe + pvtcoms.conf) so it's
  still one download; extract to a folder and run `pvtcoms.exe`. The download page explains the false
  positive and how to allow/restore, and publishes both files' SHA-256 for verification.
- The download page is now tracked in `web/` and published by the build (was an untracked artifact).

---

## [0.1.33] - 2026-06-06

### Fixed

**Ship lyrebird as separate file in a zip (no embedding) to avoid Wacatac!ml AV false positive; download page explains it** _(18:54 GMT)_ [SR-2026-06-06-024]

**App connects (and shows the banner) at launch, no contact needed** _[SR-2026-06-06-023]_

- The launch warm-up now **keeps looking** for a working relay circuit instead of giving up after one
  attempt: it stays in the green "Connecting privately over Tor…" state (spinner showing) and retries
  several rounds, so the connection is establishing from the moment the app opens — independent of
  whether any contact has been added. Only after several rounds without success does it fall back to the
  soft retry notice (and the background poll keeps trying from there).

---

## [0.1.32] - 2026-06-06

### Fixed

**Warm Tor at launch keeps looking (persistent connecting banner with spinner) until connected, no contact needed** _(16:39 GMT)_ [SR-2026-06-06-023]

**obfs4 now connects on the censored network; banner pushes the UI down** _[SR-2026-06-06-022]_

- Confirmed in the field: with obfs4, Tor **bootstraps through the DPI** (`transport: obfs4 → bootstrap
  OK`) where the vanilla bridge couldn't. The remaining relay-circuit failures (`Failed to obtain exit
  circuit`) are ordinary slow-link circuit building, not censorship — the consensus/microdescriptors are
  still streaming in after "bootstrap OK". Gave the relay connect more patience (4 tries each, longer
  backoff) so a later attempt lands once the directory fills in.
- The Tor status banner now measures its **real height** (JS → `--tor-banner-h`) and the app shifts down
  by exactly that, so a wrapped two-line message never covers the header. Shortened the message too.

---

## [0.1.31] - 2026-06-06

### Fixed

**Tor banner measures real height (push UI down, no overlap on wrap) + more relay-circuit patience for slow obfs4 link** _(16:26 GMT)_ [SR-2026-06-06-022]

**obfs4 bridge could be shadowed by a stale vanilla one; status banner covered the UI** _[SR-2026-06-06-021]_

- On a censored network the app was still bootstrapping a **vanilla** bridge (`transport: vanilla` → all
  circuits DPI-throttled) because a `pvtcoms.conf` left by an older invite paste held a vanilla bridge
  and shadowed the shipped obfs4 one. Two fixes: (1) the in-app "set relay" now writes a SEPARATE
  `pvtcoms-user.conf` so a paste can never clobber the shipped `pvtcoms.conf`; (2) `tor_bridge()` now
  **prefers an obfs4 (pluggable-transport) bridge over a vanilla one across all config files** — so the
  DPI-resistant bridge always wins. Re-download + paste the obfs4 invite + restart and obfs4 sticks.
- The "Connecting privately over Tor…" banner was a fixed overlay covering the header; it now **pushes
  the whole app down** by its height (body gets `has-tor-banner` padding) so nothing is hidden.

---

## [0.1.30] - 2026-06-06

### Fixed

**Config layering: obfs4 bridge must win over stale vanilla (separate user conf + obfs4-preference) + banner pushes UI down** _(15:59 GMT)_ [SR-2026-06-06-021]

**obfs4 bridge line was shipped quoted** _[SR-2026-06-06-020]_

- The bridge value is shell-quoted in the secrets file (it has spaces); the build script copied it into
  the sidecar *with* the quotes, so `bridge="obfs4 …"` — the leading `"` broke PT detection and arti's
  bridge parse. Strip surrounding quotes both in the build script and in the client reader (`unquote`,
  applied to `bridge=`/`relayc=`), so a quoted value can never break the transport again.

---

## [0.1.29] - 2026-06-06

### Fixed

**Strip quotes from obfs4 bridge line in shipped sidecar + client reader (packaging fix)** _(14:37 GMT)_ [SR-2026-06-06-020]

**obfs4 pluggable transport — defeat DPI on censored networks** _[SR-2026-06-06-019]_

- Even clearnet-over-Tor (0.1.28) failed on the user's network: Tor **bootstrap succeeded but every
  circuit failed** (`Failed to obtain exit circuit` / `…hidden service circuit`), while both worked from
  another box through the same bridge. That's the signature of **DPI** that fingerprints the vanilla
  bridge's Tor TLS and throttles it after the handshake — no timeout/route change beats it.
- Fix: **obfs4 pluggable transport**, which disguises the traffic as random bytes (the same mechanism
  Tor Browser uses in censored countries). arti launches a managed `lyrebird` transport (`pt-client`
  feature); the bridge line becomes `obfs4 IP:port FP cert=… iat-mode=0`.
- `lyrebird.exe` (the official Tor Project obfs4 client) is **embedded into the exe** at build time and
  extracted at runtime only when an obfs4 bridge is configured — so it stays a **single self-contained
  download** for non-technical users. Build is still reproducible (same lyrebird → same exe hash; sha256
  recorded in BUILD.txt).
- Server: the bridge now runs obfs4 (obfs4proxy) behind a socat front door on :443; the vanilla ORPort
  moved to 9001.
- Validated end-to-end over the bridge from a fresh process with the binary embedded (no helper file, no
  env): `extracted embedded obfs4 client → registering PT 'obfs4' → bootstrap OK → connect OK → deposited`.

### Notes

- The app now picks the transport automatically from the bridge line: `obfs4 …` → obfs4 (DPI-resistant);
  a bare `IP:port FP` → vanilla bridge. PT binary resolution: `PVTCOMS_PT_PATH` → next to the exe →
  embedded-and-extracted.

---

## [0.1.28] - 2026-06-06

### Fixed

**Clearnet-over-Tor relay path — reliable through a single bridge** _[SR-2026-06-06-018]_

- Raising the onion timeouts (0.1.27) wasn't enough on a real censored network: the relay's
  hidden-service circuit failed **100%** through the user's single bridge, while succeeding 100% from a
  low-latency box through the *same* bridge. Root issue is structural — an onion needs ~6 hops across
  several sub-circuits (HSDir descriptor fetch + intro + rendezvous), all funnelled through one weak
  guard link, and they can't all complete.
- Fix: reach the relay as a **clearnet destination over Tor** — arti builds a normal **3-hop exit
  circuit** (one circuit, no descriptor/rendezvous) to the relay's IP:port. The client stays anonymous
  (exit sees the relay IP, relay sees the exit, neither sees the client) and the relay IP is already
  public (it's the bridge IP in every invite), so nothing new leaks. The **onion remains an automatic
  fallback** — both paths run over Tor through the bridge (still fail-closed, no clearnet-direct path).
- New config key `relayc=IP:port` (env `PVTCOMS_RELAY_TCP`), carried in the sidecar + invites alongside
  `bridge=`. `open_relay` prefers it, falling back to the onion `relay=`.
- Server: a rate-limited `socat` front door on the exit-friendly port 80 forwards to the same localhost
  relay (shared storage; onion untouched).
- Validated over the real Tor-blocked network through the bridge: **3/3 cold-start deposits succeeded on
  the first try** (onion was 0/many on the same network).

---

## [0.1.27] - 2026-06-06

### Fixed

**Reliable onion through a single bridge** _[SR-2026-06-06-017]_

- The bridge now connects, but the hidden-service circuit to the relay failed every retry on a
  Tor-blocking network. Root causes, all fixed:
  - **arti's defaults bail too early through one guard.** The stream `connect_timeout` defaulted to
    **10s** — far too short for an HS descriptor-fetch + rendezvous funnelled through a single bridge,
    cutting `connect()` off before arti's own HS retries could finish. Raised the HS budget in
    `bootstrapped_client`: `connect_timeout` 120s, `request_timeout` 120s, `hs_desc_fetch_attempts` 12,
    `hs_intro_rend_attempts` 12.
  - **Concurrent HS builds starved each other.** Our poll loop + a send could build two onion circuits
    at once through the one bridge link (the doubled `1/6 … 1/6` interleaving in the field log); they
    congest the guard and both fail. Onion connects are now **serialized** behind a semaphore.
  - **Prime at launch.** Once a bridge is set, the app builds (and drops) a circuit to the relay at
    startup, warming arti's descriptor/rendezvous caches so the first real send is fast.
  - Validated over the real Tor-blocked network through the bridge: **3/3 cold-start deposits
    succeeded** (was 0/6).

### Added

- **Tor-status banner.** A top banner shows "Connecting privately over Tor…" while the private circuit
  is being established (soft retry notice on trouble), hiding once ready — driven by a new
  `/api/tor-status`. Background relay polling is gated until the first circuit is up.
- **Optimistic contact add.** Pasting an invite pins the contact **instantly** and shows them in the
  list; the sealed friend-request is delivered to the relay in the **background** (retried until it
  lands) instead of blocking the UI on the slow onion.

---

## [0.1.26] - 2026-06-06

### Fixed

**Bridge was silently ignored — warm-at-launch cached a bridge-less Tor client** _(SR-2026-06-06-016)_

- The real reason onion connections failed on a Tor-blocking network even with a bridge configured: the launch-time Tor warm-up (0.1.21) bootstrapped the process-wide Tor client **before** the bridge was configured (the user pastes their invite *after* launch), cached a **bridge-less** client, and the bridge set later was never used — so every onion circuit went through the blocked public relays and timed out (the debug log showed ). Now the warm-up **only runs once a bridge is configured**; otherwise the first real connection bootstraps fresh with the bridge. — 


---

## [0.1.25] - 2026-06-06

### Fixed

**Tor onion connect now retries the flaky hidden-service hop** _(SR-2026-06-06-015)_

- After the bridge gets the client onto Tor, the final hop — building a **hidden-service circuit** to the relay onion — is intermittently slow/faily, *especially* through a single bridge guard (reproduced: ~3 of 4 attempts succeed with identical Tor state, so it is flakiness, not config). The app gave up on the **first** failure ("Failed to obtain hidden service circuit"). It now **retries up to 6 times** (3 s apart, letting Tor build fresh circuits each time), which makes the connection reliable. Also added a temporary  (next to the exe data) capturing the bridge/bootstrap/onion-connect steps + arti internals, for diagnosing any remaining connection issues. — 


---

## [0.1.24] - 2026-06-06

### Added

**Tor bridge support — get past a network that blocks Tor** _(SR-2026-06-06-014)_

- Some networks (a family member's home ISP, here) **IP-block the public Tor relays**, so the app couldn't reach the relay's onion even though Tor + the relay were fine (proven: a VPS with open Tor reaches the onion; this LAN box could not — public relay IPs blocked, normal HTTPS fine). The fix: a **private, unlisted Tor bridge** (its IP isn't on Tor's public list, so the network doesn't block it). The app now reads a  line and routes its Tor through it (arti ), which then handles all onward hops from the bridge's network — so the client never touches a blocked relay IP. The bridge line travels in the relay config / invite ( alongside /), so family clients get it **automatically, zero setup**. **Proven end-to-end on the blocked network**: deposited a message to the relay onion over Tor-through-the-bridge and pulled it back intact. Anonymity unchanged (messages stay E2E+PQ; the relay stays oblivious); the only nuance is the bridge co-located on the relay VPS (operator-trust, acceptable for a self-run family relay). — ,  (arti ), building config-free (reproducible by anyone; relay config lives in the pvtcoms.conf sidecar)
✓ emitted /home/rui/Apps/pvtcoms/dist/windows/pvtcoms.conf (member sidecar — relay q23n2umsagjc…, key bef33c…)
✓ /home/rui/Apps/pvtcoms/dist/windows/pvtcoms.exe
210d7d99622bbaf453a5ce68e8ecf22938dbb3cd829dbaf08b0def155255e7c6  pvtcoms.exe


---

## [0.1.23] - 2026-06-06

---

## [0.1.22] - 2026-06-06

---

## [0.1.21] - 2026-06-06

### Fixed

**Windows: no duplicate windows + always the chromeless app-window** _(SR-2026-06-06-012)_

- Each tray double-click opened a **new** browser window (no "is one already open?" check), so several clicks stacked several UIs. Now the tray opens at most one: a short debounce for rapid clicks plus a server **liveness check** (, fed by the page's 3 s heartbeat) so it won't spawn a duplicate when a window is already up. — , 
- A new window sometimes appeared as a **plain browser tab showing ** (an address bar) instead of the chromeless app-window — that's the fallback firing because Edge wasn't found at the two hard-coded paths.  now also resolves Edge via the registry , so it stays a real app-window (no address bar) on non-default installs. — 

**Windows tray + window behaviour** _(SR-2026-06-06-011)_

- The tray icon popped its menu on **left-click** (the "two options"), and quitting left the browser UI window **still open** (Edge re-parents the app-window, so killing the server didn't close it). Now: **left double-click reopens** the UI, **right-click** shows the Open/Quit menu (standard Windows convention, via `with_menu_on_left_click(false)`), and the UI page **self-closes** when the local server stops — a 3s heartbeat to `/api/config` detects the app being quit (~9s) and closes the window (or shows a "pvtcoms has closed" state if the browser blocks `window.close()`). — `demo/src/desktop.rs`, `demo/src/gui.html`


---

## [0.1.20] - 2026-06-06

### Added

**Relay-in-invite onboarding — one paste configures the relay AND adds the contact** _(SR-2026-06-06-009)_

- An invite now **embeds the relay config**, so a brand-new user is fully set up by pasting a single invite (no separate `pvtcoms.conf`, no Settings step) — the SimpleX model where the server address rides in the invitation. Wire: a bare invite is exactly `Invite::from_bytes`-length; a relay-embedding invite is `u16 conf_len || conf || core_invite` (longer), so old and new are told apart by length with no ambiguous magic; the core invite crypto is unchanged. On `invite-request` the app extracts + applies the relay before sending the friend request, and the add-friend flow no longer hard-blocks on a pre-configured relay. The embedded config carries the relay access key **by design** — it gates relay *use*, not message confidentiality (messages stay E2E + PQ encrypted), and is rotatable. Also answers relay **rotation**: a new relay just goes into new invites, and existing members re-import a relay code (Settings → Relay). Unit-tested (bare + embedding parse, truncation rejected). — `demo/src/{invite_client,mailbox,gui}.rs`, `demo/src/gui.html`


---

## [0.1.19] - 2026-06-06

---

## [0.1.18] - 2026-06-06

---

## [0.1.17] - 2026-06-06

### Added

**GUI: whitespace-tolerant invite paste + a real "Check for updates"** _(SR-2026-06-06-005)_

**GUI: configure the relay in-app — no manual file placement** _(SR-2026-06-06-006)_

- The config-free build needs a `pvtcoms.conf` for relay settings, but requiring users to drop a file next to the exe was fragile ("no relay configured" when it was misplaced). Now **Settings → Relay** lets you paste your relay config (the contents of `pvtcoms.conf`) and Save; the app writes `pvtcoms.conf` itself (in its data directory) and re-reads it **live**, so the relay works immediately with no restart and no file fiddling. The sidecar reader no longer caches (re-reads per use). `POST /api/config/relay` is behind the same-origin CSRF guard; the access key is never echoed back or logged. — `demo/src/mailbox.rs`, `demo/src/gui.rs`, `demo/src/gui.html`

- **Invite paste** now survives a code copied with spaces or wrapped across lines: the backend `from_hex` drops ASCII whitespace before parsing (a stray *non-hex* char still fails, so a corrupted paste isn't silently accepted), matching the front-end's existing `replace(/\s+/g,'')`. Unit-tested. — `demo/src/invite_client.rs`
- **"Check for updates"** is now functional (was a static toast): a **manual, user-triggered** `GET /api/update-check` fetches a central version manifest (`PVTCOMS_UPDATE_URL`, default the release server), compares it to this build (numeric version compare, so `0.1.10 > 0.1.9`), and shows the new version + a download link + the SHA-256 to verify. It sends **no identifying data** (a plain GET) and **never auto-downloads or replaces** the unsigned binary — the user downloads + verifies the hash themselves (honours the no-background-telemetry rule). Dep-free (a small bounded HTTP/1.0 GET); version-compare + manifest-parse unit-tested. — `demo/src/gui.rs`, `demo/src/gui.html`

### Testing

**cargo-mutants on the crypto protocol core — found + closed real test gaps in the handshake & ratchet** _(SR-2026-06-05-010)_

- Extended mutation testing to the most audit-critical code: the hybrid X25519+ML-KEM **handshake** and the Double **Ratchet** (previously only the wire parsers + crypto boundary were covered). It surfaced genuine, security-relevant gaps that every *functional* test missed because both sides stay self-consistent:
  - **`handshake::signed_payload`** could be replaced by a constant and still pass — meaning no test pinned that the signed authentication transcript actually **binds the domain context, the role (anti-reflection), and the key-agreement transcript (MAL-BIND)**. Added a direct test. — `core/src/handshake.rs`
  - **ratchet KDFs** (`kdf_rk`/`kdf_ck`/`message_key_nonce`/`to32z`) could be replaced by **constants** and still pass the protocol tests — a constant KDF is catastrophic (constant chain keys = no forward secrecy; a constant message key/nonce = **AEAD nonce reuse**, claim N1) but went undetected because the per-message AAD differs by the message number, so ciphertexts differ even with a fixed key. Added a direct "KDFs are input-dependent, never constant" test, plus tests pinning `Header::to_bytes` layout, `can_send`, and the exact `MAX_SKIP` skip bound. — `core/src/ratchet.rs`
- Extended again to **`sendchain.rs`** (the offline send-chain: forward secrecy + replay + synthetic nonces — the relay metadata surface). 10 surviving → **0**: added tests pinning the PCS re-seed (`pcs_reseed_key` binds the chain key + hybrid secret + bundle id), `next_index` advancement, and the exact `MAX_SKIP` open bound. The `aad → constant` survivor was confirmed an **equivalent** mutant (the index is already authenticated by the unique per-index message key, and the version by `parse()`) — reasoned + Gemini-confirmed, documented + excluded in `.cargo/mutants.toml`. — `core/src/sendchain.rs`
- Extended to **`offline.rs`** (the offline envelope — R-derived AEAD, token/key derivations, the mix-header — the densest relay metadata surface). 18 surviving → **0**: tests pinning that every token/key/nonce derivation is input-dependent (a constant `env_nonce` = envelope nonce reuse; a constant `envelope_key`/token = collisions), `epoch_of` arithmetic, `prune_prekeys` expiry, and that `ingest` refuses a **valid oversized blob** by size before opening it (a real size-cap bypass — `> MAX_BLOB`→`==`). Two boundary off-by-ones are documented equivalents (the envelope AEAD yields the same `Malformed`; valid blobs are size-quantised and never hit the exact bound). _(Dual-advisor: Gemini flagged the upper bound, which made me find the real size-cap-bypass test rather than wave it off; Codex returned empty all session.)_ — `core/src/offline.rs`
- Extended to **`prekey.rs`** (one-time prekey bundles, X3DH-style). 4 surviving → **0**: the `combine` mix-in (`ss_dh ‖ ss_kem`) could be constant-folded and pass — silently destroying the **hybrid post-quantum binding** of the PCS mix-in (both peers still 'agree' on the same wrong value). Added a test pinning the exact concat layout + input-dependence on both shared secrets. — `core/src/prekey.rs`
- Extended to **`invite.rs`** (async X3DH-style onboarding). 19 surviving → **0** (the biggest gap set): the rendezvous token/key derivations, `random32`, `prekey_signed`, the signed `transcript`/`signed_transcript`, and the rendezvous getter could all be constant-folded and pass; and two parse-offset slips (`+=`→`-=`) survived because the round-trip test only checked `is_some()` over a transport-bypassing path. Added derivation-binding tests + a **re-serialise-equality** round-trip. — `core/src/invite.rs`
- Extended to **`directory.rs`** (the signed, per-contact-encrypted directory record — connect-by-identity; an untrusted post-decryption codec). 15 surviving → **0**: the survivors were all **parser-boundary** off-by-ones in `serialize`/`parse`/`read` (the count cap, the per-address length cap, the min-record and address-header truncation bounds, the unknown-kind arm, and the no-trailing-bytes check) — a `||`→`&&` flip there would have skipped the buffer-overflow check and read **out of bounds**. Added boundary tests at each exact bound (maximal-valid record round-trips; one-past rejected; an address claiming a length that overruns the buffer is refused with no OOB; truncated/trailing/wrong-version/unknown-kind rejected; `read` refuses short blobs and a legitimately-decrypting but sub-signature plaintext). Two `< → <=` flips in `read`'s length guards are documented **equivalents** (a downstream guard yields the same `None`) — reasoned + Gemini-confirmed, excluded in `.cargo/mutants.toml`. — `core/src/directory.rs`
- Extended to the **remaining thin core modules** — `identity` (Ed25519 sign/`verify_strict`), `pqkem` (ML-KEM-768 facade), `mailbox` (oblivious rotating-token relay), `store` (at-rest message log), `keystore`, `cover`, `pad`, `kat`, `pow` — closing out the **entire `core` security boundary**. Six were already **0-surviving** (thin wrappers over audited primitives with strong KAT/round-trip tests); three had real gaps, all closed: **`store::from_encrypted`** at-rest log parser bounds (a 12-byte truncated header and a text-length field overrunning the buffer must be rejected with no OOB/underflow — sealed crafted plaintexts now exercise the post-decryption parser, which no functional test reached) + `is_empty` on a non-empty log; **`pad::unpad`** at its exact 4-byte minimal-frame boundary; **`mailbox::occupied_tokens`** pinned at counts other than 1. — `core/src/{store,pad,mailbox}.rs`
- Extended to **`media`** (chunked, encrypted bulk-media manifest + transfer) and **`outbox`** (the offline store-and-forward outbox + crash-safe snapshot). `media`: pinned that the size constants, the per-chunk nonce (a constant = AEAD nonce reuse, claim N1) and aad, and the manifest parser bounds (FIXED header, MAX_MIME) all bind their inputs; that each file gets a fresh random FileKey/media_id; that chunk source-offsets are pinned (a wrong `i*CHUNK` survived only because the old test data was periodic at the chunk size); and that a manifest overstating `real_size` is rejected. `outbox`: pinned that `mark_delivered` drops exactly the named item (a swapped `==`/`!=` and an inverted `retain` both passed the old tests), and that `restore` accepts a snapshot at **exactly** the `MAX_OUTBOX_ITEMS` / `MAX_PREKEYS` / `MAX_BLOB` caps (the off-by-one boundary no test hit). — `core/src/{media,outbox}.rs`
- Extended to **`relay`** (the oblivious-relay policy engine — the networked attack surface: freshness/capability/PoW/replay gates, anti-DoS storage caps, the deposit/pull/peek request+response codec, export/import). 23 surviving → **0** (modulo 2 documented equivalents): pinned the default policy constants (the day/second + KiB arithmetic), that `pow_challenge` binds every request field (a constant would let a PoW stamp be replayed across tokens/ops), the `build_peek` / `pow_difficulty` / `occupied_tokens` accessors, that `import` counts toward the global cap, that a request exactly at the freshness window is accepted, that `sweep` drops emptied tokens but keeps live ones, and two `decode_blobs` codec bugs (a trailing empty blob and a ≥64 KiB blob's length byte). The 2 equivalents (`decode_blobs`' 4-byte `<`→`<=`, and `prune_seen`→no-op which is memory-only and never gates accept/reject) are reasoned + documented in `.cargo/mutants.toml`. — `core/src/relay.rs`
- **The entire `core` crate (all 23 modules) now mutation-tests 0 surviving** and runs in the daily CI `cargo-mutants` job — every KDF, AEAD boundary, wire/at-rest parser, anti-DoS bound, and crypto state machine in the security boundary. Test-only + CI/docs (plus the `media` fix below) — no protocol/behaviour change for existing data, no redeploy. — `.github/workflows/ci.yml`, `docs/TEST_PLAN.md`

### Fixed

**`decrypt_media` rejected a legitimately-encrypted max-size object (off-by-one), surfaced by mutation testing** _(SR-2026-06-06-002)_

- `decrypt_media`'s anti-DoS guard bounded `chunk_count * CHUNK_PLAIN > MAX_MEDIA`, which rejects the final partial chunk: `encrypt_media` accepts inputs up to exactly `MAX_MEDIA` (= `ceil(MAX_MEDIA/CHUNK_PLAIN)` = 1366 chunks), but decrypt rejected any object needing 1366 chunks — so the top ~16 KiB of the allowed size range encrypted fine yet could never be decrypted. Now bounds `chunk_count` directly against `ceil(MAX_MEDIA/CHUNK_PLAIN)`, making encrypt/decrypt symmetric. Verified by an (ignored, ~64 MiB) max-size round-trip test; the size-boundary mutants it covers are documented + excluded from the routine cargo-mutants loop (re-running a 64 MiB test per mutant is impractical). — `core/src/media.rs`

### Security

**Make the reproducible build actually verifiable — publish the recipe + honest two-mode reproduction** _(SR-2026-06-05-009)_

- The reproducibility claim (the anti-backdoor guarantee — "rebuild from source, confirm the hash") was **unachievable as stated**: the build script `scripts/build-windows-release.sh` was gitignored (not published, despite its own "committed" header), and the published exe **bakes the gated-relay config**, so a third party couldn't reproduce the hash. Fixed: the secret-free build recipe is now **committed** (a precise `.gitignore` exception; other dev-tooling scripts stay private), and the script tolerates a missing secrets file by building a **config-free** binary instead of hard-failing — so anyone can run it. — `scripts/build-windows-release.sh`, `.gitignore`
- **Verified byte-deterministic** (this is the property that matters): the *with-config* build is identical across rebuilds (3× → same hash), and the *config-free* build is identical across rebuilds (2× → same hash) and differs from the published one exactly as expected. `BUILD.txt` and `docs/audit/AUDIT_SCOPE.md` now document the two honest reproduction modes — **exact** (members supply the same relay config) and **source-verification** (anyone, config-free, proves the source has no backdoor). No code/binary change (the published exe is byte-identical). — `docs/audit/AUDIT_SCOPE.md`, `BUILD.txt`
- Filed the stronger follow-up (SR-2026-06-05-008): ship a config-free binary + sidecar config so the *published artifact itself* is third-party-reproducible.

**Config-free reproducible binary — the published artifact is now third-party-reproducible** _(SR-2026-06-05-008)_

- Realises the SR-009 follow-up: the published Windows exe **no longer bakes** the gated relay's `.onion` + access key, so its SHA-256 is reproducible by **anyone** from the committed recipe — one published hash, no secrets needed (previously only members holding the secret could reproduce it). The binary reads its relay config at runtime in priority order: `PVTCOMS_RELAY`/`PVTCOMS_RELAY_KEY` env → a `pvtcoms.conf` **sidecar** beside the exe (`relay=`, `key=`, optional `pow=`) → any build-time baked default (now `None` in the published build). A hand-written, bounded, **unit-tested** parser (`#`-comments, blanks, whitespace, lowercased keys); the sidecar is **never logged** (it carries the bearer secret) and is gitignored. `build-windows-release.sh` now always builds config-free and, when a secrets file is present, **emits** the member `pvtcoms.conf` (0600) alongside the exe instead of baking it in. — `demo/src/mailbox.rs`, `scripts/build-windows-release.sh`, `docs/audit/AUDIT_SCOPE.md`, `.gitignore`
- The reproducibility property is inherited from SR-009's already-verified byte-deterministic config-free build (that build is now simply the *published* one); the cross-compiled hash was not re-emitted in this slice — the change is the config-source plumbing + build recipe, not the deterministic-build machinery. No running app behaviour change (the GUI relay prefill reads the same `default_relay_addr`) → no redeploy until the next release ships the new exe + sidecar.

**Cover-deposit primitive — mask *when* a user sends (the documented sender-timing residual)** _(SR-2026-06-05-001)_

- Jittered polling (0.1.12) hid the *receiver's* cadence; this adds the audited `core::cover` primitive for masking the *sender's*. A constant-cadence deposit loop deposits *something* every (jittered) slot — the next real outbox blob if queued, else a `cover_blob` — so an uplink observer can't tell *when* a real message went out. New: `next_cover_deposit_delay_ms` (sparser 30 s base — a deposit is heavier than a poll), `should_send_cover` (real preempts cover), and `cover_blob` (HMAC-SHA256-CTR random bytes of one-bucket envelope size; AEAD ciphertext is computationally indistinguishable from random, so a cover deposit is byte- and size-identical to a real one). The poll/cover schedules now share one `jittered_delay_ms` helper (behaviour-identical refactor). 10 `core::cover` tests; `cover.rs` stays 0-surviving under cargo-mutants (one `<`→`<=` is an equivalent — `truncate` masks the extra HMAC block — documented in `.cargo/mutants.toml`). — `core/src/cover.rs`
- **Honest scope (reviewed with Gemini, reconciled):** the mask holds only if real sends also ride the cadence (a low-latency "send now" mode is a separate anonymity-vs-responsiveness tradeoff); cover targets the user's **own** self-absorbing token, which to the *oblivious* relay looks like a normal deposited-and-pulled conversation (Tor isolates each deposit/pull on a separate circuit, so the relay can't link depositor↔puller↔identity to flag a self-deposit — a random *orphan* token would be worse, flaggable as never-pulled); cover spends real bandwidth + PoW (so the loop is opt-in for a high-threat profile), and it bounds but does not erase the per-message *size* leak. The live Tor deposit-loop wiring + the send-policy choice are the integration layer that builds on this primitive. — `core/src/cover.rs`

---

## [0.1.16] - 2026-06-05

### Added

**Accept-new-identity / re-pin flow — restore trust after a legit key change without losing history** _(SR-2026-06-05-007)_

- Completes the key-change story (0.1.15): when a contact's identity legitimately changes (reinstall / new device), the warning banner now offers **"Accept new identity (re-pin)"** alongside "Send anyway". Accepting re-pins the new key, installs the new pairwise root from the just-completed session, and **resets `verified = false`** (the user must re-compare the *new* safety number) — so a key change no longer forces delete-and-re-add (which lost all message history). — `core/src/contacts.rs` (`accept_new_identity`), `demo/src/chat.rs` (`repin_contact`), `demo/src/gui.rs`, `demo/src/gui.html`
- The new key + root **never leave the server**: the live WebSocket session already holds them; the browser only sends a `__accept_key__` control signal (intercepted **only** during a changed-identity session, so it can't be triggered for a New/Known contact and a literal typed message passes through normally). A remote peer can't inject it — the WS only accepts local-browser input. _(Gemini-reviewed: PROCEED — "security semantics are solid"; confirmed resetting verified is mandatory, re-pinning under the session name is sound, and there's no remote-injection or downgrade risk.)_
- `core::contacts::accept_new_identity` (re-pin + new root + `verified=false`) is unit-tested: after accept, the new key is `Known` and the **old** key now triggers a fresh `Changed` warning. Resetting verification is mandatory — keeping it would let a swapped key inherit "verified".

---

## [0.1.15] - 2026-06-05

### Security

**GUI now surfaces a contact key-change (possible MITM) — and stops calling unverified contacts "verified"** _(SR-2026-06-05-006)_

- The most security-critical event — a pinned contact's identity key **changing** (a key reset, or a man-in-the-middle) — was detected by `check_contact` but only **`println!`'d to the server's stdout**, which a browser user never sees. Worse, the GUI then sent the browser `"✓ connected; verified peer X"`, *actively reassuring* the user that an identity-changed contact was "verified". Now `check_contact` returns a `ContactStatus` (`New` / `Known{verified}` / `Changed`), and the GUI renders a **loud red banner** on `Changed` ("…presenting a NEW identity key… could be a man-in-the-middle… don't share anything sensitive until you confirm over another channel"), showing both fingerprints. — `demo/src/chat.rs`, `demo/src/gui.rs`, `demo/src/gui.html`
- **Fails closed:** on a key change the composer **and** the 📎 attach button stay **disabled** until the user clicks an explicit *"Send anyway (I understand the risk)"* — so nothing (text or media) can be sent to a possibly-impersonated contact by reflex. The old pin is preserved (the new key is **not** auto-trusted). _(Gemini-reviewed: first BLOCK'd for failing open + telling users to "re-compare the safety number" — impossible since the old root is kept; fixed to fail-closed with honest wording, then CONCERN'd that 📎 wasn't locked too → wired the attach button into the same lock.)_
- Benign cases now use **honest** wording: a freshly-pinned or known-but-unverified contact says "pinned, NOT yet verified — compare the safety number 🛡️" (was the misleading "verified peer"); only an actually-verified contact says "VERIFIED". 2 unit tests pin the warning + wording; the `Changed` path is verified end-to-end by a live key-change test. The full **accept-new-identity / re-pin** flow (so a legitimate key reset can restore trust without delete-and-re-add) is a tracked follow-up (SR-2026-06-05-007).

---

## [0.1.14] - 2026-06-05

### Security

**Relay anti-DoS storage bounds — fail-closed instead of OOM** _(SR-2026-06-05-005)_

- The oblivious store-and-forward relay (a **deployed live service**) stored deposits **unbounded**. Proof-of-work rate-limits each request but doesn't cap total storage, so a flooder who keeps paying PoW (varying salt/blob to dodge the replay check) could grow the in-memory store without limit and OOM-crash the relay. Added three **fail-closed** storage bounds in `try_deposit`: `max_blob_bytes` (per-deposit size, checked *before* the MAC/PoW work so a giant blob is rejected cheaply), `max_per_token` (per-recipient entry cap), and `max_total_blobs` (global cap). New `RelayReject` variants `TooLarge`/`TokenFull`/`RelayFull`; conservative defaults (64 KiB / 1000 / 50 000), operator-tunable via `PVTCOMS_RELAY_MAX_*`. — `core/src/relay.rs`, `demo/src/mailbox.rs`
- The global cap uses an **O(1) running counter** (`total_blobs`), not an O(N) scan of the store — incremented on deposit/import, decremented on pull, and recomputed in the periodic `sweep` so it self-heals and can't drift. _(Gemini first CONCERN'd the O(N) `sum()` on the deposit path was itself abusable; fixed to O(1), re-reviewed PROCEED.)_ The caps are checked **after** auth, so only an authenticated/PoW-paying depositor learns the relay is full.
- 4 unit tests (oversize → `TooLarge`; per-token flood → `TokenFull`; global flood → `RelayFull` then a pull frees space; sweep frees capacity after TTL) + a **live relay** smoke (3rd deposit past `PVTCOMS_RELAY_MAX_TOTAL=2` refused with `RelayFull`, env override end-to-end). Fail-closed-vs-eviction trade-off documented in `docs/audit/SECURITY_CLAIMS_AND_LIMITATIONS.md`.

**Relay wire codec moved into audited `core` + fuzzed + 32-bit overflow fixed** _(SR-2026-06-05-004)_

- The relay's untrusted-network parsers (`parse_request` — the relay's view of a depositor/puller; `decode_blobs` — the client's view of a possibly-hostile relay's response) lived in the **demo**, outside the audited boundary and outside the fuzz suite, despite the relay being the audit package's **#1 metadata-surface target**. Moved the whole request/response codec (`encode_request`/`parse_request`/`encode_blobs`/`decode_blobs`/`REQ_HEADER`) verbatim into **`core::relay`** (a true no-op on the wire), added adversarial unit tests (truncation at every header boundary, hostile blob counts only truncate, header-exact vs one-short) and a **`relay_request` cargo-fuzz target** (ran ~9M executions, 0 crashes; added to the daily CI fuzz smoke). — `core/src/relay.rs`, `demo/src/mailbox.rs`, `core/fuzz/`, `.github/workflows/ci.yml`
- Fixed a latent **panic on 32-bit targets** that the move surfaced: `decode_blobs` did `i + l` where a hostile `l` (up to `u32::MAX`) could overflow a 32-bit `usize`, wrap past the `> len` bounds check, and panic in the slice. Now uses `checked_add` → a clean break (64-bit behaviour unchanged, so the shipped x64 build is byte-for-byte behaviour-identical; this hardens core for any future 32-bit target). _(Gemini-reviewed: PROCEED — confirmed the move is a true no-op and caught the 32-bit overflow.)_
- Behaviour-preserving on all current (64-bit) targets → no redeploy; rides the next functional release.

---

## [0.1.13] - 2026-06-05

### Security

**Unified message-length padding — the live chat path now hides length too** _(SR-2026-06-05-003)_

- Offline (store-and-forward) messages were padded to 256-byte buckets (hiding length from the relay — claim M1), but **live ratchet chat messages were sent at their true length**, so the on-wire ciphertext size leaked the message length (and made a live session distinguishable from store-and-forward by size). New shared **`core::pad`** module (`pad`/`unpad`, bucket = 256) is now applied to **both** lanes: `send_chat` pads before the ratchet seal and the receive loops strip it after decrypt, so any two sub-bucket messages are the same size on the wire. — `core/src/pad.rs`, `demo/src/chat.rs`
- `core::offline` was refactored to use `core::pad` instead of its own private `pad_to_bucket`/`strip_pad` (a true no-op — guarded by the 13 offline property tests, all green). `unpad` reads a 4-byte length prefix with bounds-checked slicing, so a hostile decrypted payload claiming a huge length returns `None` rather than panicking. Padding sits **inside** the AEAD (pad-then-encrypt), so the length prefix and padding are authenticated. — `core/src/offline.rs`, `core/src/lib.rs`
- 5 `core::pad` unit tests (round-trip across sizes, bucket boundaries, empty payload, malformed-rejection) + a chat-layer test proving two sub-bucket messages produce an **identical wire-frame length** and still round-trip. Live-verified: a real direct-TCP session establishes with matching safety emoji (the padded priming message round-trips through the real `send_chat`+recv path). _(Gemini-reviewed: PROCEED — pad-then-encrypt is the correct layering; offline refactor is a true no-op.)_
- **Note:** this changes the live-chat wire format (the ratchet plaintext is now padded); both peers must run ≥ this version. Acceptable at pre-release.

---

## [0.1.12] - 2026-06-05

### Security

**Jittered mailbox-poll cadence — defeat the fixed-interval timing fingerprint** _(SR-2026-06-05-002)_

- The GUI polled the relay on a **fixed 8 s `setInterval`** — a regular cadence is a timing fingerprint a malicious/passive relay or a network observer can use to single out and correlate a client. New **`core::cover`** module computes a **jittered** delay, uniform over `[base/2, 3·base/2]` (mean = `base`), so inter-poll gaps are irregular and unpredictable while average latency stays bounded (vs a memoryless/Poisson schedule that can stall delivery). The schedule lives in `core` (pure, 5 unit tests pinning the bounds/mean) so the desktop GUI **and** native FFI clients share one audited cadence; `base` is a hardcoded global constant on purpose (a per-user base would become a *user* fingerprint). — `core/src/cover.rs`, `core/src/lib.rs`
- Wired in via a new `GET /api/poll-delay` endpoint (computed with the OS CSPRNG, `no-store`); the front-end now self-schedules with `setTimeout` using the server-jittered delay instead of a fixed interval. Live-verified: the endpoint returns varying values in `[4000, 12000]` ms. _(Gemini-reviewed: PROCEED.)_ — `demo/src/gui.rs`, `demo/src/gui.html`
- Documented the residual limitation in `docs/audit/SECURITY_CLAIMS_AND_LIMITATIONS.md`: jitter protects the *receiver's* polling metadata, but **sender** activity is still timing-correlated until **cover-deposit** traffic lands (tracked: SR-2026-06-05-001).

---

## [0.1.11] - 2026-06-05

### Security

**Strict GUI CSP: drop `script-src 'unsafe-inline'` for a per-response nonce** _(SR-2026-06-01-017)_

- The desktop GUI's Content-Security-Policy allowed `script-src 'unsafe-inline'`, which would let any attacker-controlled string that ever reached the DOM as markup execute (the XSS→IPC→key-exfil path the threat model calls out). It now uses a **fresh 128-bit `getrandom` nonce per response**: the page's single inline `<script>` carries that nonce (templated server-side; the page is already `Cache-Control: no-store`, so every load gets a new nonce), and `script-src` is `'nonce-…'` only. Any injected inline script or event handler — which can't know the nonce — is refused by the browser. No JS changed: the existing `el.onclick = …` handlers are script-set properties inside the now-authorized script, not inline attributes. — `demo/src/gui.rs`, `demo/src/gui.html`
- Defence-in-depth: `esc()` now also wraps `initials(name)` in the three `innerHTML` contact-row builders (message text and names were already escaped). `style-src` keeps `'unsafe-inline'` — CSS can't execute JS and `img-src`/`connect-src` are `'self' blob:` only, so injected CSS has no exfil sink. Verified end-to-end against the live server (header nonce matches the served `<script>`, second request gets a different nonce) plus two unit tests. _(Gemini-reviewed: PROCEED — "solid, defensible security posture".)_

---

## [0.1.10] - 2026-06-04

### Security

**Zeroize live Double-Ratchet key state + enforce the no-secret-in-output invariant** _(SR-2026-06-04-003)_

- The live `Ratchet` held its root key (`rk`), sending/receiving chain keys (`cks`/`ckr`), and retained skipped-message keys as **raw `[u8; 32]`** — never wiped, so a core dump of a live session would expose the current ratchet keys. They are now wrapped in `zeroize::Zeroizing` (the same audited wipe-on-drop mechanism `sendchain.rs` already uses), so each key is scrubbed both when it rotates (the old value is dropped) and when the session ends. The KDFs (`kdf_rk`/`kdf_ck`/`message_key_nonce`) now return `Zeroizing` directly — wiping the transient message key and the HKDF/HMAC output buffers too, not just the long-lived fields. Behaviour-identical (all 7 ratchet tests pass). — `core/src/ratchet.rs`
- New **`core/tests/output_hygiene.rs`** static gate (runs in the normal `cargo test` gate — pre-commit + CI, no extra wiring): **R1** the `core` library stays silent (no logging/print outside tests), **R2** no secret-bearing type derives `Debug`/`Serialize` (the path by which key bytes reach a panic backtrace or a `tracing` line via `{:?}`), **R3** no demo log sink interpolates a secret identifier. Each rule was negative-tested to confirm it actually fails on a violation.
- New **`core/tests/at_rest_no_leak.rs`** proves every at-rest seal (identity, contacts incl. the secret pairwise root, raw `seal_at_rest`) produces ciphertext that does **not** contain the plaintext secret and is non-deterministic (per-write random salt). _(Gemini-reviewed: PROCEED — first BLOCKed because the transient message key was still raw on the stack; fixed by pushing `Zeroizing` into the KDFs, then re-reviewed clean.)_

### Testing

**cargo-mutants mutation testing** _(SR-2026-06-04-002)_

- Mutation-tested the untrusted-wire parsers and crypto boundary — `core/src/{wire,contacts,rotation,crypto}.rs`: **224 mutants, 0 surviving** (193 caught, 31 unviable). Surviving mutants from the first run drove targeted killing tests rather than being waved off: `AuthReply`/`AuthConfirm` full wire round-trips + truncation, a `MAX_FRAME == 65536` cap pin, `Contacts::from_encrypted` boundary cases (entry ending at the verified byte / one trailing byte / no verified byte — all must reject without an OOB read), and rotation payload-/seal-key domain+order binding. — `core/src/wire.rs`, `core/src/contacts.rs`, `core/src/rotation.rs`
- Three **provably-equivalent** mutants (no input can distinguish them — a deeper bound or the AEAD tag rejects the same case either way) are documented with rationale and line-anchored excludes in `.cargo/mutants.toml`, so the budget stays a clean **0 surviving** without masking the killable mutants on adjacent lines. _(Gemini-reviewed: PROCEED — independently re-derived all three equivalences and confirmed the tests force the inner parser to handle malformed boundaries via a valid AEAD seal; "rock solid".)_
- CI runs `cargo-mutants` **daily (scheduled only)** over those four files — it re-runs the suite once per mutant, so it's kept off the PR path; report uploaded as an artifact. — `.github/workflows/ci.yml`, `docs/TEST_PLAN.md`, `.cargo/mutants.toml`

**loom concurrency model-checking** _(SR-2026-06-04-001)_

- New **`pvtcoms-concurrency`** crate holds the per-contact lock-registry interning pattern as a generic `Registry<K,V>::get_or_create`, with sync primitives that switch to **loom**'s instrumented versions under `--cfg loom` (std otherwise). The real `offline_client::contact_lock` now uses this exact type — so loom checks the *actual* code path, not a copy. — `concurrency/`, `demo/src/offline_client.rs`
- `loom_tests` exhaustively explore every 2-thread interleaving to prove: the registry interns **exactly once per key** (so two callers for one contact share the same lock → mutual exclusion holds), distinct keys stay independent, and concurrent `fetch_add` message-ids are unique. CI runs `RUSTFLAGS="--cfg loom" cargo test -p pvtcoms-concurrency` (loom is stable Rust). _(Gemini-reviewed: SOLID — modeling the synchronous interning is the right boundary; the inner per-contact lock is tokio's already-tested async primitive, and the registry never evicts so there's no Arc-count race.)_ — `.github/workflows/ci.yml`, `docs/TEST_PLAN.md`

---

## [0.1.9] - 2026-06-03

### Security

**Strict Ed25519 verification + Wycheproof-validated crypto** _(SR-2026-06-03-006)_

- `identity::verify` now uses **`verify_strict`** (was the lenient `verify`): it rejects non-canonical R, small-order / mixed-order keys, and signature-malleability cases. Our own signatures are always canonical, so the protocol is unaffected — this only tightens acceptance of *adversarial* signatures. Surfaced by, and validated against, the Wycheproof Ed25519 suite. — `core/src/identity.rs`

### Testing

**Wycheproof vectors + cargo-fuzz harness** _(SR-2026-06-03-006)_

- **Wycheproof** (`core/tests/wycheproof.rs`) runs the full ChaCha20-Poly1305, Ed25519, and X25519 edge-case suites against our AEAD (`seal`/`open`), `identity::verify`, and the pinned X25519 — the malformed-tag / malleable-sig / small-order-point cases that hand-written KATs miss. Strict assertions (Gemini-reviewed): forgeries rejected, malleable Ed25519 sigs rejected, low-order X25519 yields exactly all-zero, and Acceptable AEAD ciphertexts must round-trip (no lenient non-canonical acceptance). Runs in `cargo test`.
- **cargo-fuzz** (`core/fuzz/`, its own nightly workspace) — libFuzzer targets for every untrusted parser: `wire_decode`, `invite_parse`, `prekey_parse`, `rotation_parse`, `media_manifest`. Survived ~40M executions with no panics/OOM. CI **builds** every target on each run and runs a 60s coverage-guided **smoke per target daily**. — `.github/workflows/ci.yml`, `docs/TEST_PLAN.md`

### Docs

**Security audit-scope package** _(SR-2026-06-03-005)_

- New [`docs/audit/`](docs/audit/) — the materials an external security auditor needs to scope and conduct a review (the last gate before any "secure/production" claim). **`AUDIT_SCOPE.md`** (system overview, in/out of scope with per-component LOC, dependencies, reproducible build, test coverage, a data-flow + trust-boundary diagram, engagement logistics, frozen-commit guidance, and the five things we most want checked); **`CRYPTO_SPEC.md`** (implementation-accurate constructions — KDF, the MAL-BIND handshake transcript, ratchet, safety number, the random-salt→subkey at-rest sealing, relay tokens, the exact wire byte-layout + SQLCipher PRAGMAs); **`SECURITY_CLAIMS_AND_LIMITATIONS.md`** (a falsifiable claims table, explicit non-claims, self-identified weaknesses, a prior-findings-and-fixes log, and our top questions); a `dependency-tree.txt` SBOM; and **`scripts/audit/local-testbed.sh`** — a deterministic local relay + alice/bob end-to-end repro (no Tor; `PVTCOMS_RELAY_POW=8`, runs in ~10s). _(Reviewed by Gemini as a completeness critic: REVISE → addressed must-adds → SHIP.)_

### Added

**Native-app foundation: UniFFI bindings (Kotlin + Swift)** _(SR-2026-06-03-004)_

- New **`pvtcoms-ffi`** crate — the FFI layer the architecture calls for, so Android/Kotlin and iOS/Swift apps can call the Rust core. It's a **separate crate** (the shipped desktop exe doesn't depend on it, so UniFFI never bloats it). Exposes the local crypto/identity operations an app's onboarding / verify / at-rest-storage screens need, as **typed** UniFFI functions. — `ffi/`, `bindings/`
- API: an **opaque `Identity` object** (the secret key never leaves Rust — `generate`, `fromSealed`/`toSealed` for persistence, `publicKey`, `fingerprint`, `sign`); plus `verify`, `fingerprint`, `safetyNumber` (the 16-emoji verify screen), and `seal`/`open` over the core's audited AEAD. Bytes are `ByteArray`/`Data` (never hex strings); errors map to a Kotlin exception / Swift `throws`. _(Redesigned after a Gemini BLOCK: opaque secrets, byte arrays not hex, no redundant JSON dispatcher.)_
- **Generated Kotlin + Swift bindings** committed under `bindings/` with usage examples and a `README.md` documenting regeneration + the mobile cdylib cross-compile recipe (`cargo-ndk` for Android, xcframework for iOS — those app shells need their platform toolchains, out of scope for this Linux build). 6 facade unit tests (sign/verify + seal→restore round-trip + wrong-key fail-closed); `cargo test --workspace`, clippy, deny, audit all green.

### Security

**macOS Keychain + Linux Secret Service for the at-rest key** _(SR-2026-06-03-003)_

- The at-rest key on **macOS** and **Linux** is now held in the OS keystore (Keychain / freedesktop Secret Service via the `keyring` crate, pure-Rust zbus — no system libdbus needed), matching the Windows DPAPI protection. Same **data-safe, downgrade-safe migration** as DPAPI: adopt the existing 32-byte dev key, store it in the keyring, and only remove the raw `devkey.bin` once **both** the keyring durably has it **and** a "migrated" marker is on disk. _(Windows is unchanged — keeps its hand-rolled DPAPI; this is `cfg(not(windows))`, so the shipped Windows exe is byte-identical.)_ — `demo/src/oskeystore.rs`, `demo/src/chat.rs`, `demo/Cargo.toml`
- **Keyring-unavailable handling** (Linux keyrings can be locked/headless): if the keyring is down but the legacy file is still present → use the file (migrate later); if we already migrated (marker set) and the keyring is locked → **fail closed** with a `KEYRING-LOCKED-README.txt` recovery note instead of regenerating over real data; a fresh install with no keyring degrades to the dev file key (works headless, documented). The 6-branch policy is unit-tested.
- **Runtime safety:** the keyring's blocking calls run on a dedicated thread with the `async-io` runtime (not tokio), and `storage_key()` is warmed on the **main thread** before any async runtime starts — so an OS keyring unlock can never stall the web server. _(Two Gemini BLOCKs on a data-loss path + a tokio-worker stall found + fixed.)_

---

## [0.1.8] - 2026-06-03

### Security

**CI supply-chain gates + hardened native libs** _(SR-2026-05-30-008)_

- **Daily security CI** — the GitHub Actions workflow now runs on a **daily cron** (not just push/PR), so a newly-disclosed advisory breaks the build even with no code change. Gates: fmt · clippy · test · Tor build · **cargo-audit** · **cargo-deny** · **osv-scanner** (OSV DB) · **Trivy** filesystem scan (break on **HIGH/CRITICAL**) · **version floors**. — `.github/workflows/ci.yml`, `osv-scanner.toml`, `.trivyignore`
- **Native-lib version floors** — SQLCipher statically bundles SQLite + OpenSSL (C), invisible to cargo-audit/deny, so we pin them: `scripts/ci/check_dep_floors.py` asserts (from `Cargo.lock`) bundled **OpenSSL ≥ 3.5.5** (today 3.6.2, read from `openssl-src` build metadata), **curve25519-dalek ≥ 4.1.3**, **rustls-webpki ≥ 0.103.10**; and a runtime test asserts **SQLite ≥ 3.50.2** (CVE-2025-6965; today 3.50.4). — `scripts/ci/check_dep_floors.py`, `demo/src/histdb.rs`
- **Hardened the bundled C** — the release build now compiles the vendored OpenSSL + SQLCipher (+ ring) with `-fstack-protector-strong -D_FORTIFY_SOURCE=2` (Rust code is hardened by default; the bundled native libs weren't). — `scripts/build-windows-release.sh`
- **Proactive updates** — Dependabot bumps Cargo deps daily (the bundled-native-lib crates grouped to surface OpenSSL/SQLite bumps fast) + GitHub Actions weekly, so C-library fixes land ahead of the reactive scanners. — `.github/dependabot.yml`
- **cargo-deny re-scoped** for the new deps: graph limited to the shipped targets (windows-gnu + linux-gnu, all-features); unmaintained advisories scoped to workspace crates (the GTK/`instant` stack from `tray-icon`/`tao` is `cfg(windows)`/Win32 and never compiled — vulnerability checks stay global); allow the internal path dep; allow `Unlicense`/`BSL-1.0`. Verified locally: audit/deny/floors all green. _(Gemini-reviewed.)_

---

## [0.1.7] - 2026-06-03

### Changed

**Message history now on SQLCipher — scales to large conversations, still metadata-free** _(SR-2026-06-03-002, ADR-010)_

- History was one sealed file per contact, re-encrypted **whole on every message** (O(N²) over a conversation). It now lives in a **SQLCipher** DB (`pvtcoms-<profile>-history.db`, page-level AES-256) — appends, reads, deletes, disappearing-message pruning, and cross-conversation search are indexed single-statement operations. Because the **whole** DB is encrypted (schema, indexes, rows), it leaks no metadata — strictly stronger than a plain DB, and it keeps the at-rest protection the old sealed files gave. — `demo/src/histdb.rs`, `demo/src/chat.rs`, `demo/Cargo.toml`
- Keyed with the device key as a **raw key** (`PRAGMA key = x'…'`, no PBKDF2 — the key is already high-entropy); `cipher_memory_security`/`secure_delete`/`trusted_schema=OFF`/WAL set; **fails hard** if the key is wrong or the file is corrupt. Verified: the on-disk DB header is encrypted (not "SQLite format 3").
- **Data-safe, idempotent migration** of existing sealed history: rows + a `migrated_contacts` flag commit in **one transaction** (atomic), and the old file is removed only after a verified commit — so a crash mid-migration never duplicates or loses messages, even if new messages arrive after a failed attempt. _(Three Gemini BLOCKs on data-loss/duplication found + fixed before ship.)_
- Crypto provider is **vendored OpenSSL** (statically linked; +~5 MB exe). This is aligned with the plan's CI gate that verifies `OpenSSL>=3.5.5`; the OpenSSL here does local DB AES only (never network input). LibTomCrypt is noted as a future surface-reduction option. See **ADR-010**.

---

## [0.1.6] - 2026-06-03

### Security

**Windows: the at-rest key is now OS-protected (DPAPI), not a plaintext file** _(SR-2026-06-03-001)_

- The 32-byte key that seals all local data (identity, contacts, history, media) was stored **raw** in `devkey.bin` — anyone who copied the folder to another PC, or another user on the same box, could decrypt everything. On Windows it's now wrapped with **DPAPI** (`CryptProtectData`, **per-user**, `CRYPTPROTECT_UI_FORBIDDEN`, app-specific entropy) and kept in its own `oskey.bin`. The on-disk blob is bound to the logged-in user's credentials, so a copied/stolen folder won't decrypt elsewhere. — `demo/src/oskeystore.rs`, `demo/src/chat.rs`
- **Data-safe, downgrade-safe migration** (reviewed hard — this key gates your data): on first 0.1.6 run the existing 32-byte dev key is **adopted unchanged** (so all existing data still decrypts), written DPAPI-wrapped to `oskey.bin`, and only **then** is the raw `devkey.bin` removed. Key invariants: never regenerate over an `oskey.bin` we can't unwrap (it **fails closed** with a `*.LOCKED-README.txt` recovery note instead of destroying it); never delete the legacy key until the new one is durably on disk; a fresh key is generated only when nothing exists. A downgraded pre-DPAPI build ignores `oskey.bin` (starts empty, never destroys the real key — re-upgrading recovers). Key material (OS buffers, intermediate Vecs) is zeroized after use. Pure migration policy unit-tested; DPAPI round-trip tested on Windows. _(Two Gemini BLOCKs on data-loss paths found + fixed before ship.)_
- _Still deferred:_ macOS Keychain / Linux Secret Service backends (same `KeyStore` shape); SQLCipher (SR-2026-05-30-010).

---

## [0.1.5] - 2026-06-02

### Added

**Message actions + window centering/memory + comfier navigation** _(SR-2026-06-02-008)_

- **Copy / Delete-for-me** — right-click (or long-press) any message for a menu: **Copy text**, or **Delete for me** (local only — your contact keeps their copy; the dialog says so). Backend `POST /api/message/delete` removes the one stored message, matched by `(timestamp_ms, sent, text)` so a same-millisecond offline batch can't delete the wrong bubble; media deletes also purge the sealed media blob. Pure match predicate unit-tested. — `demo/src/{chat.rs,gui.rs,offline_client.rs,gui.html}`
- **Window opens centered + remembers size/position** — the Edge app-window now launches `--window-size`/`--window-position` from saved geometry, or **centered** on the primary screen (Win32 `GetSystemMetrics`) on first run. The page reports its geometry to `POST /api/window` (on resize/close) so the next open restores it. — `demo/src/gui.{rs,html}`
- **Comfier back button** — back buttons are now a clear `←` with a 44px tap target + aria-label (was a thin `‹`); all icon buttons get ≥42px hit areas. — `demo/src/gui.html`

### Security

**Hardening from the v0.1.5 review (Gemini BLOCK → fixed)** _(SR-2026-06-02-008)_

- **Atomic writes** — every sealed-data write (history, contacts, identity, invites, outbox snapshots, media, timers, window geometry) now goes through `chat::atomic_write` (write sibling `.tmp` → `fsync` → atomic `rename`), so a crash/power-loss mid-write can no longer truncate or corrupt a conversation or the contact list. — `demo/src/{chat.rs,offline_client.rs,gui.rs}`
- **Localhost CSRF guard** — an axum middleware rejects any state-changing request (non-GET/HEAD) whose `Origin`/`Referer` host isn't `127.0.0.1`/`localhost`/`[::1]`, so a malicious site open in another tab can't silently drive the local API (delete messages, queue sends, change settings). Host-spoofs like `127.0.0.1.evil.com` are rejected; browsers forbid scripts from forging `Origin`/`Referer`. — `demo/src/gui.rs`
- **FFI linkage** — the `GetSystemMetrics` extern block is pinned with `#[link(name = "user32")]` (no reliance on transitive linkage). — `demo/src/gui.rs`
- _Still deferred (tracked):_ the root at-rest key (`devkey.bin`) belongs in an OS keystore (DPAPI/Keychain/Secret Service) — the `KeyStore` trait exists; and SQLCipher to replace the O(N²) full-history rewrite (SR-2026-05-30-010).

---

## [0.1.4] - 2026-06-02

### Fixed

**Make the new version actually reach you — app window, fresh port, visible version, no stale cache** _(SR-2026-06-02-007 follow-up)_

- **Opens as a real app window, not a browser tab** — on Windows the UI now launches in a chromeless **Edge app window** (`msedge --app=`, present on every Win10/11; no tabs, no address bar), falling back to the default browser only if Edge isn't found. — `demo/src/gui.rs`
- **Fresh default port `127.0.0.1:8090`** (was 8088) — a previously-running older copy holds the old port, so the new build used to just re-open the *old* instance (and its cached UI). The new port guarantees the new build runs its own server and you see the new UI immediately, even if an old copy is still in the background. — `demo/src/main.rs`
- **UI served `Cache-Control: no-store`** — the browser can no longer keep a stale `gui.html` across an update, which was another way the old screen lingered. — `demo/src/gui.rs`
- **Version shown on the home screen** (`pvtcoms vX.Y.Z` next to the title, not just in Settings) — instant confirmation of which build you're running. — `demo/src/gui.html`

---

## [0.1.3] - 2026-06-02

### Added

**Runs in the background like a real app — version, system tray, desktop notifications** _(SR-2026-06-02-007)_

- **App version** is now visible: baked from the project `VERSION` file (`include_str!`, so UI and file never drift), surfaced via `/api/config` and shown in **Settings → About** (`pvtcoms vX.Y.Z`). — `demo/src/{gui.rs,gui.html}`
- **Windows system-tray background shell** — on Windows the app now lives **near the clock**: a tray icon with **Open pvtcoms** / **Quit pvtcoms** (double-click to open). Closing the browser window no longer "quits" — the local server keeps running in the background to receive messages; quit deliberately from the tray. Pure **Win32 tray (`tray-icon`), no embedded WebView2** (the earlier WebView shell failed to open on a real PC). **Crash-isolated**: because the release profile is `panic = "abort"` (so `catch_unwind` can't shield an in-process toolkit panic — the likely cause of the prior failure), the tray runs in a **separate child process**. The parent only starts the server + opens the browser, then serves forever; a tray-child crash can't touch the parent or your messages, so the app can never fail to open. "Quit" terminates the parent tree by PID (`taskkill /T`). Replaces the off-by-default `native` (wry) feature; the tray now ships in every Windows release. — `demo/src/desktop.rs`, `demo/Cargo.toml`, `demo/src/{main.rs,gui.rs}`
- **Privacy-first desktop notifications** — opt-in (Settings → 🔔), requested on a user gesture per browser policy. When a message arrives while pvtcoms is in the background, the OS alert says only **“New message”** — never the sender or the content, so a lock screen can't reveal who you talk to. — `demo/src/gui.html`
- **Unread badges + title counter** — each chat shows an unread count (unread chats sort to the top); the browser tab title shows `(N) pvtcoms`. Opening a chat clears its count. All local UI state (`localStorage`), no protocol change. — `demo/src/gui.html`

**Message search — find chats by name + messages by content** _(SR-2026-06-02-006)_

- The contacts home screen gains a **🔍 search bar**. Typing filters your **Chats** by name instantly (client-side) and searches **message content across every conversation** (case-insensitive substring), grouped under a **Messages** section with the matched text highlighted; tapping any result opens that chat. Clearing the box (✕ or empty) restores the normal list. — `demo/src/gui.html`
- Backend `POST /api/search` runs `chat::search_history`, which decrypts each contact's stored history **in-memory only**, matches the query, and returns the newest 200 hits as `{contact,sent,ts,text}`. **Media bubbles** (sentinel-prefixed) carry no searchable text and are skipped. The query is length-bounded (≤256), **never logged**, and the response is served `Cache-Control: no-store` (as is `/api/history`) so message plaintext is never written to browser cache; the pure match predicate is unit-tested. **Live-verified** end-to-end: a real offline-delivered message ("…harbour…noon") is found by `harbour` and case-insensitively by `NOON`, while undelivered/non-matching text returns no hit. — `demo/src/{gui.rs,chat.rs}`

**Relay client speaks Tor — reach the deployed relay over its `.onion`** _(desktop-app foundation)_

- The relay client (`mailbox::relay_deposit`/`relay_pull`/`relay_peek`, used by invite/directory/mailbox) now **dials a `.onion` relay address over Tor** (behind `--features tor`, reusing the arti `bootstrapped_client`, one lazily-bootstrapped `TorClient` reused across a run); plain `host:port` stays direct TCP. A single `RelayIo` marker trait boxes both transports behind the existing framing. Non-tor builds reject a `.onion` with a clear message. This is what lets the app use the **production relay** over Tor (and lets the over-Tor invite+chat test run on a real Tor host). — `demo/src/{mailbox,tor}.rs`
- **Build-time relay config baking** — the access key + relay address fall back to compile-time `PVTCOMS_DEFAULT_RELAY_KEY` / `PVTCOMS_DEFAULT_RELAY` (`option_env!`), so a release build "just works" against the deployed relay with nothing to configure; runtime env still overrides. The values are injected at release time from a **private, gitignored** source — never committed (gated-relay key distribution per ADR-005). — `demo/src/{mailbox,gui}.rs`

**GUI for invite + rotate — browser onboarding/rotation panel** _(SR-2026-06-01-005)_

- The web GUI gains a **👥 Contacts** panel: **Create Invite** (shows the shareable hex), **Use Invite** (add the person who shared one), **Check Requests** (accept pending friend requests), **Rotate Identity** (with confirm), and **Check Rotations** (apply contacts' key changes) — relay prefilled from `PVTCOMS_RELAY`. Results render as plain text (no innerHTML). — `demo/src/gui.html`
- Small request/response **HTTP API** behind it (`/api/config`, `/api/invite/{create,request,accept}`, `/api/rotate`, `/api/rotate/check`) — hand-built JSON, no serde, strict CSP unchanged. — `demo/src/gui.rs`
- Refactored `invite_client` / `rotate_client` into **async cores** (`request_async`/`accept_async`/`rotate_async`/`rotate_check_async` + `create_invite_hex`) with thin CLI wrappers, so the operations compose inside the axum runtime. **Live-verified** over HTTP across two profile GUIs: invite (matching SAS) and rotation (Bob auto-re-pinned to Alice's new key). — `demo/src/{invite_client,rotate_client}.rs`

### Security

**ML-KEM-768: swap RustCrypto `ml-kem` → formally-verified `libcrux-ml-kem` + FIPS-203 differential KAT** _(SR-2026-06-01-004, ADR-003)_

- **`core::pqkem` now wraps `libcrux-ml-kem` 0.0.9** (Cryspen, F\*/hax-verified: panic-free, correct, secret-independent, KyberSlash-clean) behind the same facade — added free `encapsulate`/`decapsulate`; `handshake`/`invite` updated off the trait-method API. Handshake wire format **unchanged** (identical FIPS-203 encodings → interop preserved). `InviteSecret` now stores the 2400-byte expanded ML-KEM secret key (libcrux has no seed export) — local sealed blob, no wire impact.
- **Differential FIPS-203 KAT** (`pqkem.rs`): for the same public key + deterministic coins, **libcrux and RustCrypto `ml-kem` produce byte-identical ciphertext + shared secret** — two independent conformant implementations agreeing byte-for-byte (RustCrypto kept as a **dev-dependency** oracle only). Verified passing; live handshake re-verified on libcrux. cargo-audit exit 0 (552 deps).

**Pre-audit hardening: fuzz every untrusted-wire parser + panic-safety audit** _(SR-2026-06-01-003)_

- **Fuzz tests** (xorshift, thousands of inputs, assert no panic) added for all parsers that consume attacker-controlled bytes: relay request + blob-list (`demo/mailbox.rs` — the most network-exposed), invite `Invite`/`InviteSecret`/`FriendRequest`/`Accept`, and migration records. (Directory + wire already had them.) — `core/src/{invite,rotation}.rs`, `demo/src/mailbox.rs`
- **Panic-safety audit** of the core crate: confirmed every `expect`/`unwrap` outside tests is on an *infallible* op (HMAC accepts any key length; HKDF/ChaCha20-Poly1305 with valid fixed inputs; `getrandom` = environment) or local sender state — **none on attacker-parsed input**; all parsers use `?`/`Option`. Project `enforce_coding_standards` + `detect_error_patterns` checkers run clean on all nine new files; `cargo-audit` exit 0 (538 deps).

### Added

**M3 (slice 4): async friend-request/accept engine — add an OFFLINE contact** _(SR-2026-06-01-001, engine)_

- **`pvtcoms_core::invite`** — establish the pairwise root `R` with a peer who is offline, via a single-use **invite** (no synchronous handshake). X3DH-style on the existing hybrid primitives: a signed one-time **prekey** (ephemeral X25519 + ML-KEM-768), `request_friend` (verify prekey, agree key, sign transcript), `accept_friend` (complete agreement, verify, sign), `verify_accept`. Both sides derive the **identical** `K`/`R` and SAS; mutual identity authentication via transcript signatures. 5 tests (round-trip, tamper/forge rejection, serialization). Decision: **ADR-007**. — `core/src/invite.rs`
- **`pqkem` decapsulation-key seed (de)serialization** — persist an invite's one-time prekey secret as its **64-byte seed** (not the 2400-byte expanded key). +1 test. — `core/src/pqkem.rs`
- **Async onboarding over the relay + CLI** — rendezvous derivations (`request_token`/`accept_token`/`seal_key`, key-separated) so the request/accept ride the **oblivious relay** sealed + blinded; `invite-create` / `invite-request <relay> <invite> <name>` / `invite-accept <relay>`. Invite secrets persisted **sealed at rest**. **Live-verified end-to-end**: Alice and Bob — never online together — added each other via a single-use invite (matching safety numbers ⇒ identical `R`), then Bob resolved Alice's address by identity through the directory. — `core/src/invite.rs`, `demo/src/{invite_client,chat}.rs`

**M3 (slice 2): production oblivious relay — gated access + PoW + persistence + onion-only deploy** _(SR-2026-05-31-001)_

- **Relay policy engine** `pvtcoms_core::relay::Relay` — validates every request (freshness + access capability + proof-of-work + replay), stores with a deposit timestamp, and ages out undelivered mail by TTL. Pure + clock-injected; 11 tests. — `core/src/relay.rs`
- **Access gate = shared capability (HMAC), not per-identity signatures** — `Gated([u8;32])` keeps strangers out of an invited circle while the relay still cannot tell members apart (obliviousness preserved); `Open` mode is PoW-only public. Decision + trade-offs in **ADR-005**.
- **PoW anti-spam primitive** — hashcash-style `pvtcoms_core::pow` (SHA-256, leading-zero-bit difficulty, request-bound). Identity-free flood throttle; 6 tests. — `core/src/pow.rs`
- **Per-request salt** bound into PoW + capability + replay-id — a legitimate same-second re-pull is no longer a false `Replay`, while verbatim/salt-mutated replays are still caught.
- **Production daemon** — keep-alive framing (many requests/connection), `TYPE_OK`/`TYPE_REJECT` replies, periodic TTL + replay-cache sweep, and **atomic on-disk snapshot persistence** so mail survives a restart. Env config: `PVTCOMS_RELAY_KEY`/`POW`/`TTL`/`DATA`. — `demo/src/mailbox.rs`
- **VPS secure-setup bundle** — hardened Ubuntu 24.04 deploy for an **onion-only** relay: `deploy/harden.sh` (non-root sudo user w/ passwordless sudo, key-only SSH, UFW deny-all-but-SSH, fail2ban, unattended-upgrades, sysctl), `deploy/setup-relay.sh` (Tor onion + sandboxed systemd unit + generated access key in a 0600 EnvironmentFile), `deploy/README.md`. — `deploy/`
- Verified live: gated deposit/pull, wrong-key rejection, persistence across restart, burn-on-read, and same-second double-pull.

**M3 (slice 3): directory records — connect by identity** _(SR-2026-05-31-002, engine)_

- **Directory record engine** `pvtcoms_core::directory` — a contact resolves your current address (stable `.onion`; optional direct IP for calls) by **identity** instead of a pasted address. Built on the **pairwise root** `R`. 9 tests incl. a full round-trip through the real oblivious relay. — `core/src/directory.rs`
- **Key separation** — lookup token / record key / message keys are three independent HMAC derivations from `R`; derivations are **directional** (bound to publisher identity) and **per-contact** (bound to `R`), so only the intended contact can locate, decrypt, or verify the record.
- **Signed + sealed + blinded** — record signed by the publisher's Ed25519 identity, sealed with ChaCha20-Poly1305 under the record key, deposited under a per-epoch rotating token. **Epoch+seq synthetic nonce** so unchanged content re-published in a new epoch is unlinkable (no caller-random nonce). Anti-rollback via monotonic `seq` + `expires_at`. Bounded, panic-free parser (fuzzed). Decision: **ADR-006**.

- **Pairwise root `R` from the handshake** — `directory::pairwise_root(session_key)` derives the long-lived root by a label distinct from the ratchet (key separation). The chat handshake now returns `R`; `check_contact` **establishes it once** per contact (TOFU, frozen after first contact) and persists it. `contacts.rs` stores an optional per-contact root (versioned on-disk format v1). — `core/src/{directory,contacts}.rs`, `demo/src/{chat,main,gui,tor}.rs`

- **Relay-client publish/lookup + non-burning `peek`** — `dir-publish <relay> <onion> [ip:port]` publishes your address to every contact (one record each, sealed to that contact, deposited under the blinded dir-token); `dir-lookup <relay> <contact>` resolves a contact's current address by identity. Added a **`peek`** relay op (read without burning — directory records are read repeatedly, unlike burn-on-read messages) + reusable `relay_deposit`/`relay_pull`/`relay_peek` client helpers (send/recv refactored onto them). Live-verified end-to-end: alice published, bob resolved her onion + direct IP by identity, relay saw only opaque bytes. — `demo/src/{dir_client,mailbox}.rs`, `core/src/relay.rs`

- **`dial <relay> <contact>` — connect by identity** — resolves a contact's current address via the directory, then connects in one step: prefers their `.onion` over Tor (anonymous), falling back to a direct address. You connect by *who*, never pasting an address. Live-verified end-to-end (resolve → direct connect → authenticated handshake). — `demo/src/dir_client.rs`

- **GUI "Dial by ID"** — the browser chat gains a *Dial by ID* button: type a contact name, and the GUI resolves their address via the directory (relay from `PVTCOMS_RELAY`) and connects (onion over Tor when available, else direct), reusing the existing transport dispatch. Added an async `resolve_async` so resolution composes inside the web runtime. Live-verified over the WebSocket: resolve → connect → verified peer. — `demo/src/{gui.rs,gui.html,dir_client.rs}`

**M3 (slice 5): identity rotation — contacts follow a key change, end to end** _(SR-2026-06-01-002)_

- **`pvtcoms_core::rotation`** — a signed `old → new` **migration record** (signed by *both* keys: the old authorises, the new accepts/binds) so a contact who pinned the old key can verify continuity and re-pin. `create_migration` / `verify_migration` (checks against the contact's pinned key + both signatures) / serialization, plus per-contact relay derivations `migration_token` / `migration_seal_key` (bound to the old identity, blinded). Keeps the pairwise root `R` (identity-independent) + verified status; clean-reset stays available. 7 tests. Decision: **ADR-008**. — `core/src/rotation.rs`
- **`contacts::rotate_identity`** + chat `save_identity` / `rotate_contact` — re-pin to the new key after a verified migration; replace the local identity at rest. — `core/src/contacts.rs`, `demo/src/chat.rs`
- **CLI**: `rotate <relay>` (sign migration, publish to each contact over the oblivious relay, replace local identity) and `rotate-check <relay>` (pull + verify + apply pending rotations). **Live-verified**: Alice rotated `047a…→0388…`, Bob's `rotate-check` auto-re-pinned to the new key, and Bob still resolved Alice by identity afterwards (R preserved) — full continuity. — `demo/src/rotate_client.rs`

---

## [0.1.2] - 2026-05-31

### Added

**M0 walking skeleton: two Rust CLIs exchange a string over a Tor .onion (arti), manual address paste, no crypto** _(04:31 GMT)_ [SR-2026-05-30-003]

**M3 (slice 1): oblivious mailbox addressing — rotating per-epoch tokens (HMAC), an oblivious relay with deposit/blind-pull/burn-on-read** — core/src/mailbox.rs

**M1 (slice 5): Double Ratchet — per-message key rotation (forward secrecy + post-compromise security), DH ratchet (X25519), skipped-key handling for out-of-order/dropped messages** — core/src/ratchet.rs

**M1 (slice 4): SAS / safety-number (emoji) for MITM detection — both peers derive the same string from the session; demo prints it (verified identical across two containers)** — core/src/crypto.rs

**M2 (slice 1): length-bounded wire framing + handshake serialization; runnable TCP demo (pvtcoms-demo)** — core/src/wire.rs, demo/

**M1 crypto (slice 3): full hybrid X25519+ML-KEM-768 handshake (Initiator/Responder), tested end-to-end with an encrypted message** — core/src/handshake.rs

**M1 crypto (slice 2): ML-KEM-768 post-quantum KEM + hybrid X25519/ML-KEM key schedule + ChaCha20-Poly1305 AEAD, all tested** — core/src/{pqkem,crypto}.rs
**M1 crypto (slice 1): X25519 DH + HKDF transcript-binding key schedule, with tests** — core/src/crypto.rs

---

## [0.1.1] - 2026-05-30

### Added

### Changed

**Add LICENSE (AGPL-3.0-or-later verbatim) + App Store additional-permission exception + DCO + TRADEMARK policy + cargo-deny license allowlist** _(19:05 GMT)_ [SR-2026-05-30-004]

### Fixed

### Security
