sequenceDiagram
participant C as Client
participant S as Server (UDP listener)
participant FW as Firewall
Note over C: 1. Generate ephemeral Curve25519 keypair
Note over C: 2. ECDH(ephemeral_priv, server_pub) → shared_key
Note over C: 3. Build plaintext: timestamp‖random_nonce‖target_ip‖port
Note over C: 4. Encrypt plaintext → ciphertext (ChaCha20-Poly1305)
Note over C: 5. Assemble packet: version‖ephem_pub‖nonce‖ciphertext
Note over C: 6. Sign packet[0:101] with Ed25519 client_priv
C->>S: UDP packet (165 bytes) [port appears CLOSED]
Note over S: 7. Validate size and version byte
Note over S: 8. ECDH(server_priv, ephem_pub) → shared_key
Note over S: 9. Decrypt ciphertext → plaintext
Note over S: 10. Verify Ed25519 sig against client whitelist
Note over S: 11. Check timestamp within ±60s
Note over S: 12. Check random_nonce not seen before
Note over S: 13. Resolve target IP (:: → source IP)
S->>FW: Add ACCEPT rule for target_ip:port
Note over FW: Rule active for knock_timeout (default 30s)
C->>S: TCP connect (SSH, HTTPS, etc.)
Note over FW: Timer fires → rule removed
Protocol Specification
openme SPA Protocol v1
openme protocol, SPA wire format, single packet authentication spec, Curve25519 SPA, Ed25519 knock, ChaCha20-Poly1305 encryption, openme v1, packet format, cryptography, handshake, replay protection
This page is the authoritative specification of the openme wire protocol. It is intended for security researchers, developers implementing compatible clients, and anyone who wants to understand what happens on the wire.
Design Goals
| Goal | How |
|---|---|
| Port always closed | UDP only; server sniffs passively, never responds |
| Payload opacity | Full ChaCha20-Poly1305 AEAD — packet indistinguishable from random bytes |
| Authentication | Ed25519 signature verified against a per-client whitelist |
| Forward secrecy | Ephemeral Curve25519 ECDH keypair generated fresh per knock |
| Replay resistance | Timestamp window (±60s) + 128-bit random nonce seen-cache |
| Simplicity | Single 165-byte datagram; no session state; no round trips |
Version
This document describes protocol version 1, indicated by the first byte of every packet.
Packet Format
Every openme knock is a single UDP datagram of exactly 165 bytes. Packets of any other size are silently discarded by the server.
Outer Packet
0 1 33 45 101 165
┌───────┬──────┬───────┬─────────────────────┬─────────────────────┐
│version│ephem │ nonce │ ciphertext │ ed25519_sig │
│ 1 byte│pubkey│12bytes│ 56 bytes │ 64 bytes │
│ │32 byt│ │ (plaintext + tag) │ │
└───────┴──────┴───────┴─────────────────────┴─────────────────────┘
◄───────────────── signed portion (101 bytes) ────────────────────►
| Field | Offset | Size | Description |
|---|---|---|---|
version |
0 | 1 | Protocol version. Currently 0x01. |
ephemeral_pubkey |
1 | 32 | Client’s ephemeral Curve25519 public key for this knock. |
nonce |
33 | 12 | ChaCha20-Poly1305 AEAD nonce (random, per knock). |
ciphertext |
45 | 56 | Encrypted plaintext (40 bytes) + AEAD authentication tag (16 bytes). |
ed25519_sig |
101 | 64 | Ed25519 signature over bytes 0–100 (the signed portion). |
Total: 165 bytes.
The Ed25519 signature covers all fields except itself — bytes 0 through 100 inclusive. This prevents any field from being tampered with after signing.
Plaintext Payload
After successful AEAD decryption, the 40-byte plaintext contains:
0 8 24 40
┌───────────┬──────────┬──────────┐
│ timestamp │ random │target_ip │
│ 8 bytes │ nonce │ 16 bytes │
│ (int64 ns)│ 16 bytes │ (IPv6) │
└───────────┴──────────┴──────────┘
| Field | Offset | Size | Description |
|---|---|---|---|
timestamp |
0 | 8 | Unix nanoseconds (big-endian int64). Used for replay window check. |
random_nonce |
8 | 16 | 128 bits of CSPRNG output. Used for nonce uniqueness check. |
target_ip |
24 | 16 | IPv6 address (or IPv4-mapped IPv6) to open the firewall to. All-zeros = use source IP of knock packet. |
No Port Field
The plaintext contains no port number. Ports are determined entirely by the server’s per-client configuration (allowed_ports in the server config). This is a deliberate security decision: the client cannot request ports it is not authorised for, and the attack surface of the packet format is minimised.
IPv4 and IPv6
target_ip is always 16 bytes. IPv4 addresses are stored as IPv4-mapped IPv6 addresses (::ffff:a.b.c.d). All-zeros (::) means “use the source IP of the knock packet.”
Size Breakdown
| Component | Size |
|---|---|
version |
1 byte |
ephemeral_pubkey |
32 bytes |
nonce |
12 bytes |
timestamp (plaintext) |
8 bytes |
random_nonce (plaintext) |
16 bytes |
target_ip (plaintext) |
16 bytes |
| AEAD tag | 16 bytes |
ed25519_sig |
64 bytes |
| Total | 165 bytes |
Byte-Level Example
A valid knock from 192.168.1.10 requesting the firewall open for its own source IP:
Offset Bytes (hex) Field
000000 01 version = 1
000001 a3f1...b2 (32 bytes) ephemeral_pubkey
000021 c4d5e6f7a8b9 (12 bytes) nonce
00002d <56 bytes AEAD ciphertext+tag> ciphertext
000065 <64 bytes Ed25519 signature> ed25519_sig
Decrypted plaintext:
Offset Bytes (hex) Field
000000 0000018c4a3b2100 timestamp (ns since epoch)
000008 <16 random bytes> random_nonce
000018 00000000000000000000000000000000 target_ip = :: (wildcard → source IP)
Cryptography
openme uses a small, carefully chosen set of modern primitives. Every choice has a rationale grounded in current cryptographic consensus.
Primitives at a Glance
| Purpose | Algorithm | Standard |
|---|---|---|
| Key agreement | Curve25519 ECDH | RFC 7748 |
| Key derivation | HKDF-SHA256 | RFC 5869 |
| Symmetric encryption + authentication | ChaCha20-Poly1305 | RFC 8439 |
| Signature | Ed25519 | RFC 8032 |
| Replay nonce | 128-bit CSPRNG | — |
Key Types
Curve25519 — Server Static Keypair
The server holds a static Curve25519 keypair:
- Private key: 32 bytes, clamped per RFC 7748 §5, stored in
/etc/openme/config.yamlat0600 - Public key: 32 bytes, distributed to clients out-of-band (printed by
openme init, embedded in client config and QR)
The server private key must be kept secret. Compromise allows an attacker to decrypt any captured knock packet sent after the compromise — but not packets sent before (forward secrecy is provided by the client’s ephemeral key, not the server’s static key). See Security Model.
Curve25519 — Client Ephemeral Keypair
The client generates a fresh Curve25519 keypair for every knock:
ephemeral_priv ← CSPRNG(32 bytes), clamped
ephemeral_pub ← Curve25519(ephemeral_priv, basepoint)
The ephemeral private key is used once to derive the shared secret and then discarded. It is never stored or transmitted. This is the source of forward secrecy: a future attacker who compromises the server’s static key cannot decrypt past knock packets because the ephemeral private key no longer exists.
Ed25519 — Client Signing Keypair
Each registered client holds an Ed25519 keypair:
- Private key: stored in
~/.openme/config.yamlat0600 - Public key: registered on the server in
/etc/openme/config.yaml
The signature proves the knock was sent by the registered client and not by an unauthenticated third party.
Key Derivation
Raw Curve25519 output is not suitable as a symmetric key directly. openme passes it through HKDF-SHA256 (RFC 5869):
shared_secret_raw = Curve25519(local_priv, remote_pub)
symmetric_key = HKDF-SHA256(ikm=shared_secret_raw, salt=∅, info="openme-v1-chacha20poly1305", len=32)
The info string binds the derived key to this specific protocol version and cipher, preventing cross-protocol key reuse.
Encryption
The 40-byte plaintext is encrypted with ChaCha20-Poly1305 (RFC 8439):
ciphertext || tag = ChaCha20-Poly1305-Seal(key=symmetric_key, nonce=nonce, plaintext=payload, aad=∅)
keyis 32 bytes derived via HKDF abovenonceis 12 bytes of CSPRNG output, included in the packet- The 16-byte authentication tag is appended to the ciphertext (56 bytes total)
- If the tag verification fails, the server discards the packet silently
ChaCha20-Poly1305 was chosen over AES-GCM because it is:
- Fast in software without hardware AES acceleration (important on ARM servers)
- Not vulnerable to timing side-channels on platforms lacking AES-NI
- The cipher used by WireGuard, TLS 1.3, and Signal — well-studied
Signing
The Ed25519 signature covers bytes 0–100 of the packet (everything except the signature field itself):
sig = Ed25519-Sign(client_ed25519_priv, packet[0:101])
The server iterates its client whitelist and calls Ed25519-Verify for each registered public key until one matches (or all fail).
Ed25519 was chosen because:
- Signatures are 64 bytes — compact for a UDP payload
- Verification is fast (≈ 70,000/s on modern hardware)
- Deterministic — no random nonce needed during signing, no risk of nonce reuse
- Widely deployed in SSH, TLS certificates, and package signing
Nonce and Tag Sizes
| Value | Size | Notes |
|---|---|---|
| AEAD nonce | 12 bytes | ChaCha20-Poly1305 standard nonce size |
| AEAD tag | 16 bytes | 128-bit authentication |
| Random nonce | 16 bytes | 128 bits; probability of collision after 2^64 knocks |
| Ed25519 sig | 64 bytes | Fixed by the algorithm |
Handshake
The openme “handshake” is deliberately one-sided: the client sends one packet and the server acts on it. There is no response, no acknowledgement, no TCP connection, no session. This is what keeps the server stealthy.
Step-by-Step Flow
Server Processing
The server processes each received packet in a goroutine. Invalid packets are silently discarded at every step — the server never sends any response.
1. Size and version check
if len(packet) != 165 → discard
if packet[0] != 0x01 → discard
Packets of the wrong size or version look like noise and are dropped without logging at the default log level.
3. Decryption
nonce = packet[33:45]
ciphertext = packet[45:101]
plaintext = ChaCha20-Poly1305-Open(shared_key, nonce, ciphertext)
If the AEAD authentication tag fails (tampered data, wrong key, wrong nonce), the packet is discarded silently. An attacker learns nothing from this.
4. Signature verification
msg = packet[0:101]
sig = packet[101:165]
for each client in whitelist:
if Ed25519-Verify(client.pubkey, msg, sig) → matched client found
if no match → discard
All registered clients are tried. The first match wins.
5. Key expiry check
if client.expires != nil && now() > client.expires → discard
6. Replay protection
age = |now() - plaintext.timestamp|
if age > replay_window (60s) → discard
if plaintext.random_nonce in seen_cache → discard
seen_cache.add(plaintext.random_nonce, ttl=replay_window)
7. Target IP resolution
target_ip = plaintext.target_ip
if target_ip == :: (all zeros) → target_ip = source IP of UDP packet
8. Firewall rule
firewall.Open(target_ip, client.allowed_ports)
schedule: firewall.Close(target_ip, client.allowed_ports) after knock_timeout
The knock timeout (default 30 seconds) is enough to establish a TCP connection. If another valid knock arrives before the timer fires, the timer is reset.
Replay Protection
openme uses two complementary mechanisms to prevent replay attacks — where an attacker captures a valid knock packet and re-sends it later.
Mechanism 1: Timestamp Window
Every plaintext payload contains a Unix nanosecond timestamp generated by the client at knock time. The server rejects any packet whose timestamp falls outside a configurable window (default ±60 seconds):
if |now() - packet.timestamp| > replay_window → reject
Why nanoseconds? Nanosecond precision makes two legitimate knocks sent within the same second produce different timestamps, which combined with the random nonce below makes collisions negligible.
Clock skew: Client and server clocks must be within replay_window / 2 of each other. NTP is sufficient. If you run openme in an environment with unreliable clocks, increase replay_window in the server config.
Mechanism 2: Random Nonce Cache
Each knock includes 16 bytes (128 bits) of CSPRNG output. The server maintains a short-lived cache of nonces it has seen. If the same nonce appears twice, the second packet is rejected:
if nonce ∈ seen_cache → reject
seen_cache.insert(nonce, ttl = replay_window)
The cache is pruned every replay_window / 2 seconds. Memory usage is bounded: at 16 bytes per nonce and a 60-second window, even 100,000 knocks/minute would consume under 100 MB.
Why Both Mechanisms?
| Attack | Stopped by |
|---|---|
| Replay after window expires | Timestamp check |
| Replay within the window (two fast replays) | Nonce cache |
| Clock manipulation to extend window | Nonce cache |
Neither mechanism alone is sufficient:
- The timestamp alone fails if an attacker replays within 60 seconds.
- The nonce cache alone fails if the server restarts and loses the cache (the timestamp window then acts as a backstop).
Configuration
server:
replay_window: 60s # default; increase if clocks are unreliableNonce Cache After Restart
The nonce cache is in-memory only and is lost on server restart. After a restart, an attacker who captured a packet within the previous replay_window could theoretically replay it (the nonce cache is empty, and the timestamp may still be fresh). The timestamp window provides the backstop in this case.
For high-security deployments, consider persisting the nonce cache to disk (planned feature).