ascii-chat 0.11.33
Video chat in your terminal
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)

WebSocket Connections (Browser Clients)

When connecting via WebSocket URLs (ws:// or wss://), ascii-chat handles encryption differently to work efficiently with browser clients.

For wss:// connections (WebSocket Secure):

By default, ascii-chat disables ACIP application-level encryption when you use a wss:// URL. Why? Because TLS (the "S" in wss) already encrypts everything in transit. Adding another layer of encryption would be redundant and slow. So TLS handles the transport encryption, and ascii-chat focuses on what matters - authenticating the connection if you want to.

If you specify authentication flags like --key, --server-key, or --client-keys, those still work normally with wss://. The authentication handshake happens after the TLS connection is established, so the connection is both encrypted and verified.

You can override this behavior with --encrypt and --auth flags:

  • --encrypt=true - Force application-level encryption even over wss://
  • --encrypt=false - Disable application-level encryption (the wss:// default)
  • --auth=true - Force authentication (default if you specify --key or --server-key)
  • --auth=false - Skip authentication even if keys are provided

In short: wss:// is TLS-encrypted by default, and ascii-chat's application encryption is optional. Authentication still works if you want it, regardless of the transport layer.

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)
unsigned int uint32_t
Definition common.h:58
unsigned char uint8_t
Definition common.h:56

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
unsigned short uint16_t
Definition common.h:57
Network quality metrics for a single participant.
Definition metrics.h:33
Network packet header structure.
Definition packet.h:598

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
@ PACKET_TYPE_PROTOCOL_VERSION
Protocol version and capabilities negotiation.
Definition packet.h:288

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
@ PACKET_TYPE_CRYPTO_AUTH_RESPONSE
Client -> Server: {HMAC[32]} (UNENCRYPTED)
Definition packet.h:317
@ PACKET_TYPE_CRYPTO_HANDSHAKE_COMPLETE
Server -> Client: "encryption ready" (UNENCRYPTED)
Definition packet.h:323
@ PACKET_TYPE_CRYPTO_KEY_EXCHANGE_INIT
Server -> Client: {server_pubkey[32]} (UNENCRYPTED)
Definition packet.h:311
@ PACKET_TYPE_CRYPTO_KEY_EXCHANGE_RESP
Client -> Server: {client_pubkey[32]} (UNENCRYPTED)
Definition packet.h:313
@ PACKET_TYPE_CRYPTO_AUTH_FAILED
Server -> Client: "authentication failed" (UNENCRYPTED)
Definition packet.h:319
@ PACKET_TYPE_CRYPTO_SERVER_AUTH_RESP
Server -> Client: {HMAC[32]} server proves knowledge (UNENCRYPTED)
Definition packet.h:321
@ PACKET_TYPE_CRYPTO_NO_ENCRYPTION
Client -> Server: "I want to proceed without encryption" (UNENCRYPTED)
Definition packet.h:325
@ PACKET_TYPE_CRYPTO_AUTH_CHALLENGE
Server -> Client: {nonce[32]} (UNENCRYPTED)
Definition packet.h:315
@ PACKET_TYPE_CRYPTO_PARAMETERS
Server -> Client: Chosen algorithms + data sizes (UNENCRYPTED)
Definition packet.h:309
@ PACKET_TYPE_CRYPTO_CAPABILITIES
Client -> Server: Supported crypto algorithms (UNENCRYPTED)
Definition packet.h:307

Encrypted Packets (After Handshake):

PACKET_TYPE_ENCRYPTED = 24 // Wrapper for all post-handshake packets
@ PACKET_TYPE_ENCRYPTED
Encrypted packet (after handshake completion)
Definition packet.h:333

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

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 structure (Packet Type 14)
Definition packet.h:952

**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 structure (Packet Type 15)
Definition packet.h:981

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)
Check if a public key is already in ssh-agent.
Definition ssh_agent.c:50
@ KEY_TYPE_ED25519
Definition key_types.h:52

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;
}
}
bool use_ssh_agent
Definition key_types.h:97
uint8_t ed25519[64]
Definition key_types.h:94
union private_key_t::@14 key
asciichat_error_t ed25519_sign_message(const private_key_t *key, const uint8_t *message, size_t message_len, uint8_t signature[64])
Sign a message with Ed25519 (uses SSH agent if available, otherwise in-memory key)
Definition ssh_keys.c:1030
Private key structure (for server –ssh-key)
Definition key_types.h:91

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};
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)
Check if ssh-agent is running and available.
Definition ssh_agent.c:38
asciichat_error_t ssh_agent_add_key(const private_key_t *private_key, const char *key_path)
Add a private key to ssh-agent.
Definition ssh_agent.c:145
asciichat_error_t
Error and exit codes - unified status values (0-255)
Definition error_codes.h:49
@ ASCIICHAT_OK
Definition error_codes.h:51
uint8_t key[32]
Definition key_types.h:71
key_type_t type
Definition key_types.h:70
#define log_warn(...)
Log a WARN message.
Definition log/log.h:574
#define log_info(...)
Log an INFO message.
Definition log/log.h:561
#define log_debug(...)
Log a DEBUG message.
Definition log/log.h:548
Public key structure.
Definition key_types.h:69

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 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
#define SAFE_GETENV(name)
Definition common.h:434
pipe_t platform_pipe_connect(const char *path)
Connect to an agent via named pipe (Windows) or Unix socket (POSIX)
int platform_pipe_close(pipe_t pipe)
Close a pipe connection.
#define INVALID_PIPE_VALUE
Invalid pipe value (POSIX: -1)
Definition pipe.h:42

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 and Encryption Decoupling

The crypto system supports independent control of authentication and encryption layers through command-line flags. This enables four distinct operational modes:

Mode Flags Auth Encrypt ACIP Use Case
Mode 1: Authenticated + Encrypted (default) βœ… βœ… Enabled Production, trusted peers
Mode 2: Authenticated, Plaintext --no-encrypt βœ… ❌ Auth layer only Development, debug
Mode 3: Anonymous Encrypted --no-auth ❌ βœ… Enabled Quick sessions, low-threat
Mode 4: Plaintext Only --no-auth --no-encrypt ❌ ❌ Disabled Testing, packet inspection

Key Design Notes:

  • Default behavior: Both authentication and encryption are enabled
  • No explicit --auth flag: Authentication is enabled by default; use --no-auth to disable
  • Plaintext mode validation: When --no-auth is specified, authentication flags (--key, --password, etc.) are rejected
  • Complete bypass: Only when BOTH flags are used (--no-auth --no-encrypt) is the entire ACIP handshake skipped

Mode 1: Authenticated + Encrypted (Default)

Flags: (none - this is the default)

Server:

ascii-chat server

Client:

ascii-chat client

Protocol:

  • ACIP handshake performed with both authentication and encryption enabled
  • Client and server perform cryptographic key exchange
  • All subsequent packets encrypted with negotiated session key

Security Properties:

  • βœ… Privacy: All packets encrypted with ephemeral X25519/XSalsa20-Poly1305
  • βœ… Forward secrecy: Ephemeral keys per connection (long-term key only for authentication)
  • βœ… Identity verification: Server identity can be verified with –server-key, –known-hosts, or –password
  • βœ… Recommended for production use

Use case: Production deployments, trusted peer networks, when identity verification is desired

Mode 2: Authenticated + Plaintext

Flags: --no-encrypt

Server:

ascii-chat server --no-encrypt

Client:

ascii-chat client --no-encrypt

Protocol:

  • ACIP handshake performed with authentication enabled
  • Packets sent in plaintext (no encryption)
  • Server identity verification still possible

Security Properties:

  • ❌ Privacy: All packets sent in plaintext (visible to eavesdroppers)
  • βœ… Authentication: Server identity verified (with –password, –key, etc.)
  • ⚠️ Suitable only for debugging and testing

Use case: Development, debugging with tcpdump, trusted networks only

Mode 3: Anonymous + Encrypted

Flags: --no-auth

Server:

ascii-chat server --no-auth

Client:

ascii-chat client --no-auth

Protocol:

  • ACIP handshake performed with encryption enabled
  • No authentication: server identity not verified
  • All subsequent packets encrypted with ephemeral X25519/XSalsa20-Poly1305

Security Properties:

  • βœ… Privacy: All packets encrypted
  • βœ… Forward secrecy: Ephemeral keys per connection
  • ❌ No authentication: Vulnerable to MITM attacks from active adversaries
  • ⚠️ Suitable for quick sessions in low-threat environments

Use case: Quick sessions, low-threat LANs, temporary connections

Mode 4: Plaintext Only

Flags: --no-auth --no-encrypt

Server:

ascii-chat server --no-auth --no-encrypt

Client:

ascii-chat client --no-auth --no-encrypt

Protocol:

  • ACIP handshake completely skipped
  • All packets sent in plaintext with no authentication or encryption
  • Minimal protocol overhead

Security Properties:

  • ❌ Privacy: None (all packets plaintext)
  • ❌ Authentication: None (no identity verification)
  • ⚠️ Only for debugging and testing

Use case: Packet inspection, protocol debugging, isolated test environments

Mode 5: 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)",
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
);
#define MIN_PASSWORD_LENGTH
Minimum password length (8 characters)
#define MAX_PASSWORD_LENGTH
Maximum password length (256 characters)
#define log_error(...)
Log an ERROR message.
Definition log/log.h:587

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])
Compute HMAC-SHA256 for variable-length data.
bool crypto_verify_hmac_ex(const uint8_t key[32], const uint8_t *data, size_t data_len, const uint8_t expected_hmac[32])
Verify HMAC-SHA256 for variable-length data.

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);
unsigned long long uint64_t
Definition common.h:59
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:784
#define PACKET_MAGIC
Packet magic number (alias for MAGIC_PACKET_VALID)
Definition packet.h:255
@ PACKET_TYPE_ASCII_FRAME
Complete ASCII frame with all metadata.
Definition packet.h:361
uint64_t magic
Magic number (PACKET_MAGIC = 0xA5C11C4A1 "ASCIICHAT" in hex) for packet validation.
Definition packet.h:600

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);
uint32_t length
Payload data length in bytes (0 for header-only packets)
Definition packet.h:604
uint16_t type
Packet type (packet_type_t enumeration)
Definition packet.h:602

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_TYPE_CRYPTO_REKEY_COMPLETE
Initiator -> Responder: Empty (encrypted with NEW key, but still handshake)
Definition packet.h:339
@ PACKET_TYPE_CRYPTO_REKEY_REQUEST
Initiator -> Responder: {new_ephemeral_pk[32]} (UNENCRYPTED during rekey)
Definition packet.h:335
@ PACKET_TYPE_CRYPTO_REKEY_RESPONSE
Responder -> Initiator: {new_ephemeral_pk[32]} (UNENCRYPTED during rekey)
Definition packet.h:337

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)
Abort rekeying and fallback to old keys.

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)
Commit to new keys after successful REKEY_COMPLETE.

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:

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
// Continue using old key - no disruption to connection
}
Cryptographic context structure.

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!
int frame
Definition splash.c:99

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);
#define SAFE_MEMCPY(dest, dest_size, src, count)
Definition common.h:468

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
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])
Compute authentication response HMAC bound to DH shared_secret.
crypto_result_t
Cryptographic operation result codes.
bool crypto_verify_auth_response(const crypto_context_t *ctx, const uint8_t nonce[32], const uint8_t expected_hmac[32])
Verify authentication response HMAC bound to DH shared_secret.

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)
Verify an Ed25519 signature.
Definition ssh_keys.c:1126

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

Crypto Module Organization

Overview

The lib/crypto/ module is the heart of ascii-chat's cryptographic implementation. It provides:

  • Core cryptography: X25519 key exchange, XSalsa20-Poly1305 encryption, Ed25519 signatures
  • Authentication: HMAC-based challenge-response, password derivation, signature verification
  • Key management: Loading, validating, and storing cryptographic keys from multiple sources
  • Integration: SSH agent, GPG agent, HTTPS key fetching, GitHub/GitLab key lookup
  • Protocol: Complete cryptographic handshake, session rekeying, message encryption/decryption

File Organization

The module is organized into several logical subsystems:

lib/crypto/
β”œβ”€β”€ Core Cryptography
β”‚ β”œβ”€β”€ crypto.c/h - Core crypto operations (encryption, key exchange, HMAC)
β”‚ β”œβ”€β”€ crypto.h - Main public interface (1382 lines of documented functions)
β”‚ β”œβ”€β”€ key_types.h - Key type definitions (private_key_t, key_purpose_t)
β”‚ β”œβ”€β”€ pem.c/h - PEM format parsing (OpenSSH, PKCS8)
β”‚ β”œβ”€β”€ regex.c/h - Regex utilities for key format detection
β”‚ β”œβ”€β”€ sha1.c/h - SHA-1 implementation (for key fingerprinting)
β”‚ └── common.c/h - Shared utilities
β”‚
β”œβ”€β”€ SSH Key Handling
β”‚ β”œβ”€β”€ ssh/
β”‚ β”‚ β”œβ”€β”€ ssh_agent.c/h - SSH agent communication (signing, key lookup)
β”‚ β”‚ β”œβ”€β”€ ssh_keys.c/h - OpenSSH key format parsing and handling
β”‚ β”‚ └── [ssh_*.c] - SSH-specific operations
β”‚ β”œβ”€β”€ keys_validation.c/h - SSH key validation and format checking
β”‚ └── known_hosts.c/h - SSH-style known_hosts file management
β”‚
β”œβ”€β”€ GPG Key Handling
β”‚ β”œβ”€β”€ gpg/
β”‚ β”‚ β”œβ”€β”€ gpg.h - Main GPG interface (aggregates submodules)
β”‚ β”‚ β”œβ”€β”€ agent.c/h - GPG agent communication
β”‚ β”‚ β”œβ”€β”€ export.c/h - Export keys from GPG keyring
β”‚ β”‚ β”œβ”€β”€ gpg_keys.c/h - GPG key format handling
β”‚ β”‚ β”œβ”€β”€ homedir.c/h - GPG home directory detection
β”‚ β”‚ β”œβ”€β”€ openpgp.c/h - OpenPGP format parsing
β”‚ β”‚ β”œβ”€β”€ signing.c/h - Signing operations with GPG
β”‚ β”‚ └── verification.c/h - GPG signature verification
β”‚ └── [gpg_*.c] - Additional GPG operations
β”‚
β”œβ”€β”€ HTTPS & Key Fetching
β”‚ β”œβ”€β”€ https_keys.c/h - HTTPS key fetching (using BearSSL)
β”‚ β”œβ”€β”€ discovery_keys.c/h - ACDS discovery server key management
β”‚ └── http_client.c/h - Low-level HTTPS client
β”‚
β”œβ”€β”€ Handshake & Protocol
β”‚ β”œβ”€β”€ handshake/
β”‚ β”‚ β”œβ”€β”€ client.c/h - Client-side handshake implementation
β”‚ β”‚ β”œβ”€β”€ server.c/h - Server-side handshake implementation
β”‚ β”‚ └── common.c/h - Shared handshake utilities
β”‚ β”œβ”€β”€ handshake_protocol.md - Protocol specification
β”‚ └── [handshake_*.c] - Handshake state machines
β”‚
β”œβ”€β”€ Key Management
β”‚ β”œβ”€β”€ keys.c/h - Master key loading and management
β”‚ β”œβ”€β”€ discovery_keys.c/h - Discovery service key caching
β”‚ └── key_types.h - Key structure definitions
β”‚
└── Support Files
β”œβ”€β”€ CMakeLists.txt - Build configuration
β”œβ”€β”€ README.md - Module README (usage examples)
└── Doxygen docs/
β”œβ”€β”€ crypto.dox - Comprehensive crypto reference
β”œβ”€β”€ client_crypto.dox - Client-side crypto details
└── server_crypto.dox - Server-side crypto details
void usage(FILE *desc, asciichat_mode_t mode)
Print usage information for a specific mode.

Subsystems

1. Core Cryptography (crypto.c/h)

Purpose: Low-level cryptographic operations

Key Functions:

Dependencies: libsodium only

Size: ~3000 lines (crypto.c + crypto.h)

Interfaces:

// Key exchange protocol (3 steps)
crypto_get_public_key(ctx, public_key_out); // Step 1: Send our key
crypto_set_peer_public_key(ctx, peer_public_key); // Step 2: Receive peer key
bool ready = crypto_is_ready(ctx); // Step 3: Ready for encryption
// Encryption/Decryption
crypto_encrypt(ctx, plaintext, plaintext_len, ciphertext_out, ...);
crypto_decrypt(ctx, ciphertext, ciphertext_len, plaintext_out, ...);
crypto_result_t crypto_set_peer_public_key(crypto_context_t *ctx, const uint8_t *peer_public_key)
Set peer's public key and compute shared secret (step 2 of handshake)
crypto_result_t crypto_encrypt(crypto_context_t *ctx, const uint8_t *plaintext, size_t plaintext_len, uint8_t *ciphertext_out, size_t ciphertext_out_size, size_t *ciphertext_len_out)
Encrypt data using XSalsa20-Poly1305.
crypto_result_t crypto_get_public_key(const crypto_context_t *ctx, uint8_t *public_key_out)
Get public key for sending to peer (step 1 of handshake)
bool crypto_is_ready(const crypto_context_t *ctx)
Check if key exchange is complete and ready for encryption.
crypto_result_t crypto_decrypt(crypto_context_t *ctx, const uint8_t *ciphertext, size_t ciphertext_len, uint8_t *plaintext_out, size_t plaintext_out_size, size_t *plaintext_len_out)
Decrypt data using XSalsa20-Poly1305.

2. SSH Key Handling (ssh/*)

Purpose: Support for SSH keys and SSH agent integration

Submodules:

Key Functions:

  • ssh_agent_is_available() - Check if ssh-agent is running
  • ssh_agent_sign() - Request signature from agent
  • ssh_agent_list_keys() - List available keys in agent
  • ssh_keys_parse() - Parse OpenSSH key format
  • ssh_keys_export() - Export key to OpenSSH format

Integration Points:

  1. Key Loading: lib/crypto/keys.c loads SSH keys via ssh_keys_parse()
  2. Agent Signing: During handshake, if key is in agent, use ssh_agent_sign()
  3. Verification: Server verifies Ed25519 signature from client

Trust Flow:

User provides: --key ~/.ssh/id_ed25519
↓
Load via ssh_keys_parse() - Check if encrypted
↓
If encrypted & SSH agent available:
- Detect key in agent via ssh_agent_is_available()
- Set agent signing mode
↓
During handshake:
- Generate ephemeral X25519 key
- Sign ephemeral key with identity key
- If agent: use ssh_agent_sign()
- If not agent: use in-memory key (requires password)
asciichat_error_t ssh_agent_sign(const public_key_t *public_key, const uint8_t *message, size_t message_len, uint8_t signature[64])
Sign data using SSH agent with the specified public key.
Definition ssh_agent.c:256

3. Known Hosts Management (known_hosts.c/h)

Purpose: SSH-style Trust On First Use (TOFU) verification

Location: ~/.ascii-chat/known_hosts (same format as SSH)

File Format:

hostname:port ssh-ed25519 AAAAC3Nza... # Server identity key (32 bytes)
127.0.0.1:27224 ssh-ed25519 BBBBC3Nza...

Key Functions:

  • known_hosts_verify() - Check if server key is in known_hosts
  • known_hosts_add() - Add new server key to known_hosts
  • known_hosts_update() - Update existing entry
  • known_hosts_remove() - Remove entry

Usage in Handshake:

Client receives: [ephemeral_key || identity_key || signature]
↓
Verify signature: ed25519_verify(identity_key, ephemeral_key, sig)
↓
Check known_hosts: known_hosts_verify("localhost:27224", identity_key)
↓
If key exists: Accept (already trusted)
If key new: Ask user: "Trust this server? [y/N]"
- Yes: Save to known_hosts
- No: Reject connection
If key changed: Warn "Server key changed! Possible MITM!"

Security Properties:

  • βœ… Protects against MITM on subsequent connections
  • ⚠️ First connection vulnerable to MITM (inherent to TOFU)
  • βœ… Detects key changes (alerts user)

4. GPG Key Handling (gpg/*)

Purpose: Support for GPG keys and GPG agent integration

Submodules:

Key Functions:

  • gpg_agent_sign() - Request signature from gpg-agent
  • gpg_export_public_key() - Export public key from keyring
  • gpg_verify_signature() - Verify GPG signature
  • gpg_keys_parse() - Parse GPG key format

Integration Points:

  1. Key Loading: lib/crypto/keys.c loads GPG keys via gpg_export_public_key()
  2. Agent Signing: If key is in gpg-agent, use gpg_agent_sign()
  3. Verification: Server verifies signature from client

Trust Flow:

User provides: --key gpg:zfogg
↓
Load via gpg_export_public_key()
↓
If GPG agent available:
- Set agent signing mode
- No passphrase prompt needed
↓
During handshake:
- Generate ephemeral X25519 key
- Sign ephemeral key with identity key
- Use gpg_agent_sign() for signature
int gpg_agent_sign(int handle_as_int, const char *keygrip, const uint8_t *message, size_t message_len, uint8_t *signature_out, size_t *signature_len_out)
Sign a message using GPG agent.
Definition agent.c:154

5. HTTPS Key Fetching (https_keys.c/h, discovery_keys.c/h)

Purpose: Fetch public keys from HTTPS URLs (GitHub, GitLab, custom servers)

Supported Formats:

  • SSH public key format (RFC 4253) - e.g., GitHub API
  • OpenPGP armor - e.g., GitLab API
  • HTTPS URLs - e.g., discovery.ascii-chat.com/key.pub

Key Functions:

  • https_keys_fetch() - Fetch key from HTTPS URL using BearSSL
  • discovery_keys_verify() - Verify and cache discovery service keys
  • github_keys_fetch() - Special case for GitHub public keys API
  • gitlab_keys_fetch() - Special case for GitLab public keys API

Trust Model:

User provides: --server-key github:zfogg
↓
Fetch from: GET https://github.com/zfogg.keys
↓
Parse SSH keys from response
↓
During handshake:
- Verify server identity key against fetched key
- If matches: Accept
- If mismatch: Reject (possible MITM)

Security Properties:

  • βœ… Trust delegated to GitHub/GitLab (TLS + identity verification)
  • βœ… User can verify identity via GitHub profile
  • ⚠️ Requires HTTPS access to GitHub/GitLab
  • βœ… No local trust anchors needed

6. Key Validation (keys_validation.c/h)

Purpose: Validate cryptographic keys for security and format correctness

Key Functions:

  • keys_validate_format() - Check key format (SSH, PEM, OpenPGP)
  • keys_validate_size() - Verify key is correct size for algorithm
  • keys_validate_permissions() - Check file permissions (0600 on Unix)
  • keys_validate_expiration() - Check GPG key expiration
  • keys_validate_strength() - Verify key strength (e.g., 256-bit)

Validation Points:

  1. After loading: Validate format, size, permissions
  2. Before use: Validate expiration, strength
  3. During handshake: Validate peer key format and size

Integration:

// When loading a key
keys_validate_format(key_data, key_len); // Check format
keys_validate_size(key_data, key_len); // Check size
keys_validate_permissions(key_path); // Check file perms

7. Discovery Keys (discovery_keys.c/h)

Purpose: Manage ACDS (ASCII-Chat Discovery Service) server keys with caching

Cache Location: ~/.ascii-chat/acds_keys/<hostname>/

Key Functions:

  • discovery_keys_verify() - Verify and cache ACDS server key
  • discovery_keys_invalidate() - Invalidate cache on key change
  • discovery_keys_get_cache() - Get cached key

Trust Model for Official Server:

User wants: ACDS connection to discovery.ascii-chat.com
↓
discovery_keys_verify("discovery.ascii-chat.com", NULL, pubkey)
↓
If cached: Verify cache is valid, use cached key
If not cached:
- Download from https://discovery.ascii-chat.com/key.pub
- Trust without verification (official server)
- Cache key
↓
Use key to verify ACDS server identity
bool valid
asciichat_error_t discovery_keys_verify(const char *acds_server, const char *key_spec, uint8_t pubkey_out[32])
Verify and load ACDS server public key.

Trust Model for Third-Party Server:

User provides: --discovery-service-key https://acds.example.com/key.pub
↓
discovery_keys_verify("acds.example.com", "https://...", pubkey)
↓
Download and verify key (requires explicit specification)
Cache key
↓
Use key to verify ACDS server identity

8. Handshake Protocol (handshake/*)

Purpose: Complete cryptographic handshake implementation

Files:

Phases:

  1. Key Exchange: Exchange ephemeral public keys (X25519)
  2. Authentication: Prove identity via password/key/signature
  3. Session Setup: Negotiate algorithms, establish encryption keys
  4. Ready: Begin encrypted communication

Integration with Other Subsystems:

Handshake
β”œβ”€β”€ Core Crypto (crypto.c)
β”‚ β”œβ”€β”€ Generate ephemeral keys
β”‚ β”œβ”€β”€ Compute shared secret
β”‚ β”œβ”€β”€ Derive password key
β”‚ └── Compute HMAC
β”‚
β”œβ”€β”€ SSH Keys (if --key ~/.ssh/id_ed25519)
β”‚ β”œβ”€β”€ Load SSH key via ssh_keys_parse()
β”‚ β”œβ”€β”€ Check SSH agent
β”‚ └── Sign ephemeral key
β”‚
β”œβ”€β”€ GPG Keys (if --key gpg:zfogg)
β”‚ β”œβ”€β”€ Export key from GPG keyring
β”‚ β”œβ”€β”€ Check GPG agent
β”‚ └── Request signature
β”‚
β”œβ”€β”€ Known Hosts (client-side, if --use-known-hosts)
β”‚ β”œβ”€β”€ Verify server identity key
β”‚ β”œβ”€β”€ Check for key changes
β”‚ └── Prompt user if needed
β”‚
β”œβ”€β”€ GitHub Keys (if --server-key github:zfogg)
β”‚ β”œβ”€β”€ Fetch keys from GitHub API
β”‚ └── Verify server identity
β”‚
└── Discovery Keys (if using ACDS)
β”œβ”€β”€ Verify ACDS server key
└── Establish secure ACDS connection

Data Flow Through Subsystems

Client Initialization

User provides: --key /path/to/key.pem --server-key github:zfogg --use-known-hosts
↓
1. Load identity key
keys.c:1 Load from file/agent/gpg
β”œβ”€β”€ Detect format (SSH/PEM/GPG)
β”œβ”€β”€ Parse with pem.c / ssh_keys.c / gpg/export.c
β”œβ”€β”€ Validate with keys_validation.c
└── Store in memory
2. Load server verification key
https_keys.c:1 Fetch from HTTPS
β”œβ”€β”€ Parse github:zfogg β†’ https://github.com/zfogg.keys
β”œβ”€β”€ Fetch via HTTPS using BearSSL
β”œβ”€β”€ Parse SSH format
└── Store server_identity_key
3. Setup known_hosts verification
known_hosts.c:1 Load ~/.ascii-chat/known_hosts
└── Cache in memory
4. Initialize crypto
crypto.c:1 Initialize libsodium
β”œβ”€β”€ Generate ephemeral X25519 keypair
β”œβ”€β”€ Initialize nonce counter
└── Ready for handshake

Handshake Execution

Client Server
══════════════════════════════════════════════════════════════════════════
1. KEY_EXCHANGE_REQUEST
Send: ephemeral_pub || identity_pub || signature
↓
β”‚ Receive
β”‚ - Parse keys (ssh_keys.c / key_types.h)
β”‚ - Verify signature (crypto.c: ed25519_verify)
β”‚ - Check whitelist (keys_validation.c)
β”‚ - Generate server ephemeral keys (crypto.c)
↓
Send: ephemeral_pub || identity_pub || signature
2. SHARED_SECRET_COMPUTED
crypto.c: DH(client_ephemeral, server_ephemeral)
↓
β”‚ DH(server_ephemeral, client_ephemeral)
β”‚ Same shared secret βœ“
↓
3. AUTHENTICATION
If password mode:
- Compute HMAC: crypto_compute_auth_response()
If signature mode:
- SSH agent sign: ssh_agent_sign()
- GPG agent sign: gpg_agent_sign()
4. KNOWN_HOSTS VERIFICATION (client-side)
known_hosts_verify("localhost:27224", server_identity_key)
β”œβ”€β”€ In cache? Accept βœ“
β”œβ”€β”€ New key? Ask user & save
└── Key changed? Warn & reject
5. READY FOR ENCRYPTION
crypto.c: crypto_is_ready() = true
crypto_result_t crypto_derive_password_key(crypto_context_t *ctx, const char *password)
Derive key from password using Argon2id.
#define true
Definition stdbool.h:62

Integration Patterns

Pattern 1: SSH Key with SSH Agent

User: ./ascii-chat client --key ~/.ssh/id_ed25519
Flow:
1. ssh_keys_parse() detects key is encrypted
3. During handshake:
- ssh_agent_sign() requests signature from agent
- No passphrase prompt needed βœ“

Pattern 2: GPG Key with GPG Agent

User: ./ascii-chat client --key gpg:zfogg
Flow:
1. gpg_export_public_key() loads key from GPG keyring
2. GPG agent available?
- Yes: gpg_agent_sign() in handshake βœ“
- No: Prompt for passphrase

Pattern 3: GitHub Key Verification

User: ./ascii-chat client --server-key github:zfogg
Flow:
1. https_keys_fetch("https://github.com/zfogg.keys")
2. Parse SSH public keys from response
3. During handshake:
- Verify server's identity key matches fetched key
- Blocks MITM attacks βœ“

Pattern 4: Known Hosts Trust On First Use

User: ./ascii-chat client --use-known-hosts
Flow:
1. First connection:
- Verify server identity key signature βœ“
- Key not in known_hosts β†’ Ask user
- User confirms β†’ Save to known_hosts βœ“
2. Second connection:
- Verify identity key signature βœ“
- Key in known_hosts β†’ Accept βœ“
3. If server key changes:
- Warn "Server key has changed!" ⚠️
- Possible MITM attack

Pattern 5: Discovery Keys with ACDS

User: ./ascii-chat client --discovery-service acds.example.com
Flow:
1. discovery_keys_verify("acds.example.com", NULL, pubkey)
2. If official server:
- Download https://discovery.ascii-chat.com/key.pub
- Cache without verification βœ“
3. Connect to ACDS server:
- Verify ACDS identity with cached key βœ“
4. ACDS provides session discovery

File Locations Summary

Source Code:

  • lib/crypto/*.c/*.h - Implementation files

Configuration & Keys:

  • ~/.ascii-chat/ - Config directory
  • ~/.ascii-chat/authorized_clients.txt - Server whitelist
  • ~/.ascii-chat/known_hosts - Known servers (SSH-style)
  • ~/.ascii-chat/acds_keys/ - Cached ACDS server keys
  • ~/.ssh/ - User SSH keys
  • ~/.gnupg/ - User GPG keyring

Headers (Public API):

Documentation:

Building & Testing

Build Configuration:

# From CMakeLists.txt
add_library(ascii-chat-lib-crypto
lib/crypto/crypto.c
lib/crypto/handshake/client.c
lib/crypto/handshake/server.c
lib/crypto/handshake/common.c
lib/crypto/ssh/ssh_agent.c
lib/crypto/ssh/ssh_keys.c
lib/crypto/gpg/agent.c
lib/crypto/gpg/export.c
# ... etc
)
target_link_libraries(ascii-chat-lib-crypto libsodium BearSSL)

Dependencies:

  • libsodium - Modern cryptography (X25519, XSalsa20-Poly1305, Argon2id, Ed25519)
  • BearSSL - TLS for HTTPS key fetching
  • OpenSSL - System integration (gpg-agent communication)
  • libgpgme (optional) - GPG operations

Testing:

# Unit tests for crypto operations
ctest --test-dir build -R crypto
# Handshake protocol tests
ctest --test-dir build -R handshake
# SSH key tests
ctest --test-dir build -R ssh
# Known hosts tests
ctest --test-dir build -R known_hosts

Performance Considerations

Initialization:

  • crypto_init() - ~100ΞΌs (libsodium init + key generation)
  • ssh_keys_parse() - ~50ΞΌs (format detection + parsing)
  • gpg_export_public_key() - ~500ms (calls external gpg process)

Handshake:

  • Key exchange - ~500ΞΌs (X25519 computation)
  • HMAC computation - ~50ΞΌs (SHA256)
  • SSH agent sign - ~1ms (IPC + SSH protocol)
  • GPG agent sign - ~50ms (IPC + GPG protocol)
  • HTTPS key fetch - ~200ms (network + TLS)

Per-Packet:

  • Encryption - ~20ΞΌs per KB (XSalsa20)
  • Decryption - ~20ΞΌs per KB (XSalsa20)
  • MAC verification - ~10ΞΌs (Poly1305)

Optimization Tips:

  1. Cache SSH agent public keys (avoid repeated queries)
  2. Cache HTTPS fetched keys (use discovery_keys for ACDS)
  3. Use password auth for repeated connections (faster than SSH agent)
  4. Prefer SSH agent over GPG agent (faster protocol)

Security Properties

Encryption:

  • βœ… XSalsa20-Poly1305 (authenticated encryption)
  • βœ… Constant-time MAC verification
  • βœ… Unique nonces (session_id || counter)
  • βœ… Automatic rekeying (1 hour or 1M packets)

Key Exchange:

  • βœ… X25519 ephemeral keys (forward secrecy)
  • βœ… Signature-based authentication (when provided)
  • βœ… Password-based optional encryption (Argon2id KDF)

Authentication:

  • βœ… HMAC-SHA256 challenge-response
  • βœ… Ed25519 signatures
  • βœ… SSH agent integration (keys never in memory)
  • βœ… GPG agent integration (keys never in memory)

Vulnerabilities (Known & Fixed):

  • βœ… Replay attacks (fixed via session ID in nonce)
  • βœ… Timing attacks (fixed via sodium_memcmp)
  • βœ… Code duplication (fixed via shared functions)
  • See docs/topics/crypto.dox for full vulnerability disclosure

Future Enhancements

  1. Post-Quantum Cryptography
    • Hybrid X25519-Kyber1024 key exchange
    • Hybrid Ed25519-Dilithium3 signatures
    • Status: RFC in progress
  2. Verification Server
    • Global key registry (like Certificate Authorities)
    • Key pinning without pre-sharing
    • Status: Planned (Issue #82)
  3. Hardware Security Modules
    • PKCS#11 support
    • YubiKey integration
    • Status: Research phase
  4. Double Ratchet Protocol
    • Signal-style per-message forward secrecy
    • Status: Deferred (session rekeying sufficient)
See also
Cryptography Reference
Client-Side Crypto Details
Server-Side Crypto Details
Handshake Protocol Specification