Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5434d45
[handler] harden decap: anti-replay after auth (APO-645), empty-paylo…
dilyevsky Jun 1, 2026
a124a35
[forwarder] recover datapath transform panics into frame drops
dilyevsky Jun 1, 2026
85f253d
[control] add PSP key derivation: SP 800-108 KDF + AES-CMAC (APO-648)
dilyevsky Jun 1, 2026
4eae224
[control] add QUIC/mTLS control plane with PSP SA negotiation (APO-648)
dilyevsky Jun 1, 2026
f7332ab
[handler] bind the AES-GCM nonce to the SPI and guard the key-install…
dilyevsky Jun 1, 2026
faaac88
[cli] document enforced rx!=tx + restart nonce-reuse caveat (APO-644)
dilyevsky Jun 1, 2026
8a052c3
[control] add control-plane tunnel orchestrator wiring SAs into the d…
dilyevsky Jun 1, 2026
2ad8447
[cli] wire the control plane into the tunnel; add genkey/pubkey (APO-…
dilyevsky Jun 1, 2026
4f707b6
[handler] pin the per-epoch TX-counter reset invariant (APO-648)
dilyevsky Jun 2, 2026
0b0151f
[control] add durable epoch high-water for seamless one-sided restart…
dilyevsky Jun 2, 2026
bf7a422
[cli] wire --state-file/--require-state durable epoch state (APO-648)
dilyevsky Jun 2, 2026
dc50124
[control] require a fresh ECDHE handshake per session (APO-644)
dilyevsky Jun 2, 2026
ca8982c
[handler] add per-direction SPI install seam with key-aware anti-rese…
dilyevsky Jun 2, 2026
84c94c1
[control] negotiate per-direction SPIs; drop durable epoch high-water…
dilyevsky Jun 2, 2026
e158045
[cli] drive per-direction SAs; drop --state-file/--require-state (APO…
dilyevsky Jun 2, 2026
a488852
[cli] document fresh-key restart recovery; drop persisted-state docs …
dilyevsky Jun 2, 2026
4de880a
[cli] retire static-INI keying; require the control plane (APO-644)
dilyevsky Jun 14, 2026
f79c792
[handler] reframe UpdateVirtualNetworkKeys as a general single-epoch …
dilyevsky Jun 14, 2026
f0ec409
[control] remove the keying-mode machinery (Mode/SelectMode)
dilyevsky Jun 14, 2026
839a2c1
[control] rename PSPVersion → ICXVersion (cipher-suite selector) (APO…
dilyevsky Jun 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions _examples/keyexchange/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Command keyexchange is a runnable demonstration of the ICX control plane:
// two peers establish a forward-secret, mutually-authenticated QUIC/mTLS
// session over loopback, derive PSP master keys from the TLS exporter, and
// negotiate per-direction Security Associations whose AES-GCM keys feed the
// Geneve/AF_XDP data plane.
//
// It runs both peers in one process and self-verifies the result, so it doubles
// as living documentation and a smoke test. Build under GODEBUG=fips140=on to
// confirm the whole exchange uses only FIPS-approved primitives:
//
// GODEBUG=fips140=on go run ./_examples/keyexchange
//
// This example tracks the control-plane API as it evolves; keep it building.
package main

import (
"context"
"crypto/sha256"
"crypto/tls"
"flag"
"fmt"
"log"
"net"
"time"

"github.com/apoxy-dev/icx/control"
)

func main() {
useV1 := flag.Bool("v1", false, "use the AES-GCM-256 cipher suite instead of AES-GCM-128")
flag.Parse()

version := control.AESGCM128
if *useV1 {
version = control.AESGCM256
}

if err := run(version); err != nil {
log.Fatalf("keyexchange demo failed: %v", err)
}
}

func run(version control.ICXVersion) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()

// 1. Long-term identities. In production each side holds its own private key
// and is configured with the peer's public key (--peer-key), WireGuard
// style. Here we mint both.
initiatorID, err := control.GenerateIdentity()
if err != nil {
return err
}
responderID, err := control.GenerateIdentity()
if err != nil {
return err
}
iFP, _ := initiatorID.Fingerprint()
rFP, _ := responderID.Fingerprint()
fmt.Printf("identities:\n initiator %s\n responder %s\n", iFP, rFP)

// 2. Loopback UDP sockets (the control-plane port; AF_XDP owns the data port).
srvConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv6loopback})
if err != nil {
return err
}
defer srvConn.Close()
cliConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv6loopback})
if err != nil {
return err
}
defer cliConn.Close()

// 3. Responder listens (and pins the initiator's key).
ln, err := control.Listen(srvConn, responderID, initiatorID.PublicKey())
if err != nil {
return err
}
defer ln.Close()

type negResult struct {
sess *control.Session
sas *control.DirectionalSAs
err error
}
respCh := make(chan negResult, 1)
go func() {
sess, err := ln.Accept(ctx)
if err != nil {
respCh <- negResult{err: err}
return
}
sas, err := sess.NegotiateSAs(ctx, version)
respCh <- negResult{sess: sess, sas: sas, err: err}
}()

// 4. Initiator dials (and pins the responder's key) — this is the TLS 1.3
// handshake: mutual auth + ephemeral ECDHE (forward secrecy).
initSess, err := control.Dial(ctx, cliConn, ln.Addr(), initiatorID, responderID.PublicKey())
if err != nil {
return fmt.Errorf("dial: %w", err)
}
defer initSess.Close()

st := initSess.TLSState()
fmt.Printf("handshake: TLS %s, cipher %s, ALPN %q\n",
tlsVersionName(st.Version), tls.CipherSuiteName(st.CipherSuite), st.NegotiatedProtocol)

// 5. Negotiate SAs (initiator side).
initSAs, err := initSess.NegotiateSAs(ctx, version)
if err != nil {
return fmt.Errorf("initiator NegotiateSAs: %w", err)
}

r := <-respCh
if r.sess != nil {
defer r.sess.Close()
}
if r.err != nil {
return fmt.Errorf("responder side: %w", r.err)
}
respSAs := r.sas

// 6. Report and verify.
fmt.Printf("master keys agree: %v\n", initSess.MasterKeys() != nil && r.sess.MasterKeys() != nil)
fmt.Printf("SAs (%s):\n", suiteName(version))
fmt.Printf(" initiator: tx spi=%#08x key=%s | rx spi=%#08x key=%s\n",
initSAs.Tx.SPI, fp(initSAs.Tx.Key), initSAs.Rx.SPI, fp(initSAs.Rx.Key))
fmt.Printf(" responder: tx spi=%#08x key=%s | rx spi=%#08x key=%s\n",
respSAs.Tx.SPI, fp(respSAs.Tx.Key), respSAs.Rx.SPI, fp(respSAs.Rx.Key))

if !equal(initSAs.Tx.Key, respSAs.Rx.Key) || !equal(initSAs.Rx.Key, respSAs.Tx.Key) {
return fmt.Errorf("VERIFY FAILED: tx/rx keys do not cross-match between peers")
}
if equal(initSAs.Tx.Key, initSAs.Rx.Key) {
return fmt.Errorf("VERIFY FAILED: initiator tx and rx keys collided")
}
if len(initSAs.Tx.Key) != expectedKeyLen(version) {
return fmt.Errorf("VERIFY FAILED: key length %d, want %d", len(initSAs.Tx.Key), expectedKeyLen(version))
}

fmt.Println("VERIFY OK: cross-matched, tx≠rx, FIPS-suite handshake, keys never crossed the wire")
return nil
}

func fp(key []byte) string {
sum := sha256.Sum256(key)
return fmt.Sprintf("%x", sum[:6])
}

func equal(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

func expectedKeyLen(v control.ICXVersion) int {
if v == control.AESGCM256 {
return 32
}
return 16
}

func suiteName(v control.ICXVersion) string {
if v == control.AESGCM256 {
return "v1/AES-256-GCM"
}
return "v0/AES-128-GCM"
}

func tlsVersionName(v uint16) string {
if v == tls.VersionTLS13 {
return "1.3"
}
return fmt.Sprintf("%#x", v)
}
142 changes: 82 additions & 60 deletions cli/README.md
Original file line number Diff line number Diff line change
@@ -1,82 +1,104 @@
# InterCloud eXpress (ICX) - CLI

## Usage
ICX encrypts tunnel traffic with AES-128-GCM. Keys are established by a **QUIC/mTLS
control plane**: a channel that authenticates the peers, negotiates fresh,
forward-secret, per-session keys, and rotates them automatically — so the tunnel is safe
across restarts with no persisted key state.

ICX uses a pair of **ephemeral, per-session** symmetric keys for encrypting traffic.
**Do not reuse keys** across sessions (to avoid nonce reuse risks).
## Control plane

In production, use a secure key exchange mechanism (e.g., IKEv2) to
generate and distribute keys.
Each node has a long-term **identity key** (ECDSA P-256). Peers authenticate each other
WireGuard-style by pinning the expected public key — there is no CA. The control channel
runs on its own UDP port (`--control-port`, default `6082`), separate from the Geneve
data port (`--port`, default `6081`); the XDP filter only redirects the data port to
AF_XDP, so the control port rides the normal kernel stack.

### 1) Generate two one-time keys
### 1) Generate an identity on each host

```bash
# Key used for A → B traffic
K_AB=$(openssl rand -hex 16)
# Key used for B → A traffic
K_BA=$(openssl rand -hex 16)
```

### 2) Create an INI file on each host

Each host reads keys from an INI file at --key-file. The required format is:
# Host A
icx genkey --identity-key /etc/icx/identity.pem
# prints Host A's public key (base64) to stderr

```ini
[keys]
rx=<32 hex chars> # the key this host expects to RECEIVE with
tx=<32 hex chars> # the key this host will TRANSMIT with
# Optional expiry (defaults to 24h if omitted):
# - as a Go duration (e.g. 24h, 90m)
# - or an RFC3339 timestamp (e.g. 2025-10-16T12:34:56Z)
expires=24h
# Host B
icx genkey --identity-key /etc/icx/identity.pem
```

For Host A:
`genkey` refuses to overwrite an existing key file (pass `--force` to override). Recover
a public key at any time:

```ini
[keys]
rx=${K_BA}
tx=${K_AB}
expires=24h
```bash
icx pubkey --identity-key /etc/icx/identity.pem
```

For Host B:
### 2) Exchange public keys

```ini
[keys]
rx=${K_AB}
tx=${K_BA}
expires=24h
```
Distribute each host's public key to the other out of band. The value is what you pass
as the peer's `--peer-key` (it accepts the base64 string directly or a path to a file
containing it).

### 3) Start ICX on both hosts

```bash
icx -i <iface> --key-file=/path/to/icx.ini <peer_ip>:<port>
```

#### Examples:
Both hosts run the same command shape; the dialer/listener roles are elected
deterministically from the two public keys, so no extra configuration is needed. Use the
**same `--control-port`** on both ends.

```bash
# Host A
icx -i eth0 --key-file=/etc/icx/keys.ini 203.0.113.2:6081

# Host B
icx -i eth0 --key-file=/etc/icx/keys.ini 198.51.100.7:6081
# Host A (peer is B's data address)
icx -i eth0 \
--identity-key /etc/icx/identity.pem \
--peer-key '<B public key>' \
198.51.100.7:6081

# Host B (peer is A's data address)
icx -i eth0 \
--identity-key /etc/icx/identity.pem \
--peer-key '<A public key>' \
203.0.113.2:6081
```

This creates an icx0 interface on both hosts, which you can use to securely
send and receive traffic over the ICX tunnel.

### 4) Key rotation (SIGHUP)

To rotate keys, update the same INI file with new rx/tx values, then send
SIGHUP to the running process:

```bash
pkill -HUP icx
# or: kill -HUP <pid>
```
ICX establishes the control plane (fail-closed: if the handshake or first negotiation
fails, the tunnel does not come up), installs the negotiated keys, and renegotiates a
fresh security association every `--rekey-interval` (default `2m`). Rotation is
make-before-break: the previous receive key is honored for a 30s grace period.

Relevant flags:

- `--identity-key PATH` — this node's identity private key.
- `--peer-key STR|PATH` — the peer's pinned public key.
- `--control-port PORT` — control-plane UDP port (default `6082`; must match on both ends).
- `--peer-control-port PORT` — peer's control port if it differs (defaults to `--control-port`).
- `--rekey-interval DUR` — SA rotation period (default `2m`).
- `--require-fips` — refuse to start unless the Go FIPS 140-3 module is active
(build/run with `GODEBUG=fips140=on`).

### Operational notes

**Startup ordering.** The peers elect dialer/listener roles from their keys; the dialer
retries the QUIC handshake only for the handshake window (~10s). If the listener is not up
within that window the dialer's process exits (fail-closed — no tunnel comes up). Start both
ends close together, and run under a supervisor (systemd `Restart=always`, a container
restart policy) so a larger startup skew self-heals on restart. Once established, the control
plane reconnects on its own indefinitely.

**Restart / reconnect.** Control-plane keys are ephemeral, so any restart or reconnect is
both crypto-safe and seamless, with **no persisted state to manage**.

Each direction is a simplex SA with its own SPI: the receiver allocates it, the sender
encrypts to it (`nonce = SPI‖counter`). The receive-SPI allocator resets to 1 on every
(re)connect, but because each session is a fresh ECDHE handshake (no 0-RTT, no session
resumption — both are disabled and asserted fail-closed), every generation also derives a
**fresh master key**. A reset or regressed SPI is therefore always paired with a key that has
never been used, so its from-zero counter is a fresh nonce space and no AES-GCM nonce can
repeat. The data-plane install seam accepts the reset SPI for exactly this reason; the only
thing it refuses is re-installing the *currently-live* transmit SPI (which would reset a live
counter under an unchanged key).

This makes every recovery path seamless and symmetric:

- **Transient reconnect** (a network blip, both processes survive) — the next session derives
fresh keys and both directions resume immediately.
- **One-sided restart** (either peer) — the restarted peer comes back with a fresh allocator
and a fresh handshake; the survivor accepts the reset SPI under its fresh key and traffic
resumes immediately. There is no high-water to carry forward and no peer to cycle.

ICX will reload the INI, bump the epoch, and apply the new keys. If the
reloaded keys are identical to the current ones, the reload is refused (epoch unchanged).
9 changes: 5 additions & 4 deletions cli/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ require (
github.com/google/gopacket v1.1.19
github.com/urfave/cli/v2 v2.27.7
github.com/vishvananda/netlink v1.3.1
gopkg.in/ini.v1 v1.67.0
golang.org/x/sync v0.16.0
gvisor.dev/gvisor v0.0.0-20250606001031-fa4c4dd86b43
)

Expand All @@ -19,12 +19,13 @@ require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/phemmer/go-iptrie v0.0.0-20240326174613-ba542f5282c9 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/safchain/ethtool v0.6.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/time v0.7.0 // indirect
)
Loading
Loading