Cryptographic operations, handshake protocol, and key management.
This page provides comprehensive documentation on ascii-chat's cryptographic implementation, including threat models, algorithms, protocols, security considerations, and known vulnerabilities.
Overview
ascii-chat implements end-to-end encryption by default using modern cryptographic primitives from libsodium. All data packets (headers and payloads) are encrypted after the initial handshake, protecting against eavesdropping and tampering.
Key Features
- Encrypted by default - No configuration required
- Modern crypto - X25519, XSalsa20-Poly1305, Argon2id
- SSH/GPG key integration - Use existing Ed25519 keys
- SSH agent support - Auto-adds keys, eliminates password prompts
- GPG agent support - Use gpg-agent for signing, no passphrase prompts
- GitHub/GitLab integration - Fetch public keys automatically
- Password protection - Optional shared password authentication
- Client whitelisting - Server-side access control
- Known hosts - SSH-style server verification
- Forward secrecy - Ephemeral key exchange per connection
- Dynamic algorithm negotiation - Future-proof crypto with post-quantum support
Non-Goals
β Not a replacement for TLS/HTTPS - Different trust model β Not anonymous - Focus is encryption, not anonymity β Not quantum-resistant - Uses elliptic curve cryptography (X25519) - Post-quantum support planned via dynamic algorithm negotiation
Philosophy & Threat Model
The MITM Problem
ascii-chat faces a fundamental cryptographic challenge: there is no pre-existing trust infrastructure like the Certificate Authority (CA) system used by HTTPS. This creates a security tradeoff:
Without verification:
- β
Privacy: Encrypted against passive eavesdropping (ISP, WiFi admin, etc.)
- β Security: Vulnerable to active Man-in-the-Middle (MITM) attacks
With verification:
- β
Privacy: Encrypted against passive eavesdropping
- β
Security: Protected against MITM attacks via key pinning
Default Behavior: Privacy Without Trust
By default, ascii-chat provides privacy but not authentication:
# Server (ephemeral key generated)
./ascii-chat server
# Client (ephemeral key generated, no verification)
./ascii-chat client
Why this default?
- No certificate infrastructure - Unlike HTTPS, there's no global CA system
- Ease of use - Works immediately without configuration
- Better than nothing - Protects against passive attacks (most common threat)
- User choice - Advanced users can add verification with
--server-key
This is similar to Bluetooth pairing or Signal safety numbers - the first connection is vulnerable, but subsequent connections can be verified.
Trust Models Supported
| Mode | Trust Mechanism | MITM Protection | Use Case |
| Default | None (ephemeral DH) | β | Quick sessions, low-threat environments |
| Password | Shared secret | β
| Friends exchanging password out-of-band |
| SSH Keys | Key pinning | β
| Tech users with existing SSH keys |
| GPG Keys | Key pinning + gpg-agent | β
| GPG users, no passphrase prompts |
| GitHub/GitLab | Social proof + keys | β
| Verify identity via public profiles |
| Known Hosts | First-use trust | β οΈ | Like SSH - detect key changes |
| Whitelist | Pre-approved keys | β
| Private servers, access control |
Comparison to Other Protocols
| Protocol | Default Encryption | Trust Model | Verification Difficulty |
| HTTPS | β
Always | CA system | Automatic (OS trust store) |
| SSH | β
Always | Known hosts | Manual (first connection prompts) |
| Signal | β
Always | Safety numbers | Manual (QR code scanning) |
| ascii-chat | β
Always | Ephemeral DH | Optional (–server-key flag) |
| Zoom | β
Sometimes | Central server | None (trust Zoom) |
Cryptographic Primitives
ascii-chat uses libsodium, a modern, portable, easy-to-use crypto library based on NaCl.
X25519 (Key Exchange)
- Algorithm: Elliptic Curve Diffie-Hellman (ECDH) on Curve25519
- Key Size: 32 bytes (256 bits)
- Purpose: Establish shared secret between client and server
- Properties: Forward secrecy, constant-time operations
- Function:
crypto_box_beforenm()
Why X25519?
- Fast: ~40,000 operations/second on modern CPUs
- Secure: No known practical attacks, constant-time implementation
- Small: 32-byte keys, 32-byte shared secrets
- Standard: RFC 7748, widely used (TLS 1.3, SSH, Signal)
XSalsa20-Poly1305 (Encryption)
- Algorithm: Stream cipher (XSalsa20) + MAC (Poly1305)
- Key Size: 32 bytes (256 bits)
- Nonce Size: 24 bytes (192 bits)
- MAC Size: 16 bytes (128 bits)
- Purpose: Encrypt packet data with authenticated encryption
- Function:
crypto_secretbox_easy()
Why XSalsa20-Poly1305?
- AEAD: Authenticated Encryption with Associated Data (prevents tampering)
- Fast: ~700 MB/s encryption on modern CPUs
- Nonce-misuse resistant: Large 192-bit nonce makes collisions astronomically unlikely
- Proven: Used in libsodium, NaCl, and many production systems
Encryption formula:
ciphertext = XSalsa20(key, nonce, plaintext) + Poly1305(key, ciphertext)
Argon2id (Password Hashing)
- Algorithm: Argon2id (hybrid Argon2i + Argon2d)
- Purpose: Derive encryption keys from passwords
- Function:
crypto_pwhash()
- Parameters:
- Memory: 64 MB (interactive limit)
- Operations: 2 (OPSLIMIT_INTERACTIVE)
- Parallelism: 1
- Password Requirements:
- Minimum: 8 characters
- Maximum: 256 characters
- Validation: Enforced on both client and server
Why Argon2id?
- Memory-hard: Resistant to GPU/ASIC brute-force attacks
- Modern: Winner of Password Hashing Competition (2015)
- Hybrid: Combines data-dependent (Argon2d) and data-independent (Argon2i) modes
- Tunable: Can increase difficulty as hardware improves
Ed25519 (Signatures - SSH Keys)
- Algorithm: EdDSA signatures on Edwards curve (Curve25519)
- Key Size: 32 bytes public key, 64 bytes private key (seed + public)
- Signature Size: 64 bytes
- Purpose: Authenticate clients with SSH keys
- Functions:
crypto_sign_detached(), crypto_sign_verify_detached()
Ed25519 to X25519 Conversion:
crypto_sign_ed25519_pk_to_curve25519(x25519_pk, ed25519_pk);
crypto_sign_ed25519_sk_to_curve25519(x25519_sk, ed25519_sk);
This allows using existing SSH Ed25519 keys for both signing (authentication) and key exchange (encryption).
bcrypt_pbkdf (SSH Key Decryption)
- Algorithm: bcrypt-based password-based key derivation function (OpenBSD)
- Purpose: Decrypt encrypted SSH Ed25519 private keys (OpenSSH format)
- Library: libsodium-bcrypt-pbkdf (GitHub: openssh/openssh-portable)
- Integration: cmake/LibsodiumBcryptPbkdf.cmake
Supported OpenSSH Key Encryption:
- Cipher: aes256-ctr, aes256-cbc (OpenSSH defaults for Ed25519 keys)
- KDF: bcrypt (bcrypt_pbkdf with configurable rounds, typically 16-24)
- Key Format: openssh-key-v1 (OpenSSH private key format)
Native Decryption Process:
uint8_t derived[48];
sodium_bcrypt_pbkdf(password, strlen(password), salt, salt_len,
derived, sizeof(derived), rounds);
const uint8_t *key = derived;
const uint8_t *derived_iv = derived + 32;
br_aes_ct_ctr_keys aes_ctx;
br_aes_ct_ctr_init(&aes_ctx, key, 32);
uint32_t initial_counter = ((uint32_t)derived_iv[12] << 24) |
((uint32_t)derived_iv[13] << 16) |
((uint32_t)derived_iv[14] << 8) |
((uint32_t)derived_iv[15]);
memcpy(decrypted, encrypted_blob, blob_len);
br_aes_ct_ctr_run(&aes_ctx, derived_iv, initial_counter, decrypted, blob_len);
Why bcrypt_pbkdf?
- Memory-hard: Resistant to GPU/FPGA brute-force attacks
- OpenSSH standard: Same algorithm as ssh-keygen uses for encryption
- Battle-tested: Used by OpenSSH since 2013 (OpenSSH 6.5+)
- Configurable: Adjustable rounds parameter (higher = slower = more secure)
The PBKDF Security Tradeoff:
PBKDF creates an asymmetric cost between legitimate users and attackers:
- Legitimate user: Pays the cost once per session (~200ms to load SSH key)
- Attacker: Pays the cost for every password guess (billions of attempts)
Without PBKDF (direct SHA256):
User experience: Instant key loading
Attacker speed: 60 billion passwords/second (GPU)
Weak password: Cracked in 0.016 seconds β οΈ
With bcrypt_pbkdf (rounds=16):
User experience: 200ms delay (barely noticeable)
Attacker speed: ~5 passwords/second (memory-hard slowdown)
Weak password: Cracked in 6.3 years β
Strong password: Effectively uncrackable (10^20+ years)
Keyspace vs. Compute Time:
A common observation: "Attackers get a smaller keyspace (passwords) instead of full AES-256 keyspace (2^256),
but they sacrifice compute time for it."
This is precisely correct. The tradeoff works as follows:
| Attack Strategy | Keyspace Size | Keys/Second | Time to Exhaust |
| Try random AES keys | 2^256 (~10^77) | 10^9 | 3.67 Γ 10^60 years (IMPOSSIBLE) |
| Try passwords (no PBKDF) | ~10^12 common | 60 Γ 10^9 | 16 minutes β οΈ |
| Try passwords (bcrypt r=16) | ~10^12 common | 5 | 6,300 years β
|
Key insight: The attacker chooses a drastically smaller keyspace (password space instead of full AES keyspace) to make the problem tractable, but PBKDF makes that small keyspace exponentially more expensive to search by adding computational cost to each attempt.
The result: Legitimate users pay ~200ms once, attackers pay years of compute time trying password guesses. This asymmetry is what makes password-based encryption practical and secure.
Tunable security (rounds parameter):
rounds=10: ~50ms delay, attacker: 50,000 guesses/sec (weak)
rounds=16: ~200ms delay, attacker: 5,000 guesses/sec (OpenSSH default)
rounds=20: ~800ms delay, attacker: 1,250 guesses/sec (stronger)
rounds=24: ~3.2sec delay, attacker: 312 guesses/sec (very strong)
OpenSSH chose rounds=16 as optimal: barely noticeable for users, devastating for attackers.
Why attackers can't skip PBKDF:
- The encrypted file contains salt + rounds (attacker knows the parameters)
- The AES key was derived using bcrypt_pbkdf (not a random key)
- Attacker must replicate the exact derivation to get the correct key
- Trying random 256-bit keys is hopeless (2^256 keyspace, would take 10^60 years)
- Only viable attack: guess passwords and run them through bcrypt_pbkdf
Salt prevents rainbow tables:
- Each encrypted key file has unique random salt (16 bytes)
- Same password + different salt = completely different derived key
- Attacker cannot pre-compute passwordβkey mappings
- Must derive keys fresh for each target file
Implementation verification:
- Byte-for-byte validation against ssh-keygen decryption output
- Supports same key formats as OpenSSH (aes256-ctr, aes256-cbc)
- No external tool dependencies (fully native C implementation)
See also:
- lib/crypto/keys/ssh_keys.c - Native decryption implementation
- cmake/LibsodiumBcryptPbkdf.cmake - Library integration
- deps/ascii-chat-deps/bearssl/ - BearSSL minimal TLS library (AES-CTR/CBC)
Randomness Source
CSPRNG: randombytes_buf() (libsodium)
- Implementation: Platform-specific secure random
- Linux/BSD:
/dev/urandom
- Windows:
CryptGenRandom() / BCryptGenRandom()
- macOS:
arc4random_buf()
- Properties: Cryptographically secure, non-blocking
Protocol Architecture
Packet Structure
All packets (encrypted and unencrypted) share a common header:
typedef struct {
uint32_t magic;
uint16_t type;
uint32_t length;
uint32_t crc32;
uint32_t client_id;
__attribute__((constructor))
Register fork handlers for common module.
Note: All multi-byte fields are in network byte order (big-endian).
Packet Types
Protocol Negotiation Packets (Always Unencrypted):
PACKET_TYPE_PROTOCOL_VERSION = 1
Crypto Handshake Packets (Always Unencrypted):
PACKET_TYPE_CRYPTO_CAPABILITIES = 14
PACKET_TYPE_CRYPTO_PARAMETERS = 15
PACKET_TYPE_CRYPTO_KEY_EXCHANGE_INIT = 16
PACKET_TYPE_CRYPTO_KEY_EXCHANGE_RESP = 17
PACKET_TYPE_CRYPTO_AUTH_CHALLENGE = 18
PACKET_TYPE_CRYPTO_AUTH_RESPONSE = 19
PACKET_TYPE_CRYPTO_AUTH_FAILED = 20
PACKET_TYPE_CRYPTO_SERVER_AUTH_RESP = 21
PACKET_TYPE_CRYPTO_HANDSHAKE_COMPLETE = 22
PACKET_TYPE_CRYPTO_NO_ENCRYPTION = 23
Encrypted Packets (After Handshake):
PACKET_TYPE_ENCRYPTED = 24
All application packets (video, audio, control) are wrapped in PACKET_TYPE_ENCRYPTED after successful handshake.
Dynamic Algorithm Negotiation: The CRYPTO_CAPABILITIES and CRYPTO_PARAMETERS packets enable future-proof algorithm selection:
- Client Capabilities: Declares supported algorithms (X25519, Ed25519, XSalsa20-Poly1305)
- Server Parameters: Selects algorithms and provides key sizes for dynamic handshake
- Future-Proof: Designed to support post-quantum algorithms (Kyber, Dilithium) when available
Why unencrypted? These packets establish the encryption keys - they cannot be encrypted with keys that don't exist yet. This is standard for all key exchange protocols (TLS, SSH, etc.).
Wire Format: Encrypted vs Unencrypted
Unencrypted Packet (Handshake):
[Header: 18 bytes][Payload: N bytes]
| |
magic=0xDEADBEEF type-specific data
type=14-21
length=N
crc32=checksum
client_id=0
Encrypted Packet (Post-Handshake):
[Outer Header: 18 bytes][Encrypted Blob]
| |
magic=0xDEADBEEF [Nonce: 24 bytes][Inner Header + Payload: N bytes][MAC: 16 bytes]
type=20 | | |
length=24+N+16 Random nonce Real packet data Poly1305 tag
crc32=0
client_id=0
Decryption process:
- Verify outer header (magic, type)
- Extract nonce (first 24 bytes of encrypted blob)
- Decrypt remaining blob with XSalsa20-Poly1305
- Parse inner header from plaintext
- Extract real payload
Result: An attacker sees:
- Outer packet magic (needed for framing)
- Fact that packet is encrypted
- Total encrypted size (nonce + ciphertext + MAC)
Hidden from attacker:
- Real packet type (video, audio, control?)
- Real payload size
- Client ID
- All payload data
Handshake Protocol
Sequence Diagram
Client Server
| |
|------ TCP Connect ------------------->|
| |
|<----- PROTOCOL_VERSION ----------------|
|------ PROTOCOL_VERSION --------------->|
| [version, encryption support] |
| |
|------ CRYPTO_CAPABILITIES ------------>|
| [supported algorithms] |
| [preferred algorithms] |
| |
|<----- CRYPTO_PARAMETERS ---------------|
| [selected algorithms + sizes] |
| |
| | Generate ephemeral keypair
| | (or use --key loaded SSH key)
| |
|<----- CRYPTO_KEY_EXCHANGE_INIT --------|
| [32-byte X25519 public key] |
| [32-byte Ed25519 identity key] | (if using SSH key)
| [64-byte Ed25519 signature] | (signature of X25519 key)
| |
| Verify signature (if present) |
| Check against --server-key (if set) |
| Check known_hosts (if exists) |
| Compute DH shared secret |
| |
|------ CRYPTO_KEY_EXCHANGE_RESP ------->|
| [32-byte X25519 public key] |
| [32-byte Ed25519 identity key] | (if using SSH key)
| [64-byte Ed25519 signature] | (signature of X25519 key)
| |
| | Verify signature (if present)
| | Compute DH shared secret
| |
|<----- CRYPTO_AUTH_CHALLENGE -----------|
| [32-byte random nonce] |
| [1-byte flags] | (password required? key required?)
| |
| Compute HMAC(shared_secret, nonce) |
| If password: HMAC(password_key, nonce) |
| |
|------ CRYPTO_AUTH_RESPONSE ----------->|
| [32-byte HMAC] |
| |
| | Verify HMAC
| | Check password (if required)
| | Check client key whitelist (
if enabled)
| |
|<----- CRYPTO_HANDSHAKE_COMPLETE -------|
| |
| β
Encryption active | β
Encryption active
| |
|<===== ENCRYPTED PACKETS ==============>|
| All future packets encrypted |
bool enabled
Is filtering active?
Handshake Phases Explained
Phase 0: Dynamic Algorithm Negotiation
**Client Capabilities (CRYPTO_CAPABILITIES):**
typedef struct {
uint16_t supported_kex_algorithms;
uint16_t supported_auth_algorithms;
uint16_t supported_cipher_algorithms;
uint8_t requires_verification;
uint8_t preferred_kex;
uint8_t preferred_auth;
uint8_t preferred_cipher;
} crypto_capabilities_packet_t;
**Server Parameters (CRYPTO_PARAMETERS):**
typedef struct {
uint8_t selected_kex;
uint8_t selected_auth;
uint8_t selected_cipher;
uint8_t verification_enabled;
uint16_t kex_public_key_size;
uint16_t auth_public_key_size;
uint16_t signature_size;
uint16_t shared_secret_size;
uint8_t nonce_size;
uint8_t mac_size;
uint8_t hmac_size;
} crypto_parameters_packet_t;
Current Algorithm Constants:
#define KEX_ALGO_X25519 0x01
#define AUTH_ALGO_ED25519 0x01
#define AUTH_ALGO_NONE 0x00
#define CIPHER_ALGO_XSALSA20_POLY1305 0x01
Future-Proofing Design:
- Algorithm negotiation enables post-quantum migration
- Hybrid mode support (classical + post-quantum)
- Backward compatibility with older clients
- Dynamic key sizes for different algorithms
Phase 1: Key Exchange Init (Server β Client)
Server sends:
typedef struct {
uint8_t ephemeral_public_key[32];
uint8_t identity_public_key[32];
uint8_t signature[64];
} key_exchange_init_packet_t;
Packet size:
- 32 bytes: Ephemeral mode (default)
- 128 bytes: Authenticated mode (
--key SSH key)
Client verifies:
- If
--server-key provided: Verify identity key matches expected key β ABORT if mismatch
- If signature present: Verify
signature is valid for ephemeral_public_key using identity_public_key
- Check known_hosts: If server:port in
~/.ascii-chat/known_hosts, verify identity key matches
- First connection: Prompt user to save to known_hosts
Security note: The signature binds the ephemeral key to the long-term identity key, preventing an attacker from replacing the DH key while keeping the identity key.
Phase 2: Key Exchange Response (Client β Server)
Client sends:
typedef struct {
uint8_t ephemeral_public_key[32];
uint8_t identity_public_key[32];
uint8_t signature[64];
} key_exchange_response_packet_t;
Server verifies:
- If
--client-keys provided: Check if identity_public_key is in whitelist β REJECT if not found
- If signature present: Verify signature is valid
- Compute shared secret:
shared_secret = X25519(server_private, client_public)
At this point both sides have:
- β
Ephemeral DH shared secret (32 bytes)
- β
Peer's identity public key (if authenticated mode)
Phase 3: Authentication Challenge (Server β Client)
Server sends:
typedef struct {
uint8_t nonce[32];
uint8_t flags;
} auth_challenge_packet_t;
Flags:
AUTH_REQUIRE_PASSWORD (0x01): Server has --password, client must prove knowledge
AUTH_REQUIRE_CLIENT_KEY (0x02): Server has --client-keys, client must be whitelisted
Client prepares response:
- If password set:
HMAC(password_key, nonce || shared_secret) using Argon2-derived key
- If no password:
HMAC(shared_secret, nonce || shared_secret) using DH shared secret
- If client has SSH key: Also sign nonce with Ed25519 identity key
Phase 4: Authentication Response (Client β Server)
Client sends:
typedef struct {
uint8_t hmac[32];
} auth_response_packet_t;
Server verifies:
- Recompute HMAC using same key (password_key or shared_secret) and same data (nonce || shared_secret)
- Constant-time compare with received HMAC β REJECT if mismatch
- If whitelist enabled: Verify client's identity key is in
--client-keys
Phase 5: Server Authentication Response (Server β Client)
Server sends:
typedef struct {
uint8_t hmac[32];
} server_auth_response_packet_t;
Client verifies:
- Recompute HMAC using shared_secret and client_nonce
- Constant-time compare with received HMAC β ABORT if mismatch
This provides mutual authentication - both sides prove knowledge of the shared secret.
Phase 6: Handshake Complete or Failed
Success:
PACKET_TYPE_HANDSHAKE_COMPLETE
Failure:
After HANDSHAKE_COMPLETE, both sides:
- β
Enable packet encryption using XSalsa20-Poly1305
- β
Use shared secret as encryption key
- β
Increment nonce counter for each packet
Keys Module
Design Principle: Separation of Identity and Encryption
Core architecture decision: SSH keys are ONLY used for authentication (identity proof), NEVER for encryption.
Why this matters:
- Forward Secrecy is Critical
- If your SSH key is compromised today, an attacker should NOT be able to decrypt conversations from last week
- Ephemeral keys provide this guarantee: each session has unique encryption keys that are destroyed after use
- Using long-term keys for encryption breaks forward secrecy (recorded traffic can be decrypted later)
- Consistency Across Environments
- SSH agent availability varies by system (Unix-only, requires configuration)
- Users should get identical security regardless of environment
- Matches SSH Protocol Design
- SSH itself uses this exact model: long-term host/user keys for identity, ephemeral DH keys for encryption
- Proven design with 30+ years of security analysis
- No need to reinvent the wheel
SSH Agent Integration
ascii-chat supports SSH agent for encrypted private keys, allowing password-free authentication when your SSH key is already loaded in the agent.
How it works:
When you provide an encrypted SSH key via --key, ascii-chat automatically checks if that specific key is available in the SSH agent:
# 1. Start SSH agent (if not already running)
eval "$(ssh-agent -s)"
# 2. Add your encrypted key to the agent (prompts for password ONCE)
ssh-add ~/.ssh/id_ed25519
Enter passphrase for /Users/you/.ssh/id_ed25519: [password]
Identity added: /Users/you/.ssh/id_ed25519
# 3. Start server or client using the key - uses ssh-agent; NO password prompt!
ascii-chat server --key ~/.ssh/id_ed25519
# or
ascii-chat client --key ~/.ssh/id_ed25519
INFO: Using SSH agent for this key (agent signing + ephemeral encryption)
INFO: SSH agent mode: Will use agent for identity signing, ephemeral X25519 for encryption
# There was no password prompt because the key was decrypted in the ssh-agent already.
If the key is not in the ssh-agent and ssh-agent is running, ascii-chat will prompt for the password and decrypt the key and store it in the ssh-agent securely for future use. This is done via ssh-agent's official protocol and is safe for production use - this is the intended usage of ssh-agent.
GitHub/GitLab Key Fetching
Fetch SSH keys from public profiles:
# Server: Use your GitHub SSH key
ascii-chat server --key github:zfogg
# Client: Verify server using GitHub profile
ascii-chat client --server-key github:zfogg
How it works:
- HTTPS fetch:
GET https://github.com/zfogg.keys (using BearSSL)
- Parse response: Extract
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... lines
- Filter: Only accept Ed25519 keys (RSA/ECDSA rejected)
- Use first key: If multiple Ed25519 keys, use the first one
Security properties:
- β
TLS-protected fetch (BearSSL verifies GitHub's certificate)
- β
Public keys are not secret (safe to fetch over network)
- β
Social proof: Attacker must compromise GitHub account
- β οΈ Trust GitHub's key infrastructure
GitLab support: Same mechanism, https://gitlab.com/username.keys
SSH Ed25519 Keys
ascii-chat can use existing SSH Ed25519 keys for authentication (identity proof via signatures):
# Server: Use SSH private key for identity
ascii-chat server --key ~/.ssh/id_ed25519
# Client: Verify server's SSH public key
ascii-chat client --server-key ~/.ssh/server_id_ed25519.pub
Key file formats supported:
- OpenSSH native format (
BEGIN OPENSSH PRIVATE KEY)
- OpenSSH public key format (
ssh-ed25519 AAAAC3...)
- Encrypted private keys (prompts for passphrase)
Ed25519 to X25519 conversion:
ascii-chat can convert Ed25519 keys to X25519 format for compatibility, but does NOT use the converted key for encryption:
crypto_sign_ed25519_pk_to_curve25519(x25519_pk, ed25519_pk);
crypto_sign_ed25519_sk_to_curve25519(x25519_sk, ed25519_sk);
Why this works: Both Ed25519 and X25519 use the same underlying curve (Curve25519). Ed25519 uses the Edwards form for signing, X25519 uses the Montgomery form for DH. libsodium provides safe conversion functions.
Security architecture:
- β
SSH keys used for: Identity authentication (Ed25519 signatures only)
- β
Ephemeral keys used for: Encryption (X25519 Diffie-Hellman)
- β
Result: Forward secrecy + strong authentication
The SSH key proves identity through signatures. The ephemeral keys provide encryption with forward secrecy. The signature binds them together cryptographically, preventing MITM attacks while maintaining forward secrecy.
How it works:
Handshake Protocol:
1. Server generates ephemeral X25519 keypair (random, per-connection)
2. Server signs ephemeral public key with long-term Ed25519 key
3. Server sends: [ephemeral_pk][identity_pk][signature]
4. Client verifies signature (proves server has identity key)
5. Client uses ephemeral_pk for encryption (forward secrecy)
The signature cryptographically binds the ephemeral encryption key to the long-term identity key. This provides:
- β
Strong authentication (server must possess identity private key)
- β
Forward secrecy (ephemeral keys are destroyed after session)
- β
MITM protection (signature prevents key substitution attacks)
Alternative considered and rejected:
Using Ed25519βX25519 conversion for encryption would be simpler code-wise, but:
- β Breaks forward secrecy (recorded traffic can be decrypted if key is compromised later)
- β Inconsistent security (SSH agent mode would still need ephemeral keys)
- β Deviates from SSH protocol best practices
Result: All modes use ephemeral encryption keys. SSH keys are authentication-only.
SSH Agent Details
Key detection algorithm:
if (is_encrypted) {
uint8_t embedded_public_key[32];
key_out->type = KEY_TYPE_ED25519;
key_out->use_ssh_agent = true;
memcpy(key_out->public_key, embedded_public_key, 32);
return 0;
} else {
}
}
bool ssh_agent_has_key(const public_key_t *public_key)
SSH agent signing protocol:
When use_ssh_agent = true, all Ed25519 signatures are delegated to the SSH agent:
size_t message_len, uint8_t signature[64]) {
if (key->use_ssh_agent) {
int agent_fd = connect_to_agent();
uint8_t request[...];
send(agent_fd, request);
uint8_t response[...];
recv(agent_fd, response);
memcpy(signature, response + offset, 64);
return 0;
} else {
crypto_sign_detached(signature, NULL, message, message_len, key->key.ed25519);
return 0;
}
}
asciichat_error_t ed25519_sign_message(const private_key_t *key, const uint8_t *message, size_t message_len, uint8_t signature[64])
Security architecture:
ascii-chat uses a separation of concerns design where SSH keys are ONLY used for authentication, never encryption:
| Component | SSH Agent Mode | In-Memory Mode |
| Identity signing | SSH agent (Ed25519) | In-memory Ed25519 |
| Encryption keys | Ephemeral X25519 | Ephemeral X25519 |
| Private key storage | None (agent-only) | Decrypted in memory |
| Password required | No (once in agent) | Yes (every restart) |
| Forward secrecy | β
YES | β
YES |
Why always ephemeral encryption?
Both modes use ephemeral X25519 keys for encryption to provide forward secrecy:
- Identity authentication: SSH key proves identity via Ed25519 signature
- Encryption: Ephemeral X25519 keys generated fresh per connection
- Cryptographic binding: Signature covers ephemeral key, proving possession of both
- Forward secrecy: If SSH key is compromised later, past sessions remain secure
This matches SSH protocol design: long-term keys for identity, ephemeral keys for encryption.
Handshake signature binding:
Server sends: [ephemeral_X25519:32][identity_Ed25519:32][signature:64]
where: signature = sign(identity_private_key, ephemeral_X25519)
The signature cryptographically binds the ephemeral encryption key to the long-term identity key, preventing MITM attacks while maintaining forward secrecy.
Fallback behavior:
# If key is encrypted but NOT in SSH agent:
ascii-chat server --key ~/.ssh/id_ed25519
Encrypted private key detected (cipher: aes256-ctr)
Key not in SSH agent, will prompt for password
Enter passphrase for /Users/you/.ssh/id_ed25519: [password]
Successfully decrypted key, parsing...
# If SSH agent isn't running:
ascii-chat server --key ~/.ssh/id_ed25519
ssh_agent_has_specific_key: SSH_AUTH_SOCK not set
Key not in SSH agent, will prompt for password
Environment variable:
SSH agent communication requires the SSH_AUTH_SOCK environment variable:
# Check if SSH agent is running
echo $SSH_AUTH_SOCK
/tmp/ssh-XXXXXX/agent.12345
# If not set, start agent:
eval "$(ssh-agent -s)"
Security benefits:
- β
Password once per session - Add key to agent once, use many times
- β
No plaintext passwords - Agent handles passphrase, applications never see it
- β
Process isolation - Private key never leaves agent process
- β
Forward secrecy - Ephemeral X25519 keys per connection
- β
Transparent fallback - Works with or without agent
Limitations:
- β Unix-only - SSH agent uses Unix domain sockets (not available on Windows)
- β Signing only - Cannot use agent for X25519 DH operations
- β Session-scoped - Keys removed from agent on logout/reboot
Code locations:
Security guarantee: Both SSH agent mode and in-memory mode provide identical forward secrecy - ephemeral X25519 keys are used for encryption in all cases. The only difference is where signatures come from (agent vs in-memory).
Automatic SSH Agent Key Addition
Problem: Users with encrypted SSH keys had to manually add keys to ssh-agent, or enter their password repeatedly.
Solution: ascii-chat now automatically adds decrypted keys to ssh-agent after successful password entry, eliminating future password prompts.
How it works:
# First run with encrypted key (password required)
ascii-chat server --key ~/.ssh/id_ed25519
Encrypted private key detected (cipher: aes256-ctr)
Key not in SSH agent, will prompt for password
Enter passphrase for /Users/you/.ssh/id_ed25519: [enter password]
Successfully decrypted key, parsing...
[SSH Agent] Adding key to ssh-agent to avoid future password prompts...
[SSH Agent] β Key successfully added to ssh-agent
INFO: Key added to ssh-agent - password won't be required again this session
# Second run (NO password prompt!)
ascii-chat server --key ~/.ssh/id_ed25519
INFO: Using SSH agent for this key (agent signing + ephemeral encryption)
# Server starts immediately - no password needed!
What happens automatically:
- User enters password - Decrypts SSH key successfully
- Key parsed - Validates Ed25519 format
- Check ssh-agent - Verifies
$SSH_AUTH_SOCK is set
- Auto-add to agent - Uses the agent's pipe to add the key to the agent (Windows named pipe or Unix domain socket)
- Future runs - No password prompt (uses agent)
Implementation details:
The auto-add feature uses the SSH agent protocol directly via the pipe.h platform abstraction:
log_debug("ssh-agent not available, skipping auto-add");
return ASCIICHAT_OK;
}
public_key_t pub_key = {0};
pub_key.type = KEY_TYPE_ED25519;
memcpy(pub_key.key, decrypted_key + 32, 32);
log_info("Key already in ssh-agent - skipping auto-add");
return ASCIICHAT_OK;
}
log_info("Attempting to add decrypted key to ssh-agent");
if (agent_result == ASCIICHAT_OK) {
log_info("Successfully added key to ssh-agent - password will not be required on next run");
} else {
log_warn("Failed to add key to ssh-agent (non-fatal): %s", asciichat_error_string(agent_result));
}
bool ssh_agent_is_available(void)
asciichat_error_t ssh_agent_add_key(const private_key_t *private_key, const char *key_path)
Platform abstraction:
The implementation uses lib/platform/pipe.h for cross-platform agent communication:
- POSIX (Linux/macOS): Uses Unix domain sockets via
SSH_AUTH_SOCK environment variable
- Path format:
/tmp/ssh-XXXXXX/agent.XXXXXX (Unix socket)
- Implementation:
socket(AF_UNIX, SOCK_STREAM) + connect()
- Windows: Uses named pipes via
SSH_AUTH_SOCK environment variable or default path
- Path format:
\\.\pipe\openssh-ssh-agent (named pipe)
- Implementation:
CreateFileA() with GENERIC_READ | GENERIC_WRITE
Agent communication flow:
const char *auth_sock = SAFE_GETENV("SSH_AUTH_SOCK");
#ifdef _WIN32
const char *pipe_path = (auth_sock && strlen(auth_sock) > 0)
? auth_sock
: "\\\\.\\pipe\\openssh-ssh-agent";
return platform_pipe_connect(pipe_path);
#else
if (!auth_sock || strlen(auth_sock) == 0) {
return INVALID_PIPE_VALUE;
}
return platform_pipe_connect(auth_sock);
#endif
platform_pipe_close(pipe);
Security considerations:
- β
Direct protocol communication - No shell scripts or temporary files
- β
Memory zeroed - Private key material cleared with
sodium_memzero() after use
- β
Agent session-scoped - Key removed from agent on logout/reboot
- β
Idempotent operation - Adding same key multiple times is safe (agent deduplicates)
- β
Non-fatal failures - Agent add failures don't prevent key decryption/use
Platform support:
| Platform | Supported | Notes |
| Linux | β
YES | Requires SSH agent running (check $SSH_AUTH_SOCK) |
| macOS | β
YES | Built-in with macOS (ssh-agent auto-starts) |
| Windows | β
YES | Uses Windows named pipes (requires OpenSSH for Windows) |
Code locations:
lib/crypto/keys/ssh_keys.c:242 - Check if key already in agent (before decryption)
lib/crypto/keys/ssh_keys.c:767 - Auto-add implementation after successful decryption
lib/crypto/ssh_agent.h - SSH agent interface declarations
lib/crypto/ssh_agent.c - SSH agent protocol implementation
lib/platform/pipe.h - Cross-platform pipe/agent socket abstraction
lib/platform/posix/pipe.c - POSIX Unix domain socket implementation
lib/platform/windows/pipe.c - Windows named pipe implementation
GPG Key Support
ascii-chat supports GPG Ed25519 keys via gpg-agent integration:
# Server with GPG key (short key ID - 8 hex chars)
ascii-chat server --key gpg:ABCD1234
# Server with GPG key (long key ID - 16 hex chars)
ascii-chat server --key gpg:1234567890ABCDEF
# Server with GPG key (full fingerprint - 40 hex chars)
ascii-chat server --key gpg:1234567890ABCDEF1234567890ABCDEF12345678
# Client verifying server GPG key
ascii-chat client --server-key gpg:1234567890ABCDEF1234567890ABCDEF12345678
Requirements:
gpg binary must be in PATH
- GPG key must be Ed25519 (Curve 25519) type
- gpg-agent must be running for signing operations
Key ID formats:
- Short key ID: 8 hex characters (last 8 chars of fingerprint)
- Long key ID: 16 hex characters (last 16 chars of fingerprint)
- Full fingerprint: 40 hex characters (complete fingerprint)
- Optional
0x prefix is accepted for all formats
How it works:
- Extract public key:
gpg --list-keys + keygrip lookup
- Get Ed25519 key: Query gpg-agent via READKEY command (Assuan protocol)
- Sign challenges: gpg-agent signs via PKSIGN command (no passphrase prompt needed)
- Verify signatures:
gpg --verify for deterministic Ed25519 signature verification
Graceful fallback:
ERROR: GPG key requested but 'gpg' command not found
Install GPG:
Ubuntu/Debian: apt-get install gnupg
macOS: brew install gnupg
Arch: pacman -S gnupg
Or use SSH keys: --key ~/.ssh/id_ed25519
Code locations:
lib/crypto/gpg.h - GPG interface declarations
lib/crypto/gpg.c - GPG protocol implementation and gpg-agent communication
lib/crypto/keys/gpg_keys.c - GPG key parsing and Ed25519 extraction
lib/crypto/keys/keys.c:167 - GPG key loading (keygrip stored for agent signing)
Known Hosts (IP-Based TOFU)
File location: ~/.ascii-chat/known_hosts
Format:
# ascii-chat Known Hosts
# Format: IP:port x25519 <hex-key> [comment]
# IPv4 example:
192.168.1.100:27224 x25519 a1b2c3d4e5f6... homeserver
10.0.0.50:8080 x25519 1234567890ab... office-server
# IPv6 example (bracket notation):
[2001:db8::1]:27224 x25519 fedcba098765... ipv6-server
[::1]:27224 x25519 abcdef123456... localhost-ipv6
Security Design: IP Binding (Not Hostnames)
ascii-chat binds server keys to resolved IP addresses, not DNS hostnames, for critical security reasons:
Why IP addresses?
- DNS Hijacking Prevention:
- DNS responses can be spoofed or hijacked
- Attacker points
example.com β malicious server IP
- With hostname binding: Attacker's server key accepted as
example.com's key β
- With IP binding: Client connects to attacker's IP, which won't match trusted IP β
- Cryptographic Binding:
- TCP connection is to a specific IP address, not a hostname
- Hostname is resolved once via
getaddrinfo(), then IP is used
- Binding key to IP matches actual network connection
- IPv6 Support:
- Dual-stack servers accept IPv4 (
192.0.2.1) and IPv6 (2001:db8::1)
- Each IP:port combination gets its own key binding
- Bracket notation
[::1]:8080 clearly distinguishes IPv6
Example attack scenario (hostname binding):
1. User connects to example.com:27224
2. DNS resolves to 203.0.113.50 (attacker-controlled)
3. Attacker's server presents key_A
4. Client saves: "example.com:27224 β key_A"
5. Later, DNS changes to 198.51.100.25 (legitimate server)
6. Legitimate server presents key_B
7. Client sees hostname match, ACCEPTS key_B
8. No MITM detection! β
With IP binding (current implementation):
1. User connects to example.com:27224
2. DNS resolves to 203.0.113.50
3. Attacker's server presents key_A
4. Client saves: "203.0.113.50:27224 β key_A"
5. Later, example.com resolves to 198.51.100.25
6. Different IP! No key stored, prompts user
7. User realizes IP changed, investigates
8. MITM detected! β
Behavior:
- First connection: If server IP:port not in known_hosts, prompt user:
The authenticity of host '192.168.1.100:27224' can't be established.
Ed25519 key fingerprint is: SHA256:abc123...
Are you sure you want to continue connecting (yes/no)? yes
- IPv6 first connection:
The authenticity of host '[2001:db8::1]:27224' can't be established.
Ed25519 key fingerprint is: SHA256:def456...
Are you sure you want to continue connecting (yes/no)? yes
- Subsequent connections: Verify server key matches stored key β ABORT if mismatch
- Key change detected:
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
Connection ABORTED for security.
To remove old key: sed -i '/192.168.1.100:27224 /d' ~/.ascii-chat/known_hosts
Trade-offs:
| Aspect | IP Binding (Current) | Hostname Binding (SSH-style) |
| DNS Hijacking | β
Protected | β Vulnerable |
| Server IP Change | β οΈ Prompts user (manual verification) | β
Transparent |
| Dynamic DNS | β οΈ New prompt per IP | β
Works seamlessly |
| Multi-homed Servers | β οΈ Separate entry per IP | β
Single entry |
| Security Model | Paranoid (explicit trust) | Convenience (implicit trust) |
Recommendation: The security benefit of IP binding outweighs the inconvenience of re-verification when server IPs change. For production servers, IP addresses should be stable (static IPs or fixed cloud instances).
Security model: "Trust on first use" (TOFU) with IP binding - assumes first connection to specific IP:port is legitimate, detects any changes in either IP or key thereafter.
Authentication Modes
Mode 1: Default (Ephemeral DH Only)
Server:
Client:
Security:
- β
Privacy: All packets encrypted
- β
Forward secrecy: New keys per connection
- β MITM vulnerable: No identity verification
Use case: Quick sessions, low-threat environments
Mode 2: Password Authentication
Server:
ascii-chat server --password mySecretPass123
Client:
ascii-chat client --password mySecretPass123
Security:
- β
Privacy: All packets encrypted
- β
MITM protection: Attacker must know password
- β
Forward secrecy: DH keys still ephemeral
- β οΈ Password strength: Security depends on password quality
Mode 3: SSH/GPG Key Pinning
Server with SSH key:
ascii-chat server --key ~/.ssh/id_ed25519
Server with GPG key:
ascii-chat server --key gpg:897607FA43DC66F612710AF97FE90A79F2E80ED3
Client (verify using GitHub SSH key):
ascii-chat client --server-key github:zfogg
Client (verify using GitHub GPG key):
ascii-chat client --server-key github:zfogg.gpg
Client (verify using GitLab GPG key):
ascii-chat client --server-key gitlab:username.gpg
Client (verify using local GPG key):
ascii-chat client --server-key gpg:897607FA43DC66F612710AF97FE90A79F2E80ED3
Security:
- β
Privacy: All packets encrypted
- β
MITM protection: Cryptographically verified identity
- β
Forward secrecy: DH keys still ephemeral
- β
No shared secret: Public keys can be shared openly
- β
GPG agent integration: No passphrase prompts during signing
Verification flow:
- Server sends Ed25519 identity key + signature
- Client fetches expected key (from GitHub, GPG keyring, or file)
- Client verifies signature:
ed25519_verify(signature, ephemeral_key, identity_key)
- Client proceeds only if signature valid
Mode 4: Client Whitelisting
Server:
ascii-chat server --client-keys ~/.ssh/authorized_keys
Client:
# Client displays their public key on startup
ascii-chat client
# Output:
# Client public key: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFoo... alice@laptop
# Share this key with the server operator to be whitelisted
Server's authorized_keys format:
# ascii-chat Authorized Keys
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFoo... alice@laptop
ssh-ed25519 AAAAB3NzaC1yc2EAAAADAQABAAABAQC... bob@desktop
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBar... carol@phone
Security:
- β
Access control: Only pre-approved clients connect
- β
Audit trail: Server logs all connection attempts with client keys
- β
Revocable: Remove key from file to revoke access
GitHub/GitLab GPG Key Fetching:
Both --server-key (client verifies server) and --client-keys (server whitelists clients) support fetching GPG public keys from GitHub/GitLab:
# Client verifies server using GitHub GPG keys
ascii-chat client --server-key github:zfogg.gpg
# Server whitelists clients using GitHub GPG keys
ascii-chat server --key gpg:MYKEYID --client-keys github:zfogg.gpg
# GitLab works the same way
ascii-chat client --server-key gitlab:username.gpg
ascii-chat server --key gpg:MYKEYID --client-keys gitlab:username.gpg
# Full example: Client authenticates with GPG key, verifies server via GitHub
ascii-chat client --key gpg:897607FA43DC66F612710AF97FE90A79F2E80ED3 \
--server-key github:serveruser.gpg
How it works:
- Server fetches GPG armored public key block from
https://github.com/username.gpg
- Imports all GPG keys using
gpg --import (GitHub users often have multiple keys)
- Extracts full 40-character Ed25519 fingerprints using
gpg --list-keys
- Adds all valid Ed25519 keys to client whitelist
- Client authenticates by signing challenge with their GPG key via gpg-agent
Why this is useful:
- β
Social proof: Verify client identity via their public GitHub/GitLab profile
- β
Zero configuration: No need to manually exchange public keys
- β
Multi-key support: Automatically whitelists all user's Ed25519 GPG keys
- β
No secrets: Only public keys are fetched; private keys stay in gpg-agent
Mode 5: Defense in Depth (All Features)
Server with SSH key:
ascii-chat server \
--key ~/.ssh/id_ed25519 \
--password myPass123 \
--client-keys ~/.ssh/authorized_keys
Server with GPG key:
ascii-chat server \
--key gpg:897607FA43DC66F612710AF97FE90A79F2E80ED3 \
--password myPass123 \
--client-keys ~/.ssh/authorized_keys
Client:
ascii-chat client \
--password myPass123 \
--server-key github:zfogg
Security:
- β
Password authentication (both sides verify password)
- β
SSH key pinning (client verifies server identity)
- β
Client whitelist (server only accepts known clients)
- β
Forward secrecy (ephemeral DH keys)
- β
Defense in depth (multiple layers)
Mode 6: Opt-Out (No Encryption)
Server:
ascii-chat server --no-encrypt
Client:
ascii-chat client --no-encrypt
Security:
- β No protection whatsoever
- β οΈ All packets sent in plaintext
Use case: Debugging, packet inspection with tcpdump
WARNING: Only use in trusted networks or for development!
Password Derivation
Password validation:
if (strlen(password) < MIN_PASSWORD_LENGTH || strlen(password) > MAX_PASSWORD_LENGTH) {
log_error("Password length invalid (must be %d-%d characters)",
MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH);
return -1;
}
uint8_t salt[32];
uint8_t key[32];
randombytes_buf(salt, sizeof(salt));
crypto_pwhash(
key, sizeof(key),
password, strlen(password),
salt,
crypto_pwhash_OPSLIMIT_INTERACTIVE,
crypto_pwhash_MEMLIMIT_INTERACTIVE,
crypto_pwhash_ALG_ARGON2ID13
);
HMAC challenge/response:
uint8_t nonce[32];
randombytes_buf(nonce, sizeof(nonce));
uint8_t combined_data[64];
memcpy(combined_data, nonce, 32);
memcpy(combined_data + 32, shared_secret, 32);
uint8_t hmac[32];
crypto_result_t crypto_compute_hmac_ex(const crypto_context_t *ctx, const uint8_t key[32], const uint8_t *data, size_t data_len, uint8_t hmac[32])
bool crypto_verify_hmac_ex(const uint8_t key[32], const uint8_t *data, size_t data_len, const uint8_t expected_hmac[32])
Critical security note: The HMAC binds both the nonce AND the DH shared_secret, preventing MITM attacks even if the attacker knows the password. See Issue 1: Password Mode MITM Vulnerability β οΈ CRITICAL for details.
Packet Encryption
Encryption Process
For each packet after handshake:
packet_header_t inner_header = {
.magic = htonl(PACKET_MAGIC),
.type = htons(PACKET_TYPE_ASCII_FRAME),
.length = htonl(payload_len),
.crc32 = htonl(crc32(payload, payload_len)),
.client_id = htonl(client_id)
};
uint8_t plaintext[sizeof(inner_header) + payload_len];
memcpy(plaintext, &inner_header, sizeof(inner_header));
memcpy(plaintext + sizeof(inner_header), payload, payload_len);
uint8_t nonce[24];
memcpy(nonce, ctx->session_id, 16);
*(uint64_t*)(nonce + 16) = htole64(ctx->nonce_counter++);
uint8_t ciphertext[crypto_secretbox_MACBYTES + sizeof(plaintext)];
crypto_secretbox_easy(
ciphertext,
plaintext, sizeof(plaintext),
nonce,
shared_secret
);
uint8_t encrypted_blob[24 + sizeof(ciphertext)];
memcpy(encrypted_blob, nonce, 24);
memcpy(encrypted_blob + 24, ciphertext, sizeof(ciphertext));
packet_header_t outer_header = {
.magic = htonl(PACKET_MAGIC),
.type = htons(PACKET_TYPE_ENCRYPTED),
.length = htonl(sizeof(encrypted_blob)),
.crc32 = 0,
.client_id = 0
};
int send_packet(socket_t sockfd, packet_type_t type, const void *data, size_t len)
Send a basic packet without encryption.
Wire format:
[Outer Header: 18 bytes]
magic: 0xDEADBEEF
type: 20 (ENCRYPTED)
length: 24 + N + 16
[Encrypted Blob: 24 + N + 16 bytes]
[Nonce: 24 bytes] β Session ID (16) + Counter (8)
[Ciphertext: N bytes] β XSalsa20(plaintext)
[MAC: 16 bytes] β Poly1305(ciphertext)
Decryption Process
packet_header_t outer_header;
recv(socket, &outer_header, sizeof(outer_header));
if (ntohl(outer_header.magic) != PACKET_MAGIC) return -1;
if (ntohs(outer_header.type) != PACKET_TYPE_ENCRYPTED) return -1;
uint32_t blob_len = ntohl(outer_header.length);
uint8_t encrypted_blob[blob_len];
recv(socket, encrypted_blob, blob_len);
uint8_t nonce[24];
memcpy(nonce, encrypted_blob, 24);
uint8_t *ciphertext = encrypted_blob + 24;
size_t ciphertext_len = blob_len - 24;
uint8_t plaintext[ciphertext_len - crypto_secretbox_MACBYTES];
if (crypto_secretbox_open_easy(
plaintext,
ciphertext, ciphertext_len,
nonce,
shared_secret) != 0) {
return -1;
}
packet_header_t *inner_header = (packet_header_t*)plaintext;
uint16_t real_type = ntohs(inner_header->type);
uint32_t payload_len = ntohl(inner_header->length);
uint8_t *payload = plaintext + sizeof(packet_header_t);
handle_packet(real_type, payload, payload_len);
Nonce Management
Critical security requirement: Never reuse a nonce with the same key!
Implementation:
uint8_t nonce[24];
memcpy(nonce, ctx->session_id, 16);
*(uint64_t*)(nonce + 16) = htole64(ctx->nonce_counter++);
Why this is safe:
- 24-byte nonce = 192 bits
- 16-byte session ID = unique per connection/rekey
- 8-byte counter = 2^64 = 18 quintillion packets per session
- At 60 FPS video, counter lasts 9.7 trillion years
- Session ID changes on rekey, providing additional safety
Replay protection:
- Each side maintains their own send counter
- Session ID prevents cross-session replay attacks
- Received packets are not checked for sequence (UDP-like behavior)
- Poly1305 MAC prevents tampering
- Application-level sequence numbers in packet headers (not crypto-related)
Rekeying integration:
- On rekey, session_id regenerated (16 random bytes)
- Counter reset to 1
- Prevents nonce reuse even if counters wrap (extremely unlikely)
Session Rekeying Protocol
Overview
ascii-chat implements automatic session rekeying to provide forward secrecy within long-lived connections. After the initial handshake establishes encryption, the system periodically performs new Diffie-Hellman key exchanges to rotate encryption keys.
Security benefits:
- β
Intra-session forward secrecy - Compromise at time T doesn't decrypt packets before time T
- β
Reduced cryptanalytic surface - Less ciphertext encrypted with same key
- β
Industry best practice - Aligns with TLS 1.3 (rekeys at 2^24 records)
- β
Protection against long-term key exposure - Limits damage from key compromise
Rekeying Triggers
Rekeying occurs automatically when either threshold is reached (whichever comes first):
| Trigger Type | Default Threshold | Notes |
| Time-based | 3600 seconds (1 hour) | Typical video chat session length |
| Traffic-based | 1,000,000 packets | ~4.6 hours at 60 FPS video |
Configuration options:
# Custom thresholds
./ascii-chat server --rekey-time 1800 --rekey-packets 500000
# Default thresholds (1 hour OR 1M packets)
./ascii-chat server
Test mode (for development): For testing, use low thresholds to trigger frequent rekeying:
# Rekey every 30 seconds OR 1000 packets
./ascii-chat server --rekey-time 30 --rekey-packets 1000
Rekeying Protocol Flow
The rekeying protocol uses a 3-packet handshake similar to the initial key exchange:
Client (Initiator) Server (Responder)
| |
| Trigger: Time/packet threshold |
| |
|-- CRYPTO_REKEY_REQUEST --------> |
| [new_ephemeral_pk: 32 bytes] |
| |
| Generate new ephemeral X25519 keypair |
| Compute shared_secret_new = DH(my_sk, their_pk) |
| |
|<-- CRYPTO_REKEY_RESPONSE ------> |
| [new_ephemeral_pk: 32 bytes] |
| |
| Compute shared_secret_new |
| |
|-- CRYPTO_REKEY_COMPLETE --> | (encrypted with NEW key)
| |
| β
Switch to new key |
| β
Reset nonce_counter = 1 |
| β
Wipe old shared_secret |
New Packet Types:
PACKET_TYPE_CRYPTO_REKEY_REQUEST = 25
PACKET_TYPE_CRYPTO_REKEY_RESPONSE = 26
PACKET_TYPE_CRYPTO_REKEY_COMPLETE = 27
Packet Structures:
REKEY_REQUEST:
typedef struct {
uint8_t new_ephemeral_public_key[32];
} rekey_request_packet_t;
REKEY_RESPONSE:
typedef struct {
uint8_t new_ephemeral_public_key[32];
} rekey_response_packet_t;
REKEY_COMPLETE:
Key Transition Logic
Before REKEY_COMPLETE:
- All packets encrypted with old shared_secret
- Keep old key in memory for decryption
- Keep new key (temp_shared_key) ready for transition
REKEY_COMPLETE packet:
- First packet encrypted with new key
- Proves both sides successfully computed same shared secret
- If decryption fails β abort rekey, keep old key
After REKEY_COMPLETE:
- Copy temp_shared_key β shared_key
- Reset nonce_counter = 1
- Generate new random session_id (16 bytes)
- Securely wipe old shared_secret and temp keys
- Update rekey_last_time and reset rekey_packet_count
Security Features
Anti-Denial-of-Service:
#define REKEY_MIN_INTERVAL 60
if (ctx->rekey_in_progress) {
log_warn("Rekey already in progress, ignoring new request");
return -1;
}
if (rekey_failure_count > 3) {
backoff_seconds = 60 * (1 << rekey_failure_count);
}
Race Condition Handling: If both sides simultaneously initiate rekeying:
- Lower client_id wins (becomes initiator)
- Higher client_id becomes responder
- Server has client_id=0, so server always wins ties
if (received_rekey_request && ctx->rekey_in_progress) {
if (my_client_id < peer_client_id) {
log_debug("Rekey tie-break: I win (lower client_id)");
} else {
log_debug("Rekey tie-break: They win, switching to responder");
handle_rekey_request(peer_request);
}
}
void crypto_rekey_abort(crypto_context_t *ctx)
Key Confirmation: The REKEY_COMPLETE packet must decrypt successfully:
if (crypto_decrypt_with_key(packet, temp_shared_key, plaintext) != 0) {
log_error("REKEY_COMPLETE decryption failed - aborting rekey");
return -1;
}
crypto_result_t crypto_rekey_commit(crypto_context_t *ctx)
Failure Handling:
Rekey can fail for several reasons:
- Timeout - Peer doesn't respond within 10 seconds
- Bad keys - DH computation yields different secrets
- Decryption failure - REKEY_COMPLETE doesn't decrypt with new key
- Network error - Connection lost during rekey
Failure recovery:
log_warn("Aborting rekey, keeping old encryption key");
sodium_memzero(ctx->temp_public_key, sizeof(ctx->temp_public_key));
sodium_memzero(ctx->temp_private_key, sizeof(ctx->temp_private_key));
sodium_memzero(ctx->temp_shared_key, sizeof(ctx->temp_shared_key));
ctx->rekey_in_progress = false;
ctx->has_temp_key = false;
ctx->rekey_failure_count++;
}
Important: Rekey failure does NOT disconnect the session. The connection continues with the old key.
Performance impact:
- Rekey adds ~10ms latency (3 packets, DH computation)
- Occurs once per hour (or less frequently)
- Negligible impact on user experience
Security Considerations
Cryptographic Strengths
β
Modern primitives: X25519, XSalsa20-Poly1305, Argon2id (current best practices) β
Forward secrecy (connection-level): Ephemeral DH keys per connection β
Forward secrecy (session-level): Automatic rekeying within long-lived sessions β
Authenticated encryption: XSalsa20-Poly1305 AEAD prevents tampering β
Memory-hard passwords: Argon2id resistant to GPU brute-force β
Constant-time crypto: libsodium uses constant-time implementations (timing attack resistant) β
Large nonce space: 192-bit nonces make collision astronomically unlikely β
Intra-session forward secrecy: Rekeying limits exposure from key compromise
Potential Weaknesses
β οΈ Default MITM vulnerability:
- Threat: Attacker intercepts initial handshake, performs two separate DH exchanges
- Mitigation: Use
--server-key or known_hosts verification
- Acceptable because: User is warned, optional verification available
β οΈ Password quality:
- Threat: Weak passwords vulnerable to offline dictionary attacks
- Mitigation: Argon2id makes attacks expensive, but cannot prevent weak passwords
- Best practice: Use long, random passwords (20+ characters)
β οΈ No quantum resistance:
- Threat: Large quantum computers could break X25519 in the future
- Timeline: Not a practical threat as of 2025
- Future: Post-quantum algorithms (Kyber, Dilithium) may be added later
Known Vulnerabilities
Summary
All critical security vulnerabilities have been identified and fixed:
| Issue | Severity | Status |
| Password Mode MITM | π΄ Critical | β
FIXED - HMACs bound to DH shared_secret |
| No Mutual Auth | π΄ High | β
FIXED - Bidirectional challenge-response |
| Replay Across Sessions | π‘ Medium | β
FIXED - Session IDs implemented |
| No Whitelist Revocation | π’ Low | π’ Won't Fix - Out of scope, manual restart acceptable |
| TOFU Weakness | π’ Info | π’ Acceptable - Inherent to TOFU model |
| Code Duplication | π’ Low | β
FIXED - Shared authentication functions |
| Timing Attack on Keys | π΄ Critical | β
FIXED - Constant-time sodium_memcmp() |
Security Status: β
All security vulnerabilities have been addressed
CVE-None: Default MITM Vulnerability (By Design)
Severity: Medium (mitigated by user choice)
Description: By default, ascii-chat does not verify server identity. An attacker who controls the network can intercept the initial handshake and perform a man-in-the-middle attack.
Attack scenario:
- Client attempts to connect to server at 192.168.1.100:27224
- Attacker intercepts connection, poses as server to client
- Attacker poses as client to real server
- Attacker decrypts client packets, re-encrypts with server's key
- Result: Attacker sees all traffic
Why this is not a critical bug:
- Informed choice: This is the default because there's no global CA system
- User can verify:
--server-key, --password, or known_hosts provide protection
- Active attack required: Attacker must control network (harder than passive eavesdropping)
- Similar to SSH: First SSH connection has same vulnerability (known_hosts helps thereafter)
Mitigation:
# Server: Use SSH key for identity
ascii-chat server --key ~/.ssh/id_ed25519
# Client: Verify server (pick one)
ascii-chat client --server-key github:zfogg # Fetch from GitHub
ascii-chat client --server-key ~/.ssh/server.pub # Manual verification
ascii-chat client # Will prompt to save to known_hosts
Potential Bug: Nonce Counter Overflow
Severity: Low (practically impossible)
Description: If a single connection sends more than 2^64 packets, the nonce counter wraps to zero, potentially reusing nonces.
Attack scenario:
- Keep connection alive for years
- Send packets at maximum rate (10,000/sec) for ~58 million years
- Nonce counter wraps to zero
- Nonces start repeating
Likelihood: Astronomically low (would require 58 million years of continuous packets)
Impact: Nonce reuse could allow an attacker to recover plaintext of two packets with the same nonce
Mitigation: Reconnect periodically (every 24 hours is more than sufficient)
Potential Bug: SSH Key Signature Bypass
Severity: High (if implementation bug exists)
Description: If signature verification is skipped when --server-key is set, attacker could send any identity key.
Attack scenario:
- Client connects with
--server-key github:zfogg
- Attacker sends their own Ed25519 key in handshake
- BUG: Client doesn't verify signature of ephemeral key
- Client accepts attacker's identity key as valid
Current status: β
Not vulnerable (signature verification is mandatory)
Critical Security Review (Third-Party Analysis)
Note: This section documents security issues identified during independent review of the cryptographic implementation. These represent real vulnerabilities that should be addressed before production use.
Issue 1: Password Mode MITM Vulnerability β οΈ <strong>CRITICAL</strong>
Severity: Critical (actively exploitable)
Description: Password mode was vulnerable to MITM attacks despite using encryption. An attacker could intercept the key exchange and derive the password-based key themselves because the password HMAC wasn't bound to the DH shared_secret.
Previous attack scenario:
Client Attacker Server
| | |
| --------KEY_EXCHANGE_INIT---------> | |
| | ------------KEY_EXCHANGE_INIT--------------> |
| | <--------------server_pubkey---------------- |
| <-------attacker_pubkey------------ | |
| | |
| Computes: HMAC(password_key, nonce) | |
| | Attacker can compute same HMAC with password |
| | Even though DH secrets differ! |
Root cause:
- Password HMAC was computed as:
HMAC(password_key, nonce)
- This didn't bind the password to the DH exchange
- Attacker who knows the password can MITM the DH exchange and still pass authentication
Status: π’ FIXED - Password HMACs now bound to DH shared_secret
Implementation: All password HMAC computations now bind to the DH shared_secret to prevent MITM:
uint8_t combined_data[64];
memcpy(combined_data, nonce, 32);
memcpy(combined_data + 32, shared_secret, 32);
uint8_t hmac[32];
uint8_t combined_data[64];
memcpy(combined_data, client_challenge_nonce, 32);
memcpy(combined_data + 32, shared_secret, 32);
uint8_t server_hmac[32];
Why this fixes the MITM vulnerability:
- Client computes:
HMAC(password_key, nonce || DH_secret_A)
- Attacker with different DH secret computes:
HMAC(password_key, nonce || DH_secret_B)
- Server expects:
HMAC(password_key, nonce || DH_secret_A)
- Attacker's HMAC doesn't match β authentication fails
Code locations:
lib/crypto/crypto.h:168-172 - Added crypto_compute_hmac_ex() and crypto_verify_hmac_ex() for variable-length HMAC
lib/crypto/crypto.c:610-648 - Implemented extended HMAC functions
lib/crypto/handshake.c:569-582 - Client AUTH_RESPONSE binds to shared_secret
lib/crypto/handshake.c:644-657 - Client optional AUTH_RESPONSE binds to shared_secret
lib/crypto/handshake.c:706-719 - Client no-server-requirement AUTH_RESPONSE binds to shared_secret
lib/crypto/handshake.c:829-841 - Client SERVER_AUTH_RESPONSE verification binds to shared_secret
lib/crypto/handshake.c:890-902 - Server AUTH_RESPONSE verification binds to shared_secret
lib/crypto/handshake.c:962-974 - Server SERVER_AUTH_RESPONSE computation binds to shared_secret
Impact: Critical vulnerability fixed - Password mode now provides true MITM protection, not just passive eavesdropping protection
Issue 2: No Mutual Authentication in Default Mode β οΈ <strong>HIGH</strong>
Severity: High (design flaw)
Description: The challenge-response protocol only authenticated the client to the server, not the other way around. Server never proved it has the shared secret.
Previous handshake flow:
Server Client
|----KEY_EXCHANGE_INIT--------->|
|<---KEY_EXCHANGE_RESPONSE------|
| |
| Derives: shared_secret | Derives: shared_secret
| |
|----AUTH_CHALLENGE: nonce----->|
|<---AUTH_RESPONSE: HMAC--------|
| |
| Verifies HMAC β | Hopes server has key (no verification)
|----HANDSHAKE_COMPLETE-------->|
Problem: Client never received proof that server has the correct shared secret. A MITM attacker could:
- Intercept client's DH pubkey
- Generate their own server DH pubkey
- Send AUTH_CHALLENGE to client
- Client responds with HMAC (client is now authenticated)
- Attacker sends HANDSHAKE_COMPLETE (without ever proving they have the shared secret)
Status: π’ FIXED - Mutual authentication implemented
Implementation: The protocol now includes bidirectional challenge-response:
New packet type:
PACKET_TYPE_SERVER_AUTH_RESPONSE = 22
Code locations:
lib/network.h:97 - Added PACKET_TYPE_SERVER_AUTH_RESPONSE
lib/crypto/handshake.c:593 - Client sends challenge nonce
lib/crypto/handshake.c:946 - Server sends AUTH_CONFIRM
lib/crypto/handshake.c:834 - Client verifies server's HMAC
Impact: High - Previously allowed MITM without server authentication, now both sides prove knowledge of shared secret
Issue 3: Replay Vulnerability Across Sessions β οΈ <strong>MEDIUM</strong>
Severity: Medium (limited exploitation window)
Description: Nonce counter resets to 0 on each connection. An attacker who records packets from Session 1 can replay them into Session 2 if the same shared secret is used.
Attack scenario:
Session 1:
Client sends: encrypt(nonce=0, "start stream")
Client sends: encrypt(nonce=1, "video frame 1")
Attacker records these packets
Session 2 (client reconnects):
Nonce counter resets to 0
Attacker replays: encrypt(nonce=0, "start stream") β Accepted!
Attacker replays: encrypt(nonce=1, "video frame 1") β Accepted!
Root cause:
ctx->send_nonce_counter = 1;
Status: π’ FIXED - Session IDs added to nonce generation
Implementation:
randombytes_buf(ctx->session_id, 16);
SAFE_MEMCPY(nonce_out, 16, ctx->session_id, 16);
uint64_t counter = ctx->nonce_counter++;
SAFE_MEMCPY(nonce_out + 16, 8, &counter, 8);
This ensures nonces are unique across sessions even if counters reset.
Impact: Medium - Attacker can replay old packets into new sessions, but needs network position and recorded traffic
Issue 4: Whitelist Has No Revocation Mechanism β οΈ <strong>LOW</strong>
Severity: Informational (not a security issue)
Description: If a client's SSH key is compromised, the server operator must manually edit the whitelist file and restart the server to revoke access.
Why hot-reload is not needed:
ascii-chat's security model does not require hot revocation:
- Server makes no assumptions about key compromise - The server's job is to enforce the whitelist at connection time, not to detect or respond to compromise
- Manual restart is acceptable - If an operator learns a client key is compromised, a simple server restart (2 seconds) is perfectly adequate
- Symmetric responsibility - Just as clients can choose not to connect to servers with compromised keys, operators can restart servers to remove compromised client keys
- Operator responsibility - Key management and compromise response are the operator's concern, not the server's
Revocation workflow (current and sufficient):
# 1. Remove compromised key from whitelist
vim ~/.ascii-chat/authorized_clients.txt
# 2. Restart server (takes ~2 seconds)
killall ascii-chat server
./ascii-chat server --client-keys ~/.ascii-chat/authorized_clients.txt
# Result: Compromised key can no longer connect
Why this is acceptable:
- β
Simple and predictable
- β
No additional complexity or attack surface
- β
Matches SSH's
authorized_keys model (also requires service restart for revocation)
- β
If a key is compromised, a 2-second restart is not a meaningful security delay
- β
Zero-downtime reload is overkill for a video chat application
Status: π’ Won't Fix - Out of Scope - Manual restart is the intended design
Impact: None - This is not a security issue, just an operational characteristic
Issue 5: Known Hosts TOFU Weakness β οΈ <strong>INFORMATIONAL</strong>
Severity: Informational (inherent to TOFU model)
Description: Trust On First Use (TOFU) means the first connection is always vulnerable. If an attacker MITM's the first connection, their key is saved as "trusted".
This is the same vulnerability as:
- SSH on first connection
- HTTPS certificate pinning on first connection
- Signal safety numbers on first message
Attack scenario:
User's first connection to server:
1. Attacker intercepts first connection
2. Attacker presents their own Ed25519 key
3. Client saves attacker's key to known_hosts
4. Future connections verify against attacker's key β
5. Real server's key is never seen
Why this is acceptable:
- Industry standard: SSH uses the same model
- User can verify: Out-of-band key fingerprint verification (QR code, voice call, etc.)
- Detectable: Key change triggers warning
- Better than nothing: Prevents MITM on all future connections
Status: π’ Acceptable by design (future verification server will improve this)
Impact: Informational - This is an accepted trade-off in the TOFU model
Issue 6: Code Duplication in Handshake Implementation β οΈ <strong>TECHNICAL DEBT</strong>
Severity: Low (code quality issue, not a security vulnerability)
Description: The cryptographic protocol is symmetric - both client and server perform identical crypto operations - yet the code duplicates logic between crypto_client_handshake() and crypto_server_handshake().
Problem:
crypto_client_handshake()
crypto_server_handshake()
Status: π’ FIXED - Shared authentication functions implemented
Implementation: The password HMAC duplication has been eliminated by extracting shared functions:
const uint8_t nonce[32],
uint8_t hmac_out[32]);
const uint8_t nonce[32],
const uint8_t expected_hmac[32]);
crypto_result_t crypto_compute_auth_response(const crypto_context_t *ctx, const uint8_t nonce[32], uint8_t hmac_out[32])
bool crypto_verify_auth_response(const crypto_context_t *ctx, const uint8_t nonce[32], const uint8_t expected_hmac[32])
Code locations:
lib/crypto/crypto.h:182-206 - Function declarations with documentation
lib/crypto/crypto.c:655-689 - Implementation of shared functions
lib/crypto/handshake.c:572 - Client AUTH_RESPONSE (3 locations) now use shared function
lib/crypto/handshake.c:815 - Client SERVER_AUTH_RESPONSE verification uses shared function
lib/crypto/handshake.c:862 - Server AUTH_RESPONSE verification uses shared function
lib/crypto/handshake.c:923 - Server SERVER_AUTH_RESPONSE computation uses shared function
Results:
- β
~35 lines of duplicated code eliminated
- β
Single source of truth for HMAC computation and verification
- β
Bug fixes now apply to all code paths automatically
- β
Shared functions can be unit tested independently
Impact: Low - This is technical debt, not a security issue
Issue 7: Timing Attack on Public Key Comparisons β οΈ <strong>CRITICAL</strong>
Severity: Critical (side-channel information leakage)
Description: Public key comparisons used variable-time memcmp() instead of constant-time comparison, allowing timing attacks that could leak information about cryptographic keys through side-channel analysis.
Vulnerability: The standard C library function memcmp() returns early on the first byte difference. This creates timing differences that can be measured by an attacker to learn information about the expected key.
if (memcmp(server_key, expected_key.key, 32) == 0) {
return 1;
}
Attack scenario:
Attacker tries different server identity keys:
Attempt 1: Key starts with 0x00... β memcmp() fails on byte 0 β 10ns
Attempt 2: Key starts with 0xAB... β memcmp() fails on byte 0 β 10ns
Attempt 3: Key starts with 0xFF... β memcmp() succeeds on byte 0, fails on byte 1 β 11ns β
Attacker learns: First byte of expected key is 0xFF
Repeat for each byte to recover full 32-byte key
Root cause: Three locations used memcmp() for cryptographic key comparisons:
lib/crypto/handshake.c:194 - Server identity verification during client key exchange
lib/crypto/handshake.c:363 - Client whitelist verification during server auth challenge
lib/crypto/known_hosts.c:69 - Known hosts verification (client-side server verification)
Why this is critical:
- Timing attacks are practical - Measurable over network with enough samples
- Leaks information about expected keys - Helps attacker forge identity
- Affects authentication bypass - Compromise of identity verification
- Applicable to all authentication modes - SSH keys, whitelists, known_hosts
Status: π’ FIXED - All comparisons now use constant-time sodium_memcmp()
Implementation: All cryptographic key comparisons now use libsodium's constant-time comparison:
if (sodium_memcmp(server_identity_key, expected_key.key, 32) != 0) {
log_error("Server identity key mismatch - potential MITM attack!");
return -1;
}
Code locations:
lib/crypto/handshake.c:194 - β
Fixed: Server identity verification
lib/crypto/handshake.c:363 - β
Fixed: Client whitelist verification
lib/crypto/known_hosts.c:69 - β
Fixed: Known hosts verification
How sodium_memcmp() prevents timing attacks:
int sodium_memcmp(const void *b1, const void *b2, size_t len) {
const unsigned char *c1 = b1;
const unsigned char *c2 = b2;
unsigned char d = 0;
for (size_t i = 0; i < len; i++) {
d |= c1[i] ^ c2[i];
}
return (1 & ((d - 1) >> 8)) - 1;
}
Benefits of constant-time comparison:
- β
No timing variation - Takes same time regardless of where keys differ
- β
Cryptographically sound - Standard practice for key comparison
- β
libsodium guarantee - Maintained by crypto experts
- β
Zero performance cost - 32-byte comparison is <100ns either way
**Impact:** Critical vulnerability fixed - All cryptographic key comparisons now resistant to timing attacks
Future Enhancements
Post-Quantum Cryptography
**Current:** X25519 (not quantum-resistant)
**Future:** Hybrid key exchange with post-quantum algorithm
**Candidates:**
- **Kyber:** NIST-selected post-quantum KEM (key encapsulation)
- **Dilithium:** NIST-selected post-quantum signatures
- **X25519-Kyber:** Hybrid combining classical + post-quantum
- **Ed25519-Dilithium:** Hybrid signature algorithms
The dynamic algorithm negotiation system is designed to support post-quantum migration when libsodium adds support.
Verification Server (Planned - See Issue #82)
**Problem:** No global certificate authority like HTTPS
**Proposed solution:** Optional verification server for key registry
**Architecture:**
βββββββββββ
β Client ββββββ
βββββββββββ β
βββ [Verification Server] ββββ Stores public keys
βββββββββββ β - Users register keys
β Server ββββββ - Provides key lookup
βββββββββββ - Issues signed certificates
**Benefits:**
- β
Verified identity without pre-sharing keys
- β
Revocation support (server marks keys as invalid)
- β
Discovery service integration (session strings + verification)
**Trust model:** Similar to Signal's key server - optional, transparency log, user can verify out-of-band
**Status:** RFC in progress (see issue #82 for detailed spec)
Post-Quantum Cryptography Details
**Current:** X25519 (not quantum-resistant)
**Future:** Hybrid key exchange with post-quantum algorithm
**Candidates:**
- **Kyber:** NIST-selected post-quantum KEM (key encapsulation)
- **Dilithium:** NIST-selected post-quantum signatures
- **X25519-Kyber:** Hybrid combining classical + post-quantum
- **Ed25519-Dilithium:** Hybrid signature algorithms
**Implementation Strategy:** The dynamic algorithm negotiation system is designed to support post-quantum migration:
- **Phase 1**: Add post-quantum algorithms to capabilities
#define KEX_ALGO_KYBER1024 0x02
#define AUTH_ALGO_DILITHIUM3 0x02
#define KEX_ALGO_X25519_KYBER_HYBRID 0x03
#define AUTH_ALGO_ED25519_DILITHIUM_HYBRID 0x03
- **Phase 2**: Prefer hybrid mode when both sides support it
- Client capabilities include both classical and post-quantum
- Server selects hybrid algorithms when available
- Fallback to classical-only for older clients
- **Phase 3**: Gradual migration
- New clients prefer post-quantum
- Legacy clients continue working
- Server can enforce post-quantum for sensitive applications
**Timeline:** Wait for libsodium to add post-quantum support (currently in development)
**Backward compatibility:** Hybrid mode allows gradual migration without breaking existing connections
Session Rekeying (IMPLEMENTED β
)
**Status:** β
**IMPLEMENTED** - See Session Rekeying Protocol section above
**Implementation:**
- Automatic rekeying based on time (1 hour) or traffic (1M packets)
- Full DH re-exchange with new ephemeral keys
- Intra-session forward secrecy
**Future consideration:** Double Ratchet (Signal-style per-message rekeying)
- **Benefit:** Forward secrecy per message
- **Cost:** Massive complexity, state synchronization issues
- **Decision:** Current session rekeying is sufficient for video chat use case
Certificate Transparency Log
**Inspired by:** Let's Encrypt Certificate Transparency
**Idea:** Public append-only log of all server public keys
**Benefits:**
- Detect rogue keys (someone impersonating your server)
- Audit trail of key changes
- Social accountability (anyone can verify log integrity)
**Implementation:**
- Merkle tree of Ed25519 public keys
- Signed by verification server
- Clients can request inclusion proofs
**Status:** Research phase
Appendix: Cryptography Bugs to Watch For
Bug Class 1: Nonce Reuse
**Danger:** Reusing a nonce with the same key breaks XSalsa20-Poly1305 security
**How it happens:**
uint8_t nonce[24] = {0};
for (int i = 0; i < 100; i++) {
crypto_secretbox_easy(ciphertext, plaintext, nonce, key);
}
**Fix:**
uint64_t counter = 1;
for (int i = 0; i < 100; i++) {
uint8_t nonce[24] = {0};
*(uint64_t*)nonce = htole64(counter++);
crypto_secretbox_easy(ciphertext, plaintext, nonce, key);
}
**Test:** Verify nonce increments in packet capture
Bug Class 2: Timing Attacks in Verification
**Danger:** Variable-time comparison leaks information
**How it happens:**
if (memcmp(expected_hmac, received_hmac, 32) == 0) {
}
**Fix:**
if (crypto_verify_32(expected_hmac, received_hmac) == 0) {
}
**Test:** Run verification 10,000 times with random HMACs, verify timing is consistent
Bug Class 3: Signature Bypass
**Danger:** Skipping signature verification allows impersonation
**How it happens:**
if (packet_len == 128) {
identity_key = packet->identity_key;
}
Fix:
if (packet_len == 128) {
return -1;
}
}
asciichat_error_t ed25519_verify_signature(const uint8_t public_key[32], const uint8_t *message, size_t message_len, const uint8_t signature[64], const char *gpg_key_id)
Test: Send handshake with random signature bytes β should be rejected
Bug Class 4: Replay Attacks
Danger: Old packets can be re-sent by attacker
How it happens:
decrypt_packet(ciphertext, plaintext);
process_packet(plaintext);
Fix:
if (packet->sequence_number <= last_seen_sequence) {
return -1;
}
Note: ascii-chat uses nonce-based encryption which provides implicit replay resistance (same nonce won't decrypt to same plaintext due to MAC)
Bug Class 5: Key Confusion
Danger: Using wrong key for operation
How it happens:
if (password_set) {
crypto_secretbox_easy(ciphertext, plaintext, nonce, password_key);
} else {
crypto_secretbox_easy(ciphertext, plaintext, nonce, shared_secret);
}
Fix:
uint8_t *encryption_key = (key_exchange_complete && shared_secret_valid)
? shared_secret
: password_key;
crypto_secretbox_easy(ciphertext, plaintext, nonce, encryption_key);
Test: Verify encrypted packets with password can't be decrypted with DH key and vice versa
References
Document Version: 2.3 Last Updated: October 2025 (Session rekeying protocol implemented) Maintainer: ascii-chat Development Team License: Same as ascii-chat project (see LICENSE)
- See also
- topic_crypto "Cryptography Module" for module API documentation
-
topic_keys "Keys Module" for detailed key handling
-
topic_handshake "Handshake Protocol" for Handshake Module details