SCP - Secure Channel Protocol
A custom TLS-inspired secure channel protocol :3 implementing PSK auth, X25519 ephemeral key exchange and ChaCha20-Poly1305 encryption. Shout out to keeb for name. ^_^
Table of Contents
Overview
SCP creates a secure, bidirectional communication channel between two parties over TCP. It provides mutual authentication using a pre-shared key (PSK) and authenticated encryption using ChaCha20-Poly1305.
SCP is *NOT* a replacement of TLS, it is an independant protocol i built to learn and demonstrate the core things of a secure channel like handshake, key exchange, key derivation, encryption, etc. This is all implemented in Go.
The protocol completes in 2 round trips before the data phase begins. Both parties must have the same PSK before initialising the connection, there is NO certification infrastructure (yet).
Packet Format
Every SCP message is framed as a packet with 5 byte header followed by a variable length payload.
+------------+------------------+----------------------+ | Type (1B) | Length (4B, BE) | Payload (N bytes) | +------------+------------------+----------------------+
Type - tells the message type (see Section 3)
Length - unsigned 32-bit big-endian integer, length of the payload in bytes
Payload - message specific content
The maximum payload size is 2^32 - 1 bytes. You should enforce limits appropriate to your context if you are using the package.
Message Types
| Value | Name | Direction | Description |
|---|---|---|---|
| 0x01 | MsgClientHello | C -> S | Starts the handshake. Contains client ephemeral public key, none and PSK Proof. |
| 0x02 | MsgServerHello | S -> C | Server response, contains the same stuff |
| 0x03 | MsgDone | C <-> S | Handshake verification, payload is an encrypted MAC proving session key derviation was success |
| 0x04 | MsgData | C <-> S | Encrypted application data, contains nonce and ChaCha20-Poly1305 encrypted ciphertext |
| 0x05 | MsgError | C <-> S | Protocol error, contains error code and human readable message, terminates connection |
Handshake
The SCP handshake complestes in 2 round trips
Client Server | | |--- ClientHello -------->| | pub[32] nonce[16] | | psk_proof[32] | | | |<-- ServerHello ---------| | pub[32] nonce[16] | | psk_proof[32] | | | | [both derive ECDH + | | session key via HKDF] | | | |--- Done --------------->| | Enc(HMAC(sk, | | "client-done")) | | | |<-- Done ----------------| | Enc(HMAC(sk, | | "server-done")) | | | |<=== channel open ======>|
PSK Proof - ClientHello
The client computes its PSK proof as:
client_psk_proof = HMAC-SHA256(PSK, client_nonce || client_public key)
The nonce binds the proof to this specific session, preventing replay attacks.
PSK Proof - ServerHello
The server computes its PSK proof as:
server_psk_proof = HMAC-SHA256(PSK, client_nonce || server_nonce || server_public_key || client_public_key)
The server proof includes both nonces and both public keys, binding it to the exact exchange in progress.
Done Messages
After deriving the session key each party sends a MsgDone packet to prove they derived the correct key. the client sends first:
client_done_mac = HMAC-SHA256(sessionKey, "client-done") client_done_nonce = nonceCounter.Next() client_done_payload = ChaCha20-Poly1305_Encrypt(key=sessionKey, nonce=client_done_nonce, plaintext=client_done_mac)
The server verifies, then responds symmetrically with "server-done" as the "domain separator".
Domain separation ("client-done" vs "server-done") ensures neither party's Done can be replayed as the other's, even though both use the same session key
Error Handling
If any verification step fails, the detecing party sends MsgError with the appropriate error code and closes the connection.
| Code | Name | Condition |
|---|---|---|
| 0x00 | ErrUnknown | Unspecified error |
| 0x01 | ErrInvalidPSK | PSK Proof verification failed |
| 0x02 | ErrHandshakeFail | Done MAC verification failed |
| 0x03 | ErrDecryptFail | ChaCha20-Poly1305 decryption failed |
| 0x04 | ErrInvalidMessage | Unexpected message type received |
Key Derivation
The session key is derived from ECDH shared secret using HKDF-SHA256:
shared_secret = X25519(my_private_key, peer_public_key)
session_key = HKDF-SHA256(
ikm = shared_secret, // raw ECDH output
salt = PSK, // authenticates key derivation
info = "SCP-session"
|| client_nonce
|| server_nonce, // binds key to this specific session
len = 32 bytes
)ikm - the raw X25519 output is the primary entropy source. HKDF conditions it into a uniform key suitable for ChaCha20-Poly1305
salt = PSK - using the PSK as HKDF salt mixes authentication into key derivation. A session key derived without the correct PSK will differ from the one derived WITH it, even if the ECDH exchange produces same raw output.
info - the context string "SCP-session" + both nonces make the the derived key unique to to this handshake. Two sessions with the same PSK and same ECDH key but different nonce will produce different session key, so thats never gonna happens.
Data Phase
After a sucecssful handshake, both parties may exchange MsgData packets in either direction, each packet is independently encrypted and authenticated.
to do: add ts
Nonce - a 12-byte big-endian encoded counter, it increments with every sent message and is included in the packet so the receiver can decrypt without maintaning synchronized state.
Each party maintains an independent send counter, client and server nonce sequence do not interfere with each other
Cryptographic Primitives
| Primitive | Algorithm | Purpose |
|---|---|---|
| Key Exchange | X25519 (RFC 7748) | Ephemeral ECDH |
| Symmetric cipher | ChaCha20-Poly1305 (RFC 8439) | AEAD encryption |
| Key Derivation | HKDF-SHA256 (RFC 5869) | Session key from ECDH output |
| MAC | HMAC-SHA256 (RFC 2104) | PSK Proofs + Done verification |
| Handshake nonce | CSPRNG, 16 bytes | Session binding |
| Data nonce | Counter, 12 bytes | Encrypting nonce |
Wire Format Reference
MsgClientHello (0x01) - 80 bytes payload
Offset Size Field 0 32 client_ephemeral_public_key X25519 public key 32 16 client_nonce Random, 128-bit 48 32 psk_proof HMAC-SHA256(PSK, nonce||pub_key)
MsgServerHello (0x02) - 80 bytes payload
Offset Size Field 0 32 server_ephemeral_public_key X25519 public key 32 16 server_nonce Random, 128-bit 48 32 psk_proof HMAC-SHA256(PSK, c_nonce||s_nonce||s_pub||c_pub)
MsgDone (0x03) - 60 bytes payload
Offset Size Field 0 12 nonce ChaCha20-Poly1305 nonce (counter) 12 48 ciphertext Encrypt(sessionKey, nonce, HMAC(sessionKey, role))
role is "client-done" or "server-done". HMAC output is 32 bytes + 16 byte Poly1305 tag = 48 bytes ciphertext.
MsgData (0x04) - variable payload
Offset Size Field 0 12 nonce ChaCha20-Poly1305 nonce (send counter) 12 N+16 ciphertext Encrypt(sessionKey, nonce, plaintext) + 16B auth tag
MsgError (0x05) - variable payload
Offset Size Field 0 1 error_code See Section 4.4 1 N message UTF-8 string, human-readable description
SCP is an educational protocol. Do NOT use in production systems lmao. Implementation: github.com/pratiksingh94/scp