{"id":50504414,"url":"https://github.com/richer-richard/huddle","last_synced_at":"2026-06-14T06:01:12.780Z","repository":{"id":357638255,"uuid":"1237263237","full_name":"richer-richard/huddle","owner":"richer-richard","description":"Terminal + desktop end-to-end-encrypted chat over a self-hosted Tor onion relay (relay sees only ciphertext). Megolm group E2E, post-quantum hybrid DMs (X25519+ML-KEM-768) with epoch rotation, SQLCipher at rest, BIP39 recovery, full-text search, disappearing messages, reactions/replies/edits, opt-in LAN mDNS. Rust (ratatui TUI + egui GUI).","archived":false,"fork":false,"pushed_at":"2026-06-12T10:50:22.000Z","size":77620,"stargazers_count":1,"open_issues_count":7,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-12T11:07:48.077Z","etag":null,"topics":["anonymity","chat","cli","encryption","end-to-end-encryption","gossipsub","libp2p","mdns","megolm","ml-kem","onion-service","p2p","post-quantum-cryptography","privacy","ratatui","rust","sqlcipher","terminal","tor","tui"],"latest_commit_sha":null,"homepage":"https://crates.io/crates/huddle","language":"Rust","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/richer-richard.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE-APACHE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":"docs/ROADMAP-2.0-and-beyond.md","authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-13T02:55:46.000Z","updated_at":"2026-06-12T10:50:24.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/richer-richard/huddle","commit_stats":null,"previous_names":["richer-richard/huddle"],"tags_count":36,"template":false,"template_full_name":null,"purl":"pkg:github/richer-richard/huddle","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/richer-richard%2Fhuddle","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/richer-richard%2Fhuddle/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/richer-richard%2Fhuddle/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/richer-richard%2Fhuddle/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/richer-richard","download_url":"https://codeload.github.com/richer-richard/huddle/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/richer-richard%2Fhuddle/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34310801,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-14T02:00:07.365Z","response_time":62,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["anonymity","chat","cli","encryption","end-to-end-encryption","gossipsub","libp2p","mdns","megolm","ml-kem","onion-service","p2p","post-quantum-cryptography","privacy","ratatui","rust","sqlcipher","terminal","tor","tui"],"created_at":"2026-06-02T14:31:08.251Z","updated_at":"2026-06-14T06:01:12.730Z","avatar_url":"https://github.com/richer-richard.png","language":"Rust","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Huddle\n\nTerminal-native, end-to-end-encrypted chat rooms over Tor.\n\nOpen the TUI, start a room or paste an invite, and chat. Rooms can be\npublic (cleartext payloads) or encrypted (per-sender Megolm group\nsessions, session keys wrapped with an Argon2id-derived passphrase\nkey). Either way the transport is end-to-end: the relay only ever sees\nciphertext.\n\n**huddle 1.0 runs LAN discovery and the relay together by default — no\nmode switch.** Friends on the same network connect directly over libp2p\n(mDNS); everyone else is reached through a self-hostable relay\n(`crates/huddle-server`). Each message rides whichever path reaches the\npeer, and the per-chat header shows which (`via lan` / `via relay`). The\nrelay is a dumb encrypted router + offline mailbox — it never holds keys\nand never decrypts.\n\n**The relay has several \"doors\", each a different anti-censorship\ntradeoff** (run `huddle transports` to see them): a Tor v3 **onion** via\nyour system Tor (most private, the default); the same onion via a private\n**obfs4/WebTunnel bridge** (for networks that block Tor); an in-process\n**Arti** onion (with `--features arti`); and **clearnet** `ws://`/`wss://`\nstraight to a raw IP (fast, for VPN users or where Tor is fully blocked —\nthe relay sees your IP, but messages stay end-to-end encrypted). The same\n`huddle-server` process can be exposed as an onion **and** on a public IP\nat once, so all doors share one set of rooms + mailboxes. Pick a door with\n`--transport \u003cid\u003e`, set an order with `--transport-order`, or point at a\nclearnet relay with `--clearnet-server ws://\u003cip\u003e:\u003cport\u003e/ws`.\n\n**Contacts** are a durable, fingerprint-keyed address book — keyed by\nidentity, not by an ephemeral LAN address — so a conversation keeps\nworking after a peer leaves the LAN. `a` adds a contact by HD-ID; over the\nrelay this reaches them across the internet (live or via the mailbox), and\nthey accept from the Contacts pane to open a DM. DMs persist across\nrestarts and keep flowing over the relay.\n\n\u003e **Tor is optional now.** LAN works with no Tor at all, and a clearnet\n\u003e relay door needs no Tor either. The onion doors do need a local Tor\n\u003e daemon (SOCKS5 on `127.0.0.1:9050`; override with `--tor-socks`). On\n\u003e Debian/Ubuntu: `apt install tor \u0026\u0026 systemctl enable --now tor`. If Tor\n\u003e is down, huddle falls through to the next available door.\n\n\u003e **This is a learning project, not production-audited chat.**\n\u003e SQLCipher protects the database at rest under your master passphrase,\n\u003e Megolm sessions are persisted with an Argon2id-derived key, file\n\u003e bytes use ChaCha20-Poly1305, and SAS contact verification ships in\n\u003e v0.3 — but the protocol has not been audited and threat-modelling\n\u003e work is ongoing. Don't rely on it for real secrets without a\n\u003e careful review.\n\n## Build\n\nRequires Rust 1.75+ (edition 2021).\n\n```bash\ncargo build --release\n./target/release/huddle\n```\n\n## How it works (high level)\n\n1. **Launch** — your Ed25519 identity loads (or generates) from disk\n   silently. The TUI opens on the **Welcome** pane with the sidebar\n   on the left. huddle connects to the Tor onion relay in the\n   background (the relay dot `●` next to your name turns solid once\n   the link is up). With `--mode mdns`/`--mode direct` a libp2p swarm also starts\n   for LAN discovery / direct dial alongside the relay.\n2. **First launch only** — a versioned onboarding card explains\n   huddle's leaderless model (rooms outlive the creator), the master\n   passphrase vs room passphrase distinction, the sidebar layout, and\n   the new keybindings.\n3. **Direct messages** — press `m`, type a partner's HD-ID or\n   username, hit `Enter`. The DM appears in the **Direct messages**\n   section of the sidebar on both peers. DMs are end-to-end encrypted\n   on the room layer via an ECDH derivation between the two parties'\n   identity keys (huddle 0.7.1+).\n4. **Group rooms** — press `g` to create a multi-peer room. Pick a\n   name, choose public or encrypted (and a passphrase if encrypted).\n   You become the room's first *owner*; only owners can kick, grant\n   moderation, or rotate the room key. Discovered rooms you haven't\n   joined appear under the **Discover** sub-row in the sidebar.\n5. **Inbound dial gate** — if someone you don't know dials you, the\n   TUI raises an Accept / Reject / Trust+Accept modal. The peer isn't\n   added to your gossipsub mesh until you decide.\n6. **Chat, verify, moderate** — see the [Key bindings](#key-bindings)\n   tables for SAS verification (`Ctrl+V → s`), kick (`Ctrl+K`), grant\n   owner (`Ctrl+G`), invite links (`Shift+I`), join codes (`Ctrl+J` /\n   `c`), and verified-only-mode toggles (Settings pane, `o` per room).\n\n## TUI layout\n\n```\n+----------------------------------------------------------------------+\n| huddle 1.0.0  ·  745e-fe8a-…  ·  relay ●               12:34 UTC     |\n+------------------------+---------------------------------------------+\n| ▾ Profile              | # general                                   |\n|   alice  HD-AAAA-…  ●  |   4 members · encrypted                     |\n| ▾ Direct messages  (2) |                                             |\n|   ● bob       1m   (1) |   12:32  bob          hey                   |\n|   ○ dave       offline |   12:33  carol  ✓     same here              |\n| ▾ Group rooms      (1) |   12:34  you          looks good            |\n|   # general  4  E      |                                             |\n|   + Discover (2)       |                                             |\n| ▾ People               |                                             |\n|   eve  HD-EEEE-…  ✓    |   \u003e _                                       |\n| ▸ Activity             |                                             |\n| ▸ Settings             |                                             |\n+------------------------+---------------------------------------------+\n| ?help  /type  ^V verify  ^F search  ^A attach  ^L leave  ^I members  |\n+----------------------------------------------------------------------+\n```\n\nSix sidebar sections, top-to-bottom: **Profile** (you), **Direct\nmessages**, **Group rooms** (with a Discover row), **People** (known +\nverified + blocked), **Activity** (status history + transfers),\n**Settings** (toggles + go-dark). `j/k` moves the cursor; `Tab` /\n`Shift+Tab` jumps between sections; `Space` / `→` / `←` toggles\nexpand. `Enter` opens the selection in the right-hand pane. `Esc`\nfocuses the sidebar from a chat pane.\n\n## Key bindings\n\nSingle source of truth: `crates/huddle/src/keybindings.rs`. The Help\nmodal (`?`) renders the same table at runtime, so it can never drift\nfrom the actual key map.\n\n### Global (any pane, no modal open)\n| Key                | Action                                  |\n|--------------------|-----------------------------------------|\n| `?`                | Help                                    |\n| `:` or `Ctrl+P`    | Command palette — fuzzy search every action |\n| `Ctrl+H`           | Notification history (last 100 status events) |\n| `Shift+←` / `Shift+→`| Focus sidebar / pane (tmux-style)     |\n| `Esc`              | Close modal / blur input / focus sidebar |\n| `q` / `Ctrl+C`     | Quit (confirms first)                   |\n\n\u003e **About the focus-jump binding (huddle 0.7.3+):** `Shift+←` /\n\u003e `Shift+→` toggle keyboard focus between the sidebar and the pane,\n\u003e including while typing in chat input. Shift+arrows are unclaimed at\n\u003e OS and terminal level on macOS, Linux, and Windows — no Mission\n\u003e Control / Spaces conflict. (0.7.2 briefly used `Ctrl+←` / `Ctrl+→`\n\u003e but those collide with macOS's Move-between-Spaces shortcut.)\n\n### Sidebar / non-chat panes\n| Key                | Action                                  |\n|--------------------|-----------------------------------------|\n| `m`                | Start a DM (Compose-DM modal)           |\n| `g`                | Start a group room                      |\n| `p`                | Jump to the People pane                 |\n| `,`                | Jump to the Settings pane               |\n| `a`                | Add friend by HD ID or username         |\n| `d`                | Dial a peer by multiaddr or `ip:port`   |\n| `i`                | Show your identity as a QR code         |\n| `Shift+I`          | Generate an invite link (peer-only, or room-scoped from a chat pane) |\n| `v`                | Paste an invite link (`huddle://invite#…`) |\n| `c`                | Join with code (when an encrypted group is selected) |\n| `j` / `k` / arrows | Move sidebar cursor                     |\n| `Tab` / `Shift+Tab`| Jump to next / prev sidebar section     |\n| `Space` / `→` / `←`| Toggle section expand                   |\n| `Enter`            | Open the selected row                   |\n| `r`                | Refresh / reconnect (context-sensitive) |\n| `x`                | Forget the selected peer                |\n| `R` (Shift+r)      | Mark every room read                    |\n\n### Chat pane (DM or Group)\n| Key                       | Action                                |\n|---------------------------|---------------------------------------|\n| `/`                       | Focus input                           |\n| `Enter`                   | Send                                  |\n| `Alt+Enter` / `Ctrl+J`    | Newline in input                      |\n| `Esc`                     | Blur input (or focus sidebar)         |\n| `Ctrl+V`                  | Verify partner / member (SAS)         |\n| `Ctrl+F`                  | Search this room's history            |\n| `Ctrl+A`                  | Attach a file                         |\n| `Ctrl+L`                  | Leave the room                        |\n| `j` / `k`                 | Scroll messages (input blurred)       |\n| `g` / `G`                 | Scroll to top / bottom                |\n| `PageUp` / `PageDown`     | Scroll a page                         |\n| `f`                       | Focus file cards (`j/k` steps)        |\n\n### Group pane only\n| Key                       | Action                                |\n|---------------------------|---------------------------------------|\n| `Ctrl+I`                  | Toggle the right-margin member list   |\n| `Ctrl+K`                  | Kick a member (owners only)           |\n| `Ctrl+G`                  | Grant owner role (owners only)        |\n| `Ctrl+R`                  | Rotate the room key (owners only)     |\n| `Ctrl+J`                  | Generate a single-use join code (owners) |\n| `Ctrl+M`                  | Mute / unmute this room               |\n| `Ctrl+O`                  | Per-room verified-only-join toggle    |\n| `Shift+B`                 | List bans for this room (owners)      |\n\n### Settings pane (or Settings modal)\n| Key | Action                                                   |\n|-----|----------------------------------------------------------|\n| `V` | Toggle \"reject inbound from unverified\"                  |\n| `U` | Toggle the crates.io update check (opt-in)               |\n| `E` | Edit your username                                       |\n| `W` | Replay onboarding (what's new)                           |\n| `B` | Manage blocked peers                                     |\n| `Alt+Shift+1` (Option+Shift+1 on macOS) | Delete account (go dark) — passphrase-gated |\n\n## Username \u0026 ID display (huddle 0.5)\n\nEvery peer has a 96-bit fingerprint rendered as a branded\n`HD-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX` ID. Same security as before, just a\nfriendlier format. The Profile pane (sidebar's top section) shows yours.\n\nSet an optional username from the Profile or Settings pane (`E`). The username is\nbroadcast in a *signed* `ProfileUpdate` event — peers receiving it\nverify the Ed25519 signature against the claimed fingerprint, so\nnobody can spoof \"alice\" by stuffing a string into a packet. If you\nclear the field (empty input), you broadcast as `[anonymous]`.\n\nIn chat, your message label shows the username (or `[anonymous]`).\nSAS-verified peers also get a green `✓` next to their name in chat,\nmatching the existing badge in the room member list.\n\n## Add friend by HD ID or username (huddle 0.5.1+)\n\nPress `a` from the sidebar to open the add-friend modal. Takes either:\n\n- an `HD-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX` ID (or the bare 24-hex form\n  with/without dashes — normalized internally),\n- or a username string (unique-match lookup in `peer_profiles`).\n\nResolution: huddle looks the fingerprint up across recent room\nannouncements (`creator_fingerprint` + `host_addrs`) and the persisted\n`known_peers` table. Every candidate multiaddr is then handed to libp2p\nas a single `DialOpts::peer_id().addresses()` call — the swarm **races\nthem in parallel** (huddle 0.5.2+) and the first to complete wins. The\nclient also pre-sorts by transport preference (RFC1918 LAN ip4 →\nloopback → public ip4 → ip6 / dns → `/p2p-circuit`) so when latencies\nare close the LAN slot starts first. mDNS-discovered peers don't need\nthis path at all — they show up in the sidebar's People section\nautomatically.\n\nThe privacy trade-off worth knowing about: this works only for peers\nyou've **already seen on a shared gossipsub mesh** — same LAN, a relay\nyou both connect to, or a prior dial. There's deliberately no central\n\"add by ID\" directory; cold-start strangers must pass an invite link\nout-of-band first. Adding a directory (DHT, rendezvous server, central\nservice) would either centralize the architecture or leak lookup\nmetadata to bootstrap nodes — both fail the \"trusted relay, absolute\nprivacy\" goal huddle's built around.\n\n## SAS verification\n\nBoth peers select each other in the Verify modal (`^V`), one presses\n`s` to start. Each generates an ephemeral X25519 keypair, exchanges\npubkeys via signed envelopes, and derives a shared secret via ECDH.\nHKDF produces a Matrix MSC 2241-aligned 7-symbol + three-4-digit-group\ndecimal code. The TUI shows the symbols as their **English words**\n(dog, cat, lion, … — emoji-free) plus the decimal; both peers compare\nOOB (call/SMS/in-person) and press\n`m` to match. A MITM substituting an ephemeral key gets a different\nSAS code on each side — the OOB comparison catches it.\n\nOn match, the partner's fingerprint is marked verified (per-room +\nglobal). With the global \"verified-only inbound\" toggle on (Settings\npane, `V`), unverified inbound dials auto-reject without prompting.\n\n## Invite links\n\nPress `Shift+I` to generate an invite. From a chat pane the invite\nincludes the current room; from anywhere else it's peer-only. The TUI\nshows a `huddle://invite#\u003cbase64-JSON\u003e` URL plus a QR. The base64 JSON\ncarries the host multiaddr (with `/p2p/\u003cpeer-id\u003e` so libp2p enforces\nthe peer-id check on dial), the human-display fingerprint, and an\noptional room summary.\n\nPaste an invite from the sidebar with `v`. The TUI confirms the\nclaimed fingerprint and dials. After dial, the post-dial\nfingerprint check (added in 0.3.x) re-derives the peer's fingerprint\nfrom their Ed25519 pubkey on Identify and disconnects if it doesn't\nmatch the invite's claim — defense in depth, since libp2p's\n`/p2p/\u003cpeer-id\u003e` already enforces the cryptographic match.\n\nIf the invite includes an encrypted room, you're prompted for the\npassphrase next.\n\n## Owners, kick, ban\n\nThe room's creator is the first owner; owners can grant the role to\nothers (`Ctrl+G`) or kick (`Ctrl+K`). Kick = signed `BanMember` broadcast +\nimmediate `RotateRoomKey` with a freshly-generated passphrase\n(displayed to the owner for OOB re-share with the remaining members).\n\nThe banned peer still receives gossipsub bytes but can't decrypt the\nnew outbound session key. Honest peers honour the ban (drop their\nmessages); cryptographic enforcement is the key rotation, not the\nban row itself. **Soft owner model — kick is not a hard network\nquarantine.**\n\n`B` (Shift+b) lists the bans for the current room.\n\n## Internet reach\n\nBy default huddle uses LAN mDNS only. To accept dials across the\ninternet, register with a Circuit Relay v2 host:\n\n```bash\nhuddle --relay /dns4/relay.example.com/tcp/4001/p2p/12D3Koo...\n```\n\n…or persist in `config.toml`:\n\n```toml\n# macOS:  ~/Library/Application Support/huddle/config.toml\n# Linux:  ~/.config/huddle/config.toml\n# Windows: %APPDATA%\\huddle\\config.toml\nrelays = [\n  \"/dns4/relay.example.com/tcp/4001/p2p/12D3Koo...\",\n]\n```\n\nCLI flags override the config file. No relays are configured by\ndefault — you pick one explicitly. AutoNAT v2 probes test your\nreachability against the connected peer pool; DCUtR attempts a\nhole-punch upgrade to a direct connection whenever a relayed\nconnection forms. When libp2p is enabled the Profile pane / sidebar\nbadge shows the current state (`reachable` / `LAN only` / `detecting…`).\n\nRoom announcements optionally carry a `host_addrs` field with up to 4\nof the announcer's reachable addresses (relay-circuit and\nAutoNAT-confirmed external). Peers receiving an announcement they\nhave no direct connection for will opportunistically dial the first\nlisted address (rate-limited per announcer). This lets cross-internet\npeers bootstrap without invite links.\n\n## Join codes (read-only joiners)\n\nOwners press `Ctrl+J` in a Group pane to generate a single-use,\n10-minute `XXXX-XXXX` code. The owner shares it OOB. The joiner\nselects the encrypted group in the sidebar and presses `c` to enter\nthe code. The joiner's TUI generates an ephemeral X25519 keypair,\nbroadcasts a signed `CodeJoinRequest`, and waits for the owner's\n`CodeJoinResponse` (which wraps the room's session key under an\nECDH-derived key). If no response arrives within 30 s, the TUI\nsurfaces a timeout error — usually meaning the code was wrong or\nexpired.\n\nCode-joined members are **read-only**: they can read and send, but\nwithout the passphrase they can't wrap session keys for newer\njoiners. The Group pane header renders `(read-only)` next to the\nencryption marker. To upgrade, an owner can re-onboard them with the\nactual passphrase.\n\n## Go dark — irreversible account deletion (huddle 0.5)\n\nPress `Alt+Shift+1` (the Option key on macOS — same physical key) from\nanywhere — or use the labeled row on the Settings pane — to open the\n**go dark** modal. Single-field gate (huddle 0.7.6+):\n\n- If you have a **master passphrase**, that's the gate — re-derived\n  via Argon2id and constant-time compared to the in-memory SQLCipher\n  subkey. Wrong passphrase clears the field and shows an inline error.\n- In `--no-master-passphrase` sessions (no key to compare against),\n  type the literal phrase `DELETE EVERYTHING` (case sensitive) instead.\n\nOn confirm, huddle:\n\n- best-effort `MemberLeave`s every joined room (2-second cap so a\n  flapping transport can't hang the wipe),\n- shuts down the network task,\n- zeroes-then-deletes `huddle.db`, `huddle.db-shm`, `huddle.db-wal`,\n  `keychain.salt`, `huddle.log` (and any rotated logs), and\n  `config.toml` from the data dir,\n- removes the now-empty data dir, and\n- shows a brief goodbye modal before exiting.\n\nThere is no recovery. Restarting huddle after a go-dark generates a\nfresh identity from scratch.\n\n## Architecture\n\n```\nhuddle/\n  huddle-core    library: rooms, crypto, network, storage\n  huddle         terminal UI (the only frontend)\n```\n\n**Networking** — libp2p 0.56 with TCP+Noise+Yamux transport, mDNS for\nLAN discovery, gossipsub for both global room advertisement and\nper-room message broadcast, identify, ping, request-response,\nCircuit Relay v2 client, AutoNAT v2 (client + server), DCUtR. Mesh\ntopology — every member of a room receives every message; there's no\n\"host\" with special powers, and rooms survive the original creator\nleaving (as long as someone else is in them). The owner role is\nclient-enforced state, not a network-level privilege.\n\n**Encryption** — vodozemac Megolm group sessions (one outbound per\npeer). For group rooms entered via passphrase, you wrap your session\nkey with ChaCha20-Poly1305 under an Argon2id key derived from\n`(passphrase, salt)` and broadcast that for every existing member to\npick up. For group rooms entered via code, ECDH between owner and\njoiner gives a wrap key that delivers only the owner's session — the\njoiner's own outbound goes unwrapped. For DMs (huddle 0.7.1+), the\nwrap key comes from an Ed25519→X25519 ECDH between the two parties'\nidentity keys, expanded with HKDF-SHA256 bound to the canonical room\nID — both peers independently derive the same 32-byte wrap key.\n\n**App-level signing** — every protocol message whose authenticity\nmatters (`OwnerGrant`, `BanMember`, `RotateRoomKey`, SAS handshake,\n`CodeJoinRequest/Response`, `JoinRefused`) is wrapped in a\n`SignedRoomMessage` Ed25519 envelope. Receivers verify the signature,\nre-derive the fingerprint from the envelope's pubkey, and gate on\nboth `verified_signer.is_some()` and (where applicable) signer-is-owner.\n\n**Identity** — Ed25519 keypair stored under your platform's data\ndirectory. Fingerprint format: six groups of four hex chars\n(`a3b1-c2d4-e5f6-7890-1234-abcd`).\n\n**Storage** — SQLCipher (rusqlite + bundled SQLCipher + vendored\nOpenSSL). On launch you enter a master passphrase; it's stretched\nwith Argon2id (m=64 MiB, t=3, p=4) against a per-installation salt\nand used as `PRAGMA key`, plus an HKDF subkey replaces the older\nhardcoded Megolm persistence key. Tables include `identity`,\n`rooms` (with `kind` ∈ {`direct`, `group`}), `room_members` (with\n`role`, `ed25519_pubkey`), `room_megolm_sessions`, `room_messages`,\n`room_attachments`, `known_peers` (with `fingerprint`, `trusted`),\n`blocked_peers`, `room_bans`, `verified_peers`, `peer_profiles`\n(self-declared usernames, signed at the wire layer), `app_settings`.\nMigrations are additive only and tracked via `PRAGMA user_version`.\nPass `--no-master-passphrase` to fall back to an unencrypted database\nfor testing.\n\n**File attachments** — `Ctrl+A` opens a local file picker; selected\nfiles are SHA-256-hashed, chunked into 64 KiB pieces, and broadcast\nover the room's gossipsub topic with a `FileOffer` + N `FileChunk`\nmessages. In encrypted rooms (DM or group) the bytes are\nChaCha20-Poly1305-encrypted with a fresh file key that's\nMegolm-wrapped in the offer. Receivers see a focusable file card in\nchat — press `f` to enter card mode, `j/k` to step, Enter to save to\nyour platform's Downloads folder. Phase 2 cap is 1 MiB per file.\n\n## Operator notes\n\n- The first launch creates `\u003cdata_dir\u003e/keychain.salt`. Don't move or\n  delete it without your passphrase backed up — losing it forces a\n  re-derive that won't unlock the existing DB.\n- `--no-master-passphrase` opens an unencrypted DB. Testing only.\n- `--relay \u003cmultiaddr\u003e` (repeatable) registers a circuit-relay\n  reservation. The relay's identify response is the cue to start\n  listening on `\u003crelay\u003e/p2p-circuit`.\n- `--no-relay` ignores any relays in `config.toml` for this run.\n\n## Current limitations\n\n- LAN-only by default. Cross-network use needs a configured relay\n  (Phase D), an invite link with a public multiaddr, or a manual\n  `d` dial to a port-forwarded `ip:port`.\n- Code-joined members are read-only — they don't have the passphrase\n  and can't onboard further members.\n- Kick / ban are honest-client-enforced at the gossipsub layer; the\n  cryptographic teeth come from the key rotation that follows.\n- File transfer is capped at 1 MiB per file (Phase 2). Larger files\n  defer to a dedicated libp2p stream protocol (planned).\n- mDNS may not work on some corporate / restricted networks.\n- Verified-only inbound mode trusts SAS-verified + previously-trusted\n  fingerprints. Don't enable it before you've verified at least one\n  peer you can re-bootstrap from.\n- The SAS emoji table follows Matrix MSC 2241 for future cross-client\n  compatibility but is not yet interop-tested against any other client.\n- DM end-to-end encryption (huddle 0.7.1) re-derives the room wrap key\n  from both peers' long-term Ed25519 identity keys via X25519 ECDH —\n  it lacks forward secrecy at the room-key layer. A future identity-\n  key compromise unlocks historical DM session keys between those\n  two parties (Megolm message keys still ratchet, but the wrap key\n  doesn't). Per-DM ephemeral ratchets (Double Ratchet-style) are a\n  candidate follow-up.\n\n## What's new in 1.0.0 — one app, every network, every door\n\nA big one. huddle stops being \"relay-only OR libp2p-only\" and becomes a\nsingle E2EE core with both carriers on by default, a durable contact book,\nand a menu of anti-censorship transports onto the relay.\n\n- **LAN + relay on by default — no mode switch.** The startup mode now\n  resolves to libp2p mDNS (LAN) running *alongside* the relay; the\n  Settings LAN toggle picks mDNS vs direct on the next launch, and\n  `--mode` still overrides. Pure-relay (`--mode server`) and no-libp2p\n  setups are still available, but you no longer choose between \"nearby\"\n  and \"internet\" — you get both. LAN works even with Tor down.\n\n- **Contacts — a durable, fingerprint-keyed address book.** A new\n  `contacts` table keyed by the stable identity (not an ephemeral libp2p\n  multiaddr) is the link that lets two people keep chatting after they\n  leave the LAN: the relay routes by fingerprint/room, so the DM keeps\n  working. The People pane is now **Contacts**; `list_contacts()` joins\n  the book with derived username / verified / trusted / reachability.\n\n- **\"Add by HD-ID\" works over the internet.** Each client subscribes to a\n  private relay **inbox** (`inbox:\u003chash(fingerprint)\u003e` — the relay never\n  sees the raw fingerprint, and stores no contact graph). `a` sends a\n  signed `ContactRequest` there; the recipient sees it in the Contacts\n  pane's **Requests** tab and accepts to open a DM. An echo-back makes\n  both sides converge over the relay. **No `huddle-server` change** — it's\n  a pure client convention over the existing protocol.\n\n- **DMs persist across restarts.** Pre-1.0, DMs (always encrypted) were\n  parked as \"restorable\" on restart and silently dropped relay-delivered\n  messages until reopened. Now DMs re-activate automatically at startup\n  (their key derives from your identity + the partner's stored pubkey, no\n  passphrase), so a conversation keeps flowing.\n\n- **Transport \"doors\" onto the relay (anti-censorship).** `huddle\n  transports` lists every door with its privacy tradeoff and whether it's\n  usable: **onion via system Tor** (default, most private), **onion via a\n  private obfs4/WebTunnel bridge** (for blocked-Tor networks), **onion via\n  in-process Arti** (`--features arti`), and **clearnet `wss://` / `ws://`\n  to a raw IP** (fast; the relay sees your IP, content stays E2E). The app\n  tries them in a fallback order (most private first) or a pinned one\n  (`--transport`). One `huddle-server` can serve an onion **and** a\n  clearnet IP simultaneously — same rooms, same mailboxes. New flags:\n  `--clearnet-server`, `--transport`, `--transport-order`, `--tor-bridge`.\n\n- **Per-chat transport indicator.** Every DM/group header shows whether\n  it's currently reaching peers `via lan`, `via relay`, or is `offline` —\n  status only, no manual switch, so the security context is always\n  legible. Settings → Network lists the doors + marks the active one.\n\n- **Clearnet relay needs no domain.** A raw-IP `ws://\u003cip\u003e:\u003cport\u003e/ws` relay\n  works with zero extra setup (bind `huddle-server` to `0.0.0.0`, open the\n  port). `wss://` (TLS) adds transport encryption via a real cert (a free\n  subdomain + Caddy/Let's Encrypt, or Cloudflare Tunnel). This is the fast\n  lane a VPN user can take when they can't bootstrap Tor.\n\n## What's new in 0.7.12 — self-review follow-ups to the 0.7.11 audit pass\n\nA short follow-up after independent self-review of the 0.7.11 release\ncaught three issues:\n\n- **Notification focus-default trade-off.** 0.7.11 flipped the\n  \"haven't observed a focus event yet\" default from `false` (always\n  notify) to `true` (assume focused, suppress). That fixed the\n  audit's spam complaint for tmux-without-focus-events but caused\n  the opposite regression for the same cohort — they got zero\n  notifications instead of all of them. 0.7.12 splits the\n  difference: assume focused during a 5-second startup grace\n  window, then if no `FocusGained` / `FocusLost` has ever fired,\n  fall back to `false` (always notify). Terminals that DO speak\n  focus events behave normally throughout.\n- **`RelayReservationLost` was dead-wired.** 0.7.11 declared the\n  variant and a consumer in `app/mod.rs`, but libp2p 0.56's\n  `relay::client::Event` doesn't expose a `ReservationReqFailed`\n  arm we can match on, so the producer never emitted it. 0.7.12\n  removes the dead variant and consumer rather than ship code\n  that's silently unreachable. Reservation loss currently manifests\n  as the next AutoNAT probe flipping to \"private\" once the circuit\n  drops; a future health-check timer can re-introduce a dedicated\n  signal when libp2p's API supports it.\n- **SAS code incompatibility documented.** 0.7.11's rejection\n  sampler is correct, but it produces different emoji codes than\n  0.7.10's `mod 49` derivation in ~84% of pairings. A 0.7.11↔0.7.10\n  SAS verification will silently fail to match. This is a deliberate\n  break (the new derivation is uniformly distributed; the old one\n  wasn't), but it wasn't called out in the 0.7.11 notes. Both ends\n  need to be on 0.7.11+ for SAS to succeed.\n\n## What's new in 0.7.11 — security + UX hardening pass\n\nA wide audit pass on top of the 0.7.10 follow-up. The wire protocol,\nauthorization gates, panic surface, modal handling, notifier, storage,\nclipboard, and SAS derivation all got tightened. **Wire compat with\n0.7.10 and earlier is broken on purpose** — signed envelopes now carry\na timestamp and several previously-plain messages now require a\nsignature. The trade was deliberate: the 0.7.10 line had a few silent\nauthentication failures that the audit caught.\n\n### Wire protocol\n\n- `MemberLeave`, `MemberAnnounce`, and `FileOffer` must now arrive\n  inside a `SignedRoomMessage` whose signer matches the claimed\n  sender. Pre-0.7.11 these were plain, so any peer subscribed to a\n  room topic could spoof another member's leave (evicting them from\n  honest rosters) or pin a fabricated Ed25519 pubkey under a victim's\n  fingerprint via a TOFU race.\n- `SignedRoomMessage` gained a `signed_at_ms` field. The verifier\n  rejects envelopes outside a ±5 min window — closing the indefinite\n  replay of captured `BanMember` / `OwnerGrant` / `SasConfirm` /\n  `ProfileUpdate`. The timestamp is signature-bound.\n- Switched from `Ed25519::verify` to `verify_strict`, which rejects\n  low-order / mixed-order pubkeys.\n\n### Invite links\n\n- Bumped invite version to 2. v2 invites carry the creator's Ed25519\n  pubkey + an Ed25519 signature over the rest of the payload. Tampering\n  with `host_multiaddr`, `salt_b64`, `owner_fingerprints`, or any other\n  field is now detected before the receiver dials. v=1 invites still\n  decode (with a \"this invite is unsigned\" hint) so older shared links\n  keep working.\n\n### Authorization gaps\n\n- The ban filter now applies to **every** content-bearing arm\n  (`Plain`, `Encrypted`, `FileOffer`, `FileChunk`, `Typing`), not just\n  `MemberAnnounce`. Banned peers in unencrypted rooms used to keep\n  posting plaintext that honest clients rendered.\n- The outbound dial-then-auto-DM flow now consults the persistent\n  blocklist before opening a DM tab. Previously, dialing a blocked\n  peer's address still triggered `AutoOpenDm`.\n- `send_file` rejects read-only joiners (code-joined peers). Previously\n  the read-only gate only covered `send_room_message`.\n- The Direct-announcement auto-bootstrap rejects messages from blocked\n  peers before creating a DM row.\n\n### Panic prevention\n\n- `now_unix` returns 0 on a backwards clock instead of panicking. The\n  network task used to crash on every encrypt/decrypt when the wall\n  clock sat before 1970 (ARM SBCs without RTC, virt clones).\n- `wipe_file` writes zeros in a fixed 64 KiB scratch buffer rather\n  than allocating `vec![0u8; meta.len()]`. Go-dark used to OOM\n  mid-wipe when a user had downloaded a multi-GB attachment.\n- `bootstrap_direct_room` returns an error instead of `.expect()`-ing,\n  so a transient DB write failure can't take down the spawned task.\n- `cleanup_expired_pending_friend_requests` uses `saturating_sub` for\n  the cutoff so a `now \u003c TTL` clock doesn't match every row.\n\n### Critical UX\n\n- Settings → Privacy `c` opens a confirmation modal before wiping the\n  blocklist. Pre-0.7.11 it cleared everything instantly — one\n  keystroke from total data loss, and the same `c` opened the\n  join-code modal in the lobby so muscle memory was destructive.\n- Clipboard yank now runs on a dedicated OS thread with a 2 s\n  timeout. Previously, `xclip`/`wl-copy` with no display could hang\n  the entire TUI on a routine `y`.\n- File-chunk receiver caps per-chunk size (256 KiB), bounds\n  `chunk_index \u003c total_chunks`, and tracks `bytes_received` against\n  the advertised `expected_size`. Pre-0.7.11 a hostile peer could\n  advertise 1 MiB and stream multi-GB chunks before the SHA gate ran.\n- DM sidebar \"online\" dot now compares the partner's fingerprint to\n  `known_peers[i].fingerprint` instead of `.label`. Every DM showed\n  `○` offline even when the partner was connected.\n- The member-margin toggle is now bound to **Alt+M** (Ctrl+I was\n  unreachable — terminals deliver it as Tab). Hint bar and help\n  screen updated.\n- Activity pane `c` now clears the status history, matching the hint\n  text. Previously it fell through to `OpenJoinWithCode`.\n\n### Crypto correctness\n\n- SAS emoji derivation switched from `mod 49` (biased — indices 0..14\n  were twice as likely) to rejection sampling with HKDF re-expansion.\n  Restores the full uniform distribution over the 49^7 table.\n- Argon2id-derived passphrase keys returned in a `Zeroizing\u003c[u8; 32]\u003e`\n  wrapper so they don't linger on the heap after their last use.\n\n### Network resilience\n\n- `ConnectionClosed` now emits `PeerDisconnected` so the lobby's\n  \"online\" dots clear for relay / internet peers, not just mDNS\n  expiries. Also cleans gossipsub's explicit-peers set.\n- `RelayClient` events are no longer swallowed — reservation status\n  surfaces in the logs.\n- DCUtR failures cap at 6 warn-logs per peer so symmetric-NAT pairs\n  don't spam.\n\n### Modal + input\n\n- `Shift+?` now opens the \"what's new\" card from the sidebar (the\n  cheat sheet advertised this for a while; the handler was missing).\n- Inside the command palette, **Ctrl+N / Ctrl+P** navigate the result\n  list instead of typing literal `n`/`p` into the filter. Other Ctrl\n  chords inside the palette are dropped instead of corrupting the\n  query.\n- `Help` / `Info` / `QrIdentity` / `ShowJoinCode` / `ShowInvite` now\n  dismiss only on `Esc` / `Enter` / `q`. Pre-0.7.11 any unbound key\n  closed them — reflexive vim-`h` or `?` silently dismissed.\n- `Modal::Sas` no longer cancels on bare `c` or `q`. Common letters\n  when reading emoji words aloud used to abort the verification.\n- `Modal::AttachPicker` no longer ascends on bare `h` (typo hazard).\n- `Ctrl+C` only opens the quit-confirm modal when no modal is open.\n  Mid-typing a passphrase / username / GoDark confirmation, an\n  accidental Ctrl+C used to discard the typed buffer.\n- Settings tab digits 1-4 require pane focus, matching the 0.7.9\n  Tab/BackTab fix.\n- The Onboarding modal degrades gracefully on tiny terminals instead\n  of returning a zero-rect and silently disappearing.\n\n### Storage\n\n- Migrations now run inside `BEGIN; …; PRAGMA user_version = N;\n  COMMIT;` so a partial-batch failure rolls back cleanly. Pre-0.7.11\n  a mid-migration error left the schema half-applied with\n  `user_version` un-bumped and wedged every subsequent startup.\n- After `PRAGMA key`, we run `SELECT count(*) FROM sqlite_master` as\n  a sentinel. A wrong master passphrase now returns a clean\n  \"wrong master passphrase, or DB file corrupt\" instead of a cryptic\n  downstream `CREATE TABLE` error.\n\n### Notifier\n\n- macOS / Linux / Windows notifier paths strip control characters\n  from titles + bodies. Pre-0.7.11 a peer-controllable room name\n  with a literal CR broke the AppleScript invocation silently.\n- `notify-send` now passes `--category=im.received` for proper\n  app-grouping in GNOME Shell / KDE.\n- `is_focused()` defaults to `true` when no FocusChange event has\n  ever been observed. tmux without `set -g focus-events on` and\n  basic SSH shells no longer fire a desktop notification for every\n  message regardless of focus.\n\n### Polish + dead code\n\n- Removed dead `let r = app.active_room()` shadow, unused\n  `Theme.accent_dim`, unused `UnreadCounts::unread_count` /\n  `pending_count`. Build now warning-free at warn level.\n- Mention detection bumped from a 4-hex-char prefix to 8 hex chars,\n  cutting false positives from ~1/65 K to ~1/4 B per token and\n  closing the trivial \"include the victim's prefix to bell their\n  terminal\" weaponization.\n- SAS double-fire race fixed via a `finalized` latch on `SasFlow`.\n- Selected encrypted-room rows in the sidebar now preserve the\n  magenta lock-marker color instead of stomping it to selection-yellow.\n- Generate-join-code doc clarified: 31 chars / ~39.6 bits, not 32.\n\n## What's new in 0.7.10 — restore the Profile sidebar-nav gate\n\nA follow-up to 0.7.9. Dropping the pane-focus gate on Profile's\n`j/k/y` trapped sidebar navigation: when the cursor scrolled into the\nProfile sub-item, `sync_pane_from_selection` live-previewed the pane\n(intentional 0.7 design), and the ungated `j/k/y` handler then stole\nevery subsequent arrow/letter — so the cursor couldn't reach Direct,\nGroup, People, Activity, or Settings without `Shift+Tab`'ing past it.\n\n0.7.10 reinstates the `SidebarFocus::Pane` gate on `j/k/y`. Capital-\ncase `E` / `Q` chords stay ungated — they don't conflict with\nsidebar nav and the one-keystroke discovery flow is worth keeping.\n\nThe People analogy 0.7.9 cited turned out to be sharper than it\nlooked: People only captures `j/k` inside the Pending sub-tab, and\nreaching Pending requires `Tab`'ing into the pane first. Profile\nauto-switches on selection, so the equivalent gate is \"user has\nexplicitly `Shift+→`'d into the pane\".\n\n## What's new in 0.7.9 — keybinding-scope fixes from a self-audit\n\nA small follow-up patch from a self-review of the 0.7.8 release. No new\nfeatures; three keybinding bugs that the 0.7.8 ship-checklist missed:\n\n- **Tab in Settings no longer swallows the focus toggle.** In 0.7.8,\n  pressing Tab anywhere in `Pane::Settings` cycled tabs even when the\n  sidebar was focused, which silently disabled the universal \"Tab =\n  toggle sidebar↔pane focus\" gesture for users in Settings. 0.7.9 only\n  intercepts Tab / Shift+Tab for tab cycling when the pane itself is\n  focused. From sidebar focus, Tab now correctly moves focus into the\n  pane (one keystroke), then subsequent Tabs cycle.\n- **Profile j/k/y match People's pattern.** 0.7.8 required pane focus\n  for the Profile field cursor; People's analogous sublist nav has\n  always worked regardless of focus. 0.7.9 makes Profile consistent —\n  pane-active is enough to claim j/k/y, no separate focus gate.\n- **Dead `Action::OpenSettings` removed.** The `,` chord routes through\n  `JumpToSettingsPane` (which now resets to the Account tab). The\n  legacy `OpenSettings` variant was unreachable in 0.7.8; removed in\n  0.7.9 along with its dispatcher.\n\n## What's new in 0.7.8 — three connection paths, tabbed Settings, copyable identity\n\nA round of UX polish that borrows the right things from neighbouring apps\nwithout backsliding on huddle's privacy stance. Three discovery/connection\npaths now read as **co-equal parallel options** instead of \"mDNS first,\neverything else as fallback\", Settings became a tabbed pane that finally\nincludes the toggles that used to live in `config.toml`, the Profile pane\ncopies fields to the OS clipboard, and the People sidebar surfaces\npending friend-request counts where you can actually see them.\n\n- **Three connection paths, equally surfaced.** Welcome copy spells out\n  the trio: LAN (mDNS) · direct IP dial · invite link. The Settings →\n  Network tab shows the same three rows with their live status. A new\n  `M` toggle in Settings → Network lets you disable LAN broadcast\n  entirely for privacy — peers can still reach you over direct dial or\n  invite link with no LAN advertisement (restart-required to apply;\n  flipping a `Toggle\u003cMdns\u003e` mid-run would have required a behaviour\n  rebuild for negligible benefit).\n\n- **Tabbed Settings pane.** `Modal::Settings` is gone. Pressing `,`\n  lands you on `Pane::Settings` with four tabs cycled via Tab /\n  Shift+Tab or numeric jumps `1`–`4`:\n  - **Account** — username (`E`), HD-ID, derived **Safety Code**\n    (`SAFE-XXXX-XXXX-XXXX`), QR (`Q`), replay onboarding (`W`).\n  - **Network** — LAN mDNS toggle (`M`), reachability badge, listen\n    addresses, relay list from `config.toml`.\n  - **Appearance** — placeholder (single read-only `theme: dark` row;\n    light + high-contrast in a future release).\n  - **Privacy** — verified-only inbound (`V`), desktop notifications\n    (`N`), update check (`U`), blocked peers (`c` clears all), and\n    the Go Dark `Alt+Shift+1` chord.\n\n- **Copyable identity fields.** The Profile pane is now a cursor-\n  navigable list: `j`/`k` move, `y` copies the highlighted field to the\n  OS clipboard. Username, HD-ID, Safety Code, full fingerprint, and\n  every listen address each get their own yankable row. Clipboard\n  helper shells out to `pbcopy` (macOS) / `wl-copy` then `xclip` /\n  `clip.exe` (Windows) — no new crate dependency, failures degrade\n  to a status message instead of crashing.\n\n- **Sidebar density.** Direct messages and Group rooms each pin a\n  `+ Add Friend` / `+ New Group` row at the top so the action is a\n  cursor-and-Enter away, not a chord lookup. Pending friend requests\n  surface twice in the People section: as `N pending` next to the section\n  header, and as a dedicated row at the top of an expanded section\n  when there's at least one outstanding request.\n\n- **Notifications opt-out.** `Settings → Privacy → N` toggles the\n  OS-native toast notifications introduced in 0.7.4. Default ON;\n  turning it OFF skips both the per-message path and the startup\n  catch-up summary. Notifications remain 100% local — the toggle is\n  for users who don't want any signal leaving the terminal at all.\n\n- **No protocol changes.** Only new local rows in the existing\n  `app_settings` KV table (`mdns_enabled`, `notifications_enabled`).\n  Both default to ON so existing users see zero functional change\n  until they opt out.\n\n## What's new in 0.7.7 — friends, invites, and a fixed dial dead-end\n\nThree coordinated UX fixes around the \"first contact\" flow. Dialing a peer\nnow actually opens a chat instead of dead-ending at a connection, the\nPeople pane shows real usernames, friend requests survive longer than\n15 seconds, and inviting peers to a group no longer requires pasting a\nlink into Signal.\n\n- **Dial → DM auto-open.** When you initiate a dial (`d` IP:port, `a`\n  HD-ID, or paste-invite), the post-Identify handler now opens (or reuses)\n  a DM with the peer and switches your pane to the new `Dm(room_id)`. No\n  more \"connected to 192.168.1.5\" status with no way to chat. Auto-\n  reconnects and announcement-driven opportunistic dials do NOT trigger\n  this — only paths the user explicitly chose register an address in\n  `pending_auto_dm_addrs`.\n- **Usernames in Known peers.** The People pane's Known sublist now\n  renders each peer as `username · HD-XXXX-XXXX · address · last`,\n  pulling the username from the cached `peer_profiles` table. Falls back\n  to `[anonymous] · HD-pending` for peers we haven't yet seen a signed\n  `ProfileUpdate` from.\n- **Row actions actually fire.** The People pane header advertises\n  `m message · r reconnect · b block · x forget · u unblock`, but those\n  keystrokes were previously hitting the *global* handlers (e.g. `m`\n  opened an empty Compose-DM modal instead of DM'ing the selected peer).\n  Now they route to the selection-aware row actions. Tab cycles the\n  sub-tabs (Pending / Known / Verified / Blocked).\n- **Friend requests survive 3 days.** Previously an inbound dial modal\n  auto-rejected (with a `block_peer`!) after 15 seconds. Now the 15-second\n  timeout *spills the request* to a new `pending_friend_requests` table\n  and just disconnects the live socket; the user has up to 3 days to\n  Accept (re-dial + trust) or Reject (delete + block) from the People\n  pane's new \"Pending requests\" sublist. A startup sweep prunes rows\n  older than the TTL. The pane header shows `(N pending)` so a forgotten\n  request from yesterday is the first thing you see on landing.\n- **Invite picker — pick peers and they get the link auto-DM'd.** New\n  `Modal::InvitePicker` (Ctrl+I inside a group room; also reachable from\n  the `+ Add member` row pinned at the bottom of the member margin, and\n  from the command palette as `invite peers to room…`). Lists candidates\n  in three tiers — **Verified** (SAS-completed, safest), **DM partners**\n  (existing trust), **Known peers** (weakest) — with checkboxes, live\n  `/` filter, soft-cap of 20 selections per send. Enter sends: each\n  selected peer gets an idempotent DM (`start_direct`) containing the\n  same invite link `Shift+I` produces. `Shift+I` (OOB link copy) is\n  unchanged — the picker is purely additive for peers you already have\n  some trust relationship with.\n\nThe dial-then-DM auto-open is the load-bearing fix: huddle now behaves\nthe way a \"basic social app\" intuition expects — add someone, chat with\nthem, invite them places — without users needing to memorize the\nfingerprint resolution flow under Compose-DM.\n\n## What's new in 0.7.6 — Go Dark single-field flow\n\nA user report surfaced that the 0.5-era two-field Go Dark modal looked\nlike it \"didn't work\" even after typing `DELETE EVERYTHING`. Root cause\nwas UX, not logic: the modal required filling **both** a master\npassphrase field AND a typed `DELETE EVERYTHING` field, with `Tab` to\nswitch between them. Default focus was the passphrase, so typing\n`DELETE EVERYTHING` straight away put the phrase into the wrong field\nand the validation error rendered at the bottom of an already-tall red\nmodal — easy to miss.\n\n- **Single field, mode-aware.** Sessions with a master passphrase now\n  use the passphrase directly as the gate (the natural strong secret\n  the user already knows — Argon2id-derived, constant-time compared).\n  `--no-master-passphrase` sessions keep the typed `DELETE EVERYTHING`\n  phrase as their only available gate, since they have no key to\n  compare against.\n- **Loud error feedback.** Wrong attempts now render `✗ \u003creason\u003e` with\n  a bold red banner directly above the Enter/Esc hint bar, instead of\n  being buried at the bottom of the modal. The input field also clears\n  on failure so the next attempt starts fresh.\n- **No more `Tab`.** Removed `GoDarkNextField` action and the\n  `KeyCode::Tab` mapping inside the Go Dark modal arm — single field\n  means nothing to switch to.\n- **New accessor** `AppHandle::has_master_passphrase() -\u003e bool` so the\n  TUI can pick the right gate at modal-open time without leaking the\n  in-memory subkey.\n\n## What's new in 0.7.5 — notifier hardening\n\nSelf-review of 0.7.4 surfaced four follow-up items. All landed in 0.7.5:\n\n- **Conservative initial focus state.** 0.7.4 defaulted `focused = true`,\n  which suppressed notifications if huddle launched in a terminal that\n  was already in the background (no `FocusGained` event ever fired).\n  0.7.5 treats \"no focus event observed yet\" as **unfocused** — false\n  positives (one extra notification) only.\n- **Sliding catch-up grace.** The 5-second post-launch summary window\n  now extends by 2s on every inbound message during the window, capped\n  at a hard 30s ceiling from start. Slow gossipsub backlogs are\n  correctly batched into one summary instead of leaking into\n  per-message alerts.\n- **Notification rate-limit.** A 2-second cooldown coalesces bursts:\n  the first notification in a burst fires immediately with full\n  detail (room / sender / preview); within the next 2s, additional\n  notifications are counted and a single \"N more new messages\" summary\n  fires when the window closes. Prevents process / thread spam for\n  busy rooms.\n- **ASCII chord labels.** `⌥⇧1` keycap glyphs were dropped in favor\n  of `Alt+Shift+1` (and `Option+Shift+1 on macOS` callouts where it\n  helps) — fonts render the Unicode keycaps too inconsistently\n  across terminals. The Mac runtime behavior is unchanged (the\n  `⁄` glyph and the `ALT|SHIFT+!` event both still trigger Go Dark).\n\n## What's new in 0.7.4 — desktop notifications + safer go-dark chord\n\n- **Desktop notifications when the terminal isn't focused.** Every\n  inbound message fires a native notification (`osascript` on macOS,\n  `notify-send` on Linux, PowerShell BalloonTip on Windows — no extra\n  dependency) when crossterm reports the terminal as unfocused.\n  Notifications include the room name, sender display name, and a\n  trimmed message preview. When the terminal IS focused, no\n  notification is sent — the message is already on screen and the\n  unread badge does the work.\n- **Catch-up summary on startup.** When huddle reopens, messages\n  received during a 5-second catch-up window are batched into ONE\n  notification: `huddle · N new messages while you were away`. After\n  the window closes, real-time notifications kick in.\n- **Focus reporting via crossterm `EnableFocusChange`.** Supported by\n  iTerm2, Terminal.app, Alacritty, Kitty, wezterm, Windows Terminal,\n  and GNOME Terminal. On a terminal that doesn't emit\n  `FocusGained` / `FocusLost`, the app stays in \"focused = true\" mode\n  and never fires per-message notifications — graceful degradation.\n- **Go dark rebound to `Alt+Shift+1`** (Option+Shift+1 on macOS). Plain\n  `!` was just Shift+1 — one accidental keystroke could open the\n  destructive flow. The Mac chord works out of the box on Terminal.app\n  via the unicode glyph `⁄` that Option+Shift+1 produces, AND via the\n  `ALT|SHIFT+!` event that Alt-as-Meta terminals emit. On Linux/Windows\n  the same Alt+Shift+1 chord is uncontested.\n- **First-time macOS notification permission prompt.** macOS will ask\n  to allow Script Editor (or Terminal) to send notifications the first\n  time huddle fires one. Click Allow once and you're set.\n\n## What's new in 0.7.3 — UX polish round 2\n\n- **Focus-jump rebound to `Shift+←` / `Shift+→`.** 0.7.2's `Ctrl+←` /\n  `Ctrl+→` collided with macOS Mission Control's Move-between-Spaces\n  shortcut (and `Cmd+←` / `Cmd+→` is Terminal/iTerm2 tab-switching,\n  ruling that out too). Shift+arrows are unclaimed everywhere.\n- **Sidebar cursor is visible again.** The previous bg-only highlight\n  on the selected row used `Color::Rgb(40, 40, 60)` which is\n  near-indistinguishable from default terminal bg on Terminal.app.\n  Selected rows now recolor every span's foreground to **yellow**\n  (warn) when the sidebar is focused, dim text when not — readable\n  on every dark theme.\n- **2-col gutter between sidebar and pane.** Panes with `Borders::NONE`\n  (Welcome, Profile) used to render text flush against the sidebar\n  separator line. The outer layout now inserts a 2-column gap before\n  the pane rect, so every pane has visible breathing room.\n- **Settings pane keybindings actually fire.** The pane displays\n  `V verified-only / U update check / E username / W replay\n  onboarding / ! go dark` — but 0.7.0–0.7.2 only dispatched those\n  inside the Settings *modal*, leaving the pane rows inert. They now\n  fire from the pane itself.\n- **`!` (go dark) is global.** Previously only available from the\n  Settings modal; now reachable from any non-chat pane. The modal's\n  two-factor passphrase + \"DELETE EVERYTHING\" confirm protects\n  against accidental triggers.\n\n## What's new in 0.7.2 — UX polish\n\n- **`Ctrl+←` / `Ctrl+→` focus jump** between sidebar and pane (works\n  from any context, including while typing in chat input). One\n  keystroke instead of `Esc` → `Tab`. macOS users may need to disable\n  Mission Control's Move-left/right-a-space shortcut (System Settings\n  → Keyboard → Keyboard Shortcuts → Mission Control). When focus\n  jumps to a chat pane, the input is auto-activated so you can type\n  immediately.\n- **Settings pane padding fix.** The value column was jammed flush\n  against the label column when a label was exactly 24 chars wide\n  (`update check (crates.io)on` rendered with no gap). Labels now pad\n  to 28 chars, guaranteeing visible whitespace before every value.\n- **Sidebar focus border** continues to highlight which region owns\n  the keystrokes (already shipped in 0.7; surfaced more clearly with\n  the new focus-jump bindings).\n\n## What's new in 0.7.1 — E2E DMs\n\nDirect messages are now end-to-end encrypted on the room layer.\n\n- New `crate::crypto::dm::derive_dm_key` derives a 32-byte room key\n  from one side's Ed25519 secret seed and the other side's Ed25519\n  public key via X25519 ECDH + HKDF-SHA256.\n- `start_direct` creates DMs as `encrypted = true` with the\n  ECDH-derived key as the Megolm wrap key. The \"passphrase salt\"\n  slot stores the canonical room_id so re-bootstraps re-derive\n  identically.\n- When we don't yet have the partner's pubkey (e.g. fingerprint\n  resolved from a QR / invite / username), the room is created with\n  no wrap key. The next `MemberAnnounce` from the partner carries\n  their pubkey; we derive the key lazily, then re-broadcast our own\n  `MemberAnnounce` with the wrapped Megolm session key.\n- Backward compatibility: DMs created against pre-0.7.1 peers stay\n  in their original `encrypted=false` mode (the rooms table records\n  it). New 0.7.1+ DMs are always E2E.\n\n## What's new in 0.7 — TUI 2.0\n\n`0.7.0` rewrote the TUI around a **sidebar + pane** layout\n(Discord/Slack-style), with explicit separation of **Direct messages**\nfrom **Group rooms**. The legacy `Screen::{Lobby, InRoom}` flat-screen\nmodel and the tab-bar were retired.\n\nSee [TUI layout](#tui-layout) and [Key bindings](#key-bindings) for\nthe current state. Notable shipped items:\n\n- New `RoomKind::{Direct, Group}` persisted on the rooms table;\n  `RoomAnnouncement.kind` (serde-default for back-compat) tags every\n  wire announcement so 0.7 peers can split DMs from groups.\n- Canonical DM room IDs: `sha256(\"huddle-dm-v1\\0\" || min(fp_a, fp_b)\n  || \"\\0\" || max(fp_a, fp_b))` — both peers, regardless of who\n  presses `m` first, derive identical IDs. `start_direct` is\n  idempotent across both peers and reinstalls.\n- DM-visibility filter at honest 0.7+ consumers: Direct\n  announcements addressed to anyone else are dropped, so a DM never\n  leaks past the two participants' sidebars.\n- 2-member cap enforced locally on `RoomKind::Direct` rooms.\n- New panes: Profile, People (known + verified + blocked sublists),\n  Activity (status history + transfers), Settings (toggles, blocked\n  peers, go-dark).\n- New `Modal::ComposeDm` with inline autocomplete from\n  `known_peers` + `peer_profiles`; falls back to `AddFriend`\n  semantics on unrecognized input — no modal-on-modal.\n- Centralized `Theme` module so colors live in one place.\n\n**Retired in 0.7**: `Screen::{Lobby, InRoom}`, the tab-bar, numeric\n`1..9` tab jumps, `Ctrl+B` (back-to-lobby in chat — `Esc` focuses\nsidebar instead), `LobbyFocus` (replaced by `SidebarFocus`), the flat\n`discovered_rooms` list (now split into DM / Group sections).\n\n## What's new in 0.6 (UX overhaul)\n\n`0.6.0` is a focused UX release. The protocol surface didn't change;\nthe TUI did.\n\n- **Command palette** (`:` or `Ctrl+P`) — fuzzy-search every action.\n  Drives discoverability without bloating the visible chrome. You no\n  longer need to remember `a/d/i/,/c/I/v/!/u/o/^J/^I/^K/^G/^V` to find\n  things.\n- **Notification history** (`Ctrl+H`) — the last 100 status-bar\n  messages, scrollable, with timestamps. Replaces the \"goldfish\"\n  status bar where two events in quick succession overwrote each\n  other.\n- **Help is now generated from `input.rs`** — every keybinding is\n  documented, scroll with `j/k`. Help is sectioned by context\n  (Lobby / In a room / Card focus / etc.) and can never drift from\n  the actual key map again.\n- **Onboarding versioning** — the welcome card now re-fires only the\n  \"what's new in X.Y\" page when you upgrade between versions. You can\n  also replay it any time from `Settings → w`.\n- **Pending-modal indicator** — when an async event (inbound dial,\n  rotation, error) arrives behind another modal, the status bar shows\n  `[N pending · Ctrl+H to view]` so it never silently disappears.\n  Queue is FIFO and capped at 16.\n- **Adaptive hint bar** — the bottom-of-screen hints rotate based on\n  what's most likely to be useful next (empty lobby surfaces \"add\n  friend\"; unread tab surfaces \"join\"; etc.).\n- **Lobby header polish** — `huddle 0.6.0` version anchor, clock,\n  live peer counter alongside the NAT reachability badge.\n- **Scroll indicator + day separators in chat** — the message pane\n  shows `N/M · live` (or `N/M · ↑ K above`) at the bottom border, and\n  date dividers (`─── 2026-05-15 ───`) appear when conversations span\n  days.\n- **Unread counts in tabs** — `[2] room-name (3)` shows the actual\n  count instead of a vague `*`. `R` (shift-r) in the lobby zeros every\n  tab at once.\n- **Opt-in update detection** — a tiny ureq-backed background task\n  pings `https://crates.io/api/v1/crates/huddle` once per 24 h. If a\n  newer version exists, a banner appears under the lobby header. OFF\n  by default; toggle via `Settings → U` or the command palette.\n- **`huddle doctor` CLI** — `huddle doctor` prints version, data\n  paths, file sizes, and config without touching the network or\n  asking for the master passphrase. Paste it into bug reports.\n\n## Testing\n\n```bash\ncargo test --workspace -- --test-threads=1\n```\n\n`--test-threads=1` keeps the mDNS-based integration tests from\nfighting each other on a single host. The suite covers two-node\nplain + encrypted round-trip, Phase A inbound-dial accept and reject,\nPhase B kick-and-rotate (3-node), and Phase F code-join. See\n`MANUAL_TESTING.md` for the two-machine checklist.\n\n## Data directory\n\n- **macOS:** `~/Library/Application Support/huddle/`\n- **Linux:** `~/.local/share/huddle/`\n- **Windows:** `%APPDATA%\\huddle\\`\n\n## License\n\nLicensed under either of\n\n- Apache License, Version 2.0 ([`LICENSE-APACHE`](LICENSE-APACHE) or\n  \u003chttp://www.apache.org/licenses/LICENSE-2.0\u003e)\n- MIT license ([`LICENSE-MIT`](LICENSE-MIT) or\n  \u003chttp://opensource.org/licenses/MIT\u003e)\n\nat your option.\n\n### Contribution\n\nUnless you explicitly state otherwise, any contribution intentionally\nsubmitted for inclusion in the work by you, as defined in the Apache-2.0\nlicense, shall be dual licensed as above, without any additional terms or\nconditions.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fricher-richard%2Fhuddle","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fricher-richard%2Fhuddle","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fricher-richard%2Fhuddle/lists"}