# jkipsec
Userspace IKEv2/IPsec responder built to terminate iOS VPN tunnels and hand
the inner IP traffic to your own code - typically [`jktcp`](https://crates.io/crates/jktcp)
for a complete userspace TCP/IP stack.
```text
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`](https://crates.io/crates/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:
| 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`:
```toml
[dependencies]
jkipsec = "0.1"
jktcp = "0.1"
tokio = { version = "1", features = ["full"] }
```
```rust
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`:
```bash
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`](https://crates.io/crates/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
```bash
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`](LICENSE).