Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bf8da739b | ||
|
|
6bc3c00b64 | ||
|
|
ab1f74214a |
@@ -103,6 +103,18 @@ jobs:
|
||||
run: cargo run --release -- connect --help
|
||||
- name: rustunnel generate --help
|
||||
run: cargo run --release -- generate --help
|
||||
- name: rustunnel keygen prints a rtun3. key
|
||||
shell: bash
|
||||
run: |
|
||||
KEY=$(cargo run --release -- keygen)
|
||||
case "$KEY" in rtun3.*) ;; *) echo "ERROR: keygen output should start with rtun3., got: $KEY"; exit 1;; esac
|
||||
- name: connect requires --target
|
||||
shell: bash
|
||||
run: |
|
||||
if cargo run --release -- connect "$(cargo run --release -- keygen)" 2>&1; then
|
||||
echo "ERROR: connect without --target should have failed"
|
||||
exit 1
|
||||
fi
|
||||
- name: Invalid command fails
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -57,6 +57,20 @@ Use the SOCKS5 proxy from a local client:
|
||||
curl --proxy socks5h://127.0.0.1:1180 http://127.0.0.1:4181/
|
||||
```
|
||||
|
||||
## Connection keys
|
||||
|
||||
Instead of certificate files you can use a short connection key, from which both
|
||||
endpoints derive identical TLS material on the spot:
|
||||
|
||||
```sh
|
||||
rustunnel keygen # prints a ~49-char rtun3. key
|
||||
rustunnel listen --listen 0.0.0.0:4180 --connection-key '<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
|
||||
|
||||
- HTTPS listener: `127.0.0.1:4180`
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Connection Key Compression (rtun2) — Design
|
||||
|
||||
## Goal
|
||||
|
||||
Shrink the copy-paste connection key string so it is easy to copy and paste, without
|
||||
changing any user-facing workflow. A current `rtun1.` key is roughly 4 KB as a single
|
||||
unwieldy line; the goal is roughly half that.
|
||||
|
||||
## Approach
|
||||
|
||||
Compress the existing JSON payload with pure-Rust DEFLATE (`miniz_oxide`) before
|
||||
base64url-encoding. This is a clean break on the wire format:
|
||||
|
||||
- New keys: `rtun2.` + `base64url(deflate(json))`
|
||||
- `decode` accepts only `rtun2.` keys; legacy `rtun1.` keys are dropped (per user decision)
|
||||
- `ConnectionKey` struct, CLI, and `listen`/`connect` integration are unchanged
|
||||
|
||||
## Format
|
||||
|
||||
```
|
||||
rtun2.<base64url-no-padding-of-deflate-of-json>
|
||||
```
|
||||
|
||||
The JSON payload keeps the same shape, with `version` bumped to `2`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"target": "127.0.0.1:4180",
|
||||
"ca_cert_pem": "...",
|
||||
"server_cert_pem": "...",
|
||||
"server_key_pem": "...",
|
||||
"client_cert_pem": "...",
|
||||
"client_key_pem": "...",
|
||||
"auth_token": "..."
|
||||
}
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
| File | Change |
|
||||
| ---- | ------ |
|
||||
| `Cargo.toml` | Add `miniz_oxide` dependency |
|
||||
| `src/connkey.rs` | Prefix `rtun2.`, compress on `encode`, decompress on `decode`, version `2` |
|
||||
| `src/redact.rs` | `is_connection_key` detects `rtun2.` prefix |
|
||||
| Wiki (`droid-wiki/**`) | Document the `rtun2.` format and compression |
|
||||
|
||||
## Validation
|
||||
|
||||
`decode` validates, in order:
|
||||
|
||||
- String starts with `rtun2.`
|
||||
- Base64url decode succeeds
|
||||
- DEFLATE decompression succeeds
|
||||
- JSON deserialization succeeds
|
||||
- `version == 2`
|
||||
- All string fields non-empty after trim
|
||||
|
||||
## Testing
|
||||
|
||||
- Round-trip test: encode → decode returns identical material; assert prefix `rtun2.`
|
||||
- Size assertion test: encoded key must be materially smaller than the raw JSON
|
||||
(`base64url(json)` without compression), with a generous threshold so the build
|
||||
stays robust across certificate size variation
|
||||
- Update redaction prefix tests
|
||||
|
||||
## Docs
|
||||
|
||||
Update wiki pages that document the key format: `features/connection-keys.md`,
|
||||
`reference/data-models.md`, `overview/glossary.md`, `applications/rustunnel-cli.md`,
|
||||
`security.md`, and `how-to-contribute/patterns-and-conventions.md`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- QR codes, binary container formats, zstd/brotli, key registry/rotation
|
||||
- `generate` command and file-based credential output
|
||||
@@ -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:
|
||||
- `--listen` — bind address (default `0.0.0.0:4180`)
|
||||
- `--advertise` — public address embedded in auto-generated connection keys
|
||||
- `--advertise` — public host added to the derived server cert and used in the printed connect hint
|
||||
- `--connection-key` — reusable key generated by `keygen` or a previous `listen` run
|
||||
- `--socks` — optional server-side SOCKS5 proxy address for connector-side network access
|
||||
- `--cert`, `--key`, `--ca-cert` — TLS material paths (required unless using a connection key)
|
||||
@@ -25,7 +25,7 @@ Connect to the listener and expose a local SOCKS5 proxy.
|
||||
|
||||
Key arguments:
|
||||
- `CONNECTION_KEY` — positional connection key (optional)
|
||||
- `--target` — listener address (default from connection key)
|
||||
- `--target` — listener address (required; not stored in the key)
|
||||
- `--connection-key` — connection key via flag or env `RUSTUNNEL_KEY`
|
||||
- `--socks` — local SOCKS5 proxy address (default `127.0.0.1:1180`)
|
||||
- `--cert`, `--key`, `--ca-cert` — TLS material paths (required unless using a connection key)
|
||||
@@ -39,7 +39,9 @@ Creates: `ca.pem`, `ca.key`, `server.crt`, `server.key`, `client.crt`, `client.k
|
||||
|
||||
### `keygen`
|
||||
|
||||
Generate a single reusable connection key string. This bundles all certificate material, the auth token, and the target address into a base64-encoded JSON blob prefixed with `rtun1.`.
|
||||
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`
|
||||
|
||||
|
||||
@@ -1,58 +1,65 @@
|
||||
# Connection keys
|
||||
|
||||
A connection key bundles all credential material and the target address into a single copy-pasteable string.
|
||||
|
||||
## Purpose
|
||||
|
||||
Simplify distribution of tunnel credentials between machines. Instead of transferring eight separate files, a user can generate one key and paste it into the `listen` and `connect` commands.
|
||||
A connection key is a short random **seed** from which both endpoints derive
|
||||
identical TLS material on the spot. It is the only value a user must copy/paste
|
||||
to stand up a tunnel.
|
||||
|
||||
## Format
|
||||
|
||||
Connection keys start with the prefix `rtun1.` followed by base64url-encoded (no padding) JSON:
|
||||
Connection keys are ~49 characters: the prefix `rtun3.` followed by a
|
||||
base64url-encoded 32-byte seed:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"target": "198.51.100.10:4180",
|
||||
"ca_cert_pem": "-----BEGIN CERTIFICATE-----...",
|
||||
"server_cert_pem": "-----BEGIN CERTIFICATE-----...",
|
||||
"server_key_pem": "-----BEGIN PRIVATE KEY-----...",
|
||||
"client_cert_pem": "-----BEGIN CERTIFICATE-----...",
|
||||
"client_key_pem": "-----BEGIN PRIVATE KEY-----...",
|
||||
"auth_token": "a1b2c3..."
|
||||
}
|
||||
```
|
||||
rtun3.OH2TI3p1bM2dYjBcHxnLmZT9qKjW8tVvXQ0N7y4E3wA
|
||||
```
|
||||
|
||||
The key ships **no certificates**. Instead, both endpoints derive an identical CA
|
||||
from the seed and mint ephemeral leaf certificates locally (`src/keyderive.rs`):
|
||||
|
||||
- **CA** — Ed25519 keypair from the seed, self-signed and deterministic, so any two
|
||||
endpoints that share the seed produce the same CA.
|
||||
- **Server leaf** — generated at `listen` startup, signed by the derived CA, with
|
||||
SANs for the bind address (and optional `--advertise` host).
|
||||
- **Client leaf** — generated at `connect` startup, signed by the derived CA.
|
||||
- **Auth token** — derived as `sha256(seed || "rustunnel-auth-token")` (first 16
|
||||
bytes hex) and checked in the existing app-layer handshake.
|
||||
|
||||
Because the CA is derived from the seed, a connector holding a different seed cannot
|
||||
authenticate to a listener — possession of the key is what grants access.
|
||||
|
||||
## Key abstractions
|
||||
|
||||
| Type | File | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
| `ConnectionKey` | `src/connkey.rs` | Struct with all fields, version check, validation |
|
||||
| `ConnectionKey::encode` | `src/connkey.rs` | Serialize to JSON, base64url-encode, prepend prefix |
|
||||
| `ConnectionKey::decode` | `src/connkey.rs` | Strip prefix, base64url-decode, deserialize, validate |
|
||||
| `looks_like_connection_key` | `src/connkey.rs` | Quick check if a string starts with `rtun1.` |
|
||||
| Type/Function | File | Description |
|
||||
| ------------- | ---- | ----------- |
|
||||
| `ConnectionKey` | `src/connkey.rs` | Seed value, `new`/`encode`/`decode` (rtun3) |
|
||||
| `derive_server_material` | `src/keyderive.rs` | CA + server leaf PEM for `listen` |
|
||||
| `derive_client_material` | `src/keyderive.rs` | CA + client leaf PEM for `connect` |
|
||||
| `derive_auth_token` | `src/keyderive.rs` | App-layer token derived from the seed |
|
||||
| `looks_like_connection_key` | `src/connkey.rs` | Quick check if a string starts with `rtun3.` |
|
||||
|
||||
## Validation
|
||||
|
||||
`decode` validates:
|
||||
`ConnectionKey::decode` validates:
|
||||
|
||||
- Prefix must be `rtun1.`
|
||||
- Base64 decoding must succeed
|
||||
- JSON deserialization must succeed
|
||||
- Version must be exactly `1`
|
||||
- All string fields must be non-empty after trimming
|
||||
- Prefix must be `rtun3.` (legacy `rtun1.`/`rtun2.` are rejected)
|
||||
- Base64url decode succeeds and yields exactly 32 bytes
|
||||
- The seed is not all zeros
|
||||
|
||||
## Integration
|
||||
|
||||
`src/main.rs` uses `ConnectionKey::decode` when the `--connection-key` flag or positional argument is provided. The decoded material is passed to `ServerTlsMaterial::Pem` or `ClientTlsMaterial::Pem` variants, which bypass file loading and use the embedded PEM strings directly.
|
||||
`src/main.rs` decodes a seed key (positional arg, `--connection-key`, or
|
||||
`RUSTUNNEL_KEY`), derives the appropriate material at startup, and feeds the PEMs
|
||||
into `ServerTlsMaterial::Pem` / `ClientTlsMaterial::Pem`. The TLS layer
|
||||
(`src/tls.rs`) is unchanged. `connect` requires an explicit `--target`.
|
||||
|
||||
## Entry points for modification
|
||||
|
||||
- To change the key format or add versioning: modify `src/connkey.rs`.
|
||||
- To add compression or encryption: consider extending the encode/decode pipeline in `ConnectionKey`.
|
||||
- To change derivation or signing parameters: modify `src/keyderive.rs`.
|
||||
- To change the seed format or versioning: modify `src/connkey.rs`.
|
||||
|
||||
## Key source files
|
||||
|
||||
| File | Purpose |
|
||||
| ---- | ------- |
|
||||
| `src/connkey.rs` | Connection key struct, encoding, decoding, validation |
|
||||
| `src/connkey.rs` | Seed key struct, encoding, decoding |
|
||||
| `src/keyderive.rs` | CA + leaf + token derivation from the seed |
|
||||
|
||||
@@ -13,7 +13,7 @@ The project uses a layered error strategy:
|
||||
- The `Redacted` wrapper in `src/redact.rs` must be used before logging any secret.
|
||||
- `Redacted::inner()` and `into_inner()` are marked `#[allow(dead_code)]` to discourage use, but available for file writes.
|
||||
- `is_sensitive()` detects PEM private key blocks. `is_auth_token()` detects long alphanumeric strings.
|
||||
- `is_connection_key()` detects strings starting with `rtun1.`.
|
||||
- `is_connection_key()` detects strings starting with `rtun3.`.
|
||||
|
||||
## Async patterns
|
||||
|
||||
|
||||
@@ -76,26 +76,27 @@ curl --proxy socks5h://127.0.0.1:1180 http://127.0.0.1:4181/
|
||||
|
||||
## Using a connection key
|
||||
|
||||
Instead of passing individual certificate paths, you can generate a single connection key:
|
||||
Instead of passing individual certificate paths, you can use a short connection key
|
||||
from which both ends derive identical credentials:
|
||||
|
||||
```sh
|
||||
./target/release/rustunnel keygen --target 127.0.0.1:4180
|
||||
./target/release/rustunnel keygen
|
||||
```
|
||||
|
||||
Then start the listener with the key (it auto-generates credentials):
|
||||
Start the listener with the key:
|
||||
|
||||
```sh
|
||||
./target/release/rustunnel listen --connection-key <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
|
||||
./target/release/rustunnel connect <key>
|
||||
./target/release/rustunnel connect --target 127.0.0.1:4180 <key>
|
||||
```
|
||||
|
||||
## Defaults
|
||||
|
||||
- Listener bind address: `0.0.0.0:4180`
|
||||
- Connector target: required (or taken from connection key)
|
||||
- Connector target: required (not stored in the key)
|
||||
- Connector SOCKS5 proxy: `127.0.0.1:1180`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Glossary
|
||||
|
||||
**Connection key** — a base64-encoded JSON blob (prefix `rtun1.`) 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.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ The tool is designed for local labs, development environments, and controlled te
|
||||
- **Listener** (`rustunnel listen`) — binds an HTTPS server that accepts mTLS connections from connectors, validates an auth token, then upgrades the connection to a persistent binary-framed tunnel.
|
||||
- **Connector** (`rustunnel connect`) — connects to the listener over HTTPS with mTLS, authenticates, then exposes a local SOCKS5 proxy that forwards traffic through the tunnel.
|
||||
- **Credential generation** (`rustunnel generate`) — creates a self-signed CA, server certificate, client certificate, and auth token for a local session.
|
||||
- **Connection keys** (`rustunnel keygen`) — bundles all credential material into a single base64-encoded 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
|
||||
|
||||
|
||||
@@ -4,18 +4,13 @@
|
||||
|
||||
```rust
|
||||
pub struct ConnectionKey {
|
||||
pub version: u8, // must be 1
|
||||
pub target: String, // listener address
|
||||
pub ca_cert_pem: String,
|
||||
pub server_cert_pem: String,
|
||||
pub server_key_pem: String,
|
||||
pub client_cert_pem: String,
|
||||
pub client_key_pem: String,
|
||||
pub auth_token: String,
|
||||
seed: [u8; 32], // random seed from which all TLS material is derived
|
||||
}
|
||||
```
|
||||
|
||||
Encoded as: `rtun1.` + base64url(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
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
| `hyper-util` | 0.1 | HTTP utilities |
|
||||
| `http-body-util` | 0.1 | HTTP body utilities |
|
||||
| `tower-service` | 0.3 | Service trait (Hyper ecosystem) |
|
||||
| `base64` | 0.22 | Connection key encoding |
|
||||
| `sha2` | 0.10 | Certificate fingerprinting |
|
||||
| `base64` | 0.22 | Connection key seed encoding |
|
||||
| `sha2` | 0.10 | Auth-token derivation from the seed |
|
||||
| `bytes` | 1 | Byte buffers for framing |
|
||||
| `futures` | 0.3 | Future utilities |
|
||||
| `url` | 2 | URL parsing |
|
||||
|
||||
+11
-2
@@ -21,11 +21,20 @@ The E2E test `auth_required_in_addition_to_mtls` verifies that valid mTLS alone
|
||||
|
||||
## Auth token
|
||||
|
||||
- Generated as 32 random bytes encoded as 64 hex characters.
|
||||
- Derived from the connection key seed as `sha256(seed || "rustunnel-auth-token")`
|
||||
(first 16 bytes, hex), or 32 random bytes as 64 hex characters for the file-based path.
|
||||
- Compared with a constant-time XOR loop to prevent timing attacks.
|
||||
- Never logged in raw form. All log output uses the `Redacted` wrapper.
|
||||
- Auth failures are terminal: the connector's reconnect loop exits on auth failure rather than retrying.
|
||||
|
||||
## Connection key as seed
|
||||
|
||||
- The connection key is a 32-byte seed (prefix `rtun3.`). It confers full access —
|
||||
possession of the key lets you derive the same CA and ephemeral leaves, so treat it
|
||||
as a secret.
|
||||
- Both endpoints derive the same CA; a connector using a different seed cannot
|
||||
authenticate to a listener (verified by `e2e_wrong_seed_key_is_rejected`).
|
||||
|
||||
## Secret redaction
|
||||
|
||||
- `Redacted` in `src/redact.rs` wraps strings and displays `[REDACTED(len=N)]`.
|
||||
@@ -33,7 +42,7 @@ The E2E test `auth_required_in_addition_to_mtls` verifies that valid mTLS alone
|
||||
- `redact_config_json()` scrubs sensitive keys from JSON.
|
||||
- `is_sensitive()` detects PEM private key blocks.
|
||||
- `is_auth_token()` detects long alphanumeric strings.
|
||||
- `is_connection_key()` detects strings starting with `rtun1.`.
|
||||
- `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.
|
||||
|
||||
|
||||
+4
-17
@@ -180,24 +180,11 @@ pub enum Commands {
|
||||
},
|
||||
|
||||
/// Generate a reusable copy/paste connection key
|
||||
///
|
||||
/// Prints a short key (prefix `rtun3.`) from which both endpoints derive
|
||||
/// identical TLS material. Pass the target address separately on `connect`.
|
||||
#[command(trailing_var_arg = true)]
|
||||
Keygen {
|
||||
/// Listener address that connectors should dial
|
||||
#[arg(long, default_value = "127.0.0.1:4180", value_name = "ADDR:PORT")]
|
||||
target: String,
|
||||
|
||||
/// Common name for the CA certificate
|
||||
#[arg(long, default_value = "rustunnel-ca", value_name = "CN")]
|
||||
ca_name: String,
|
||||
|
||||
/// Common name for the server certificate
|
||||
#[arg(long, default_value = "rustunnel-server", value_name = "CN")]
|
||||
server_name: String,
|
||||
|
||||
/// Common name for the client certificate
|
||||
#[arg(long, default_value = "rustunnel-client", value_name = "CN")]
|
||||
client_name: String,
|
||||
},
|
||||
Keygen,
|
||||
|
||||
/// Print version information
|
||||
#[command(trailing_var_arg = true)]
|
||||
|
||||
+86
-48
@@ -1,40 +1,36 @@
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::generate::GeneratedMaterial;
|
||||
const PREFIX: &str = "rtun3.";
|
||||
pub const SEED_LEN: usize = 32;
|
||||
|
||||
const PREFIX: &str = "rtun1.";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
/// A connection key is a random 32-byte seed from which both endpoints derive
|
||||
/// identical certificate material (a CA and ephemeral leaves) on the spot.
|
||||
/// No certificates are ever shipped in the key itself.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ConnectionKey {
|
||||
pub version: u8,
|
||||
pub target: String,
|
||||
pub ca_cert_pem: String,
|
||||
pub server_cert_pem: String,
|
||||
pub server_key_pem: String,
|
||||
pub client_cert_pem: String,
|
||||
pub client_key_pem: String,
|
||||
pub auth_token: String,
|
||||
seed: [u8; SEED_LEN],
|
||||
}
|
||||
|
||||
impl ConnectionKey {
|
||||
pub fn new(target: String, material: GeneratedMaterial) -> Self {
|
||||
Self {
|
||||
version: 1,
|
||||
target,
|
||||
ca_cert_pem: material.ca_cert_pem,
|
||||
server_cert_pem: material.server_cert_pem,
|
||||
server_key_pem: material.server_key_pem,
|
||||
client_cert_pem: material.client_cert_pem,
|
||||
client_key_pem: material.client_key_pem,
|
||||
auth_token: material.auth_token,
|
||||
/// Generate a fresh random connection key.
|
||||
pub fn new() -> Self {
|
||||
loop {
|
||||
let seed: [u8; SEED_LEN] = rand::random();
|
||||
if !is_zero(&seed) {
|
||||
return Self { seed };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> anyhow::Result<String> {
|
||||
let json = serde_json::to_vec(self)?;
|
||||
Ok(format!("{}{}", PREFIX, URL_SAFE_NO_PAD.encode(json)))
|
||||
/// The raw seed bytes.
|
||||
pub fn seed(&self) -> &[u8; SEED_LEN] {
|
||||
&self.seed
|
||||
}
|
||||
|
||||
/// Encode the key as `rtun3.` + base64url(seed).
|
||||
pub fn encode(&self) -> String {
|
||||
format!("{}{}", PREFIX, URL_SAFE_NO_PAD.encode(self.seed))
|
||||
}
|
||||
|
||||
pub fn decode(input: &str) -> anyhow::Result<Self> {
|
||||
@@ -45,25 +41,32 @@ impl ConnectionKey {
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(encoded)
|
||||
.map_err(|e| anyhow::anyhow!("invalid connection key encoding: {}", e))?;
|
||||
let key: Self = serde_json::from_slice(&bytes)
|
||||
.map_err(|e| anyhow::anyhow!("invalid connection key payload: {}", e))?;
|
||||
if key.version != 1 {
|
||||
anyhow::bail!("unsupported connection key version {}", key.version);
|
||||
if bytes.len() != SEED_LEN {
|
||||
anyhow::bail!(
|
||||
"connection key payload must be {} bytes, got {}",
|
||||
SEED_LEN,
|
||||
bytes.len()
|
||||
);
|
||||
}
|
||||
if key.target.trim().is_empty()
|
||||
|| key.ca_cert_pem.trim().is_empty()
|
||||
|| key.server_cert_pem.trim().is_empty()
|
||||
|| key.server_key_pem.trim().is_empty()
|
||||
|| key.client_cert_pem.trim().is_empty()
|
||||
|| key.client_key_pem.trim().is_empty()
|
||||
|| key.auth_token.trim().is_empty()
|
||||
{
|
||||
anyhow::bail!("connection key is missing required material");
|
||||
let mut seed = [0u8; SEED_LEN];
|
||||
seed.copy_from_slice(&bytes);
|
||||
if is_zero(&seed) {
|
||||
anyhow::bail!("connection key must not be all zeros");
|
||||
}
|
||||
Ok(key)
|
||||
Ok(Self { seed })
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConnectionKey {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_zero(seed: &[u8; SEED_LEN]) -> bool {
|
||||
seed.iter().all(|&b| b == 0)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn looks_like_connection_key(input: &str) -> bool {
|
||||
input.trim_start().starts_with(PREFIX)
|
||||
@@ -75,19 +78,54 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn connection_key_roundtrip() {
|
||||
let material =
|
||||
crate::generate::generate_material("ca", "server", "client").expect("material");
|
||||
let key = ConnectionKey::new("127.0.0.1:4180".to_string(), material);
|
||||
let encoded = key.encode().unwrap();
|
||||
assert!(encoded.starts_with("rtun1."));
|
||||
let key = ConnectionKey::new();
|
||||
let encoded = key.encode();
|
||||
assert!(encoded.starts_with("rtun3."));
|
||||
assert_eq!(encoded.len(), PREFIX.len() + 43);
|
||||
let decoded = ConnectionKey::decode(&encoded).unwrap();
|
||||
assert_eq!(decoded.version, 1);
|
||||
assert_eq!(decoded.target, "127.0.0.1:4180");
|
||||
assert_eq!(decoded.auth_token, key.auth_token);
|
||||
assert_eq!(decoded.seed, key.seed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_keys_are_unique() {
|
||||
let a = ConnectionKey::new();
|
||||
let b = ConnectionKey::new();
|
||||
assert_ne!(a.seed, b.seed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_prefix_fails() {
|
||||
assert!(ConnectionKey::decode("bad").is_err());
|
||||
assert!(
|
||||
ConnectionKey::decode("rtun1.xxx").is_err(),
|
||||
"legacy rtun1. keys are rejected"
|
||||
);
|
||||
assert!(
|
||||
ConnectionKey::decode("rtun2.xxx").is_err(),
|
||||
"legacy rtun2. keys are rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_payload_length_fails() {
|
||||
let key = ConnectionKey::new();
|
||||
let prefix = key.encode();
|
||||
let _ = prefix;
|
||||
// A 16-byte seed is invalid.
|
||||
let short = URL_SAFE_NO_PAD.encode([0u8; 16]);
|
||||
assert!(ConnectionKey::decode(&format!("{}{}", PREFIX, short)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_zero_seed_fails() {
|
||||
let zero = URL_SAFE_NO_PAD.encode([0u8; SEED_LEN]);
|
||||
assert!(ConnectionKey::decode(&format!("{}{}", PREFIX, zero)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_connection_key_detects_rtun3_prefix() {
|
||||
assert!(looks_like_connection_key("rtun3.abc"));
|
||||
assert!(!looks_like_connection_key("abc"));
|
||||
assert!(!looks_like_connection_key("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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
-152
@@ -4,6 +4,7 @@ mod connkey;
|
||||
mod errors;
|
||||
mod framing;
|
||||
mod generate;
|
||||
mod keyderive;
|
||||
mod redact;
|
||||
mod signal;
|
||||
mod socks5;
|
||||
@@ -117,12 +118,7 @@ fn main() {
|
||||
server_name,
|
||||
client_name,
|
||||
} => run_generate(&out, &ca_name, &server_name, &client_name),
|
||||
crate::cli::Commands::Keygen {
|
||||
target,
|
||||
ca_name,
|
||||
server_name,
|
||||
client_name,
|
||||
} => run_keygen(&target, &ca_name, &server_name, &client_name),
|
||||
crate::cli::Commands::Keygen => run_keygen(),
|
||||
crate::cli::Commands::Version => run_version(),
|
||||
};
|
||||
|
||||
@@ -204,17 +200,41 @@ fn run_listen(
|
||||
None
|
||||
};
|
||||
|
||||
let extra_sans: Vec<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 {
|
||||
match ConnectionKey::decode(&raw_key) {
|
||||
Ok(k) => (
|
||||
ServerTlsMaterial::Pem {
|
||||
cert: Arc::new(k.server_cert_pem),
|
||||
key: Arc::new(k.server_key_pem),
|
||||
ca_cert: Arc::new(k.ca_cert_pem),
|
||||
},
|
||||
k.auth_token,
|
||||
None,
|
||||
),
|
||||
Ok(k) => {
|
||||
let seed = *k.seed();
|
||||
let (server_cert_pem, server_key_pem, ca_cert_pem) =
|
||||
match keyderive::derive_server_material(&seed, &extra_sans) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
(
|
||||
ServerTlsMaterial::Pem {
|
||||
cert: Arc::new(server_cert_pem),
|
||||
key: Arc::new(server_key_pem),
|
||||
ca_cert: Arc::new(ca_cert_pem),
|
||||
},
|
||||
keyderive::derive_auth_token(&seed),
|
||||
None,
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
return 1;
|
||||
@@ -226,31 +246,17 @@ fn run_listen(
|
||||
&& auth_token.is_empty()
|
||||
&& auth_token_file.is_none()
|
||||
{
|
||||
let target = advertise.unwrap_or_else(|| {
|
||||
if host == "0.0.0.0" || host == "::" {
|
||||
format!("127.0.0.1:{}", port)
|
||||
} else {
|
||||
listen.clone()
|
||||
}
|
||||
});
|
||||
if let Err(e) = cli::parse_host_port(&target) {
|
||||
eprintln!("Error: invalid --advertise value: {}", e);
|
||||
return 1;
|
||||
}
|
||||
match generate_key(
|
||||
&target,
|
||||
"rustunnel-ca",
|
||||
"rustunnel-server",
|
||||
"rustunnel-client",
|
||||
) {
|
||||
Ok((key, encoded)) => (
|
||||
let key = ConnectionKey::new();
|
||||
let seed = *key.seed();
|
||||
match keyderive::derive_server_material(&seed, &extra_sans) {
|
||||
Ok((server_cert_pem, server_key_pem, ca_cert_pem)) => (
|
||||
ServerTlsMaterial::Pem {
|
||||
cert: Arc::new(key.server_cert_pem),
|
||||
key: Arc::new(key.server_key_pem),
|
||||
ca_cert: Arc::new(key.ca_cert_pem),
|
||||
cert: Arc::new(server_cert_pem),
|
||||
key: Arc::new(server_key_pem),
|
||||
ca_cert: Arc::new(ca_cert_pem),
|
||||
},
|
||||
key.auth_token,
|
||||
Some(encoded),
|
||||
keyderive::derive_auth_token(&seed),
|
||||
Some(key.encode()),
|
||||
),
|
||||
Err(e) => {
|
||||
eprintln!("Error generating connection key: {}", e);
|
||||
@@ -310,8 +316,18 @@ fn run_listen(
|
||||
port
|
||||
);
|
||||
if let Some(key) = generated_key {
|
||||
let hint_target = advertise.unwrap_or_else(|| {
|
||||
if host == "0.0.0.0" || host == "::" {
|
||||
format!("127.0.0.1:{}", port)
|
||||
} else {
|
||||
listen.clone()
|
||||
}
|
||||
});
|
||||
println!("Connection key:\n{}\n", key);
|
||||
println!("Connect with:\nrustunnel connect {}", key);
|
||||
println!(
|
||||
"Connect with:\nrustunnel connect --target {} \"{}\"",
|
||||
hint_target, key
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
"Auth token configured: {}",
|
||||
@@ -386,19 +402,14 @@ fn run_connect(
|
||||
None
|
||||
};
|
||||
|
||||
let target_value = match select_connect_target(target.as_deref(), decoded_key.as_ref()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
let target_value = match target {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
eprintln!("Error: --target is required for connect");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
if should_warn_target_override(target.as_deref(), decoded_key.as_ref()) {
|
||||
tracing::warn!(
|
||||
"--target overrides the listener address embedded in the connection key; TLS trust still uses the key's CA/client certificate material"
|
||||
);
|
||||
}
|
||||
let (target_host, target_port) = match cli::parse_host_port(target_value) {
|
||||
let (target_host, target_port) = match cli::parse_host_port(&target_value) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
@@ -415,13 +426,22 @@ fn run_connect(
|
||||
};
|
||||
|
||||
let (tls, auth_token) = if let Some(k) = decoded_key {
|
||||
let seed = *k.seed();
|
||||
let (client_cert_pem, client_key_pem, ca_cert_pem) =
|
||||
match keyderive::derive_client_material(&seed) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
(
|
||||
ClientTlsMaterial::Pem {
|
||||
cert: Arc::new(k.client_cert_pem),
|
||||
key: Arc::new(k.client_key_pem),
|
||||
ca_cert: Arc::new(k.ca_cert_pem),
|
||||
cert: Arc::new(client_cert_pem),
|
||||
key: Arc::new(client_key_pem),
|
||||
ca_cert: Arc::new(ca_cert_pem),
|
||||
},
|
||||
k.auth_token,
|
||||
keyderive::derive_auth_token(&seed),
|
||||
)
|
||||
} else {
|
||||
if cert.is_empty() {
|
||||
@@ -547,58 +567,10 @@ fn configure_performance(
|
||||
});
|
||||
}
|
||||
|
||||
fn select_connect_target<'a>(
|
||||
target: Option<&'a str>,
|
||||
decoded_key: Option<&'a ConnectionKey>,
|
||||
) -> anyhow::Result<&'a str> {
|
||||
if let Some(target) = target {
|
||||
return Ok(target);
|
||||
}
|
||||
|
||||
if let Some(k) = decoded_key {
|
||||
return Ok(k.target.as_str());
|
||||
}
|
||||
|
||||
anyhow::bail!("--target is required for connect unless using a connection key")
|
||||
}
|
||||
|
||||
fn should_warn_target_override(target: Option<&str>, decoded_key: Option<&ConnectionKey>) -> bool {
|
||||
matches!((target, decoded_key), (Some(target), Some(k)) if target != k.target)
|
||||
}
|
||||
|
||||
fn generate_key(
|
||||
target: &str,
|
||||
ca_name: &str,
|
||||
server_name: &str,
|
||||
client_name: &str,
|
||||
) -> anyhow::Result<(ConnectionKey, String)> {
|
||||
let (host, _) = cli::parse_host_port(target)?;
|
||||
let material = generate::generate_material_with_server_host(
|
||||
ca_name,
|
||||
server_name,
|
||||
client_name,
|
||||
Some(&host),
|
||||
)?;
|
||||
let key = ConnectionKey::new(target.to_string(), material);
|
||||
let encoded = key.encode()?;
|
||||
Ok((key, encoded))
|
||||
}
|
||||
|
||||
fn run_keygen(target: &str, ca_name: &str, server_name: &str, client_name: &str) -> i32 {
|
||||
if let Err(e) = cli::parse_host_port(target) {
|
||||
eprintln!("Error: {}", e);
|
||||
return 1;
|
||||
}
|
||||
match generate_key(target, ca_name, server_name, client_name) {
|
||||
Ok((_key, encoded)) => {
|
||||
println!("{}", encoded);
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error generating connection key: {}", e);
|
||||
1
|
||||
}
|
||||
}
|
||||
fn run_keygen() -> i32 {
|
||||
let key = ConnectionKey::new();
|
||||
println!("{}", key.encode());
|
||||
0
|
||||
}
|
||||
|
||||
fn run_generate(out: &str, ca_name: &str, server_name: &str, client_name: &str) -> i32 {
|
||||
@@ -631,53 +603,11 @@ fn run_version() -> i32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_connection_key(target: &str) -> ConnectionKey {
|
||||
ConnectionKey {
|
||||
version: 1,
|
||||
target: target.to_string(),
|
||||
ca_cert_pem: "ca".to_string(),
|
||||
server_cert_pem: "server-cert".to_string(),
|
||||
server_key_pem: "server-key".to_string(),
|
||||
client_cert_pem: "client-cert".to_string(),
|
||||
client_key_pem: "client-key".to_string(),
|
||||
auth_token: "token".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_connect_target_overrides_key_target() {
|
||||
let key = test_connection_key("198.51.100.10:443");
|
||||
let selected = select_connect_target(Some("23.167.32.42:443"), Some(&key)).unwrap();
|
||||
assert_eq!(selected, "23.167.32.42:443");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_uses_key_target_when_explicit_target_absent() {
|
||||
let key = test_connection_key("198.51.100.10:443");
|
||||
let selected = select_connect_target(None, Some(&key)).unwrap();
|
||||
assert_eq!(selected, "198.51.100.10:443");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_target_is_required_without_key() {
|
||||
let err = select_connect_target(None, None).unwrap_err();
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"--target is required for connect unless using a connection key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warns_when_explicit_target_differs_from_key_target() {
|
||||
let key = test_connection_key("198.51.100.10:443");
|
||||
assert!(should_warn_target_override(
|
||||
Some("23.167.32.42:443"),
|
||||
Some(&key)
|
||||
));
|
||||
assert!(!should_warn_target_override(
|
||||
Some("198.51.100.10:443"),
|
||||
Some(&key)
|
||||
));
|
||||
assert!(!should_warn_target_override(Some("23.167.32.42:443"), None));
|
||||
fn keygen_produces_a_decodable_seed_key() {
|
||||
let encoded = ConnectionKey::new().encode();
|
||||
assert!(encoded.starts_with("rtun3."));
|
||||
let decoded = ConnectionKey::decode(&encoded).unwrap();
|
||||
assert_eq!(decoded.encode(), encoded);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -120,7 +120,7 @@ pub fn is_auth_token(content: &str) -> bool {
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_connection_key(content: &str) -> bool {
|
||||
content.trim_start().starts_with("rtun1.")
|
||||
content.trim_start().starts_with("rtun3.")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -213,8 +213,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn is_connection_key_detects_rtun_prefix() {
|
||||
assert!(is_connection_key("rtun1.abc"));
|
||||
assert!(is_connection_key("rtun3.abc"));
|
||||
assert!(!is_connection_key("abc"));
|
||||
assert!(!is_connection_key("rtun2.abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+109
@@ -2234,6 +2234,115 @@ mod tests {
|
||||
listener_task.abort();
|
||||
}
|
||||
|
||||
/// A connection-key (seed-derived) listener and connector establish an mTLS
|
||||
/// tunnel using no on-disk certificate files at all.
|
||||
#[tokio::test]
|
||||
async fn e2e_seed_key_establishes_tunnel_without_files() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let key = crate::connkey::ConnectionKey::new();
|
||||
let seed = *key.seed();
|
||||
let token = crate::keyderive::derive_auth_token(&seed);
|
||||
let port = get_free_port();
|
||||
let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
||||
|
||||
let (server_cert, server_key, ca_cert) =
|
||||
crate::keyderive::derive_server_material(&seed, &["127.0.0.1".to_string()]).unwrap();
|
||||
let (client_cert, client_key, client_ca) =
|
||||
crate::keyderive::derive_client_material(&seed).unwrap();
|
||||
assert_eq!(ca_cert, client_ca, "both sides must derive the same CA");
|
||||
|
||||
let listener_config = ListenerConfig {
|
||||
insecure_skip_tls_verify: false,
|
||||
bind_addr,
|
||||
socks_addr: None,
|
||||
tls: ServerTlsMaterial::Pem {
|
||||
cert: Arc::new(server_cert),
|
||||
key: Arc::new(server_key),
|
||||
ca_cert: Arc::new(ca_cert),
|
||||
},
|
||||
auth_token: Arc::new(token.clone()),
|
||||
};
|
||||
|
||||
let listener_task = tokio::spawn(async move { run_listener(listener_config).await });
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
|
||||
let connector_config = ConnectorConfig {
|
||||
insecure_skip_tls_verify: false,
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: port,
|
||||
tls: ClientTlsMaterial::Pem {
|
||||
cert: Arc::new(client_cert),
|
||||
key: Arc::new(client_key),
|
||||
ca_cert: Arc::new(client_ca),
|
||||
},
|
||||
auth_token: Arc::new(token),
|
||||
};
|
||||
|
||||
let result = quick_connect(&connector_config).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Seed-derived mTLS should establish tunnel: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
listener_task.abort();
|
||||
}
|
||||
|
||||
/// A connector deriving material from a DIFFERENT seed cannot authenticate
|
||||
/// to a listener paired with another seed (the derived CA gates identity).
|
||||
#[tokio::test]
|
||||
async fn e2e_wrong_seed_key_is_rejected() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let seed_a = *crate::connkey::ConnectionKey::new().seed();
|
||||
let seed_b = *crate::connkey::ConnectionKey::new().seed();
|
||||
assert_ne!(seed_a, seed_b);
|
||||
let port = get_free_port();
|
||||
let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
||||
|
||||
let (server_cert, server_key, ca_cert) =
|
||||
crate::keyderive::derive_server_material(&seed_a, &["127.0.0.1".to_string()]).unwrap();
|
||||
let (client_cert, client_key, client_ca) =
|
||||
crate::keyderive::derive_client_material(&seed_b).unwrap();
|
||||
|
||||
let listener_config = ListenerConfig {
|
||||
insecure_skip_tls_verify: false,
|
||||
bind_addr,
|
||||
socks_addr: None,
|
||||
tls: ServerTlsMaterial::Pem {
|
||||
cert: Arc::new(server_cert),
|
||||
key: Arc::new(server_key),
|
||||
ca_cert: Arc::new(ca_cert.clone()),
|
||||
},
|
||||
auth_token: Arc::new(crate::keyderive::derive_auth_token(&seed_a)),
|
||||
};
|
||||
|
||||
let listener_task = tokio::spawn(async move { run_listener(listener_config).await });
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
|
||||
let connector_config = ConnectorConfig {
|
||||
insecure_skip_tls_verify: false,
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: port,
|
||||
tls: ClientTlsMaterial::Pem {
|
||||
cert: Arc::new(client_cert),
|
||||
key: Arc::new(client_key),
|
||||
ca_cert: Arc::new(client_ca),
|
||||
},
|
||||
auth_token: Arc::new(crate::keyderive::derive_auth_token(&seed_a)),
|
||||
};
|
||||
|
||||
// Even with the correct auth token, the wrong-seed client cert must not
|
||||
// authenticate against the listener's derived CA.
|
||||
let result = quick_connect(&connector_config).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Connector with material derived from another seed must be rejected: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
listener_task.abort();
|
||||
}
|
||||
|
||||
/// Test: insecure listener accepts a connector without valid client certificate.
|
||||
#[tokio::test]
|
||||
async fn insecure_listener_accepts_no_client_cert() {
|
||||
|
||||
Reference in New Issue
Block a user