The cryptographic handshake protocol establishes secure, authenticated connections between ascii-chat clients and servers. The handshake implements mutual authentication, key exchange, and session establishment with perfect forward secrecy.
Overview
The handshake protocol manages the complete security setup for each connection:
- Capabilities negotiation: Client and server agree on cryptographic algorithms
- Parameters negotiation: Algorithm-specific sizes and configuration
- Key exchange: Diffie-Hellman key exchange establishes shared secret
- Authentication: Password or client key authentication proves identity
- Session establishment: Encryption keys derived and encryption enabled
Implementation: lib/crypto/handshake.c/h
Key Features:
- Perfect forward secrecy via ephemeral X25519 key exchange
- Mutual authentication (client and server both prove identity)
- Password-based or SSH key-based authentication
- Server identity verification via known_hosts (TOFU)
- Client whitelisting for access control
- Session rekeying for long-lived connections
- State machine with strict validation
Handshake Sequence
The complete handshake follows this sequence:
Phase 0: Algorithm Negotiation
Client → Server: CRYPTO_CAPABILITIES
- Client advertises supported algorithms (key exchange, authentication, cipher)
- Client specifies preferred algorithms
- Client indicates whether server verification is required
Server → Client: CRYPTO_PARAMETERS
- Server selects algorithms based on client capabilities
- Server provides algorithm-specific sizes (key sizes, signature sizes, etc.)
- Server indicates whether verification is enabled
Packet Formats:
typedef struct {
typedef struct {
Crypto capabilities packet structure (Packet Type 14)
Crypto parameters packet structure (Packet Type 15)
Current Algorithms:
- Key Exchange: X25519 (Curve25519 Diffie-Hellman)
- Authentication: Ed25519 (Edwards-curve signatures)
- Cipher: XSalsa20-Poly1305 (authenticated encryption)
Future-Proofing: The negotiation protocol supports post-quantum algorithms. Future versions can add Kyber (key exchange), Dilithium (signatures), and hybrid modes (classical + post-quantum) without breaking compatibility.
Phase 1: Key Exchange Init (Server → Client)
Server sends: CRYPTO_KEY_EXCHANGE_INIT
The server sends its ephemeral public key and optionally its identity key:
Simple Format (no server identity key):
typedef struct {
} key_exchange_init_simple_t;
Authenticated Format (server has identity key):
typedef struct {
} key_exchange_init_authenticated_t;
Client Processing:
- Extract ephemeral public key (always present)
- If identity key present:
- Verify signature proves server has identity private key
- Check against
--server-key if provided (must match)
- Check against
known_hosts file (TOFU verification)
- If first connection: prompt user to save to known_hosts
- Generate client's ephemeral keypair
- Compute shared secret:
shared_secret = X25519(client_private, server_public)
Security Note: The signature cryptographically binds the ephemeral key to the identity key, preventing man-in-the-middle attacks. An attacker cannot replace the ephemeral key without also possessing the identity private key.
Phase 2: Key Exchange Response (Client → Server)
Client sends: CRYPTO_KEY_EXCHANGE_RESP
The client responds with its ephemeral public key and optionally its identity key:
Simple Format (no client identity key):
typedef struct {
} key_exchange_response_simple_t;
Authenticated Format (client has identity key):
typedef struct {
} key_exchange_response_authenticated_t;
Server Processing:
- Extract client's ephemeral public key (always present)
- If identity key present:
- Verify signature proves client has identity private key
- Check against
--client-keys whitelist if provided
- Reject if whitelist enabled and client not found
- Compute shared secret:
shared_secret = X25519(server_private, client_public)
At this point both sides have:
- ✅ Ephemeral Diffie-Hellman shared secret (32 bytes)
- ✅ Peer's identity public key (if authenticated mode)
- ✅ Verified peer's identity (signature verification)
Phase 3: Authentication Challenge (Server → Client)
Server sends: CRYPTO_AUTH_CHALLENGE
The server sends an authentication challenge with flags indicating requirements:
typedef struct {
} auth_challenge_packet_t;
Flags:
AUTH_REQUIRE_PASSWORD (0x01): Server requires password authentication
AUTH_REQUIRE_CLIENT_KEY (0x02): Server requires client key authentication (whitelist)
Client Processing:
- Extract challenge nonce (32 random bytes)
- Determine authentication method based on flags and client configuration
- Prepare authentication response:
- Password mode:
HMAC(password_key, nonce || shared_secret) where password_key = Argon2id(password, salt) (derived from password)
- Client key mode:
HMAC(shared_secret, nonce) if no password, otherwise still use password_key for HMAC
- Bind authentication to shared secret to prevent replay attacks
Security Note: Authentication is bound to the DH shared secret. This prevents an attacker from replaying authentication responses without knowing the shared secret, even if they intercepted the initial key exchange packets.
Phase 4: Authentication Response (Client → Server)
Client sends: CRYPTO_AUTH_RESPONSE
The client sends an HMAC proving knowledge of the password or shared secret:
typedef struct {
} auth_response_packet_t;
Server Processing:
- Extract HMAC from response
- Recompute expected HMAC using same method as client:
- Password mode:
HMAC(password_key, nonce || shared_secret)
- No password:
HMAC(shared_secret, nonce)
- Constant-time compare with received HMAC
- If HMAC matches:
- If client sent identity key: Verify client is in whitelist
- Proceed to handshake completion
- If HMAC mismatch: Send
CRYPTO_AUTH_FAILED and terminate handshake
HMAC Binding: The HMAC includes both the challenge nonce AND the shared secret. This ensures that:
- Authentication cannot be replayed by an attacker who doesn't know the shared secret
- Even if an attacker intercepts the challenge, they cannot forge a response
- The authentication is cryptographically bound to this specific session
Phase 5: Server Authentication (Optional - Server → Client)
Server sends: CRYPTO_SERVER_AUTH_RESP
If client requested server verification, server proves knowledge of shared secret:
typedef struct {
} server_auth_response_packet_t;
Client Processing:
- Extract server's HMAC
- Recompute expected HMAC:
HMAC(shared_secret, client_challenge_nonce || shared_secret)
- Constant-time compare with received HMAC
- If HMAC matches: Server has proven knowledge of shared secret (mutual authentication)
- If HMAC mismatch: Reject connection (possible MITM attack)
Mutual Authentication: This phase ensures both parties have proven knowledge of the shared secret. Combined with the identity key signatures in phase 1/2, this provides strong mutual authentication: server identity (Ed25519 signature) + shared secret (HMAC).
Phase 6: Handshake Complete (Server → Client)
Server sends: CRYPTO_HANDSHAKE_COMPLETE
Empty packet (0 bytes payload) indicating handshake is complete:
Both Sides:
- ✅ Handshake state transitions to
CRYPTO_HANDSHAKE_READY
- ✅ Session encryption keys derived from shared secret
- ✅ All subsequent packets encrypted with XSalsa20-Poly1305
- ✅ Nonce counters initialized for send/receive directions
After Completion:
- All application packets (video, audio, control) are wrapped in
PACKET_TYPE_ENCRYPTED
- Handshake packets are NEVER encrypted (they use
packet_is_handshake_type() check)
- Encryption is automatic via
send_packet_secure() / receive_packet_secure()
State Machine
The handshake follows a strict state machine with validation at each transition:
Handshake States:
typedef enum {
crypto_handshake_state_t
Cryptographic handshake state enumeration.
@ CRYPTO_HANDSHAKE_FAILED
@ CRYPTO_HANDSHAKE_AUTHENTICATING
@ CRYPTO_HANDSHAKE_DISABLED
@ CRYPTO_HANDSHAKE_KEY_EXCHANGE
State Transitions:
ANY → FAILED (on error)
asciichat_error_t crypto_handshake_init(crypto_handshake_context_t *ctx, bool is_server)
Initialize crypto handshake context.
asciichat_error_t crypto_handshake_client_auth_response(crypto_handshake_context_t *ctx, socket_t client_socket)
Client: Process auth challenge and send response.
asciichat_error_t crypto_handshake_client_key_exchange(crypto_handshake_context_t *ctx, socket_t client_socket)
Client: Process server's public key and send our public key.
asciichat_error_t crypto_handshake_client_complete(crypto_handshake_context_t *ctx, socket_t client_socket)
Client: Wait for handshake complete confirmation.
asciichat_error_t crypto_handshake_server_complete(crypto_handshake_context_t *ctx, socket_t client_socket)
Server: Process auth response and complete handshake.
asciichat_error_t crypto_handshake_server_start(crypto_handshake_context_t *ctx, socket_t client_socket)
Server: Start crypto handshake by sending public key.
asciichat_error_t crypto_handshake_server_auth_challenge(crypto_handshake_context_t *ctx, socket_t client_socket)
Server: Process client's public key and send auth challenge.
State Validation: All handshake functions validate current state before proceeding. Invalid state transitions return ERROR_INVALID_STATE. This prevents:
- Out-of-order packet processing
- Race conditions in concurrent handshakes
- Protocol violations
Authentication Methods
Password Authentication
Password-based authentication uses Argon2id key derivation:
Key Derivation:
memset(salt, 0, sizeof(salt));
memcpy(salt, "ascii-chat-password-salt-v1", 27);
crypto_pwhash(
password_key, 32,
password, strlen(password),
salt,
crypto_pwhash_OPSLIMIT_INTERACTIVE,
crypto_pwhash_MEMLIMIT_INTERACTIVE,
crypto_pwhash_ALG_DEFAULT
);
#define ARGON2ID_SALT_SIZE
Argon2id salt size in bytes.
Authentication:
memcpy(input, nonce, 32);
memcpy(input + 32, shared_secret, 32);
crypto_generichash_state state;
crypto_generichash_init(&state, password_key, 32, 32);
crypto_generichash_update(&state, input, 64);
crypto_generichash_final(&state, hmac, 32);
Security Properties:
- Password is never transmitted over the network
- Argon2id provides strong key derivation (resistant to GPU/ASIC attacks)
- HMAC binding to shared secret prevents offline password guessing
- Salt prevents rainbow table attacks
Client Key Authentication
SSH Ed25519 key-based authentication for client identity:
Key Exchange:
- Client sends Ed25519 identity public key in
CRYPTO_KEY_EXCHANGE_RESP
- Client signs ephemeral X25519 public key with Ed25519 private key
- Server verifies signature and checks client key against whitelist
Whitelist Checking:
bool client_authorized = false;
for (size_t i = 0; i < num_whitelisted_clients; i++) {
if (memcmp(client_identity_key, whitelist[i].public_key, 32) == 0) {
client_authorized = true;
break;
}
}
if (!client_authorized && require_client_auth) {
"Client not in whitelist");
}
#define SET_ERRNO(code, context_msg,...)
Set error code with custom context message and log it.
Security Properties:
- Client identity proven via Ed25519 signature
- Server maintains whitelist for access control
- Forward secrecy maintained (ephemeral keys still used for encryption)
- Identity key never used for encryption (separation of identity and encryption)
Server Identity Verification
Server identity verification via known_hosts (TOFU - Trust On First Use):
Known Hosts File: ~/.config/ascii-chat/known_hosts Format: IP_address public_key timestamp
Verification Flow:
if (server_has_identity_key) {
if (expected_server_key &&
memcmp(server_identity_key, expected_server_key, 32) != 0) {
}
char known_hosts_path[256];
snprintf(known_hosts_path, sizeof(known_hosts_path),
"%s/.config/ascii-chat/known_hosts", getenv("HOME"));
if (known_hosts_has_entry(known_hosts_path, server_ip, server_port)) {
known_hosts_get_key(known_hosts_path, server_ip, server_port, saved_key);
if (memcmp(server_identity_key, saved_key, 32) != 0) {
return ERROR_CRYPTO_KNOWN_HOSTS;
}
} else {
printf("The authenticity of server %s:%d can't be established.\n"
"Server's Ed25519 key fingerprint is:\n"
"%s\n"
"Are you sure you want to continue connecting? (yes/no) ",
server_ip, server_port, key_fingerprint);
known_hosts_add_entry(known_hosts_path, server_ip, server_port,
server_identity_key);
}
}
Security Properties:
- TOFU model: Trust server on first connection, verify on subsequent
- Detects MITM attacks if server key changes between connections
- User confirmation required for first connection
- Manual server key verification via
--server-key overrides TOFU
Session Rekeying
ascii-chat implements periodic session rekeying to provide forward secrecy within long-lived connections. After the initial handshake, the system performs new Diffie-Hellman key exchanges to rotate encryption keys.
Rekeying Triggers:
- Time threshold: Rekey after 1 hour of active connection
- Packet count threshold: Rekey after 1 million encrypted packets
- Whichever comes first triggers rekeying
Rekeying Protocol:
The rekeying flow uses a 3-packet handshake similar to the initial key exchange:
Initiator Responder
| |
|--- CRYPTO_REKEY_REQUEST -------->|
| [new_ephemeral_pk[32]] |
| | Generate new ephemeral keypair
| | Compute temp_shared_secret
| |
|<-- CRYPTO_REKEY_RESPONSE --------|
| [new_ephemeral_pk[32]] |
| |
| Compute temp_shared_secret |
| |
|--- CRYPTO_REKEY_COMPLETE ------->|
| [empty, encrypted with NEW key]|
| | Decrypt with temp_shared_secret
| | If successful, commit to new key
| |
| ✅ Commit to new key | ✅ Commit to new key
Key Commitment:
- Old keys remain active until
CRYPTO_REKEY_COMPLETE is verified
- This ensures no service interruption during rekeying
REKEY_COMPLETE packet is encrypted with NEW shared secret to prove both sides computed the same secret
- Once verified, both sides replace
shared_key with temp_shared_key
- Old keys are securely zeroed from memory
Security Benefits:
- Limits exposure if encryption key is compromised
- Periodic key rotation maintains forward secrecy
- Old session data cannot be decrypted with new keys
- Automatic rekeying requires no user intervention
Security Considerations
Forward Secrecy
Perfect Forward Secrecy:
- Each connection uses unique ephemeral X25519 keypairs
- Shared secret is computed fresh for each session
- Ephemeral keys are destroyed after handshake completion
- Compromising identity keys does NOT reveal past session keys
- Recorded traffic cannot be decrypted even if identity keys are later compromised
Why This Matters:
- Long-term identity keys may be compromised months or years later
- Forward secrecy ensures past conversations remain confidential
- Each session is cryptographically isolated
- Regular rekeying limits exposure for long-lived connections
MITM Protection
Identity Key Signatures:
- Server signs ephemeral key with identity key (if
--key provided)
- Client signs ephemeral key with identity key (if
--key provided)
- Signatures prove ownership of identity keys
- Attacker cannot forge signatures without identity private keys
Known Hosts Verification:
- First connection: User confirms server identity (TOFU)
- Subsequent connections: Automatic verification against known_hosts
- Key changes detected (possible MITM attack)
- Manual verification via
--server-key provides stronger guarantee
HMAC Binding:
- Authentication responses are bound to DH shared secret
- Attacker cannot replay authentication without knowing shared secret
- Prevents authentication bypass even if challenge is intercepted
Replay Protection
Challenge Nonces:
- Server generates random 32-byte nonce for each handshake
- Nonce is unique per handshake attempt
- Authentication responses cannot be replayed to different challenges
Shared Secret Binding:
- HMAC includes both nonce AND shared secret
- Even if nonce is reused (shouldn't happen), attacker needs shared secret
- Prevents replay attacks across different sessions
Timing Attack Resistance
Constant-Time Operations:
- HMAC comparison uses constant-time memcmp
- Key comparisons use constant-time sodium_memcmp
- No data-dependent branches in crypto operations
- Resistant to timing side-channel attacks
Usage Examples
Server-Side Handshake
return err;
}
if (server_key_file) {
ed25519_key_t server_key;
err = load_ssh_key(server_key_file, &server_key, NULL);
sodium_memzero(&server_key, sizeof(server_key));
}
}
if (client_keys_file) {
}
}
if (password) {
}
&envelope);
}
.kex_public_key_size = 32,
.auth_public_key_size = 32,
.signature_size = 64,
.shared_secret_size = 32,
.nonce_size = 24,
.mac_size = 16,
.hmac_size = 32
};
asciichat_error_t crypto_handshake_set_parameters(crypto_handshake_context_t *ctx, const crypto_parameters_packet_t *params)
Set crypto parameters from crypto_parameters_packet_t.
asciichat_error_t
Error and exit codes - unified status values (0-255)
uint8_t selected_kex
Selected key exchange algorithm (KEX_ALGO_*)
#define AUTH_ALGO_ED25519
Ed25519 authentication (Edwards-curve signatures)
#define KEX_ALGO_X25519
X25519 key exchange (Curve25519)
packet_recv_result_t receive_packet_secure(socket_t sockfd, void *crypto_ctx, bool enforce_encryption, packet_envelope_t *envelope)
Receive a packet with decryption and decompression support.
packet_type_t type
Packet type (from packet_types.h)
packet_recv_result_t
Packet reception result codes.
#define CIPHER_ALGO_XSALSA20_POLY1305
XSalsa20-Poly1305 authenticated encryption.
@ PACKET_RECV_SUCCESS
Packet received successfully.
@ PACKET_TYPE_CRYPTO_CAPABILITIES
Client -> Server: Supported crypto algorithms (UNENCRYPTED)
Cryptographic handshake context structure.
public_key_t server_public_key
private_key_t server_private_key
public_key_t * client_whitelist
size_t num_whitelisted_clients
Packet envelope containing received packet data.