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
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
utuninterface 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.
- 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/Libreswanreplacement. 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.
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.
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 probeConfigure 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
identityinAuthChallenge - 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.
- 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
crossfirechannels - noArc<Mutex<...>>on hot paths. - Per-session outbound ESP runs in its own task, owning its keys and
using
UdpSocket::send_todirectly. ESP fast-path therefore avoids any channel hop on send. EspTunnelwrapsMAsyncRx+MTxto satisfy jktcp'sSyncbound. The singlestd::sync::Mutexit holds is uncontended (onlypoll_readtouches 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
/32forgateway_ip, so iOS only routes traffic destined for that single address through the tunnel - your gateway code is the only thing the device sees.
cargo test # unit tests + doctests
cargo clippy --all-targets
cargo run --example probe22 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.
MIT - see LICENSE.