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
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:
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,59 +567,11 @@ 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 {
|
||||||
let out_dir = std::path::Path::new(out);
|
let out_dir = std::path::Path::new(out);
|
||||||
@@ -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
@@ -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
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user