# HexDroid `+AGM` wire format Version 1. AES-256-GCM end-to-end encryption for IRC messages. This document is the authoritative reference for the `+AGM` scheme. Any client that wants to interoperate with HexDroid AGM-encrypted channels needs to implement this exactly. ## Goal Encrypted message content over IRC's PRIVMSG/NOTICE primitives, with: Authentication (a tampered message is rejected, not silently corrupted) Replay protection across channels (a ciphertext for `#a` won't decrypt in `#b`) Per-message nonces (no IV reuse across messages with the same key) Pre-shared key (no key exchange in v1; users share keys out of band) This is **not** intended to compete with Signal or any other E2EE apps. It's a pragmatic upgrade path from FiSH/Blowfish for IRC users who want better authenticated encryption without changing the IRC protocol. ## Wire format ``` +AGM ``` Where: | Field | Size | Description | | ------------ | ---------- | ---------------------------------------------------- | | `version` | 1 byte | Format version. `0x01` for this spec. | | `nonce` | 12 bytes | Random per message, from a CSPRNG. | | `ciphertext` | N bytes | AES-256-GCM of the UTF-8-encoded plaintext. | | `tag` | 16 bytes | GCM authentication tag. | The base64 alphabet is the standard RFC 4648 alphabet. Padding (`=`) is optional, both padded and unpadded encodings must be accepted on receive. Senders MAY emit either; the reference implementations emit unpadded. A space separates the `+AGM` literal from the base64 blob. Receivers MUST require exactly one space and MUST reject any other separator. ## Cipher parameters **Algorithm:** AES-256-GCM (`AES/GCM/NoPadding` in Java/Kotlin terms) **Key length:** 32 bytes (256 bits) **Nonce length:** 12 bytes (96 bits, GCM standard) **Tag length:** 16 bytes (128 bits) ## Additional Authenticated Data (AAD) The lowercase UTF-8 encoding of the target name (channel or nick) is the AAD: ```python aad = target.lower().encode('utf-8') ``` For channel messages, `target` is the channel name (e.g. `#secret`). For query (private) messages, `target` is: The **recipient's** nick when encrypting (sender's view). The **sender's** nick when decrypting (receiver's view). This asymmetry binds the ciphertext to the conversation rather than to either party in isolation, so a ciphertext from user1 to user2 can't be re-used by an attacker to impersonate user2 to user1 (the AAD would differ). ## Key material Keys are 32 bytes of cryptographically random data. Implementations MUST NOT derive keys from user passphrases without an explicit, salted KDF. the v1 scheme assumes high-entropy random key material from the start. Key distribution is out of scope for this spec. The reference implementations support: Manual copy/paste (base64-encoded key) Share-sheet handoff (e.g. via Signal, SMS) ## Safety numbers (fingerprints) For out-of-band verification users compute a fingerprint: ``` sha256(scheme_byte || raw_key)[:5] ``` `scheme_byte` is `0x00` for AGM. The 40-bit truncation is base32-encoded with the Crockford alphabet (no `0/1/I/O`) into 8 characters, with a hyphen between the first and second halves: `K4XR-T9BS`. Both endpoints display this fingerprint. Users compare them out of band (over Signal, in person, etc.) to detect MITM at setup time. ## Multi-line and chunking A single AGM payload corresponds to one IRC line. Messages longer than the IRC line-length limit MUST be split before encryption, with each chunk encoded as its own `+AGM` line with its own nonce. Receivers reassemble at the plaintext layer if they want to; the protocol does not specify a multi-line batch mechanism. The IRC line-length budget after base64 overhead is approximately: ``` plaintext_max ≈ (max_payload_bytes − 5 − 4) × 3/4 − 13 ≈ 354 bytes (UTF-8) for the typical 400-byte PRIVMSG budget ``` (5 bytes for `+AGM `, ~4 bytes for base64 rounding, 3/4 for base64 inflation, 13 for version+nonce header.) ## CTCP framing CTCP messages (the `\x01CMD args\x01` envelope) are encrypted as follows: - The CTCP framing bytes (`\x01`, the command name, the space, the trailing `\x01`) stay **cleartext**. - The argument string after the command is encrypted as a `+AGM` payload. So `/me waves` arriving at the wire as `PRIVMSG #foo :\x01ACTION waves\x01` becomes `PRIVMSG #foo :\x01ACTION +AGM \x01`. This is so non-AGM clients still see a well-formed CTCP and can render it as "`* nick`" with garbled-but-readable text rather than producing a malformed CTCP error. The CTCP command name remains a fingerprint to attackers (you can see "this person sent an ACTION") but the content is hidden. CTCP queries with no arguments (`\x01VERSION\x01`) pass through untouched since there is no content to protect. ## Failure handling | Failure mode | Receiver behaviour | | ---------------------------------- | --------------------------------------------------- | | Wrong key (tag mismatch) | Display the raw `+AGM ...` line with a tamper hint | | Wrong scheme version | Same as above | | Malformed base64 | Same as above | | Truncated payload | Same as above | | Cross-channel replay (AAD mismatch)| Same as above (tag fails) | | No key configured for target | Pass through, render the `+AGM ...` line verbatim | Receivers MUST NOT decrypt under a key from a different target as a fallback (e.g. trying the `#foo` key on a message that arrived in `#bar`). That would break the cross-channel replay protection. ## Threat model AGM v1 protects: - Message content confidentiality against passive eavesdroppers (IRC ops, ISPs, bouncer operators if the IRC connection is TLS-terminated at the client). - Integrity (a tampered message is rejected, not silently mangled). - Cross-channel replay. A v2 scheme (`+AGE`) is planned to add per-conversation X25519 key exchange and a double-ratchet for forward secrecy / post-compromise security. v1 intentionally ships first because it's a strict improvement over FiSH without protocol complexity. ## Reference implementations **HexDroid**: `app/src/main/java/com/boxlabs/hexdroid/crypto/AesGcmCipher.kt` **HexChat plugin**: `aes-client-plugins/hexchat/hexdroid_agm.py` in the HexDroid repo.