jkipsec 0.1.0

Userspace IKEv2/IPsec VPN responder for terminating iOS VPN tunnels and exposing the inner IP traffic. Pairs with jktcp for a fully userspace TCP/IP stack.
Documentation

jkipsec

Userspace IKEv2/IPsec responder built to terminate iOS VPN tunnels and hand the inner IP traffic to your own code - typically jktcp for a complete userspace TCP/IP stack.

   iOS device                Internet                  jkipsec
  ────────────                ────────                ──────────
                                                        UDP 500
   IKE_SA_INIT  ──────────────────────────────────►  ┐
                                                     │  Server task
   IKE_AUTH(SK)  ─────────────────────────────────►  │  (no locks,
                                                     │   crossfire
   ESP packets   ─────────────────────────────────►  │   channels)
                                                     │
                                                     ▼
                                        decrypted IP packets
                                                     │
                                                     ▼
                                              jktcp::Adapter
                                                     │
                                                     ▼
                                            your application code

What this is

A small Rust library that speaks just enough IKEv2 + ESP to let an iOS device build an IPsec VPN tunnel to a process you control, with the inner IP packets exposed as an AsyncRead + AsyncWrite stream you can plug into a userspace TCP stack.

It exists because:

  • iOS' WireGuard implementation creates a utun interface whose policies block some inbound services (notably lockdown on TCP/62078). iOS' built-in IKEv2 client uses the same OS path but with different policy hooks, and can reach those services. (edit: ipsec is now blocked too)
  • Implementing IKEv2/IPsec in userspace lets you run as a non-root user and multiplex multiple devices without touching the kernel.

The companion crate jktcp gives you a full userspace TCP stack on top of the raw IP frames jkipsec produces.

What this is not

  • Not a production VPN gateway. Designed for terminating one or a few trusted iOS devices to talk to specific services on the host.
  • Not a general IPsec implementation. Negotiates exactly two IKE/ESP suites, both with DH group 19 (P-256). No MODP groups, no AH, no transport mode, no IPv6 inner support, no rekeying, no DPD, no MOBIKE, no fragmentation, no IKE_INTERMEDIATE / RFC 9370 hybrid PQ.
  • Not security-audited. The crypto primitives are RustCrypto, but the protocol code is hand-written and lightly tested. Don't use it where a motivated attacker on the network is part of your threat model.
  • Not a drop-in strongSwan / Libreswan replacement. Tested against iPhone 5, iPhone 8, and modern iPhones - that's it. Other clients (Windows, Android, strongSwan-as-initiator) are likely to fall outside the narrow set of behaviors we accept.

Negotiated suites

Both suites use DH group 19 (NIST P-256) for IKE_SA_INIT and PRF HMAC-SHA-256. The selector tries them in this preference order and picks the first one the peer offers:

# Cipher Integrity Notes
1 AES-GCM-16-256 (AEAD) (built into AEAD) Modern iOS default
2 AES-CBC-256 HMAC-SHA-256-128 Older iOS (iPhone 5–8) only ships this

Authentication is PSK only (RFC 7296 §2.15 shared key MIC). The "PSK" is never stored - only the 32-byte derived auth key, which the user's callback returns or verifies via AuthChallenge::approve_with.

Quick start

Cargo.toml:

[dependencies]
jkipsec = "0.1"
jktcp = "0.1"
tokio = { version = "1", features = ["full"] }
use std::net::{IpAddr, Ipv4Addr};
use jkipsec::api::{AuthDecision, JkispecConfig, JkispecServer, PortRole};
use jkipsec::crypto::derive_auth_key;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::main]
async fn main() {
    // Pre-derive the auth key once, store this in your DB instead of the PSK.
    let alice_key = derive_auth_key(b"correct horse battery staple");

    let server = JkispecServer::start(JkispecConfig {
        binds: vec![
            ("0.0.0.0:500".into(), PortRole::Ike500),
            ("0.0.0.0:4500".into(), PortRole::Ike4500),
        ],
        public_ip: "203.0.113.1".parse().unwrap(),
        public_port: 500,
        identity: "vpn.example.com".into(),
        virtual_ip: Ipv4Addr::new(10, 8, 0, 2),
        gateway_ip: Ipv4Addr::new(10, 8, 0, 1),
        virtual_dns: Ipv4Addr::new(1, 1, 1, 1),
        auth: Box::new(move |challenge| {
            let alice_key = alice_key;
            Box::pin(async move {
                // Look up the auth_key for this identity in your DB. The
                // example below approves anyone presenting "alice".
                if challenge.identity() == b"alice" {
                    challenge.approve_with(&alice_key)
                } else {
                    AuthDecision::Reject
                }
            })
        }),
    })
    .await;

    // Each iOS device that connects yields a `Client`.
    while let Some(mut client) = server.accept().await {
        println!("connected: {}", client.identity_str());
        tokio::spawn(async move {
            // TCP from gateway_ip:<random> to virtual_ip:62078 over IPsec.
            let mut stream = client.connect(62078).await.unwrap();
            stream.write_all(b"hello\n").await.unwrap();
            let mut buf = [0u8; 256];
            let n = stream.read(&mut buf).await.unwrap();
            println!("got {} bytes", n);
            // Dropping `client` sends an IKE DELETE.
        });
    }
}

A more complete runnable demo lives in examples/probe.rs:

JKIPSEC_PUBLIC_IP=1.2.3.4 \
JKIPSEC_PSK=secret \
JKIPSEC_PROBE_PORTS=12345 \
cargo run --example probe

iOS profile

Configure on the device (Settings -> VPN -> Add VPN Configuration):

  • Type: IKEv2
  • Server: your server's public IP
  • Remote ID: must match JkispecConfig.identity
  • Local ID: any string - this becomes the identity in AuthChallenge
  • User Authentication: None
  • Use Certificate: off
  • Secret: set to the raw PSK whose derive_auth_key(...) you stored

For older iOS, the same flow works; jkipsec falls back to AES-CBC + HMAC.

Architecture notes

  • Single server task owns all IKE/ESP state. UDP readers, UDP writers, per-session outbound ESP encryptors, and the application code all communicate through lockless crossfire channels - no Arc<Mutex<...>> on hot paths.
  • Per-session outbound ESP runs in its own task, owning its keys and using UdpSocket::send_to directly. ESP fast-path therefore avoids any channel hop on send.
  • EspTunnel wraps MAsyncRx + MTx to satisfy jktcp's Sync bound. The single std::sync::Mutex it holds is uncontended (only poll_read touches it) and exists solely to bridge the trait bound.
  • NAT-T: detected automatically; iOS will float to UDP/4500 with the 4-byte non-ESP marker. We bind both ports.
  • Split tunnel: TSr in IKE_AUTH is narrowed to a /32 for gateway_ip, so iOS only routes traffic destined for that single address through the tunnel - your gateway code is the only thing the device sees.

Testing

cargo test           # unit tests + doctests
cargo clippy --all-targets
cargo run --example probe

22 unit tests cover the IKE parser, crypto primitives, ESP round-trips for both suites, and an end-to-end IKE_SA_INIT round-trip against a captured iOS packet.

License

MIT - see LICENSE.