feat: replace connection keys with short seed-derived rtun3 keys
CI / cargo fmt (push) Canceled after 0s
CI / cargo clippy (macos-latest) (push) Canceled after 0s
CI / cargo clippy (ubuntu-latest) (push) Canceled after 0s
CI / cargo clippy (windows-latest) (push) Canceled after 0s
CI / cargo test (macos-latest) (push) Canceled after 0s
CI / cargo test (ubuntu-latest) (push) Canceled after 0s
CI / cargo test (windows-latest) (push) Canceled after 0s
CI / cargo build (macos-latest) (push) Canceled after 0s
CI / cargo build (ubuntu-latest) (push) Canceled after 0s
CI / cargo build (windows-latest) (push) Canceled after 0s
CI / cargo build --release (macos-latest) (push) Canceled after 0s
CI / cargo build --release (ubuntu-latest) (push) Canceled after 0s
CI / cargo build --release (windows-latest) (push) Canceled after 0s
CI / CLI smoke (macos-latest) (push) Canceled after 0s
CI / CLI smoke (ubuntu-latest) (push) Canceled after 0s
CI / CLI smoke (windows-latest) (push) Canceled after 0s
CI / Minimal E2E (macos-latest) (push) Canceled after 0s
CI / Minimal E2E (ubuntu-latest) (push) Canceled after 0s
CI / Minimal E2E (windows-latest) (push) Canceled after 0s

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
This commit is contained in:
bzuccaro
2026-08-02 12:31:53 -06:00
parent 6bc3c00b64
commit 2bf8da739b
20 changed files with 616 additions and 338 deletions
+12
View File
@@ -103,6 +103,18 @@ jobs:
run: cargo run --release -- connect --help run: cargo run --release -- connect --help
- name: rustunnel generate --help - name: rustunnel generate --help
run: cargo run --release -- 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 - name: Invalid command fails
shell: bash shell: bash
run: | run: |
Generated
-16
View File
@@ -2,12 +2,6 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@@ -931,15 +925,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" 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]] [[package]]
name = "mio" name = "mio"
version = "1.2.1" version = "1.2.1"
@@ -1309,7 +1294,6 @@ dependencies = [
"hyper", "hyper",
"hyper-util", "hyper-util",
"libc", "libc",
"miniz_oxide",
"rand", "rand",
"rcgen", "rcgen",
"rustls", "rustls",
-1
View File
@@ -29,7 +29,6 @@ hyper-util = { version = "0.1", features = ["full", "server-graceful"] }
http-body-util = "0.1" http-body-util = "0.1"
tower-service = "0.3" tower-service = "0.3"
base64 = "0.22" base64 = "0.22"
miniz_oxide = "0.8"
sha2 = "0.10" sha2 = "0.10"
bytes = "1" bytes = "1"
futures = "0.3" futures = "0.3"
+14
View File
@@ -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/ 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 '<key>' # server side
rustunnel connect --target 127.0.0.1:4180 '<key>' # 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 ## Defaults and custom ports
- HTTPS listener: `127.0.0.1:4180` - HTTPS listener: `127.0.0.1:4180`
@@ -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.<base64url(32-byte seed)>
```
- 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.<seed>`. 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.
+5 -3
View File
@@ -10,7 +10,7 @@ Start the HTTPS tunnel listener. Binds an HTTPS server that accepts mTLS connect
Key arguments: Key arguments:
- `--listen` — bind address (default `0.0.0.0:4180`) - `--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 - `--connection-key` — reusable key generated by `keygen` or a previous `listen` run
- `--socks` — optional server-side SOCKS5 proxy address for connector-side network access - `--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) - `--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: Key arguments:
- `CONNECTION_KEY` — positional connection key (optional) - `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` - `--connection-key` — connection key via flag or env `RUSTUNNEL_KEY`
- `--socks` — local SOCKS5 proxy address (default `127.0.0.1:1180`) - `--socks` — local SOCKS5 proxy address (default `127.0.0.1:1180`)
- `--cert`, `--key`, `--ca-cert` — TLS material paths (required unless using a connection key) - `--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` ### `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` ### `version`
+37 -41
View File
@@ -1,69 +1,65 @@
# Connection keys # Connection keys
A connection key bundles all credential material and the target address into a single copy-pasteable string. 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
## Purpose to stand up a tunnel.
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.
## Format ## Format
Connection keys start with the prefix `rtun2.` followed by base64url-encoded (no Connection keys are ~49 characters: the prefix `rtun3.` followed by a
padding) **DEFLATE-compressed** JSON: base64url-encoded 32-byte seed:
``` ```
rtun2.<base64url(deflate(json))> rtun3.OH2TI3p1bM2dYjBcHxnLmZT9qKjW8tVvXQ0N7y4E3wA
``` ```
The JSON payload is compressed with DEFLATE (`miniz_oxide`) before base64url-encoding The key ships **no certificates**. Instead, both endpoints derive an identical CA
to keep the key short enough to copy and paste comfortably. Example payload: from the seed and mint ephemeral leaf certificates locally (`src/keyderive.rs`):
```json - **CA** — Ed25519 keypair from the seed, self-signed and deterministic, so any two
{ endpoints that share the seed produce the same CA.
"version": 2, - **Server leaf** — generated at `listen` startup, signed by the derived CA, with
"target": "198.51.100.10:4180", SANs for the bind address (and optional `--advertise` host).
"ca_cert_pem": "-----BEGIN CERTIFICATE-----...", - **Client leaf** — generated at `connect` startup, signed by the derived CA.
"server_cert_pem": "-----BEGIN CERTIFICATE-----...", - **Auth token** — derived as `sha256(seed || "rustunnel-auth-token")` (first 16
"server_key_pem": "-----BEGIN PRIVATE KEY-----...", bytes hex) and checked in the existing app-layer handshake.
"client_cert_pem": "-----BEGIN CERTIFICATE-----...",
"client_key_pem": "-----BEGIN PRIVATE KEY-----...", Because the CA is derived from the seed, a connector holding a different seed cannot
"auth_token": "a1b2c3..." authenticate to a listener — possession of the key is what grants access.
}
```
## Key abstractions ## Key abstractions
| Type | File | Description | | Type/Function | File | Description |
| ---- | ---- | ----------- | | ------------- | ---- | ----------- |
| `ConnectionKey` | `src/connkey.rs` | Struct with all fields, version check, validation | | `ConnectionKey` | `src/connkey.rs` | Seed value, `new`/`encode`/`decode` (rtun3) |
| `ConnectionKey::encode` | `src/connkey.rs` | Serialize to JSON, DEFLATE-compress, base64url-encode, prepend prefix | | `derive_server_material` | `src/keyderive.rs` | CA + server leaf PEM for `listen` |
| `ConnectionKey::decode` | `src/connkey.rs` | Strip prefix, base64url-decode, DEFLATE-decompress, deserialize, validate | | `derive_client_material` | `src/keyderive.rs` | CA + client leaf PEM for `connect` |
| `looks_like_connection_key` | `src/connkey.rs` | Quick check if a string starts with `rtun2.` | | `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 ## Validation
`decode` validates: `ConnectionKey::decode` validates:
- Prefix must be `rtun2.` - Prefix must be `rtun3.` (legacy `rtun1.`/`rtun2.` are rejected)
- Base64 decoding must succeed - Base64url decode succeeds and yields exactly 32 bytes
- DEFLATE decompression must succeed - The seed is not all zeros
- JSON deserialization must succeed
- Version must be exactly `2`
- All string fields must be non-empty after trimming
Legacy `rtun1.` keys are **not** accepted.
## Integration ## 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 ## Entry points for modification
- To change the key format or add versioning: modify `src/connkey.rs`. - To change derivation or signing parameters: modify `src/keyderive.rs`.
- To add encryption: consider extending the encode/decode pipeline in `ConnectionKey`. - To change the seed format or versioning: modify `src/connkey.rs`.
## Key source files ## Key source files
| File | Purpose | | 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 |
@@ -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. - 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. - `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_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 ## Async patterns
+8 -7
View File
@@ -76,26 +76,27 @@ curl --proxy socks5h://127.0.0.1:1180 http://127.0.0.1:4181/
## Using a connection key ## 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 ```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 ```sh
./target/release/rustunnel listen --connection-key <key> ./target/release/rustunnel listen --listen 0.0.0.0:4180 --connection-key <key>
``` ```
And connect with the same key: Connect with the same key, passing the listener address explicitly:
```sh ```sh
./target/release/rustunnel connect <key> ./target/release/rustunnel connect --target 127.0.0.1:4180 <key>
``` ```
## Defaults ## Defaults
- Listener bind address: `0.0.0.0:4180` - 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` - Connector SOCKS5 proxy: `127.0.0.1:1180`
+1 -1
View File
@@ -1,6 +1,6 @@
# Glossary # 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. **Connector** — the `rustunnel connect` side. Establishes an outbound HTTPS+mTLS connection to the listener, authenticates, then exposes a local SOCKS5 proxy for local applications.
+1 -1
View File
@@ -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. - **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. - **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. - **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 ## Quick links
+4 -9
View File
@@ -4,18 +4,13 @@
```rust ```rust
pub struct ConnectionKey { pub struct ConnectionKey {
pub version: u8, // must be 2 seed: [u8; 32], // random seed from which all TLS material is derived
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,
} }
``` ```
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 ## Config
+2 -3
View File
@@ -26,9 +26,8 @@
| `hyper-util` | 0.1 | HTTP utilities | | `hyper-util` | 0.1 | HTTP utilities |
| `http-body-util` | 0.1 | HTTP body utilities | | `http-body-util` | 0.1 | HTTP body utilities |
| `tower-service` | 0.3 | Service trait (Hyper ecosystem) | | `tower-service` | 0.3 | Service trait (Hyper ecosystem) |
| `base64` | 0.22 | Connection key encoding | | `base64` | 0.22 | Connection key seed encoding |
| `miniz_oxide` | 0.8 | DEFLATE compression for connection keys | | `sha2` | 0.10 | Auth-token derivation from the seed |
| `sha2` | 0.10 | Certificate fingerprinting |
| `bytes` | 1 | Byte buffers for framing | | `bytes` | 1 | Byte buffers for framing |
| `futures` | 0.3 | Future utilities | | `futures` | 0.3 | Future utilities |
| `url` | 2 | URL parsing | | `url` | 2 | URL parsing |
+11 -2
View File
@@ -21,11 +21,20 @@ The E2E test `auth_required_in_addition_to_mtls` verifies that valid mTLS alone
## Auth token ## 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. - Compared with a constant-time XOR loop to prevent timing attacks.
- Never logged in raw form. All log output uses the `Redacted` wrapper. - 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. - 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 ## Secret redaction
- `Redacted` in `src/redact.rs` wraps strings and displays `[REDACTED(len=N)]`. - `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. - `redact_config_json()` scrubs sensitive keys from JSON.
- `is_sensitive()` detects PEM private key blocks. - `is_sensitive()` detects PEM private key blocks.
- `is_auth_token()` detects long alphanumeric strings. - `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. The E2E test `ops_auth_failure_is_actionable_and_redacted` verifies that error messages do not contain raw token values.
+4 -17
View File
@@ -180,24 +180,11 @@ pub enum Commands {
}, },
/// Generate a reusable copy/paste connection key /// 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)] #[command(trailing_var_arg = true)]
Keygen { 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,
},
/// Print version information /// Print version information
#[command(trailing_var_arg = true)] #[command(trailing_var_arg = true)]
+77 -81
View File
@@ -1,41 +1,36 @@
use base64::Engine; use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD; 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."; /// 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.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] /// No certificates are ever shipped in the key itself.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ConnectionKey { pub struct ConnectionKey {
pub version: u8, seed: [u8; SEED_LEN],
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,
} }
impl ConnectionKey { impl ConnectionKey {
pub fn new(target: String, material: GeneratedMaterial) -> Self { /// Generate a fresh random connection key.
Self { pub fn new() -> Self {
version: 2, loop {
target, let seed: [u8; SEED_LEN] = rand::random();
ca_cert_pem: material.ca_cert_pem, if !is_zero(&seed) {
server_cert_pem: material.server_cert_pem, return Self { seed };
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,
} }
} }
pub fn encode(&self) -> anyhow::Result<String> { /// The raw seed bytes.
let json = serde_json::to_vec(self)?; pub fn seed(&self) -> &[u8; SEED_LEN] {
let compressed = miniz_oxide::deflate::compress_to_vec(&json, 6); &self.seed
Ok(format!("{}{}", PREFIX, URL_SAFE_NO_PAD.encode(compressed))) }
/// 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<Self> { pub fn decode(input: &str) -> anyhow::Result<Self> {
@@ -46,27 +41,32 @@ impl ConnectionKey {
let bytes = URL_SAFE_NO_PAD let bytes = URL_SAFE_NO_PAD
.decode(encoded) .decode(encoded)
.map_err(|e| anyhow::anyhow!("invalid connection key encoding: {}", e))?; .map_err(|e| anyhow::anyhow!("invalid connection key encoding: {}", e))?;
let json = miniz_oxide::inflate::decompress_to_vec(&bytes) if bytes.len() != SEED_LEN {
.map_err(|e| anyhow::anyhow!("invalid connection key payload: {}", e))?; anyhow::bail!(
let key: Self = serde_json::from_slice(&json) "connection key payload must be {} bytes, got {}",
.map_err(|e| anyhow::anyhow!("invalid connection key payload: {}", e))?; SEED_LEN,
if key.version != 2 { bytes.len()
anyhow::bail!("unsupported connection key version {}", key.version); );
} }
if key.target.trim().is_empty() let mut seed = [0u8; SEED_LEN];
|| key.ca_cert_pem.trim().is_empty() seed.copy_from_slice(&bytes);
|| key.server_cert_pem.trim().is_empty() if is_zero(&seed) {
|| key.server_key_pem.trim().is_empty() anyhow::bail!("connection key must not be all zeros");
|| 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");
} }
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)] #[allow(dead_code)]
pub fn looks_like_connection_key(input: &str) -> bool { pub fn looks_like_connection_key(input: &str) -> bool {
input.trim_start().starts_with(PREFIX) input.trim_start().starts_with(PREFIX)
@@ -78,58 +78,54 @@ mod tests {
#[test] #[test]
fn connection_key_roundtrip() { fn connection_key_roundtrip() {
let material = let key = ConnectionKey::new();
crate::generate::generate_material("ca", "server", "client").expect("material"); let encoded = key.encode();
let key = ConnectionKey::new("127.0.0.1:4180".to_string(), material); assert!(encoded.starts_with("rtun3."));
let encoded = key.encode().unwrap(); assert_eq!(encoded.len(), PREFIX.len() + 43);
assert!(encoded.starts_with("rtun2."));
let decoded = ConnectionKey::decode(&encoded).unwrap(); let decoded = ConnectionKey::decode(&encoded).unwrap();
assert_eq!(decoded.version, 2); assert_eq!(decoded.seed, key.seed);
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);
} }
#[test] #[test]
fn encoded_key_is_smaller_than_raw_json() { fn generated_keys_are_unique() {
let material = let a = ConnectionKey::new();
crate::generate::generate_material("ca", "server", "client").expect("material"); let b = ConnectionKey::new();
let key = ConnectionKey::new("127.0.0.1:4180".to_string(), material); assert_ne!(a.seed, b.seed);
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()
);
} }
#[test] #[test]
fn invalid_prefix_fails() { fn invalid_prefix_fails() {
assert!(ConnectionKey::decode("bad").is_err()); assert!(ConnectionKey::decode("bad").is_err());
assert!( assert!(
ConnectionKey::decode("rtun1.not-a-valid-payload").is_err(), ConnectionKey::decode("rtun1.xxx").is_err(),
"legacy rtun1. keys are not accepted" "legacy rtun1. keys are rejected"
);
assert!(
ConnectionKey::decode("rtun2.xxx").is_err(),
"legacy rtun2. keys are rejected"
); );
} }
#[test] #[test]
fn looks_like_connection_key_detects_rtun2_prefix() { fn wrong_payload_length_fails() {
assert!(looks_like_connection_key("rtun2.abc")); 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("abc"));
assert!(!looks_like_connection_key("rtun1.def")); assert!(!looks_like_connection_key("rtun2.def"));
} }
} }
+169
View File
@@ -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<u8> {
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<KeyPair> {
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<CertifiedIssuer<'static, KeyPair>> {
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::<std::net::IpAddr>() {
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"));
}
}
}
+78 -148
View File
@@ -4,6 +4,7 @@ mod connkey;
mod errors; mod errors;
mod framing; mod framing;
mod generate; mod generate;
mod keyderive;
mod redact; mod redact;
mod signal; mod signal;
mod socks5; mod socks5;
@@ -117,12 +118,7 @@ fn main() {
server_name, server_name,
client_name, client_name,
} => run_generate(&out, &ca_name, &server_name, &client_name), } => run_generate(&out, &ca_name, &server_name, &client_name),
crate::cli::Commands::Keygen { crate::cli::Commands::Keygen => run_keygen(),
target,
ca_name,
server_name,
client_name,
} => run_keygen(&target, &ca_name, &server_name, &client_name),
crate::cli::Commands::Version => run_version(), crate::cli::Commands::Version => run_version(),
}; };
@@ -204,17 +200,41 @@ fn run_listen(
None None
}; };
let extra_sans: Vec<String> = {
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 { let (tls, auth_token, generated_key) = if let Some(raw_key) = connection_key {
match ConnectionKey::decode(&raw_key) { match ConnectionKey::decode(&raw_key) {
Ok(k) => ( 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 { ServerTlsMaterial::Pem {
cert: Arc::new(k.server_cert_pem), cert: Arc::new(server_cert_pem),
key: Arc::new(k.server_key_pem), key: Arc::new(server_key_pem),
ca_cert: Arc::new(k.ca_cert_pem), ca_cert: Arc::new(ca_cert_pem),
}, },
k.auth_token, keyderive::derive_auth_token(&seed),
None, None,
), )
}
Err(e) => { Err(e) => {
eprintln!("Error: {}", e); eprintln!("Error: {}", e);
return 1; return 1;
@@ -226,31 +246,17 @@ fn run_listen(
&& auth_token.is_empty() && auth_token.is_empty()
&& auth_token_file.is_none() && auth_token_file.is_none()
{ {
let target = advertise.unwrap_or_else(|| { let key = ConnectionKey::new();
if host == "0.0.0.0" || host == "::" { let seed = *key.seed();
format!("127.0.0.1:{}", port) match keyderive::derive_server_material(&seed, &extra_sans) {
} else { Ok((server_cert_pem, server_key_pem, ca_cert_pem)) => (
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)) => (
ServerTlsMaterial::Pem { ServerTlsMaterial::Pem {
cert: Arc::new(key.server_cert_pem), cert: Arc::new(server_cert_pem),
key: Arc::new(key.server_key_pem), key: Arc::new(server_key_pem),
ca_cert: Arc::new(key.ca_cert_pem), ca_cert: Arc::new(ca_cert_pem),
}, },
key.auth_token, keyderive::derive_auth_token(&seed),
Some(encoded), Some(key.encode()),
), ),
Err(e) => { Err(e) => {
eprintln!("Error generating connection key: {}", e); eprintln!("Error generating connection key: {}", e);
@@ -310,8 +316,18 @@ fn run_listen(
port port
); );
if let Some(key) = generated_key { 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!("Connection key:\n{}\n", key);
println!("Connect with:\nrustunnel connect {}", key); println!(
"Connect with:\nrustunnel connect --target {} \"{}\"",
hint_target, key
);
} }
tracing::info!( tracing::info!(
"Auth token configured: {}", "Auth token configured: {}",
@@ -386,19 +402,14 @@ fn run_connect(
None None
}; };
let target_value = match select_connect_target(target.as_deref(), decoded_key.as_ref()) { let target_value = match target {
Ok(v) => v, Some(t) => t,
Err(e) => { None => {
eprintln!("Error: {}", e); eprintln!("Error: --target is required for connect");
return 1; return 1;
} }
}; };
if should_warn_target_override(target.as_deref(), decoded_key.as_ref()) { let (target_host, target_port) = match cli::parse_host_port(&target_value) {
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) {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
eprintln!("Error: {}", e); eprintln!("Error: {}", e);
@@ -415,13 +426,22 @@ fn run_connect(
}; };
let (tls, auth_token) = if let Some(k) = decoded_key { 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 { ClientTlsMaterial::Pem {
cert: Arc::new(k.client_cert_pem), cert: Arc::new(client_cert_pem),
key: Arc::new(k.client_key_pem), key: Arc::new(client_key_pem),
ca_cert: Arc::new(k.ca_cert_pem), ca_cert: Arc::new(ca_cert_pem),
}, },
k.auth_token, keyderive::derive_auth_token(&seed),
) )
} else { } else {
if cert.is_empty() { if cert.is_empty() {
@@ -547,58 +567,10 @@ fn configure_performance(
}); });
} }
fn select_connect_target<'a>( fn run_keygen() -> i32 {
target: Option<&'a str>, let key = ConnectionKey::new();
decoded_key: Option<&'a ConnectionKey>, println!("{}", key.encode());
) -> 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 0
}
Err(e) => {
eprintln!("Error generating connection key: {}", e);
1
}
}
} }
fn run_generate(out: &str, ca_name: &str, server_name: &str, client_name: &str) -> i32 { 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 { mod tests {
use super::*; 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] #[test]
fn explicit_connect_target_overrides_key_target() { fn keygen_produces_a_decodable_seed_key() {
let key = test_connection_key("198.51.100.10:443"); let encoded = ConnectionKey::new().encode();
let selected = select_connect_target(Some("23.167.32.42:443"), Some(&key)).unwrap(); assert!(encoded.starts_with("rtun3."));
assert_eq!(selected, "23.167.32.42:443"); let decoded = ConnectionKey::decode(&encoded).unwrap();
} assert_eq!(decoded.encode(), encoded);
#[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));
} }
} }
+3 -3
View File
@@ -120,7 +120,7 @@ pub fn is_auth_token(content: &str) -> bool {
#[allow(dead_code)] #[allow(dead_code)]
pub fn is_connection_key(content: &str) -> bool { pub fn is_connection_key(content: &str) -> bool {
content.trim_start().starts_with("rtun2.") content.trim_start().starts_with("rtun3.")
} }
#[cfg(test)] #[cfg(test)]
@@ -213,9 +213,9 @@ mod tests {
#[test] #[test]
fn is_connection_key_detects_rtun_prefix() { 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("abc"));
assert!(!is_connection_key("rtun1.abc")); assert!(!is_connection_key("rtun2.abc"));
} }
#[test] #[test]
+109
View File
@@ -2234,6 +2234,115 @@ mod tests {
listener_task.abort(); 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. /// Test: insecure listener accepts a connector without valid client certificate.
#[tokio::test] #[tokio::test]
async fn insecure_listener_accepts_no_client_cert() { async fn insecure_listener_accepts_no_client_cert() {