A minimal, post-quantum VPN library written from scratch in Go. Peers talk over UDP through a central hub server; all traffic is authenticated and encrypted with keys derived from an ML-KEM-768 (FIPS 203) handshake. There is no classical key-exchange fallback — the whole construction is post-quantum.
ownvpn started life as a single client/server binary. It is now a library: the tunnel
data-plane, the hub server, the crypto, the wire codec and the TUN plumbing are all
exposed as importable Go packages, so you can embed an encrypted overlay network directly
into your own application (a CLI, a daemon, a management server, a mobile control plane,
etc.). The former standalone binary now lives under examples/sample_client
as a reference implementation you can copy from.
This README documents two things:
- How to use ownvpn as a library — the public API of each package (the new focus).
- How the protocol works — the wire format and handshake, precise enough to re-implement a client from scratch in any other language or platform.
- Features
- Installation
- Quick start (as a library)
- API reference
- Replay protection & nonces
- Reference CLI (
examples/sample_client) - Repository layout
- Wire protocol
- Handshake
- Data path
- Full tunnel
- Requirements & build
- Security notes & limitations
- License
- Post-quantum key exchange. Two-message ML-KEM-768 handshake. Each side encapsulates to the other's static public key, so both ciphertexts contribute entropy to the final session secret. There is no Diffie–Hellman, no RSA, no X25519.
- Mutual authentication. The server only accepts peers whose
nameis registered and whose static ML-KEM-768 public key matches. Decapsulation only succeeds for the holder of the matching private key, so the handshake authenticates both sides. - Authenticated encryption for data. ChaCha20-Poly1305 AEAD on every data packet with a 12-byte nonce (16-byte Poly1305 tag). The nonce is a per-session monotonic counter.
- Replay protection. Each direction carries a strictly-increasing 64-bit counter in the nonce; the receiver keeps a high-water mark and drops any packet whose counter is not greater than the highest already accepted. Counters reset on every re-handshake.
- Directional keys. Two separate ChaCha20-Poly1305 keys are derived per session with
HKDF-SHA256 over the concatenation of both shared secrets — one for client→server
(
info = "c2s") and one for server→client (info = "s2c") — so the two directions never share a key or a nonce space. - Hub topology with IP-based routing. The server inspects the destination IPv4 address of each decrypted inner packet and either delivers it locally or re-encrypts and forwards it to the matching peer.
- Runtime peer management. Add, remove, enable and disable peers on a running server and export the current peer set back to JSON — no restart required.
- Optional full tunnel. A client whose config sets
"fulltunnel": trueroutes the machine's entire traffic through the tunnel and restores the routing table on exit. - Automatic re-handshake. The client renegotiates the session key every 5 minutes, and immediately whenever encryption or decryption fails.
- Keep-alive with SYN/ACK. The client sends a 5-byte keepalive every 25 seconds to hold NAT mappings open; the server responds with an ACK variant.
- Context-driven lifecycle. Both
client.Runandserver.Runblock until thecontext.Contextyou pass them is cancelled, then tear down cleanly. - No PKI. No certificate authority, no TLS, no external services — just base64 keys in a struct you populate however you like.
go get github.com/lbodlev888/ownvpn@latestThen import the packages you need:
import (
"github.com/lbodlev888/ownvpn/client"
"github.com/lbodlev888/ownvpn/config"
"github.com/lbodlev888/ownvpn/crypto"
"github.com/lbodlev888/ownvpn/server"
)Requires Go 1.26+ (uses the standard-library crypto/mlkem and crypto/hkdf
packages) and Linux with root / CAP_NET_ADMIN at runtime (to create the TUN device
and run ip).
Every peer and the server needs its own ML-KEM-768 keypair.
priv, _ := crypto.GeneratePrivate() // base64 private (decapsulation) key
pub, _ := crypto.GetPublicKey(priv) // base64 public (encapsulation) keypackage main
import (
"context"
"os"
"os/signal"
"github.com/lbodlev888/ownvpn/config"
"github.com/lbodlev888/ownvpn/server"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
cfg := config.ServerConfig{
DecapsKey: "<server private key>",
BindAddress: "0.0.0.0:62789",
VirtualIP: "10.20.0.1",
Subnet: 24,
Peers: []config.PeerConfig{
{
Name: "laptop",
EncapsKey: "<laptop public key>",
VirtualIP: "10.20.0.3",
},
},
}
if err := server.Init(cfg); err != nil { // creates TUN, binds UDP, imports keys
panic(err)
}
server.Run(ctx) // blocks until ctx is cancelled
}package main
import (
"context"
"os"
"os/signal"
"github.com/lbodlev888/ownvpn/client"
"github.com/lbodlev888/ownvpn/config"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
cfg := config.PeerConfig{
Name: "laptop",
DecapsKey: "<laptop private key>",
EncapsKey: "<server public key>",
VirtualIP: "10.20.0.3",
Subnet: 24,
Endpoint: "203.0.113.10:62789",
FullTunnel: false,
}
client.Run(ctx, cfg) // blocks until ctx is cancelled
}Note: the client and server must run the same ownvpn version. The HKDF
infostrings ("c2s"/"s2c") and the packet framing are fixed in code; there is no negotiated version field, so mismatched builds that change either will simply fail to decrypt each other's packets.
func GeneratePrivate() (string, error)Generates a fresh ML-KEM-768 decapsulation (private) key and returns it base64-encoded.
func GetPublicKey(privKey string) (string, error)Derives the encapsulation (public) key from a base64 private key and returns it base64-encoded.
func ParseDecapsKey(s string) (*mlkem.DecapsulationKey768, error)
func ParseEncapsKey(s string) (*mlkem.EncapsulationKey768, error)Decode a base64 key string into the corresponding crypto/mlkem key type. Used internally
by server/client; exposed for callers that want to validate keys ahead of time.
func DeriveEncryptionKey(material, salt []byte, infoString string, length int) ([]byte, error)Thin wrapper over hkdf.Key(sha256.New, ...). ownvpn calls it twice per session —
DeriveEncryptionKey(ss1||ss2, nil, "c2s", 32) and DeriveEncryptionKey(ss1||ss2, nil, "s2c", 32)
— to derive the two directional 32-byte ChaCha20-Poly1305 keys. Exposed so a
re-implementation can reproduce the exact derivation.
These are plain structs with JSON tags; construct them in code or unmarshal them from a file — ownvpn does no file I/O itself.
type PeerConfig struct {
Name string `json:"name"`
DecapsKey string `json:"privkey,omitempty"` // this peer's private key (client side)
EncapsKey string `json:"pubkey"` // client: server's pubkey; server: peer's pubkey
VirtualIP string `json:"virtual_ip"`
Subnet int `json:"subnet,omitempty"`
Endpoint string `json:"endpoint,omitempty"` // "host:port" of the server (client only)
FullTunnel bool `json:"fulltunnel,omitempty"`// route all traffic through the tunnel
Disabled bool `json:"disabled,omitempty"` // server-side: reject this peer's handshakes
}
type ServerConfig struct {
DecapsKey string `json:"privkey"`
BindAddress string `json:"bind_address"` // "host:port" to listen on (UDP)
VirtualIP string `json:"virtual_ip"`
Subnet int `json:"subnet"`
Peers []PeerConfig `json:"peers"`
}Note the dual meaning of EncapsKey/pubkey: on a client config it holds the
server's public key; inside a server's Peers list it holds that peer's public key.
The server is a package-level singleton: it keeps its keys, TUN interface, UDP socket and peer tables in package globals, so you run one server per process. All functions below operate on that single instance and are safe for concurrent use (guarded by internal mutexes).
func Init(cfg config.ServerConfig) errorImports the server private key, loads the allowed peers from cfg.Peers, creates the TUN
interface (cfg.VirtualIP/cfg.Subnet) and binds the UDP socket (cfg.BindAddress). Call
once before Run.
func Run(ctx context.Context)Starts the read loops (UDP ⇄ TUN) and blocks until ctx is cancelled, at which point
it closes the interface and socket and returns.
func NewPeer(peer config.PeerConfig) errorRegisters a peer on the running server. Validates the peer's EncapsKey; returns an error
if the key is not a valid ML-KEM-768 public key. Idempotent by Name (re-registering a
name overwrites it).
func RemovePeer(name string)Removes a peer by name and evicts any live session it holds (both the virtual-IP and source-address routing entries).
func EnablePeer(name string)
func DisablePeer(name string)Toggle a peer's Disabled state. A disabled peer's handshakes are rejected and its live
session (if any) stops forwarding immediately. Enabling reverses both.
func GetAllPeers() []config.PeerConfigReturns a snapshot of every registered peer (for building a management/status API).
func MarshalPeerSettings() ([]byte, error)Serializes the current server config including the live peer set back to JSON — use it to persist runtime peer changes (adds/removes/enable/disable) so they survive a restart.
Typical management flow:
server.Init(cfg)
go server.Run(ctx)
// later, in response to your admin API:
server.NewPeer(config.PeerConfig{Name: "phone", EncapsKey: pub, VirtualIP: "10.20.0.4"})
server.DisablePeer("laptop")
peers := server.GetAllPeers()
// persist the new state:
data, _ := server.MarshalPeerSettings()
os.WriteFile("/etc/ownvpn/server.json", data, 0600)func Run(ctx context.Context, cfg config.PeerConfig)The entire client data-plane in one blocking call. It creates the TUN device, optionally
sets up the full tunnel, performs the handshake, and runs four goroutines (handshake loop,
keepalive, network→TUN reader, TUN→network writer). It returns when ctx is cancelled and
cleans up its interface/socket (and full-tunnel routes) on the way out.
Unlike the server, the client is not a singleton — you can run multiple client.Run
calls concurrently (each gets its own TUN device, auto-named bvpn%d), though each still
needs CAP_NET_ADMIN.
Low-level helpers, normally called for you by server/client. Exposed if you build a
custom data-plane.
func SetupInterface(localAddr string) (*water.Interface, error) // create+configure a TUN ("cidr" e.g. "10.20.0.3/24")
func SetupFullTunnel(endpoint string) error // add default routes via the TUN, pin endpoint via real gw
func ClearFullTunnel(endpoint string) error // remove the pinned endpoint host routeThe TUN device is named bvpn%d (first is bvpn0) and set to MTU 1420. Routing is
performed by shelling out to the ip command (Linux).
Message-type constants and encode/decode functions for every packet on the wire. You only need this package if you are re-implementing an endpoint or debugging the protocol; the full format is documented in Wire protocol.
const (
MsgClientHello byte = 0x01
MsgServerHello byte = 0x02
MsgData byte = 0x03
MsgKeepAlive byte = 0x04
MsgKeepAliveSYN byte = 0x05
MsgKeepAliveACK byte = 0x06
MLKEM768CiphertextLen = 1088
MaxNameLen = 255
)
type ClientHello struct { Name string; PublicData []byte }
type ServerHello struct { PublicData []byte }
func EncodeClientHello(ClientHello) ([]byte, error)
func DecodeClientHello([]byte) (ClientHello, error)
func EncodeServerHello(ServerHello) ([]byte, error)
func DecodeServerHello([]byte) (ServerHello, error)
func EncodeKeepAlive(flag byte) []byte
func DecodeKeepAlive(buf []byte, expectedFlag byte) boolThe 12-byte ChaCha20-Poly1305 nonce is not random — it is a per-session monotonic counter. The counter is written big-endian into the first 8 bytes of the nonce (the remaining 4 bytes are zero), so it is really a 64-bit sequence number.
Two directions, two keys, two counters. Each session derives independent keys for
client→server (info = "c2s") and server→client (info = "s2c"), and each direction has
its own send counter and its own receive high-water mark. This guarantees a (key, nonce)
pair is never reused across directions.
How a receiver rejects replays. For every inbound data packet the receiver reads the 64-bit counter and compares it to the highest value it has already accepted on that direction:
if counter <= highWaterMark: drop (replay / out-of-order)
else after successful decrypt: highWaterMark = counter
The high-water mark is only advanced after the AEAD tag verifies, so an attacker cannot lock a peer out by injecting a forged packet with a huge counter.
Counters reset on every re-handshake. Because a fresh session key is derived each handshake (every 5 minutes, or on any cipher failure), both the send counter and the receive high-water mark restart at zero, and the new key makes the reused low counters safe. On the server a re-handshake creates a brand-new session object, so its counters start at zero automatically; the client zeroes its counters before publishing the new keys.
Known limitation — strict ordering. This is a single high-water mark, not a sliding window (RFC 6479). It is simple and safe, but any packet that arrives out of order looks like a replay and is dropped, causing an upper-layer retransmit. UDP reordering is usually rare on a path, so in practice this is fine; if it ever becomes a throughput problem, a small sliding-window bitmap is the standard upgrade.
The original standalone binary is preserved as a worked example that wires the library
packages together: it parses flags, reads a JSON config, and dispatches to server.Run or
client.Run. Read it as the canonical "how do I glue these packages together" reference —
see examples/sample_client/main.go.
Build and use it directly:
go build -o ownvpn ./examples/sample_client
# key management
./ownvpn -genkey # print a new private key
./ownvpn -pubkey <private-key> # print the matching public key
# run
sudo ./ownvpn -server -config server.json
sudo ./ownvpn -config peer.json| Flag | Applies to | Description |
|---|---|---|
-server |
both | Run in server (hub) mode instead of client mode. |
-config FILE |
both | Path to the JSON config (default /etc/ownvpn/config.json). |
-genkey |
— | Generate and print a new ML-KEM-768 private key, then exit. |
-pubkey KEY |
— | Print the public key for the given private key, then exit. |
Example peer config (peer.json):
{
"name": "laptop",
"privkey": "<this peer's private key>",
"pubkey": "<server's public key>",
"virtual_ip": "10.20.0.3",
"subnet": 24,
"endpoint": "203.0.113.10:62789",
"fulltunnel": false
}Example server config (server.json):
{
"privkey": "<server's private key>",
"bind_address": "0.0.0.0:62789",
"virtual_ip": "10.20.0.1",
"subnet": 24,
"peers": [
{ "name": "laptop", "pubkey": "<peer's public key>", "virtual_ip": "10.20.0.3", "subnet": 24 }
]
}client/ # peer-side data-plane: handshake, encrypt, decrypt, keepalive
server/ # hub: accepts handshakes, decrypts, routes by inner dst IP, peer mgmt
server.go # Init/Run + exported peer-management API
network.go # UDP/TUN read loops, handshake handler, forwarding
models.go # internal peer struct for the routing table
utils.go # allowed-peer loading, key validation
crypto/ # ML-KEM-768 key import/export + HKDF-SHA256 derivation
proto/ # wire-format encoders/decoders + message-type constants
tunif/ # TUN device creation and `ip` route configuration
config/ # JSON config structs (PeerConfig, ServerConfig)
examples/sample_client # reference CLI that wires the packages together
All messages are sent as UDP datagrams. The first byte is always the message type.
| Code | Name | Direction | Length |
|---|---|---|---|
| 0x01 | ClientHello |
client → server | 2 + nameLen + 1088 |
| 0x02 | ServerHello |
server → client | 1 + 1088 |
| 0x03 | Data |
both | 1 + 12 + ct |
| 0x04 | KeepAlive |
both | 5 |
| 0x05 | KeepAliveSYN |
(flag byte) | n/a |
| 0x06 | KeepAliveACK |
(flag byte) | n/a |
Constants:
- ML-KEM-768 ciphertext is fixed at 1088 bytes; shared secret is 32 bytes.
- ChaCha20-Poly1305: 32-byte key, 12-byte nonce, 16-byte tag.
- HKDF salt: empty (nil). HKDF info string:
"c2s"for the client→server key and"s2c"for the server→client key (literal ASCII, no trailing newline). Output length: 32 bytes each. - The 12-byte data nonce is a 64-bit big-endian counter in bytes 0..7, with bytes 8..11 set to zero. It starts at 1 each session and increments by one per packet, per direction.
+------+----------+-------------------+--------------------------------+
| 0x01 | nameLen | name (nameLen B) | mlkem768 ciphertext (1088 B) |
+------+----------+-------------------+--------------------------------+
1B 1B 1..255 B 1088 B
nameLen is a single unsigned byte (1..255). name is raw ASCII/UTF-8 bytes.
+------+--------------------------------+
| 0x02 | mlkem768 ciphertext (1088 B) |
+------+--------------------------------+
1B 1088 B
+------+---------------------------+-----------------------------------+
| 0x03 | nonce = ctr(8B BE) + 0000 | chacha20poly1305 ciphertext+tag |
+------+---------------------------+-----------------------------------+
1B 12 B inner_len + 16 B
The nonce is a 64-bit big-endian counter (bytes 0..7) followed by four zero bytes. The
plaintext is a raw IPv4 packet as read from the TUN device. The AEAD is called with
additionalData = nil. See Replay protection & nonces for how
the counter is validated.
+------+-------+-----------------+
| 0x04 | flag | random 3 bytes |
+------+-------+-----------------+
1B 1B 3 B
flag is 0x05 (SYN) when sent by the client and 0x06 (ACK) when sent back by the
server. The 3 random bytes are padding; they are not validated.
Each side owns a static ML-KEM-768 keypair. The handshake is two messages and derives a fresh 32-byte ChaCha20-Poly1305 key every time.
Notation: EK_x = encapsulation (public) key of x, DK_x = decapsulation (private) key
of x. Encaps(EK) -> (ss, ct) returns a 32-byte shared secret and a 1088-byte
ciphertext. Decaps(DK, ct) -> ss recovers the same 32-byte shared secret.
- The client calls
Encaps(EK_server)and gets(ss1, ct1). - It builds
ClientHello { name, publicData = ct1 }(1 + 1 + nameLen + 1088bytes). - It sends the datagram to the server's UDP endpoint.
- Decodes the message; rejects if
nameis not registered, or if that peer isDisabled. - Looks up that peer's static public key
EK_client. - Computes
ss1 = Decaps(DK_server, ct1). Failure means the client used the wrong server public key. - Computes
Encaps(EK_client) -> (ss2, ct2). - Derives both directional keys from
ikm = ss1 || ss2(saltnil,L = 32):Kc2s = HKDF-SHA256(ikm, "c2s")andKs2c = HKDF-SHA256(ikm, "s2c"). - Stores the peer (with zeroed send/receive counters) keyed by both its UDP source address (data path) and its virtual IP (routing table). Any previous session for the same virtual IP is evicted.
- Sends
ServerHello { publicData = ct2 }.
- Reads the response with a 2-second read deadline (a lost packet retries the whole handshake). The deadline is cleared after a successful read.
- Verifies the source address matches the expected server endpoint.
- Decodes
ServerHello, computesss2 = Decaps(DK_client, ct2). Failure means the server used the wrong client public key. - Derives the same
Kc2sandKs2cfromss1 || ss2(info"c2s"/"s2c"). - Zeroes its send/receive counters, then initializes ChaCha20-Poly1305 for each direction. Tunnel is now ready.
Because ss1 can only be recovered with DK_server and ss2 only with DK_client, only
the legitimate holders of both private keys can derive the session keys. An attacker holding
one of the two keys still cannot. There are no signatures and no certificates —
authentication is a side-effect of mutual key encapsulation.
The client re-runs the handshake when any of these happen:
- The 5-minute
HANDSHAKE_TIMEOUTticker fires. - AEAD
Open(decrypt) returns an error on a received data packet. WriteTofails when sending an encrypted data packet.- A keepalive ACK reports the session is no longer valid.
On a re-handshake the client clears its keys (the reader/writer goroutines pause until the
new keys are installed), resets both nonce counters to zero, and sends a fresh
ClientHello. The server treats a new ClientHello as authoritative: it builds a new
session with fresh keys and zeroed counters, and if the same virtual IP was mapped to a
different UDP address, the old mapping is dropped.
// one-time
DK_client = load_private_key(peer.privkey)
EK_server = load_public_key(peer.pubkey)
serverAddr = resolve(peer.endpoint)
socket = DatagramSocket() // ephemeral local port
// handshake loop (once at startup, then every 300 s and on any cipher failure)
loop {
(ss1, ct1) = MLKEM768.encaps(EK_server)
send(socket, serverAddr, [0x01] || [len(name)] || name_bytes || ct1) // 2+N+1088 B
socket.setSoTimeout(2000) // 2 s
(resp, src) = recv(socket, 2048)
if src != serverAddr || resp[0] != 0x02 || resp.length != 1 + 1088 { retry }
ct2 = resp[1..1089]
ss2 = MLKEM768.decaps(DK_client, ct2)
ikm = ss1 || ss2 // 64 B
Kc2s = HKDF_SHA256(ikm, salt=null, info="c2s", 32) // client -> server key
Ks2c = HKDF_SHA256(ikm, salt=null, info="s2c", 32) // server -> client key
send_ctr = 0; recv_hwm = 0 // reset before using the new keys
wait(min(300 s, until cipher_failure))
}
// data: TUN -> network
on_tun_packet(p):
send_ctr += 1
nonce = uint64_be(send_ctr) || 0x00000000 // 8-byte counter + 4 zero bytes
ct = ChaCha20Poly1305(Kc2s).seal(nonce, plaintext=p, aad=null)
send(socket, serverAddr, [0x03] || nonce || ct)
// data: network -> TUN
on_udp_packet(buf, src):
if src != serverAddr: drop
if buf[0] == 0x04: handle_keepalive(buf); return
if buf[0] != 0x03 || buf.length < 1+12+16: drop
nonce = buf[1..13]
counter = uint64_be(nonce[0..8])
if counter <= recv_hwm: drop // replay / out-of-order
pt = ChaCha20Poly1305(Ks2c).open(nonce, buf[13..], aad=null) // on failure -> rehandshake
recv_hwm = counter // only after the tag verifies
tun.write(pt)
// keepalive (every 25 s while a session exists)
send(socket, serverAddr, [0x04, 0x05, rnd, rnd, rnd]) // SYN, expect [0x04,0x06,...] ACK
Platform notes (e.g. Android):
- The HKDF
infostrings ("c2s","s2c") and the counter-nonce layout must match the server exactly, and you must run the same ownvpn protocol version on both ends. - The nonce is a per-direction monotonic counter, not random — reset it to zero on every re-handshake, and never let it repeat under the same key.
- ML-KEM-768 is in
java.security.KeyPairGeneratoras of JDK 24 ("ML-KEM"); on Android use BouncyCastle (bcprov≥ 1.78 exposesMLKEM). - On Android the TUN device comes from
VpnService; itsParcelFileDescriptorplays the role of theifacein this codebase.
┌────────────────┐ ┌────────────────┐
bvpn0 ─→──┤ client (peer) │──UDP, AEAD──→──┤ server (hub) │──→── bvpn0
│ ChaCha20-Poly │ │ ChaCha20-Poly │
bvpn0 ←──┤ per session │←─UDP, AEAD──←──┤ per session │←── bvpn0
└────────────────┘ └────────────────┘
│
└─── re-encrypt + forward
to another peer
The server looks at bytes 16..19 of every decrypted inner packet (the IPv4 destination). If
it equals the server's own virtual IP the packet is written to the local TUN; if it matches
a known peer's virtual IP it is re-sealed under that peer's session key and sent to that
peer. If it matches neither, the packet is written to the server's TUN device (see
Full tunnel) rather than dropped. Non-IPv4 packets (version != 4) are
dropped.
By default ownvpn is a hub overlay: only traffic addressed to a virtual_ip on the tunnel
subnet crosses the tunnel; everything else uses the host's normal routes. Full tunnel
mode turns the server into a default gateway so that all of a client's traffic is
encrypted and egresses through the server.
Enable it by setting FullTunnel: true on the client's PeerConfig (or "fulltunnel": true in JSON). There is no separate flag or API call — the client acts on the config field
when client.Run starts.
When FullTunnel is true, right after the TUN device is created the client reprograms the
host routing table (via the ip command in tunif):
- Capture all traffic. It adds
0.0.0.0/1and128.0.0.0/1pointing atbvpn0. Together these two/1routes cover the whole IPv4 space and, being more specific than the existing0.0.0.0/0default, win for every destination — without deleting the original default route, so it restores cleanly. - Keep the tunnel reachable. To stop the encrypted UDP packets to the server from
being routed back into the tunnel (a loop), the client discovers the physical default
gateway (via
jackpal/gateway) and pins a host route to the server's endpoint IP through that real gateway. - Clean up on exit. On cancellation the client's
defercallsClearFullTunnel, deleting the pinned host route. The two/1routes are bound tobvpn0and vanish when the TUN closes. If the process is killed hard, the endpoint host route may linger and can be removed withip route del <endpoint-ip> via <gateway-ip>.
The endpoint IP is taken from Endpoint with the :port stripped — this assumes a literal
IPv4 endpoint; a DNS hostname is not resolved here.
Nothing needs enabling in ownvpn itself: when the server decrypts a packet whose destination is neither its own virtual IP nor a known peer, it writes it to its TUN device and lets the host kernel route it. For that to reach the internet, the server's host must be configured as a router:
# 1. Allow the kernel to forward packets between interfaces
sudo sysctl -w net.ipv4.ip_forward=1
# 2. NAT/masquerade tunnel traffic out of the physical interface
# (replace 10.20.0.0/24 with your tunnel subnet and eth0 with the WAN NIC)
sudo iptables -t nat -A POSTROUTING -s 10.20.0.0/24 -o eth0 -j MASQUERADE- Only IPv4 is tunnelled; working host IPv6 can leak outside the tunnel.
- DNS is not modified — set a resolver separately if you want to avoid your ISP's.
- Full tunnel is a client-only setting; a server serves normal and full-tunnel peers simultaneously with no extra config beyond the host NAT above.
- Go 1.26+ (stdlib
crypto/mlkem,crypto/hkdf). - Linux.
tunifshells out toipto configure the TUN device. - Root (or
CAP_NET_ADMIN) — to create the TUN device and runip link.
Build the reference CLI:
go build -o ownvpn ./examples/sample_clientCross-compile for ARMv7 (e.g. a router):
GOOS=linux GOARCH=arm GOARM=7 go build -o ownvpn_armv7 ./examples/sample_client- Post-quantum only, no hybrid. Authentication and confidentiality rest entirely on ML-KEM-768. There is no classical (X25519/RSA) layer, so there is no fallback if a flaw is found in ML-KEM. This is a deliberate design choice, not an oversight.
- No forward secrecy. Both shared secrets come from encapsulating to static keys, so the per-handshake randomness lives inside the recorded ciphertexts. An attacker who records traffic and later obtains both the server's and a peer's private keys can recover that peer's past session keys. WireGuard-style ephemeral key exchange would fix this; ownvpn does not have it.
- Replay protection is a single high-water mark, not a sliding window. Replays are rejected (see Replay protection & nonces), but genuinely out-of-order packets are dropped rather than accepted within a window.
- No inner-source-IP filtering. The server routes on the decrypted packet's destination and does not verify that its source matches the sending peer's assigned virtual IP, so an authenticated peer can spoof another peer's virtual IP. There is no WireGuard-style cryptokey-routing check.
- Handshake commits state before key confirmation. A well-formed
ClientHellofor a known peer name replaces that peer's session mapping before the initiator proves it can derive the keys, so an attacker who knows a valid peer name can knock a peer off the routing table until its next re-handshake (a DoS, not a compromise). - Metadata. Packet sizes and timing are not padded (beyond the 3 keepalive bytes), and fixed message types / 1088-byte handshakes are trivially fingerprintable by DPI.
- Keys are plaintext in the config structs/JSON — protect them at rest (file perms, secrets manager) yourself; ownvpn does not encrypt them.
- Single server instance per process (package-level state) — run multiple hubs in separate processes.
Treat ownvpn as a compact, auditable, from-scratch VPN for experimentation, self-hosting, and post-quantum research — not (yet) as a hardened replacement for WireGuard in a high-assurance production deployment.
MIT — see LICENSE.