ascii-chat 0.8.38
Real-time terminal-based video chat with ASCII art conversion
Loading...
Searching...
No Matches
Cryptography

πŸ” End-to-end encryption and secure communication More...

Modules

 Handshake Module
 πŸ€ Cryptographic handshake implementation for key exchange and authentication
 
 Keys Module
 πŸ”‘ SSH key, GPG key, and key validation APIs
 

Detailed Description

πŸ” End-to-end encryption and secure communication

Cryptographic operations, handshake protocol, and key management.

Cryptography README

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?

  1. No certificate infrastructure - Unlike HTTPS, there's no global CA system
  2. Ease of use - Works immediately without configuration
  3. Better than nothing - Protects against passive attacks (most common threat)
  4. 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:

// Convert Ed25519 public key to X25519 for DH
crypto_sign_ed25519_pk_to_curve25519(x25519_pk, ed25519_pk);
// Convert Ed25519 private key to X25519 for DH
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:

// 1. Parse OpenSSH key file format (openssh-key-v1)
// 2. Extract KDF parameters (salt, rounds)
// 3. Derive decryption key + IV using bcrypt_pbkdf:
uint8_t derived[48]; // 32-byte key + 16-byte IV
sodium_bcrypt_pbkdf(password, strlen(password), salt, salt_len,
derived, sizeof(derived), rounds);
const uint8_t *key = derived; // First 32 bytes
const uint8_t *derived_iv = derived + 32; // Last 16 bytes
// 4. Decrypt private key blob using BearSSL AES-256-CTR:
br_aes_ct_ctr_keys aes_ctx;
br_aes_ct_ctr_init(&aes_ctx, key, 32); // 32-byte key
// Extract initial counter from last 4 bytes of IV (big-endian)
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]);
// Decrypt in-place (BearSSL reads first 12 bytes of derived_iv as nonce)
memcpy(decrypted, encrypted_blob, blob_len);
br_aes_ct_ctr_run(&aes_ctx, derived_iv, initial_counter, decrypted, blob_len);
// 5. Parse decrypted Ed25519 seed (32 bytes) + public key (32 bytes)

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; // 0xDEADBEEF (packet validation)
uint16_t type; // packet_type_t (see below)
uint32_t length; // payload length in bytes
uint32_t crc32; // CRC32 checksum of payload
uint32_t client_id; // source client ID (0 = server)
} __attribute__((packed)) packet_header_t; // 18 bytes
__attribute__((constructor))
Register fork handlers for common module.
Definition common.c:104

Note: All multi-byte fields are in network byte order (big-endian).

Packet Types

Protocol Negotiation Packets (Always Unencrypted):

PACKET_TYPE_PROTOCOL_VERSION = 1 // Client β†’ Server: Protocol version, compression support, encryption enabled

Crypto Handshake Packets (Always Unencrypted):

PACKET_TYPE_CRYPTO_CAPABILITIES = 14 // Client β†’ Server: Supported crypto algorithms
PACKET_TYPE_CRYPTO_PARAMETERS = 15 // Server β†’ Client: Chosen algorithms + data sizes
PACKET_TYPE_CRYPTO_KEY_EXCHANGE_INIT = 16 // Server β†’ Client: DH public key
PACKET_TYPE_CRYPTO_KEY_EXCHANGE_RESP = 17 // Client β†’ Server: DH public key
PACKET_TYPE_CRYPTO_AUTH_CHALLENGE = 18 // Server β†’ Client: Challenge nonce
PACKET_TYPE_CRYPTO_AUTH_RESPONSE = 19 // Client β†’ Server: HMAC response
PACKET_TYPE_CRYPTO_AUTH_FAILED = 20 // Server β†’ Client: Failure
PACKET_TYPE_CRYPTO_SERVER_AUTH_RESP = 21 // Server β†’ Client: HMAC proof
PACKET_TYPE_CRYPTO_HANDSHAKE_COMPLETE = 22 // Server β†’ Client: Success
PACKET_TYPE_CRYPTO_NO_ENCRYPTION = 23 // Client β†’ Server: Opt-out

Encrypted Packets (After Handshake):

PACKET_TYPE_ENCRYPTED = 24 // Wrapper for all post-handshake packets

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:

  1. Verify outer header (magic, type)
  2. Extract nonce (first 24 bytes of encrypted blob)
  3. Decrypt remaining blob with XSalsa20-Poly1305
  4. Parse inner header from plaintext
  5. 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)
| | Check whitelist (if enabled)
| | 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?
Definition grep.c:78

Handshake Phases Explained

Phase 0: Dynamic Algorithm Negotiation

**Client Capabilities (CRYPTO_CAPABILITIES):**

typedef struct {
uint16_t supported_kex_algorithms; // Bitmask of supported KEX algorithms
uint16_t supported_auth_algorithms; // Bitmask of supported auth algorithms
uint16_t supported_cipher_algorithms; // Bitmask of supported cipher algorithms
uint8_t requires_verification; // Boolean: requires server identity verification
uint8_t preferred_kex; // Preferred KEX algorithm
uint8_t preferred_auth; // Preferred auth algorithm
uint8_t preferred_cipher; // Preferred cipher algorithm
} crypto_capabilities_packet_t;

**Server Parameters (CRYPTO_PARAMETERS):**

typedef struct {
uint8_t selected_kex; // Which KEX algorithm (KEX_ALGO_*)
uint8_t selected_auth; // Which auth algorithm (AUTH_ALGO_*)
uint8_t selected_cipher; // Which cipher algorithm (CIPHER_ALGO_*)
uint8_t verification_enabled; // Boolean: server requires verification
uint16_t kex_public_key_size; // e.g., 32 for X25519, 1568 for Kyber1024
uint16_t auth_public_key_size; // e.g., 32 for Ed25519, 1952 for Dilithium3
uint16_t signature_size; // e.g., 64 for Ed25519, 3309 for Dilithium3
uint16_t shared_secret_size; // e.g., 32 for X25519
uint8_t nonce_size; // e.g., 24 for XSalsa20 nonce
uint8_t mac_size; // e.g., 16 for Poly1305 MAC
uint8_t hmac_size; // e.g., 32 for HMAC-SHA256
} 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]; // X25519 DH public key (always)
uint8_t identity_public_key[32]; // Ed25519 identity (if --key used)
uint8_t signature[64]; // Ed25519 signature (if --key used)
} key_exchange_init_packet_t;

Packet size:

  • 32 bytes: Ephemeral mode (default)
  • 128 bytes: Authenticated mode (--key SSH key)

Client verifies:

  1. If --server-key provided: Verify identity key matches expected key β†’ ABORT if mismatch
  2. If signature present: Verify signature is valid for ephemeral_public_key using identity_public_key
  3. Check known_hosts: If server:port in ~/.ascii-chat/known_hosts, verify identity key matches
  4. 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]; // X25519 DH public key (always)
uint8_t identity_public_key[32]; // Ed25519 identity (if --key used)
uint8_t signature[64]; // Ed25519 signature (if --key used)
} key_exchange_response_packet_t;

Server verifies:

  1. If --client-keys provided: Check if identity_public_key is in whitelist β†’ REJECT if not found
  2. If signature present: Verify signature is valid
  3. 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]; // Random challenge nonce
uint8_t flags; // AUTH_REQUIRE_PASSWORD | AUTH_REQUIRE_CLIENT_KEY
} 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:

  1. If password set: HMAC(password_key, nonce || shared_secret) using Argon2-derived key
  2. If no password: HMAC(shared_secret, nonce || shared_secret) using DH shared secret
  3. 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]; // HMAC of challenge nonce || shared_secret
} auth_response_packet_t;

Server verifies:

  1. Recompute HMAC using same key (password_key or shared_secret) and same data (nonce || shared_secret)
  2. Constant-time compare with received HMAC β†’ REJECT if mismatch
  3. 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]; // HMAC of client_nonce || shared_secret
} server_auth_response_packet_t;

Client verifies:

  1. Recompute HMAC using shared_secret and client_nonce
  2. 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 // Empty packet

Failure:

PACKET_TYPE_AUTH_FAILED // Empty packet

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:

  1. 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)
  2. Consistency Across Environments
    • SSH agent availability varies by system (Unix-only, requires configuration)
    • Users should get identical security regardless of environment
  3. 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:

  1. HTTPS fetch: GET https://github.com/zfogg.keys (using BearSSL)
  2. Parse response: Extract ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... lines
  3. Filter: Only accept Ed25519 keys (RSA/ECDSA rejected)
  4. 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:

// Public key: Ed25519 (signing) β†’ X25519 (for compatibility only)
crypto_sign_ed25519_pk_to_curve25519(x25519_pk, ed25519_pk);
// Private key: Ed25519 (signing) β†’ X25519 (NEVER used for encryption)
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:

// lib/crypto/keys.c:823-844
// When parsing an encrypted private key:
if (is_encrypted) {
// Extract the public key from the encrypted key file
uint8_t embedded_public_key[32];
// Check if THIS SPECIFIC key is in SSH agent (not just any Ed25519 key)
bool ssh_agent_has_key = ssh_agent_has_specific_key(embedded_public_key);
// Mode 1: SSH agent mode
key_out->type = KEY_TYPE_ED25519;
key_out->use_ssh_agent = true;
memcpy(key_out->public_key, embedded_public_key, 32);
// Use agent for identity signing, ephemeral X25519 for encryption
return 0;
} else {
// Mode 2: Password prompt (agent doesn't have this key)
// Prompts for passphrase and decrypts key
}
}
bool ssh_agent_has_key(const public_key_t *public_key)
Definition ssh_agent.c:50

SSH agent signing protocol:

When use_ssh_agent = true, all Ed25519 signatures are delegated to the SSH agent:

// lib/crypto/keys.c:1318-1494
int ed25519_sign_message(const private_key_t *key, const uint8_t *message,
size_t message_len, uint8_t signature[64]) {
if (key->use_ssh_agent) {
// 1. Connect to SSH agent Unix socket ($SSH_AUTH_SOCK)
int agent_fd = connect_to_agent();
// 2. Build SSH_AGENTC_SIGN_REQUEST (type 13)
// [pubkey_blob][data_to_sign][flags]
uint8_t request[...];
send(agent_fd, request);
// 3. Receive SSH_AGENT_SIGN_RESPONSE (type 14)
// [signature_blob: "ssh-ed25519" + 64-byte signature]
uint8_t response[...];
recv(agent_fd, response);
// 4. Extract 64-byte Ed25519 signature
memcpy(signature, response + offset, 64);
return 0;
} else {
// Use in-memory Ed25519 key to sign
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])
Definition ssh_keys.c:1031

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:

  1. Identity authentication: SSH key proves identity via Ed25519 signature
  2. Encryption: Ephemeral X25519 keys generated fresh per connection
  3. Cryptographic binding: Signature covers ephemeral key, proving possession of both
  4. 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 proves: "I possess identity_Ed25519 AND I'm using ephemeral_X25519"
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:

  1. User enters password - Decrypts SSH key successfully
  2. Key parsed - Validates Ed25519 format
  3. Check ssh-agent - Verifies $SSH_AUTH_SOCK is set
  4. Auto-add to agent - Uses the agent's pipe to add the key to the agent (Windows named pipe or Unix domain socket)
  5. 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:

// lib/crypto/keys/ssh_keys.c:765-774
// After successfully decrypting the key from file:
// 1. Check if ssh-agent is available
log_debug("ssh-agent not available, skipping auto-add");
return ASCIICHAT_OK; // Non-fatal: key is already decrypted
}
// 2. Check if key is already in agent (to avoid duplicate adds)
public_key_t pub_key = {0};
pub_key.type = KEY_TYPE_ED25519;
memcpy(pub_key.key, decrypted_key + 32, 32); // Extract public key
if (ssh_agent_has_key(&pub_key)) {
log_info("Key already in ssh-agent - skipping auto-add");
return ASCIICHAT_OK;
}
// 3. Add decrypted key to agent using SSH agent protocol
log_info("Attempting to add decrypted key to ssh-agent");
asciichat_error_t agent_result = ssh_agent_add_key(key_out, key_path);
if (agent_result == ASCIICHAT_OK) {
log_info("Successfully added key to ssh-agent - password will not be required on next run");
} else {
// Non-fatal: key is already decrypted and loaded
log_warn("Failed to add key to ssh-agent (non-fatal): %s", asciichat_error_string(agent_result));
}
bool ssh_agent_is_available(void)
Definition ssh_agent.c:38
asciichat_error_t ssh_agent_add_key(const private_key_t *private_key, const char *key_path)
Definition ssh_agent.c:145

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:

// lib/crypto/ssh_agent.c: ssh_agent_open_pipe()
// 1. Get agent path from environment or use default
const char *auth_sock = SAFE_GETENV("SSH_AUTH_SOCK");
#ifdef _WIN32
// Windows: Use named pipe path (default or from SSH_AUTH_SOCK)
const char *pipe_path = (auth_sock && strlen(auth_sock) > 0)
? auth_sock
: "\\\\.\\pipe\\openssh-ssh-agent";
return platform_pipe_connect(pipe_path); // CreateFileA() to named pipe
#else
// POSIX: Use Unix domain socket from SSH_AUTH_SOCK
if (!auth_sock || strlen(auth_sock) == 0) {
return INVALID_PIPE_VALUE;
}
return platform_pipe_connect(auth_sock); // socket() + connect() to Unix socket
#endif
// 2. Send SSH agent protocol messages via platform_pipe_read/platform_pipe_write
// - SSH2_AGENTC_REQUEST_IDENTITIES (11) - List keys
// - SSH2_AGENTC_ADD_IDENTITY (17) - Add key
// - SSH_AGENT_SUCCESS (6) - Success response
// 3. Close connection when done
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:

  1. Extract public key: gpg --list-keys + keygrip lookup
  2. Get Ed25519 key: Query gpg-agent via READKEY command (Assuan protocol)
  3. Sign challenges: gpg-agent signs via PKSIGN command (no passphrase prompt needed)
  4. 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?

  1. 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 βœ…
  2. 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
  3. 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:

  1. 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
  2. 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
  3. Subsequent connections: Verify server key matches stored key β†’ ABORT if mismatch
  4. 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:

ascii-chat server

Client:

ascii-chat 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:

  1. Server sends Ed25519 identity key + signature
  2. Client fetches expected key (from GitHub, GPG keyring, or file)
  3. Client verifies signature: ed25519_verify(signature, ephemeral_key, identity_key)
  4. 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:

  1. Server fetches GPG armored public key block from https://github.com/username.gpg
  2. Imports all GPG keys using gpg --import (GitHub users often have multiple keys)
  3. Extracts full 40-character Ed25519 fingerprints using gpg --list-keys
  4. Adds all valid Ed25519 keys to client whitelist
  5. 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:

// 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;
}
// Argon2id parameters
uint8_t salt[32]; // Random, generated once
uint8_t key[32]; // Derived key
randombytes_buf(salt, sizeof(salt));
crypto_pwhash(
key, sizeof(key),
password, strlen(password),
salt,
crypto_pwhash_OPSLIMIT_INTERACTIVE, // 2 iterations
crypto_pwhash_MEMLIMIT_INTERACTIVE, // 64 MB
crypto_pwhash_ALG_ARGON2ID13
);

HMAC challenge/response:

// Server generates challenge
uint8_t nonce[32];
randombytes_buf(nonce, sizeof(nonce));
// Client computes response (binds to shared_secret!)
uint8_t combined_data[64]; // nonce || shared_secret
memcpy(combined_data, nonce, 32);
memcpy(combined_data + 32, shared_secret, 32);
uint8_t hmac[32];
crypto_compute_hmac_ex(password_key, combined_data, 64, hmac);
// Server verifies (also binds to shared_secret)
crypto_verify_hmac_ex(password_key, combined_data, 64, hmac);
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:

// 1. Construct inner packet (real type, real payload)
packet_header_t inner_header = {
.magic = htonl(PACKET_MAGIC),
.type = htons(PACKET_TYPE_ASCII_FRAME), // Real type
.length = htonl(payload_len),
.crc32 = htonl(crc32(payload, payload_len)),
.client_id = htonl(client_id)
};
// 2. Combine inner header + payload
uint8_t plaintext[sizeof(inner_header) + payload_len];
memcpy(plaintext, &inner_header, sizeof(inner_header));
memcpy(plaintext + sizeof(inner_header), payload, payload_len);
// 3. Generate nonce (counter-based, never reused)
uint8_t nonce[24];
memcpy(nonce, ctx->session_id, 16); // Session ID (unique per connection)
*(uint64_t*)(nonce + 16) = htole64(ctx->nonce_counter++); // Increment counter
// 4. Encrypt with XSalsa20-Poly1305
uint8_t ciphertext[crypto_secretbox_MACBYTES + sizeof(plaintext)];
crypto_secretbox_easy(
ciphertext,
plaintext, sizeof(plaintext),
nonce,
shared_secret // 32-byte key from DH
);
// 5. Build encrypted packet: [nonce][ciphertext][MAC]
uint8_t encrypted_blob[24 + sizeof(ciphertext)];
memcpy(encrypted_blob, nonce, 24);
memcpy(encrypted_blob + 24, ciphertext, sizeof(ciphertext));
// 6. Send outer packet (type=ENCRYPTED)
packet_header_t outer_header = {
.magic = htonl(PACKET_MAGIC),
.type = htons(PACKET_TYPE_ENCRYPTED),
.length = htonl(sizeof(encrypted_blob)),
.crc32 = 0, // Not used for encrypted packets
.client_id = 0
};
send_packet(socket, &outer_header, encrypted_blob);
int send_packet(socket_t sockfd, packet_type_t type, const void *data, size_t len)
Send a basic packet without encryption.
Definition packet.c:753

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

// 1. Receive outer packet
packet_header_t outer_header;
recv(socket, &outer_header, sizeof(outer_header));
// Verify outer header
if (ntohl(outer_header.magic) != PACKET_MAGIC) return -1;
if (ntohs(outer_header.type) != PACKET_TYPE_ENCRYPTED) return -1;
// 2. Read encrypted blob
uint32_t blob_len = ntohl(outer_header.length);
uint8_t encrypted_blob[blob_len];
recv(socket, encrypted_blob, blob_len);
// 3. Extract nonce and ciphertext
uint8_t nonce[24];
memcpy(nonce, encrypted_blob, 24);
uint8_t *ciphertext = encrypted_blob + 24;
size_t ciphertext_len = blob_len - 24;
// 4. Decrypt with XSalsa20-Poly1305
uint8_t plaintext[ciphertext_len - crypto_secretbox_MACBYTES];
if (crypto_secretbox_open_easy(
plaintext,
ciphertext, ciphertext_len,
nonce,
shared_secret) != 0) {
// MAC verification failed - packet was tampered with!
return -1;
}
// 5. Parse inner header
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);
// 6. Extract payload
uint8_t *payload = plaintext + sizeof(packet_header_t);
// 7. Process packet based on real type
handle_packet(real_type, payload, payload_len);

Nonce Management

Critical security requirement: Never reuse a nonce with the same key!

Implementation:

// Nonce format (24 bytes for XSalsa20-Poly1305):
// Bytes 0-15: Session ID (random, unique per connection/rekey)
// Bytes 16-23: Counter (increments per packet, little-endian uint64_t)
// Generate nonce for each packet
uint8_t nonce[24];
memcpy(nonce, ctx->session_id, 16); // Session ID prevents cross-session replay
*(uint64_t*)(nonce + 16) = htole64(ctx->nonce_counter++); // Counter prevents within-session replay

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 |
| βœ… Generate new session_id |
| βœ… Wipe old shared_secret |
uint8_t session_id[16]

New Packet Types:

PACKET_TYPE_CRYPTO_REKEY_REQUEST = 25 // Initiator sends new ephemeral public key
PACKET_TYPE_CRYPTO_REKEY_RESPONSE = 26 // Responder sends new ephemeral public key
PACKET_TYPE_CRYPTO_REKEY_COMPLETE = 27 // Initiator confirms (encrypted with new key)

Packet Structures:

REKEY_REQUEST:

typedef struct {
uint8_t new_ephemeral_public_key[32]; // Fresh X25519 public key
} rekey_request_packet_t;

REKEY_RESPONSE:

typedef struct {
uint8_t new_ephemeral_public_key[32]; // Fresh X25519 public key
} rekey_response_packet_t;

REKEY_COMPLETE:

// Empty packet (confirmation only)
// CRITICAL: Must be encrypted with NEW shared secret

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:

// Rate limiting
#define REKEY_MIN_INTERVAL 60 // Minimum 60 seconds between rekeys
// Reject if rekey already in progress
if (ctx->rekey_in_progress) {
log_warn("Rekey already in progress, ignoring new request");
return -1;
}
// Exponential backoff on repeated failures
if (rekey_failure_count > 3) {
backoff_seconds = 60 * (1 << rekey_failure_count); // 60s, 120s, 240s, ...
}

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) {
// Both sides initiated simultaneously
if (my_client_id < peer_client_id) {
// I win - continue as initiator, ignore their request
log_debug("Rekey tie-break: I win (lower client_id)");
} else {
// They win - abort my rekey, become responder
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:

// Attempt to decrypt REKEY_COMPLETE with new key
if (crypto_decrypt_with_key(packet, temp_shared_key, plaintext) != 0) {
log_error("REKEY_COMPLETE decryption failed - aborting rekey");
crypto_rekey_abort(ctx); // Keep old key
return -1;
}
// Success - both sides have same key
crypto_rekey_commit(ctx); // Switch to new key
crypto_result_t crypto_rekey_commit(crypto_context_t *ctx)

Failure Handling:

Rekey can fail for several reasons:

  1. Timeout - Peer doesn't respond within 10 seconds
  2. Bad keys - DH computation yields different secrets
  3. Decryption failure - REKEY_COMPLETE doesn't decrypt with new key
  4. Network error - Connection lost during rekey

Failure recovery:

void crypto_rekey_abort(crypto_context_t *ctx) {
log_warn("Aborting rekey, keeping old encryption key");
// Wipe temporary keys
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));
// Reset state
ctx->rekey_in_progress = false;
ctx->has_temp_key = false;
// Increment failure counter for backoff
ctx->rekey_failure_count++;
// Continue using old key - no disruption to connection
}

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:

  1. Client attempts to connect to server at 192.168.1.100:27224
  2. Attacker intercepts connection, poses as server to client
  3. Attacker poses as client to real server
  4. Attacker decrypts client packets, re-encrypts with server's key
  5. Result: Attacker sees all traffic

Why this is not a critical bug:

  1. Informed choice: This is the default because there's no global CA system
  2. User can verify: --server-key, --password, or known_hosts provide protection
  3. Active attack required: Attacker must control network (harder than passive eavesdropping)
  4. 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:

  1. Keep connection alive for years
  2. Send packets at maximum rate (10,000/sec) for ~58 million years
  3. Nonce counter wraps to zero
  4. 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:

  1. Client connects with --server-key github:zfogg
  2. Attacker sends their own Ed25519 key in handshake
  3. BUG: Client doesn't verify signature of ephemeral key
  4. 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:

// Client and server now both compute (for AUTH_RESPONSE):
uint8_t combined_data[64]; // nonce || shared_secret
memcpy(combined_data, nonce, 32);
memcpy(combined_data + 32, shared_secret, 32);
uint8_t hmac[32];
crypto_compute_hmac_ex(password_key, combined_data, 64, hmac);
// Server verifies (for SERVER_AUTH_RESPONSE):
uint8_t combined_data[64]; // client_nonce || shared_secret
memcpy(combined_data, client_challenge_nonce, 32);
memcpy(combined_data + 32, shared_secret, 32);
uint8_t server_hmac[32];
crypto_compute_hmac_ex(password_key, combined_data, 64, server_hmac);

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:

  1. Intercept client's DH pubkey
  2. Generate their own server DH pubkey
  3. Send AUTH_CHALLENGE to client
  4. Client responds with HMAC (client is now authenticated)
  5. 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 handshake flow with mutual authentication:
// 1. Server β†’ Client: AUTH_CHALLENGE (server_nonce)
// 2. Client generates client_nonce
// 3. Client β†’ Server: AUTH_RESPONSE (HMAC + client_nonce)
// - Password mode: HMAC(32) + client_nonce(32) = 64 bytes
// - Ed25519 mode: signature(64) + client_nonce(32) = 96 bytes
// 4. Server verifies client's HMAC/signature
// 5. Server computes HMAC(shared_secret, client_nonce)
// 6. Server β†’ Client: AUTH_CONFIRM (server's HMAC of client_nonce)
// 7. Client verifies server's HMAC
// Both sides now authenticated

New packet type:

PACKET_TYPE_SERVER_AUTH_RESPONSE = 22 // Server β†’ Client: HMAC(32 bytes)

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:

// crypto_context_init() in lib/crypto/crypto.c
ctx->send_nonce_counter = 1; // Resets to 1 every connection!

Status: 🟒 FIXED - Session IDs added to nonce generation

Implementation:

// Each connection gets a unique random session ID
randombytes_buf(ctx->session_id, 16);
// Nonce format: [session_id (16 bytes)][counter (8 bytes)]
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:

  1. 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
  2. Manual restart is acceptable - If an operator learns a client key is compromised, a simple server restart (2 seconds) is perfectly adequate
  3. Symmetric responsibility - Just as clients can choose not to connect to servers with compromised keys, operators can restart servers to remove compromised client keys
  4. 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:

  1. Industry standard: SSH uses the same model
  2. User can verify: Out-of-band key fingerprint verification (QR code, voice call, etc.)
  3. Detectable: Key change triggers warning
  4. 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:

// lib/crypto/handshake.c has two nearly identical functions:
crypto_client_handshake() // Lines 416+
crypto_server_handshake() // Lines 772+
// Both compute identical crypto operations:
// - HMAC(password_key, nonce || shared_secret)
// - crypto_verify_hmac_ex(password_key, combined_data, 64, hmac)
// - Ed25519 signature generation/verification
// - DH key derivation

Status: 🟒 FIXED - Shared authentication functions implemented

Implementation: The password HMAC duplication has been eliminated by extracting shared functions:

// lib/crypto/crypto.c - New shared authentication helpers
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]);
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.

// ❌ WRONG: memcmp() is NOT constant-time
if (memcmp(server_key, expected_key.key, 32) == 0) {
return 1; // Match
}

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:

  1. lib/crypto/handshake.c:194 - Server identity verification during client key exchange
  2. lib/crypto/handshake.c:363 - Client whitelist verification during server auth challenge
  3. 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:

// βœ… CORRECT: sodium_memcmp() is constant-time
// Compare server's IDENTITY key with expected key (constant-time to prevent timing attacks)
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:

// From libsodium source (simplified):
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;
// Always compares ALL bytes, never returns early
for (size_t i = 0; i < len; i++) {
d |= c1[i] ^ c2[i];
}
return (1 & ((d - 1) >> 8)) - 1; // Constant-time final comparison
}

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:

  1. **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
  2. **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
  3. **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:**

// ❌ WRONG: Same nonce for multiple packets
uint8_t nonce[24] = {0}; // Never changes!
for (int i = 0; i < 100; i++) {
crypto_secretbox_easy(ciphertext, plaintext, nonce, key); // Same nonce!
}

**Fix:**

// βœ… CORRECT: Increment nonce counter
uint64_t counter = 1;
for (int i = 0; i < 100; i++) {
uint8_t nonce[24] = {0};
*(uint64_t*)nonce = htole64(counter++); // Unique nonce per packet
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:**

// ❌ WRONG: memcmp() returns early on first mismatch
if (memcmp(expected_hmac, received_hmac, 32) == 0) {
// Attacker can measure timing differences to guess HMAC
}

**Fix:**

// βœ… CORRECT: Constant-time comparison
if (crypto_verify_32(expected_hmac, received_hmac) == 0) {
// Takes same time regardless of mismatch location
}

**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:**

// ❌ WRONG: Assuming presence of signature field means it's valid
if (packet_len == 128) {
// Has signature field, assume it's correct
identity_key = packet->identity_key;
}

Fix:

// βœ… CORRECT: Always verify signature if present
if (packet_len == 128) {
if (ed25519_verify_signature(identity_key, ephemeral_key, signature) != 0) {
return -1; // REJECT invalid signature
}
}
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)
Definition ssh_keys.c:1127

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:

// ❌ WRONG: No replay protection
decrypt_packet(ciphertext, plaintext);
process_packet(plaintext); // Attacker can replay old encrypted packets

Fix:

// βœ… CORRECT: Check sequence numbers or use nonces
if (packet->sequence_number <= last_seen_sequence) {
return -1; // REJECT replayed packet
}

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:

// ❌ WRONG: Using password key when DH key should be used
if (password_set) {
crypto_secretbox_easy(ciphertext, plaintext, nonce, password_key);
} else {
crypto_secretbox_easy(ciphertext, plaintext, nonce, shared_secret);
}
// Attacker can cause decrypt with wrong key by manipulating password flag

Fix:

// βœ… CORRECT: Consistent key selection logic
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