feat: compress connection keys (rtun2) for easier copy/paste

This commit is contained in:
bzuccaro
2026-08-02 12:07:54 -06:00
parent ab1f74214a
commit 6bc3c00b64
13 changed files with 97 additions and 25 deletions
Generated
+16
View File
@@ -2,6 +2,12 @@
# 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"
@@ -925,6 +931,15 @@ 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"
@@ -1294,6 +1309,7 @@ dependencies = [
"hyper", "hyper",
"hyper-util", "hyper-util",
"libc", "libc",
"miniz_oxide",
"rand", "rand",
"rcgen", "rcgen",
"rustls", "rustls",
+1
View File
@@ -29,6 +29,7 @@ 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"
+1 -1
View File
@@ -39,7 +39,7 @@ 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 base64-encoded JSON blob prefixed with `rtun1.`. 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.`.
### `version` ### `version`
+19 -8
View File
@@ -8,11 +8,19 @@ Simplify distribution of tunnel credentials between machines. Instead of transfe
## Format ## Format
Connection keys start with the prefix `rtun1.` followed by base64url-encoded (no padding) JSON: Connection keys start with the prefix `rtun2.` followed by base64url-encoded (no
padding) **DEFLATE-compressed** JSON:
```
rtun2.<base64url(deflate(json))>
```
The JSON payload is compressed with DEFLATE (`miniz_oxide`) before base64url-encoding
to keep the key short enough to copy and paste comfortably. Example payload:
```json ```json
{ {
"version": 1, "version": 2,
"target": "198.51.100.10:4180", "target": "198.51.100.10:4180",
"ca_cert_pem": "-----BEGIN CERTIFICATE-----...", "ca_cert_pem": "-----BEGIN CERTIFICATE-----...",
"server_cert_pem": "-----BEGIN CERTIFICATE-----...", "server_cert_pem": "-----BEGIN CERTIFICATE-----...",
@@ -28,20 +36,23 @@ Connection keys start with the prefix `rtun1.` followed by base64url-encoded (no
| Type | File | Description | | Type | File | Description |
| ---- | ---- | ----------- | | ---- | ---- | ----------- |
| `ConnectionKey` | `src/connkey.rs` | Struct with all fields, version check, validation | | `ConnectionKey` | `src/connkey.rs` | Struct with all fields, version check, validation |
| `ConnectionKey::encode` | `src/connkey.rs` | Serialize to JSON, base64url-encode, prepend prefix | | `ConnectionKey::encode` | `src/connkey.rs` | Serialize to JSON, DEFLATE-compress, base64url-encode, prepend prefix |
| `ConnectionKey::decode` | `src/connkey.rs` | Strip prefix, base64url-decode, deserialize, validate | | `ConnectionKey::decode` | `src/connkey.rs` | Strip prefix, base64url-decode, DEFLATE-decompress, deserialize, validate |
| `looks_like_connection_key` | `src/connkey.rs` | Quick check if a string starts with `rtun1.` | | `looks_like_connection_key` | `src/connkey.rs` | Quick check if a string starts with `rtun2.` |
## Validation ## Validation
`decode` validates: `decode` validates:
- Prefix must be `rtun1.` - Prefix must be `rtun2.`
- Base64 decoding must succeed - Base64 decoding must succeed
- DEFLATE decompression must succeed
- JSON deserialization must succeed - JSON deserialization must succeed
- Version must be exactly `1` - Version must be exactly `2`
- All string fields must be non-empty after trimming - 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` 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.
@@ -49,7 +60,7 @@ Connection keys start with the prefix `rtun1.` followed by base64url-encoded (no
## Entry points for modification ## Entry points for modification
- To change the key format or add versioning: modify `src/connkey.rs`. - 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 add encryption: consider extending the encode/decode pipeline in `ConnectionKey`.
## Key source files ## Key source files
@@ -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 `rtun1.`. - `is_connection_key()` detects strings starting with `rtun2.`.
## Async patterns ## Async patterns
+1 -1
View File
@@ -1,6 +1,6 @@
# Glossary # 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 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`.
**Connector** — the `rustunnel connect` side. Establishes an outbound HTTPS+mTLS connection to the listener, authenticates, then exposes a local SOCKS5 proxy for local applications. **Connector** — the `rustunnel connect` side. Establishes an outbound HTTPS+mTLS connection to the listener, authenticates, then exposes a local SOCKS5 proxy for local applications.
+1 -1
View File
@@ -9,7 +9,7 @@ The tool is designed for local labs, development environments, and controlled te
- **Listener** (`rustunnel listen`) — binds an HTTPS server that accepts mTLS connections from connectors, validates an auth token, then upgrades the connection to a persistent binary-framed tunnel. - **Listener** (`rustunnel listen`) — binds an HTTPS server that accepts mTLS connections from connectors, validates an auth token, then upgrades the connection to a persistent binary-framed tunnel.
- **Connector** (`rustunnel connect`) — connects to the listener over HTTPS with mTLS, authenticates, then exposes a local SOCKS5 proxy that forwards traffic through the tunnel. - **Connector** (`rustunnel connect`) — connects to the listener over HTTPS with mTLS, authenticates, then exposes a local SOCKS5 proxy that forwards traffic through the tunnel.
- **Credential generation** (`rustunnel generate`) — creates a self-signed CA, server certificate, client certificate, and auth token for a local session. - **Credential generation** (`rustunnel generate`) — creates a self-signed CA, server certificate, client certificate, and auth token for a local session.
- **Connection keys** (`rustunnel keygen`) — bundles all credential material into a single base64-encoded string that can be copy-pasted between machines. - **Connection keys** (`rustunnel keygen`) — bundles all credential material into a single compressed base64url string that can be copy-pasted between machines.
## Quick links ## Quick links
+2 -2
View File
@@ -4,7 +4,7 @@
```rust ```rust
pub struct ConnectionKey { pub struct ConnectionKey {
pub version: u8, // must be 1 pub version: u8, // must be 2
pub target: String, // listener address pub target: String, // listener address
pub ca_cert_pem: String, pub ca_cert_pem: String,
pub server_cert_pem: String, pub server_cert_pem: String,
@@ -15,7 +15,7 @@ pub struct ConnectionKey {
} }
``` ```
Encoded as: `rtun1.` + base64url(JSON) — no padding. Encoded as: `rtun2.` + base64url(deflate(JSON)) — no padding.
## Config ## Config
+1
View File
@@ -27,6 +27,7 @@
| `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 encoding |
| `miniz_oxide` | 0.8 | DEFLATE compression for connection keys |
| `sha2` | 0.10 | Certificate fingerprinting | | `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 |
+1 -1
View File
@@ -33,7 +33,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 `rtun1.`. - `is_connection_key()` detects strings starting with `rtun2.`.
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.
+49 -7
View File
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use crate::generate::GeneratedMaterial; use crate::generate::GeneratedMaterial;
const PREFIX: &str = "rtun1."; const PREFIX: &str = "rtun2.";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ConnectionKey { pub struct ConnectionKey {
@@ -21,7 +21,7 @@ pub struct ConnectionKey {
impl ConnectionKey { impl ConnectionKey {
pub fn new(target: String, material: GeneratedMaterial) -> Self { pub fn new(target: String, material: GeneratedMaterial) -> Self {
Self { Self {
version: 1, version: 2,
target, target,
ca_cert_pem: material.ca_cert_pem, ca_cert_pem: material.ca_cert_pem,
server_cert_pem: material.server_cert_pem, server_cert_pem: material.server_cert_pem,
@@ -34,7 +34,8 @@ impl ConnectionKey {
pub fn encode(&self) -> anyhow::Result<String> { pub fn encode(&self) -> anyhow::Result<String> {
let json = serde_json::to_vec(self)?; let json = serde_json::to_vec(self)?;
Ok(format!("{}{}", PREFIX, URL_SAFE_NO_PAD.encode(json))) let compressed = miniz_oxide::deflate::compress_to_vec(&json, 6);
Ok(format!("{}{}", PREFIX, URL_SAFE_NO_PAD.encode(compressed)))
} }
pub fn decode(input: &str) -> anyhow::Result<Self> { pub fn decode(input: &str) -> anyhow::Result<Self> {
@@ -45,9 +46,11 @@ 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 key: Self = serde_json::from_slice(&bytes) let json = miniz_oxide::inflate::decompress_to_vec(&bytes)
.map_err(|e| anyhow::anyhow!("invalid connection key payload: {}", e))?; .map_err(|e| anyhow::anyhow!("invalid connection key payload: {}", e))?;
if key.version != 1 { let key: Self = serde_json::from_slice(&json)
.map_err(|e| anyhow::anyhow!("invalid connection key payload: {}", e))?;
if key.version != 2 {
anyhow::bail!("unsupported connection key version {}", key.version); anyhow::bail!("unsupported connection key version {}", key.version);
} }
if key.target.trim().is_empty() if key.target.trim().is_empty()
@@ -79,15 +82,54 @@ mod tests {
crate::generate::generate_material("ca", "server", "client").expect("material"); crate::generate::generate_material("ca", "server", "client").expect("material");
let key = ConnectionKey::new("127.0.0.1:4180".to_string(), material); let key = ConnectionKey::new("127.0.0.1:4180".to_string(), material);
let encoded = key.encode().unwrap(); let encoded = key.encode().unwrap();
assert!(encoded.starts_with("rtun1.")); assert!(encoded.starts_with("rtun2."));
let decoded = ConnectionKey::decode(&encoded).unwrap(); let decoded = ConnectionKey::decode(&encoded).unwrap();
assert_eq!(decoded.version, 1); assert_eq!(decoded.version, 2);
assert_eq!(decoded.target, "127.0.0.1:4180"); assert_eq!(decoded.target, "127.0.0.1:4180");
assert_eq!(decoded.auth_token, key.auth_token); 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]
fn encoded_key_is_smaller_than_raw_json() {
let material =
crate::generate::generate_material("ca", "server", "client").expect("material");
let key = ConnectionKey::new("127.0.0.1:4180".to_string(), material);
let encoded = key.encode().unwrap();
let json = serde_json::to_vec(&key).unwrap();
let raw = format!("{}{}", PREFIX, URL_SAFE_NO_PAD.encode(json));
assert!(
encoded.len() < raw.len(),
"compressed key ({} chars) should be shorter than uncompressed key ({} chars)",
encoded.len(),
raw.len()
);
assert!(
encoded.len() < (raw.len() * 8) / 10,
"compressed key ({} chars) should be meaningfully shorter than uncompressed ({} chars)",
encoded.len(),
raw.len()
);
} }
#[test] #[test]
fn invalid_prefix_fails() { fn invalid_prefix_fails() {
assert!(ConnectionKey::decode("bad").is_err()); assert!(ConnectionKey::decode("bad").is_err());
assert!(
ConnectionKey::decode("rtun1.not-a-valid-payload").is_err(),
"legacy rtun1. keys are not accepted"
);
}
#[test]
fn looks_like_connection_key_detects_rtun2_prefix() {
assert!(looks_like_connection_key("rtun2.abc"));
assert!(!looks_like_connection_key("abc"));
assert!(!looks_like_connection_key("rtun1.def"));
} }
} }
+1 -1
View File
@@ -633,7 +633,7 @@ mod tests {
fn test_connection_key(target: &str) -> ConnectionKey { fn test_connection_key(target: &str) -> ConnectionKey {
ConnectionKey { ConnectionKey {
version: 1, version: 2,
target: target.to_string(), target: target.to_string(),
ca_cert_pem: "ca".to_string(), ca_cert_pem: "ca".to_string(),
server_cert_pem: "server-cert".to_string(), server_cert_pem: "server-cert".to_string(),
+3 -2
View File
@@ -120,7 +120,7 @@ pub fn is_auth_token(content: &str) -> bool {
#[allow(dead_code)] #[allow(dead_code)]
pub fn is_connection_key(content: &str) -> bool { pub fn is_connection_key(content: &str) -> bool {
content.trim_start().starts_with("rtun1.") content.trim_start().starts_with("rtun2.")
} }
#[cfg(test)] #[cfg(test)]
@@ -213,8 +213,9 @@ mod tests {
#[test] #[test]
fn is_connection_key_detects_rtun_prefix() { fn is_connection_key_detects_rtun_prefix() {
assert!(is_connection_key("rtun1.abc")); assert!(is_connection_key("rtun2.abc"));
assert!(!is_connection_key("abc")); assert!(!is_connection_key("abc"));
assert!(!is_connection_key("rtun1.abc"));
} }
#[test] #[test]