Protocol Specification

openme SPA Protocol v1

Keywords

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.yaml at 0600
  • 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.yaml at 0600
  • 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=∅)
  • key is 32 bytes derived via HKDF above
  • nonce is 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

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

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.

2. ECDH shared key derivation

ephemeral_pub = packet[1:33]
shared_key    = HKDF-SHA256(Curve25519(server_priv, ephemeral_pub), info="openme-v1-chacha20poly1305")

If Curve25519 returns an error (e.g. low-order point), the packet is discarded.

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 unreliable

Nonce 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).