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`](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:

| # | 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`:

```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).