From 2bf8da739bf84b1e2b1dff905b450deca7f39e14 Mon Sep 17 00:00:00 2001 From: bzuccaro Date: Sun, 2 Aug 2026 12:31:53 -0600 Subject: [PATCH] feat: replace connection keys with short seed-derived rtun3 keys Connection keys are now a ~49-char seed (rtun3.) instead of a bundled ~1740-char certificate blob. Both endpoints deterministically derive an identical Ed25519 CA from the seed and mint ephemeral server/client leaves at startup (keyderive.rs); the app-layer auth token is derived from the seed. Target is passed separately on connect (resocks-style). - connkey.rs: rtun3 seed parse/format - keyderive.rs: CA/server/client/token derivation - keygen takes no args; connect requires --target - remove miniz_oxide; TLS layer unchanged - add determinism + key-based e2e + wrong-seed-rejected tests - update wiki, README, design spec, CI smoke --- .github/workflows/ci.yml | 12 + Cargo.lock | 16 -- Cargo.toml | 1 - README.md | 14 ++ ...-08-02-connection-key-rtun3-seed-design.md | 76 ++++++ droid-wiki/applications/rustunnel-cli.md | 8 +- droid-wiki/features/connection-keys.md | 78 +++--- .../patterns-and-conventions.md | 2 +- droid-wiki/overview/getting-started.md | 15 +- droid-wiki/overview/glossary.md | 2 +- droid-wiki/overview/index.md | 2 +- droid-wiki/reference/data-models.md | 13 +- droid-wiki/reference/dependencies.md | 5 +- droid-wiki/security.md | 13 +- src/cli.rs | 21 +- src/connkey.rs | 158 ++++++------ src/keyderive.rs | 169 +++++++++++++ src/main.rs | 234 ++++++------------ src/redact.rs | 6 +- src/tunnel.rs | 109 ++++++++ 20 files changed, 616 insertions(+), 338 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-02-connection-key-rtun3-seed-design.md create mode 100644 src/keyderive.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6edc52..cc8d19c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,18 @@ jobs: run: cargo run --release -- connect --help - name: rustunnel generate --help run: cargo run --release -- generate --help + - name: rustunnel keygen prints a rtun3. key + shell: bash + run: | + KEY=$(cargo run --release -- keygen) + case "$KEY" in rtun3.*) ;; *) echo "ERROR: keygen output should start with rtun3., got: $KEY"; exit 1;; esac + - name: connect requires --target + shell: bash + run: | + if cargo run --release -- connect "$(cargo run --release -- keygen)" 2>&1; then + echo "ERROR: connect without --target should have failed" + exit 1 + fi - name: Invalid command fails shell: bash run: | diff --git a/Cargo.lock b/Cargo.lock index f9771b7..d31dc6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aho-corasick" version = "1.1.4" @@ -931,15 +925,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - [[package]] name = "mio" version = "1.2.1" @@ -1309,7 +1294,6 @@ dependencies = [ "hyper", "hyper-util", "libc", - "miniz_oxide", "rand", "rcgen", "rustls", diff --git a/Cargo.toml b/Cargo.toml index de596ab..47f0faf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,6 @@ hyper-util = { version = "0.1", features = ["full", "server-graceful"] } http-body-util = "0.1" tower-service = "0.3" base64 = "0.22" -miniz_oxide = "0.8" sha2 = "0.10" bytes = "1" futures = "0.3" diff --git a/README.md b/README.md index 783a024..247aef8 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,20 @@ Use the SOCKS5 proxy from a local client: curl --proxy socks5h://127.0.0.1:1180 http://127.0.0.1:4181/ ``` +## Connection keys + +Instead of certificate files you can use a short connection key, from which both +endpoints derive identical TLS material on the spot: + +```sh +rustunnel keygen # prints a ~49-char rtun3. key +rustunnel listen --listen 0.0.0.0:4180 --connection-key '' # server side +rustunnel connect --target 127.0.0.1:4180 '' # each client +``` + +The key is a random 32-byte seed; no certificates are ever shipped in it. The target +address is passed separately on `connect`. + ## Defaults and custom ports - HTTPS listener: `127.0.0.1:4180` diff --git a/docs/superpowers/specs/2026-08-02-connection-key-rtun3-seed-design.md b/docs/superpowers/specs/2026-08-02-connection-key-rtun3-seed-design.md new file mode 100644 index 0000000..fcf1f20 --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-connection-key-rtun3-seed-design.md @@ -0,0 +1,76 @@ +# Connection Keys as Seeds (rtun3) — Design + +## Goal + +Replace the bundled-certificate connection key (rtun1/rtun2, ~1740 chars) with a +**~49-char seed key**. No certificates are ever shipped; both endpoints derive an +identical CA from the seed and mint ephemeral leaf certs locally. Target is passed +separately on `connect` (resocks-style). App-layer auth token is derived from the +seed. Supersedes the rtun2 compression work entirely. + +Motivation: the connection key should be trivial to copy/paste. Compression could +only halve the size; seed-derivation (as in resocks/kbtls) makes it ~35x smaller. + +## Format + +``` +rtun3. +``` + +- Parse accepts only the `rtun3.` prefix; `rtun1.`/`rtun2.` are rejected. +- Payload must decode to exactly 32 bytes; all-zero seed is rejected. +- `keygen` now takes no arguments — it just prints a fresh seed key. + +## Derivation (keyderive.rs) + +Deterministic and identical on both endpoints (same seed → same result): + +- **CA**: Ed25519 keypair from the seed (`Seed PKCS#8` → rcgen `KeyPair`), self-signed + with fixed serial/CN/validity. Ed25519 signatures are deterministic, so both sides + produce the **byte-identical CA**. +- **Server leaf** (at `listen`): fresh random Ed25519 key signed by the derived CA, + SANs = localhost/rustunnel/127.0.0.1 + bind IP + optional `--advertise` host. +- **Client leaf** (at `connect`): fresh random Ed25519 key signed by the derived CA. +- **Auth token**: `sha256(seed || "rustunnel-auth-token")` → first 16 bytes hex. + +Derived PEM strings feed the existing `build_server_config_from_pem` / +`build_client_config_from_pem` builders — the TLS layer is unchanged. + +## Command changes + +- `keygen`: `rustunnel keygen` → prints `rtun3.`. Removed `--target` and the CN + flags (they only shaped bundled certs). +- `connect`: `--target` is now required (there is no target in the key). +- `listen --connection-key $KEY`: decode seed → derive server material + token. +- Auto path (no certs/no key): generate fresh seed, print key + connect hint. +- File-based mTLS (`generate --out`, `--cert/--key/--ca-cert`, `--auth-token`) unchanged. + +## Files + +| File | Change | +| ---- | ------ | +| `Cargo.toml` | Remove `miniz_oxide` (no direct `ring` dep needed; rcgen provides crypto) | +| `src/connkey.rs` | Rewrite: seed key parse/format (rtun3) | +| `src/keyderive.rs` | New: CA/server/client/token derivation | +| `src/redact.rs` | `is_connection_key` detects `rtun3.` | +| `src/main.rs` | Rework listen/connect/keygen seed flow | +| `src/tunnel.rs` | Add key-based e2e tests | + +## Testing + +- Seed round-trip, format, reject bad/length/zero/rtun1/rtun2. +- Determinism: same seed → identical CA + token; different seed → different. +- Server/client material share the same CA. +- Key-based e2e: derived material establishes an mTLS tunnel with no files. +- Wrong-seed connector is rejected even with the correct token. +- Redaction prefix tests. + +## Docs + +Update wiki (`connection-keys.md`, `data-models.md`, `glossary.md`, +`rustunnel-cli.md`, `getting-started.md`, `security.md`, +`patterns-and-conventions.md`, `dependencies.md`, `overview/index.md`) and README. + +## Out of scope + +- File-based mTLS, `insecure_skip_tls_verify`, performance tuning, key registry. diff --git a/droid-wiki/applications/rustunnel-cli.md b/droid-wiki/applications/rustunnel-cli.md index 5ea8d98..802ccf6 100644 --- a/droid-wiki/applications/rustunnel-cli.md +++ b/droid-wiki/applications/rustunnel-cli.md @@ -10,7 +10,7 @@ Start the HTTPS tunnel listener. Binds an HTTPS server that accepts mTLS connect Key arguments: - `--listen` — bind address (default `0.0.0.0:4180`) -- `--advertise` — public address embedded in auto-generated connection keys +- `--advertise` — public host added to the derived server cert and used in the printed connect hint - `--connection-key` — reusable key generated by `keygen` or a previous `listen` run - `--socks` — optional server-side SOCKS5 proxy address for connector-side network access - `--cert`, `--key`, `--ca-cert` — TLS material paths (required unless using a connection key) @@ -25,7 +25,7 @@ Connect to the listener and expose a local SOCKS5 proxy. Key arguments: - `CONNECTION_KEY` — positional connection key (optional) -- `--target` — listener address (default from connection key) +- `--target` — listener address (required; not stored in the key) - `--connection-key` — connection key via flag or env `RUSTUNNEL_KEY` - `--socks` — local SOCKS5 proxy address (default `127.0.0.1:1180`) - `--cert`, `--key`, `--ca-cert` — TLS material paths (required unless using a connection key) @@ -39,7 +39,9 @@ Creates: `ca.pem`, `ca.key`, `server.crt`, `server.key`, `client.crt`, `client.k ### `keygen` -Generate a single reusable connection key string. This bundles all certificate material, the auth token, and the target address into a base64url-encoded, DEFLATE-compressed JSON blob prefixed with `rtun2.`. +Print a fresh ~49-char connection key (prefix `rtun3.`). The key is a random 32-byte +seed from which both endpoints derive identical TLS material; no certificates are +shipped. Pass the target address separately on `connect`. ### `version` diff --git a/droid-wiki/features/connection-keys.md b/droid-wiki/features/connection-keys.md index 87eb8e4..cf21bfa 100644 --- a/droid-wiki/features/connection-keys.md +++ b/droid-wiki/features/connection-keys.md @@ -1,69 +1,65 @@ # Connection keys -A connection key bundles all credential material and the target address into a single copy-pasteable string. - -## Purpose - -Simplify distribution of tunnel credentials between machines. Instead of transferring eight separate files, a user can generate one key and paste it into the `listen` and `connect` commands. +A connection key is a short random **seed** from which both endpoints derive +identical TLS material on the spot. It is the only value a user must copy/paste +to stand up a tunnel. ## Format -Connection keys start with the prefix `rtun2.` followed by base64url-encoded (no -padding) **DEFLATE-compressed** JSON: +Connection keys are ~49 characters: the prefix `rtun3.` followed by a +base64url-encoded 32-byte seed: ``` -rtun2. +rtun3.OH2TI3p1bM2dYjBcHxnLmZT9qKjW8tVvXQ0N7y4E3wA ``` -The JSON payload is compressed with DEFLATE (`miniz_oxide`) before base64url-encoding -to keep the key short enough to copy and paste comfortably. Example payload: +The key ships **no certificates**. Instead, both endpoints derive an identical CA +from the seed and mint ephemeral leaf certificates locally (`src/keyderive.rs`): -```json -{ - "version": 2, - "target": "198.51.100.10:4180", - "ca_cert_pem": "-----BEGIN CERTIFICATE-----...", - "server_cert_pem": "-----BEGIN CERTIFICATE-----...", - "server_key_pem": "-----BEGIN PRIVATE KEY-----...", - "client_cert_pem": "-----BEGIN CERTIFICATE-----...", - "client_key_pem": "-----BEGIN PRIVATE KEY-----...", - "auth_token": "a1b2c3..." -} -``` +- **CA** — Ed25519 keypair from the seed, self-signed and deterministic, so any two + endpoints that share the seed produce the same CA. +- **Server leaf** — generated at `listen` startup, signed by the derived CA, with + SANs for the bind address (and optional `--advertise` host). +- **Client leaf** — generated at `connect` startup, signed by the derived CA. +- **Auth token** — derived as `sha256(seed || "rustunnel-auth-token")` (first 16 + bytes hex) and checked in the existing app-layer handshake. + +Because the CA is derived from the seed, a connector holding a different seed cannot +authenticate to a listener — possession of the key is what grants access. ## Key abstractions -| Type | File | Description | -| ---- | ---- | ----------- | -| `ConnectionKey` | `src/connkey.rs` | Struct with all fields, version check, validation | -| `ConnectionKey::encode` | `src/connkey.rs` | Serialize to JSON, DEFLATE-compress, base64url-encode, prepend prefix | -| `ConnectionKey::decode` | `src/connkey.rs` | Strip prefix, base64url-decode, DEFLATE-decompress, deserialize, validate | -| `looks_like_connection_key` | `src/connkey.rs` | Quick check if a string starts with `rtun2.` | +| Type/Function | File | Description | +| ------------- | ---- | ----------- | +| `ConnectionKey` | `src/connkey.rs` | Seed value, `new`/`encode`/`decode` (rtun3) | +| `derive_server_material` | `src/keyderive.rs` | CA + server leaf PEM for `listen` | +| `derive_client_material` | `src/keyderive.rs` | CA + client leaf PEM for `connect` | +| `derive_auth_token` | `src/keyderive.rs` | App-layer token derived from the seed | +| `looks_like_connection_key` | `src/connkey.rs` | Quick check if a string starts with `rtun3.` | ## Validation -`decode` validates: +`ConnectionKey::decode` validates: -- Prefix must be `rtun2.` -- Base64 decoding must succeed -- DEFLATE decompression must succeed -- JSON deserialization must succeed -- Version must be exactly `2` -- All string fields must be non-empty after trimming - -Legacy `rtun1.` keys are **not** accepted. +- Prefix must be `rtun3.` (legacy `rtun1.`/`rtun2.` are rejected) +- Base64url decode succeeds and yields exactly 32 bytes +- The seed is not all zeros ## Integration -`src/main.rs` uses `ConnectionKey::decode` when the `--connection-key` flag or positional argument is provided. The decoded material is passed to `ServerTlsMaterial::Pem` or `ClientTlsMaterial::Pem` variants, which bypass file loading and use the embedded PEM strings directly. +`src/main.rs` decodes a seed key (positional arg, `--connection-key`, or +`RUSTUNNEL_KEY`), derives the appropriate material at startup, and feeds the PEMs +into `ServerTlsMaterial::Pem` / `ClientTlsMaterial::Pem`. The TLS layer +(`src/tls.rs`) is unchanged. `connect` requires an explicit `--target`. ## Entry points for modification -- To change the key format or add versioning: modify `src/connkey.rs`. -- To add encryption: consider extending the encode/decode pipeline in `ConnectionKey`. +- To change derivation or signing parameters: modify `src/keyderive.rs`. +- To change the seed format or versioning: modify `src/connkey.rs`. ## Key source files | File | Purpose | | ---- | ------- | -| `src/connkey.rs` | Connection key struct, encoding, decoding, validation | +| `src/connkey.rs` | Seed key struct, encoding, decoding | +| `src/keyderive.rs` | CA + leaf + token derivation from the seed | diff --git a/droid-wiki/how-to-contribute/patterns-and-conventions.md b/droid-wiki/how-to-contribute/patterns-and-conventions.md index 45bf910..c18b4e1 100644 --- a/droid-wiki/how-to-contribute/patterns-and-conventions.md +++ b/droid-wiki/how-to-contribute/patterns-and-conventions.md @@ -13,7 +13,7 @@ The project uses a layered error strategy: - The `Redacted` wrapper in `src/redact.rs` must be used before logging any secret. - `Redacted::inner()` and `into_inner()` are marked `#[allow(dead_code)]` to discourage use, but available for file writes. - `is_sensitive()` detects PEM private key blocks. `is_auth_token()` detects long alphanumeric strings. -- `is_connection_key()` detects strings starting with `rtun2.`. +- `is_connection_key()` detects strings starting with `rtun3.`. ## Async patterns diff --git a/droid-wiki/overview/getting-started.md b/droid-wiki/overview/getting-started.md index 4d12bba..a0eff80 100644 --- a/droid-wiki/overview/getting-started.md +++ b/droid-wiki/overview/getting-started.md @@ -76,26 +76,27 @@ curl --proxy socks5h://127.0.0.1:1180 http://127.0.0.1:4181/ ## Using a connection key -Instead of passing individual certificate paths, you can generate a single connection key: +Instead of passing individual certificate paths, you can use a short connection key +from which both ends derive identical credentials: ```sh -./target/release/rustunnel keygen --target 127.0.0.1:4180 +./target/release/rustunnel keygen ``` -Then start the listener with the key (it auto-generates credentials): +Start the listener with the key: ```sh -./target/release/rustunnel listen --connection-key +./target/release/rustunnel listen --listen 0.0.0.0:4180 --connection-key ``` -And connect with the same key: +Connect with the same key, passing the listener address explicitly: ```sh -./target/release/rustunnel connect +./target/release/rustunnel connect --target 127.0.0.1:4180 ``` ## Defaults - Listener bind address: `0.0.0.0:4180` -- Connector target: required (or taken from connection key) +- Connector target: required (not stored in the key) - Connector SOCKS5 proxy: `127.0.0.1:1180` diff --git a/droid-wiki/overview/glossary.md b/droid-wiki/overview/glossary.md index 25584c8..4a42ac9 100644 --- a/droid-wiki/overview/glossary.md +++ b/droid-wiki/overview/glossary.md @@ -1,6 +1,6 @@ # Glossary -**Connection key** — a base64url-encoded, DEFLATE-compressed JSON blob (prefix `rtun2.`) that bundles the CA certificate, server certificate, server key, client certificate, client key, target address, auth token, and version. Generated by `rustunnel keygen` and consumed by `rustunnel listen` and `rustunnel connect`. +**Connection key** — a short (~49-char) base64url-encoded 32-byte seed (prefix `rtun3.`). Both endpoints derive an identical CA and ephemeral leaf certificates from the seed, so no certificates are ever shipped. Generated by `rustunnel keygen` and consumed by `rustunnel listen` and `rustunnel connect`. **Connector** — the `rustunnel connect` side. Establishes an outbound HTTPS+mTLS connection to the listener, authenticates, then exposes a local SOCKS5 proxy for local applications. diff --git a/droid-wiki/overview/index.md b/droid-wiki/overview/index.md index 248b798..fe6a5db 100644 --- a/droid-wiki/overview/index.md +++ b/droid-wiki/overview/index.md @@ -9,7 +9,7 @@ The tool is designed for local labs, development environments, and controlled te - **Listener** (`rustunnel listen`) — binds an HTTPS server that accepts mTLS connections from connectors, validates an auth token, then upgrades the connection to a persistent binary-framed tunnel. - **Connector** (`rustunnel connect`) — connects to the listener over HTTPS with mTLS, authenticates, then exposes a local SOCKS5 proxy that forwards traffic through the tunnel. - **Credential generation** (`rustunnel generate`) — creates a self-signed CA, server certificate, client certificate, and auth token for a local session. -- **Connection keys** (`rustunnel keygen`) — bundles all credential material into a single compressed base64url string that can be copy-pasted between machines. +- **Connection keys** (`rustunnel keygen`) — a short ~49-char seed from which both endpoints derive identical TLS material and which can be copy-pasted between machines. ## Quick links diff --git a/droid-wiki/reference/data-models.md b/droid-wiki/reference/data-models.md index 2cb6e67..9af4e7c 100644 --- a/droid-wiki/reference/data-models.md +++ b/droid-wiki/reference/data-models.md @@ -4,18 +4,13 @@ ```rust pub struct ConnectionKey { - pub version: u8, // must be 2 - pub target: String, // listener address - pub ca_cert_pem: String, - pub server_cert_pem: String, - pub server_key_pem: String, - pub client_cert_pem: String, - pub client_key_pem: String, - pub auth_token: String, + seed: [u8; 32], // random seed from which all TLS material is derived } ``` -Encoded as: `rtun2.` + base64url(deflate(JSON)) — no padding. +Encoded as: `rtun3.` + base64url(32-byte seed) — no padding (~49 chars). No +certificates are stored in the key; both endpoints derive the same CA and +ephemeral leaves from the seed (`src/keyderive.rs`). ## Config diff --git a/droid-wiki/reference/dependencies.md b/droid-wiki/reference/dependencies.md index 8f29fa1..00a9e94 100644 --- a/droid-wiki/reference/dependencies.md +++ b/droid-wiki/reference/dependencies.md @@ -26,9 +26,8 @@ | `hyper-util` | 0.1 | HTTP utilities | | `http-body-util` | 0.1 | HTTP body utilities | | `tower-service` | 0.3 | Service trait (Hyper ecosystem) | -| `base64` | 0.22 | Connection key encoding | -| `miniz_oxide` | 0.8 | DEFLATE compression for connection keys | -| `sha2` | 0.10 | Certificate fingerprinting | +| `base64` | 0.22 | Connection key seed encoding | +| `sha2` | 0.10 | Auth-token derivation from the seed | | `bytes` | 1 | Byte buffers for framing | | `futures` | 0.3 | Future utilities | | `url` | 2 | URL parsing | diff --git a/droid-wiki/security.md b/droid-wiki/security.md index 6778206..bba8c15 100644 --- a/droid-wiki/security.md +++ b/droid-wiki/security.md @@ -21,11 +21,20 @@ The E2E test `auth_required_in_addition_to_mtls` verifies that valid mTLS alone ## Auth token -- Generated as 32 random bytes encoded as 64 hex characters. +- Derived from the connection key seed as `sha256(seed || "rustunnel-auth-token")` + (first 16 bytes, hex), or 32 random bytes as 64 hex characters for the file-based path. - Compared with a constant-time XOR loop to prevent timing attacks. - Never logged in raw form. All log output uses the `Redacted` wrapper. - Auth failures are terminal: the connector's reconnect loop exits on auth failure rather than retrying. +## Connection key as seed + +- The connection key is a 32-byte seed (prefix `rtun3.`). It confers full access — + possession of the key lets you derive the same CA and ephemeral leaves, so treat it + as a secret. +- Both endpoints derive the same CA; a connector using a different seed cannot + authenticate to a listener (verified by `e2e_wrong_seed_key_is_rejected`). + ## Secret redaction - `Redacted` in `src/redact.rs` wraps strings and displays `[REDACTED(len=N)]`. @@ -33,7 +42,7 @@ The E2E test `auth_required_in_addition_to_mtls` verifies that valid mTLS alone - `redact_config_json()` scrubs sensitive keys from JSON. - `is_sensitive()` detects PEM private key blocks. - `is_auth_token()` detects long alphanumeric strings. -- `is_connection_key()` detects strings starting with `rtun2.`. +- `is_connection_key()` detects strings starting with `rtun3.`. The E2E test `ops_auth_failure_is_actionable_and_redacted` verifies that error messages do not contain raw token values. diff --git a/src/cli.rs b/src/cli.rs index 7e16d14..5530b02 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -180,24 +180,11 @@ pub enum Commands { }, /// Generate a reusable copy/paste connection key + /// + /// Prints a short key (prefix `rtun3.`) from which both endpoints derive + /// identical TLS material. Pass the target address separately on `connect`. #[command(trailing_var_arg = true)] - Keygen { - /// Listener address that connectors should dial - #[arg(long, default_value = "127.0.0.1:4180", value_name = "ADDR:PORT")] - target: String, - - /// Common name for the CA certificate - #[arg(long, default_value = "rustunnel-ca", value_name = "CN")] - ca_name: String, - - /// Common name for the server certificate - #[arg(long, default_value = "rustunnel-server", value_name = "CN")] - server_name: String, - - /// Common name for the client certificate - #[arg(long, default_value = "rustunnel-client", value_name = "CN")] - client_name: String, - }, + Keygen, /// Print version information #[command(trailing_var_arg = true)] diff --git a/src/connkey.rs b/src/connkey.rs index 526bd2a..0807c06 100644 --- a/src/connkey.rs +++ b/src/connkey.rs @@ -1,41 +1,36 @@ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use serde::{Deserialize, Serialize}; -use crate::generate::GeneratedMaterial; +const PREFIX: &str = "rtun3."; +pub const SEED_LEN: usize = 32; -const PREFIX: &str = "rtun2."; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// A connection key is a random 32-byte seed from which both endpoints derive +/// identical certificate material (a CA and ephemeral leaves) on the spot. +/// No certificates are ever shipped in the key itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ConnectionKey { - pub version: u8, - pub target: String, - pub ca_cert_pem: String, - pub server_cert_pem: String, - pub server_key_pem: String, - pub client_cert_pem: String, - pub client_key_pem: String, - pub auth_token: String, + seed: [u8; SEED_LEN], } impl ConnectionKey { - pub fn new(target: String, material: GeneratedMaterial) -> Self { - Self { - version: 2, - target, - ca_cert_pem: material.ca_cert_pem, - server_cert_pem: material.server_cert_pem, - server_key_pem: material.server_key_pem, - client_cert_pem: material.client_cert_pem, - client_key_pem: material.client_key_pem, - auth_token: material.auth_token, + /// Generate a fresh random connection key. + pub fn new() -> Self { + loop { + let seed: [u8; SEED_LEN] = rand::random(); + if !is_zero(&seed) { + return Self { seed }; + } } } - pub fn encode(&self) -> anyhow::Result { - let json = serde_json::to_vec(self)?; - let compressed = miniz_oxide::deflate::compress_to_vec(&json, 6); - Ok(format!("{}{}", PREFIX, URL_SAFE_NO_PAD.encode(compressed))) + /// The raw seed bytes. + pub fn seed(&self) -> &[u8; SEED_LEN] { + &self.seed + } + + /// Encode the key as `rtun3.` + base64url(seed). + pub fn encode(&self) -> String { + format!("{}{}", PREFIX, URL_SAFE_NO_PAD.encode(self.seed)) } pub fn decode(input: &str) -> anyhow::Result { @@ -46,27 +41,32 @@ impl ConnectionKey { let bytes = URL_SAFE_NO_PAD .decode(encoded) .map_err(|e| anyhow::anyhow!("invalid connection key encoding: {}", e))?; - let json = miniz_oxide::inflate::decompress_to_vec(&bytes) - .map_err(|e| anyhow::anyhow!("invalid connection key payload: {}", e))?; - let key: Self = serde_json::from_slice(&json) - .map_err(|e| anyhow::anyhow!("invalid connection key payload: {}", e))?; - if key.version != 2 { - anyhow::bail!("unsupported connection key version {}", key.version); + if bytes.len() != SEED_LEN { + anyhow::bail!( + "connection key payload must be {} bytes, got {}", + SEED_LEN, + bytes.len() + ); } - if key.target.trim().is_empty() - || key.ca_cert_pem.trim().is_empty() - || key.server_cert_pem.trim().is_empty() - || key.server_key_pem.trim().is_empty() - || key.client_cert_pem.trim().is_empty() - || key.client_key_pem.trim().is_empty() - || key.auth_token.trim().is_empty() - { - anyhow::bail!("connection key is missing required material"); + let mut seed = [0u8; SEED_LEN]; + seed.copy_from_slice(&bytes); + if is_zero(&seed) { + anyhow::bail!("connection key must not be all zeros"); } - Ok(key) + Ok(Self { seed }) } } +impl Default for ConnectionKey { + fn default() -> Self { + Self::new() + } +} + +fn is_zero(seed: &[u8; SEED_LEN]) -> bool { + seed.iter().all(|&b| b == 0) +} + #[allow(dead_code)] pub fn looks_like_connection_key(input: &str) -> bool { input.trim_start().starts_with(PREFIX) @@ -78,58 +78,54 @@ mod tests { #[test] fn connection_key_roundtrip() { - let material = - crate::generate::generate_material("ca", "server", "client").expect("material"); - let key = ConnectionKey::new("127.0.0.1:4180".to_string(), material); - let encoded = key.encode().unwrap(); - assert!(encoded.starts_with("rtun2.")); + let key = ConnectionKey::new(); + let encoded = key.encode(); + assert!(encoded.starts_with("rtun3.")); + assert_eq!(encoded.len(), PREFIX.len() + 43); let decoded = ConnectionKey::decode(&encoded).unwrap(); - assert_eq!(decoded.version, 2); - assert_eq!(decoded.target, "127.0.0.1:4180"); - assert_eq!(decoded.auth_token, key.auth_token); - assert_eq!(decoded.ca_cert_pem, key.ca_cert_pem); - assert_eq!(decoded.server_cert_pem, key.server_cert_pem); - assert_eq!(decoded.server_key_pem, key.server_key_pem); - assert_eq!(decoded.client_cert_pem, key.client_cert_pem); - assert_eq!(decoded.client_key_pem, key.client_key_pem); + assert_eq!(decoded.seed, key.seed); } #[test] - fn encoded_key_is_smaller_than_raw_json() { - let material = - crate::generate::generate_material("ca", "server", "client").expect("material"); - let key = ConnectionKey::new("127.0.0.1:4180".to_string(), material); - let encoded = key.encode().unwrap(); - - let json = serde_json::to_vec(&key).unwrap(); - let raw = format!("{}{}", PREFIX, URL_SAFE_NO_PAD.encode(json)); - assert!( - encoded.len() < raw.len(), - "compressed key ({} chars) should be shorter than uncompressed key ({} chars)", - encoded.len(), - raw.len() - ); - assert!( - encoded.len() < (raw.len() * 8) / 10, - "compressed key ({} chars) should be meaningfully shorter than uncompressed ({} chars)", - encoded.len(), - raw.len() - ); + fn generated_keys_are_unique() { + let a = ConnectionKey::new(); + let b = ConnectionKey::new(); + assert_ne!(a.seed, b.seed); } #[test] fn invalid_prefix_fails() { assert!(ConnectionKey::decode("bad").is_err()); assert!( - ConnectionKey::decode("rtun1.not-a-valid-payload").is_err(), - "legacy rtun1. keys are not accepted" + ConnectionKey::decode("rtun1.xxx").is_err(), + "legacy rtun1. keys are rejected" + ); + assert!( + ConnectionKey::decode("rtun2.xxx").is_err(), + "legacy rtun2. keys are rejected" ); } #[test] - fn looks_like_connection_key_detects_rtun2_prefix() { - assert!(looks_like_connection_key("rtun2.abc")); + fn wrong_payload_length_fails() { + let key = ConnectionKey::new(); + let prefix = key.encode(); + let _ = prefix; + // A 16-byte seed is invalid. + let short = URL_SAFE_NO_PAD.encode([0u8; 16]); + assert!(ConnectionKey::decode(&format!("{}{}", PREFIX, short)).is_err()); + } + + #[test] + fn all_zero_seed_fails() { + let zero = URL_SAFE_NO_PAD.encode([0u8; SEED_LEN]); + assert!(ConnectionKey::decode(&format!("{}{}", PREFIX, zero)).is_err()); + } + + #[test] + fn looks_like_connection_key_detects_rtun3_prefix() { + assert!(looks_like_connection_key("rtun3.abc")); assert!(!looks_like_connection_key("abc")); - assert!(!looks_like_connection_key("rtun1.def")); + assert!(!looks_like_connection_key("rtun2.def")); } } diff --git a/src/keyderive.rs b/src/keyderive.rs new file mode 100644 index 0000000..aff9aa0 --- /dev/null +++ b/src/keyderive.rs @@ -0,0 +1,169 @@ +use anyhow::Context; +use rcgen::{ + BasicConstraints, CertificateParams, CertifiedIssuer, DistinguishedName, DnType, IsCa, KeyPair, + SanType, SerialNumber, +}; +use sha2::{Digest, Sha256}; + +use crate::connkey::SEED_LEN; + +/// Build the PKCS#8 (RFC 5958 v0, "seed"/unchecked) encoding of an Ed25519 +/// key derived from a 32-byte seed. This is a fixed 48-byte structure; the +/// seed is the Ed25519 private key seed. +fn ed25519_pkcs8_from_seed(seed: &[u8; SEED_LEN]) -> Vec { + let mut out = Vec::with_capacity(48); + out.extend_from_slice(&[ + 0x30, 0x2e, 0x02, 0x01, 0x00, // SEQUENCE { INTEGER 0 + 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, // SEQUENCE { OID 1.3.101.112 } + 0x04, 0x22, 0x04, 0x20, // OCTET STRING { OCTET STRING { + ]); + out.extend_from_slice(seed); + out +} + +/// Deterministically derive the Ed25519 CA keypair from the seed. +fn ca_key_from_seed(seed: &[u8; SEED_LEN]) -> anyhow::Result { + let pkcs8 = ed25519_pkcs8_from_seed(seed); + KeyPair::try_from(pkcs8.as_slice()) + .map_err(|e| anyhow::anyhow!("failed to derive CA key from seed: {}", e)) +} + +/// Derive a deterministic, self-signed CA certificate from the seed. +/// Any two endpoints that share the seed produce the identical CA. +fn derive_ca(seed: &[u8; SEED_LEN]) -> anyhow::Result> { + let key = ca_key_from_seed(seed)?; + let mut params = CertificateParams::default(); + let mut dn = DistinguishedName::new(); + dn.push( + DnType::CommonName, + format!("rustunnel-{}", hex::encode(&seed[..8])), + ); + dn.push(DnType::OrganizationName, "rustunnel"); + params.distinguished_name = dn; + params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); + params.serial_number = Some(SerialNumber::from(1)); + CertifiedIssuer::self_signed(params, key) + .map_err(|e| anyhow::anyhow!("failed to create derived CA: {}", e)) +} + +fn ca_pem(ca: &CertifiedIssuer<'_, KeyPair>) -> String { + ca.as_ref().pem() +} + +/// Derive the auth token for the app-layer handshake. +/// Both endpoints derive the identical token from the shared seed. +pub fn derive_auth_token(seed: &[u8; SEED_LEN]) -> String { + let mut h = Sha256::new(); + h.update(seed); + h.update(b"rustunnel-auth-token"); + hex::encode(&h.finalize()[..16]) +} + +/// Server (listener) material: (server_cert_pem, server_key_pem, ca_pem). +/// `extra_sans` are additional hostnames/IPs to include in the server leaf's +/// SANs (typically the bind address and any advertised public host). +pub fn derive_server_material( + seed: &[u8; SEED_LEN], + extra_sans: &[String], +) -> anyhow::Result<(String, String, String)> { + let ca = derive_ca(seed)?; + let mut params = CertificateParams::default(); + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "rustunnel-server"); + dn.push(DnType::OrganizationName, "rustunnel"); + params.distinguished_name = dn; + params.is_ca = IsCa::NoCa; + params.serial_number = Some(SerialNumber::from(2)); + params.subject_alt_names = vec![ + SanType::DnsName("localhost".try_into().unwrap()), + SanType::DnsName("rustunnel".try_into().unwrap()), + SanType::IpAddress(std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1))), + ]; + for san in extra_sans { + if let Ok(ip) = san.parse::() { + params.subject_alt_names.push(SanType::IpAddress(ip)); + } else if let Ok(dns) = san.clone().try_into() { + params.subject_alt_names.push(SanType::DnsName(dns)); + } + } + + let key_pair = KeyPair::generate_for(&rcgen::PKCS_ED25519) + .with_context(|| "failed to generate server signing key")?; + let cert = params + .signed_by(&key_pair, &ca) + .map_err(|e| anyhow::anyhow!("failed to sign server certificate: {}", e))?; + Ok((cert.pem(), key_pair.serialize_pem(), ca_pem(&ca))) +} + +/// Client (connector) material: (client_cert_pem, client_key_pem, ca_pem). +pub fn derive_client_material(seed: &[u8; SEED_LEN]) -> anyhow::Result<(String, String, String)> { + let ca = derive_ca(seed)?; + let mut params = CertificateParams::default(); + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "rustunnel-client"); + dn.push(DnType::OrganizationName, "rustunnel"); + params.distinguished_name = dn; + params.is_ca = IsCa::NoCa; + params.serial_number = Some(SerialNumber::from(3)); + + let key_pair = KeyPair::generate_for(&rcgen::PKCS_ED25519) + .with_context(|| "failed to generate client signing key")?; + let cert = params + .signed_by(&key_pair, &ca) + .map_err(|e| anyhow::anyhow!("failed to sign client certificate: {}", e))?; + Ok((cert.pem(), key_pair.serialize_pem(), ca_pem(&ca))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn seed(n: u8) -> [u8; SEED_LEN] { + [n; SEED_LEN] + } + + #[test] + fn ca_is_deterministic_for_same_seed() { + let a = crate::keyderive::derive_ca(&seed(7)) + .unwrap() + .as_ref() + .pem(); + let b = crate::keyderive::derive_ca(&seed(7)) + .unwrap() + .as_ref() + .pem(); + assert_eq!(a, b); + let c = crate::keyderive::derive_ca(&seed(8)) + .unwrap() + .as_ref() + .pem(); + assert_ne!(a, c, "different seeds must differ"); + } + + #[test] + fn auth_token_is_deterministic() { + assert_eq!(derive_auth_token(&seed(1)), derive_auth_token(&seed(1))); + assert_ne!(derive_auth_token(&seed(1)), derive_auth_token(&seed(2))); + assert_eq!(derive_auth_token(&seed(9)).len(), 32); + } + + #[test] + fn server_and_client_share_the_same_ca() { + let (_, _, server_ca) = derive_server_material(&seed(1), &[]).unwrap(); + let (_, _, client_ca) = derive_client_material(&seed(1)).unwrap(); + assert_eq!(server_ca, client_ca, "both sides must derive the same CA"); + // Different seeds produce a different CA. + let (_, _, other_ca) = derive_client_material(&seed(2)).unwrap(); + assert_ne!(server_ca, other_ca); + } + + #[test] + fn derived_material_is_valid_pem() { + let (server_cert, server_key, ca) = derive_server_material(&seed(3), &[]).unwrap(); + let (client_cert, client_key, _) = derive_client_material(&seed(3)).unwrap(); + for s in [&server_cert, &server_key, &ca, &client_cert, &client_key] { + assert!(s.starts_with("-----BEGIN")); + assert!(s.contains("-----END")); + } + } +} diff --git a/src/main.rs b/src/main.rs index 6e962ac..b80a268 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod connkey; mod errors; mod framing; mod generate; +mod keyderive; mod redact; mod signal; mod socks5; @@ -117,12 +118,7 @@ fn main() { server_name, client_name, } => run_generate(&out, &ca_name, &server_name, &client_name), - crate::cli::Commands::Keygen { - target, - ca_name, - server_name, - client_name, - } => run_keygen(&target, &ca_name, &server_name, &client_name), + crate::cli::Commands::Keygen => run_keygen(), crate::cli::Commands::Version => run_version(), }; @@ -204,17 +200,41 @@ fn run_listen( None }; + let extra_sans: Vec = { + let mut v = Vec::with_capacity(2); + if host != "0.0.0.0" && host != "::" { + v.push(host.clone()); + } + if let Some(ad) = &advertise + && let Ok((ad_host, _)) = cli::parse_host_port(ad) + { + v.push(ad_host); + } + v + }; + let (tls, auth_token, generated_key) = if let Some(raw_key) = connection_key { match ConnectionKey::decode(&raw_key) { - Ok(k) => ( - ServerTlsMaterial::Pem { - cert: Arc::new(k.server_cert_pem), - key: Arc::new(k.server_key_pem), - ca_cert: Arc::new(k.ca_cert_pem), - }, - k.auth_token, - None, - ), + Ok(k) => { + let seed = *k.seed(); + let (server_cert_pem, server_key_pem, ca_cert_pem) = + match keyderive::derive_server_material(&seed, &extra_sans) { + Ok(m) => m, + Err(e) => { + eprintln!("Error: {}", e); + return 1; + } + }; + ( + ServerTlsMaterial::Pem { + cert: Arc::new(server_cert_pem), + key: Arc::new(server_key_pem), + ca_cert: Arc::new(ca_cert_pem), + }, + keyderive::derive_auth_token(&seed), + None, + ) + } Err(e) => { eprintln!("Error: {}", e); return 1; @@ -226,31 +246,17 @@ fn run_listen( && auth_token.is_empty() && auth_token_file.is_none() { - let target = advertise.unwrap_or_else(|| { - if host == "0.0.0.0" || host == "::" { - format!("127.0.0.1:{}", port) - } else { - listen.clone() - } - }); - if let Err(e) = cli::parse_host_port(&target) { - eprintln!("Error: invalid --advertise value: {}", e); - return 1; - } - match generate_key( - &target, - "rustunnel-ca", - "rustunnel-server", - "rustunnel-client", - ) { - Ok((key, encoded)) => ( + let key = ConnectionKey::new(); + let seed = *key.seed(); + match keyderive::derive_server_material(&seed, &extra_sans) { + Ok((server_cert_pem, server_key_pem, ca_cert_pem)) => ( ServerTlsMaterial::Pem { - cert: Arc::new(key.server_cert_pem), - key: Arc::new(key.server_key_pem), - ca_cert: Arc::new(key.ca_cert_pem), + cert: Arc::new(server_cert_pem), + key: Arc::new(server_key_pem), + ca_cert: Arc::new(ca_cert_pem), }, - key.auth_token, - Some(encoded), + keyderive::derive_auth_token(&seed), + Some(key.encode()), ), Err(e) => { eprintln!("Error generating connection key: {}", e); @@ -310,8 +316,18 @@ fn run_listen( port ); if let Some(key) = generated_key { + let hint_target = advertise.unwrap_or_else(|| { + if host == "0.0.0.0" || host == "::" { + format!("127.0.0.1:{}", port) + } else { + listen.clone() + } + }); println!("Connection key:\n{}\n", key); - println!("Connect with:\nrustunnel connect {}", key); + println!( + "Connect with:\nrustunnel connect --target {} \"{}\"", + hint_target, key + ); } tracing::info!( "Auth token configured: {}", @@ -386,19 +402,14 @@ fn run_connect( None }; - let target_value = match select_connect_target(target.as_deref(), decoded_key.as_ref()) { - Ok(v) => v, - Err(e) => { - eprintln!("Error: {}", e); + let target_value = match target { + Some(t) => t, + None => { + eprintln!("Error: --target is required for connect"); return 1; } }; - if should_warn_target_override(target.as_deref(), decoded_key.as_ref()) { - tracing::warn!( - "--target overrides the listener address embedded in the connection key; TLS trust still uses the key's CA/client certificate material" - ); - } - let (target_host, target_port) = match cli::parse_host_port(target_value) { + let (target_host, target_port) = match cli::parse_host_port(&target_value) { Ok(v) => v, Err(e) => { eprintln!("Error: {}", e); @@ -415,13 +426,22 @@ fn run_connect( }; let (tls, auth_token) = if let Some(k) = decoded_key { + let seed = *k.seed(); + let (client_cert_pem, client_key_pem, ca_cert_pem) = + match keyderive::derive_client_material(&seed) { + Ok(m) => m, + Err(e) => { + eprintln!("Error: {}", e); + return 1; + } + }; ( ClientTlsMaterial::Pem { - cert: Arc::new(k.client_cert_pem), - key: Arc::new(k.client_key_pem), - ca_cert: Arc::new(k.ca_cert_pem), + cert: Arc::new(client_cert_pem), + key: Arc::new(client_key_pem), + ca_cert: Arc::new(ca_cert_pem), }, - k.auth_token, + keyderive::derive_auth_token(&seed), ) } else { if cert.is_empty() { @@ -547,58 +567,10 @@ fn configure_performance( }); } -fn select_connect_target<'a>( - target: Option<&'a str>, - decoded_key: Option<&'a ConnectionKey>, -) -> anyhow::Result<&'a str> { - if let Some(target) = target { - return Ok(target); - } - - if let Some(k) = decoded_key { - return Ok(k.target.as_str()); - } - - anyhow::bail!("--target is required for connect unless using a connection key") -} - -fn should_warn_target_override(target: Option<&str>, decoded_key: Option<&ConnectionKey>) -> bool { - matches!((target, decoded_key), (Some(target), Some(k)) if target != k.target) -} - -fn generate_key( - target: &str, - ca_name: &str, - server_name: &str, - client_name: &str, -) -> anyhow::Result<(ConnectionKey, String)> { - let (host, _) = cli::parse_host_port(target)?; - let material = generate::generate_material_with_server_host( - ca_name, - server_name, - client_name, - Some(&host), - )?; - let key = ConnectionKey::new(target.to_string(), material); - let encoded = key.encode()?; - Ok((key, encoded)) -} - -fn run_keygen(target: &str, ca_name: &str, server_name: &str, client_name: &str) -> i32 { - if let Err(e) = cli::parse_host_port(target) { - eprintln!("Error: {}", e); - return 1; - } - match generate_key(target, ca_name, server_name, client_name) { - Ok((_key, encoded)) => { - println!("{}", encoded); - 0 - } - Err(e) => { - eprintln!("Error generating connection key: {}", e); - 1 - } - } +fn run_keygen() -> i32 { + let key = ConnectionKey::new(); + println!("{}", key.encode()); + 0 } fn run_generate(out: &str, ca_name: &str, server_name: &str, client_name: &str) -> i32 { @@ -631,53 +603,11 @@ fn run_version() -> i32 { mod tests { use super::*; - fn test_connection_key(target: &str) -> ConnectionKey { - ConnectionKey { - version: 2, - target: target.to_string(), - ca_cert_pem: "ca".to_string(), - server_cert_pem: "server-cert".to_string(), - server_key_pem: "server-key".to_string(), - client_cert_pem: "client-cert".to_string(), - client_key_pem: "client-key".to_string(), - auth_token: "token".to_string(), - } - } - #[test] - fn explicit_connect_target_overrides_key_target() { - let key = test_connection_key("198.51.100.10:443"); - let selected = select_connect_target(Some("23.167.32.42:443"), Some(&key)).unwrap(); - assert_eq!(selected, "23.167.32.42:443"); - } - - #[test] - fn connect_uses_key_target_when_explicit_target_absent() { - let key = test_connection_key("198.51.100.10:443"); - let selected = select_connect_target(None, Some(&key)).unwrap(); - assert_eq!(selected, "198.51.100.10:443"); - } - - #[test] - fn connect_target_is_required_without_key() { - let err = select_connect_target(None, None).unwrap_err(); - assert_eq!( - err.to_string(), - "--target is required for connect unless using a connection key" - ); - } - - #[test] - fn warns_when_explicit_target_differs_from_key_target() { - let key = test_connection_key("198.51.100.10:443"); - assert!(should_warn_target_override( - Some("23.167.32.42:443"), - Some(&key) - )); - assert!(!should_warn_target_override( - Some("198.51.100.10:443"), - Some(&key) - )); - assert!(!should_warn_target_override(Some("23.167.32.42:443"), None)); + fn keygen_produces_a_decodable_seed_key() { + let encoded = ConnectionKey::new().encode(); + assert!(encoded.starts_with("rtun3.")); + let decoded = ConnectionKey::decode(&encoded).unwrap(); + assert_eq!(decoded.encode(), encoded); } } diff --git a/src/redact.rs b/src/redact.rs index 90e38ac..66e682d 100644 --- a/src/redact.rs +++ b/src/redact.rs @@ -120,7 +120,7 @@ pub fn is_auth_token(content: &str) -> bool { #[allow(dead_code)] pub fn is_connection_key(content: &str) -> bool { - content.trim_start().starts_with("rtun2.") + content.trim_start().starts_with("rtun3.") } #[cfg(test)] @@ -213,9 +213,9 @@ mod tests { #[test] fn is_connection_key_detects_rtun_prefix() { - assert!(is_connection_key("rtun2.abc")); + assert!(is_connection_key("rtun3.abc")); assert!(!is_connection_key("abc")); - assert!(!is_connection_key("rtun1.abc")); + assert!(!is_connection_key("rtun2.abc")); } #[test] diff --git a/src/tunnel.rs b/src/tunnel.rs index 7494cca..42c411f 100644 --- a/src/tunnel.rs +++ b/src/tunnel.rs @@ -2234,6 +2234,115 @@ mod tests { listener_task.abort(); } + /// A connection-key (seed-derived) listener and connector establish an mTLS + /// tunnel using no on-disk certificate files at all. + #[tokio::test] + async fn e2e_seed_key_establishes_tunnel_without_files() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let key = crate::connkey::ConnectionKey::new(); + let seed = *key.seed(); + let token = crate::keyderive::derive_auth_token(&seed); + let port = get_free_port(); + let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap(); + + let (server_cert, server_key, ca_cert) = + crate::keyderive::derive_server_material(&seed, &["127.0.0.1".to_string()]).unwrap(); + let (client_cert, client_key, client_ca) = + crate::keyderive::derive_client_material(&seed).unwrap(); + assert_eq!(ca_cert, client_ca, "both sides must derive the same CA"); + + let listener_config = ListenerConfig { + insecure_skip_tls_verify: false, + bind_addr, + socks_addr: None, + tls: ServerTlsMaterial::Pem { + cert: Arc::new(server_cert), + key: Arc::new(server_key), + ca_cert: Arc::new(ca_cert), + }, + auth_token: Arc::new(token.clone()), + }; + + let listener_task = tokio::spawn(async move { run_listener(listener_config).await }); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + let connector_config = ConnectorConfig { + insecure_skip_tls_verify: false, + target_host: "127.0.0.1".to_string(), + target_port: port, + tls: ClientTlsMaterial::Pem { + cert: Arc::new(client_cert), + key: Arc::new(client_key), + ca_cert: Arc::new(client_ca), + }, + auth_token: Arc::new(token), + }; + + let result = quick_connect(&connector_config).await; + assert!( + result.is_ok(), + "Seed-derived mTLS should establish tunnel: {:?}", + result + ); + + listener_task.abort(); + } + + /// A connector deriving material from a DIFFERENT seed cannot authenticate + /// to a listener paired with another seed (the derived CA gates identity). + #[tokio::test] + async fn e2e_wrong_seed_key_is_rejected() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let seed_a = *crate::connkey::ConnectionKey::new().seed(); + let seed_b = *crate::connkey::ConnectionKey::new().seed(); + assert_ne!(seed_a, seed_b); + let port = get_free_port(); + let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap(); + + let (server_cert, server_key, ca_cert) = + crate::keyderive::derive_server_material(&seed_a, &["127.0.0.1".to_string()]).unwrap(); + let (client_cert, client_key, client_ca) = + crate::keyderive::derive_client_material(&seed_b).unwrap(); + + let listener_config = ListenerConfig { + insecure_skip_tls_verify: false, + bind_addr, + socks_addr: None, + tls: ServerTlsMaterial::Pem { + cert: Arc::new(server_cert), + key: Arc::new(server_key), + ca_cert: Arc::new(ca_cert.clone()), + }, + auth_token: Arc::new(crate::keyderive::derive_auth_token(&seed_a)), + }; + + let listener_task = tokio::spawn(async move { run_listener(listener_config).await }); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + let connector_config = ConnectorConfig { + insecure_skip_tls_verify: false, + target_host: "127.0.0.1".to_string(), + target_port: port, + tls: ClientTlsMaterial::Pem { + cert: Arc::new(client_cert), + key: Arc::new(client_key), + ca_cert: Arc::new(client_ca), + }, + auth_token: Arc::new(crate::keyderive::derive_auth_token(&seed_a)), + }; + + // Even with the correct auth token, the wrong-seed client cert must not + // authenticate against the listener's derived CA. + let result = quick_connect(&connector_config).await; + assert!( + result.is_err(), + "Connector with material derived from another seed must be rejected: {:?}", + result + ); + + listener_task.abort(); + } + /// Test: insecure listener accepts a connector without valid client certificate. #[tokio::test] async fn insecure_listener_accepts_no_client_cert() {