Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ self-hosting — is documented once in
https://github.com/flexaccessdev/iroh-common-architecture. Do not duplicate it in
this repo; update it there and link to it.

That shared layer's code — `RelayConfig` and the relay probe, endpoint
building and rebuild, the home-relay watchdog, the endpoint-bound auth
transcript — lives in the `flexaccess-iroh` crate (`../flexaccess-iroh`,
consumed by git tag). Fix it there, tag a release, and bump the tag here; never
re-implement or fork a copy of it in this repo. Only ezvpn-specific pieces (the
VPN ALPN, QUIC transport tuning, the auth context, the bounded connect, key
files) belong in `src/transport/` and `src/auth.rs`. ezvpn depends on a fork of
iroh, so the fork is applied through `[patch.crates-io]` in `Cargo.toml` rather
than as a git dependency, so that the shared crate's `iroh` resolves to it too.

The mobile apps live in sibling repos: `../ezvpn-apple` (Swift, see
`docs/Apple-App.md`) and `../ezvpn-android` (Kotlin, see `docs/Android-App.md`).
Both drive the fd-based `MobileSession` in `src/tunnel/mobile.rs` through
Expand Down
18 changes: 17 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 16 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "ezvpn"
version = "0.0.45"
version = "0.0.46"
edition = "2024"
description = "IP-over-QUIC VPN tunnel via iroh P2P"
readme = "README.md"
Expand Down Expand Up @@ -31,10 +31,13 @@ etherparse = "0.20"
flexaccess-keys = { git = "https://github.com/flexaccessdev/flexaccess-keys", tag = "v0.0.2", default-features = false }
futures = "0.3"
ipnet = { version = "2", features = ["serde"] }
# Forked from iroh v1.1.0. This branch preserves established-path transport
# backpressure so noq retains and retries the transmit instead of treating a
# local discard as successful. Cargo.lock pins the tested branch commit.
iroh = { version = "=1.1.0", git = "https://github.com/andrewtheguy/iroh.git", branch = "ezvpn-send-backpressure-1.1.0" }
# Shared iroh transport layer (RelayConfig + per-relay probe, endpoint
# build/rebuild, home-relay watchdog, endpoint-bound auth transcript). No
# `mdns` feature: ezvpn runs no local-network discovery.
flexaccess-iroh = { git = "https://github.com/flexaccessdev/flexaccess-iroh", tag = "v0.0.3" }
# Redirected to the fork below via `[patch.crates-io]`, which also covers the
# shared crate's own `iroh` dependency so the graph holds a single `iroh`.
iroh = "1.1.0"
noq-proto = "1.2.0"
n0-watcher = "1.0.0"
log = "0.4"
Expand Down Expand Up @@ -105,3 +108,11 @@ strip = true
lto = "thin"
codegen-units = 1
panic = "abort"

# Forked from iroh v1.1.0. This branch preserves established-path transport
# backpressure so noq retains and retries the transmit instead of treating a
# local discard as successful. Cargo.lock pins the tested branch commit. A
# patch rather than a git dependency so `flexaccess-iroh`'s `iroh` resolves to
# the same package (see its README, "Consumers on a fork of iroh").
[patch.crates-io]
iroh = { git = "https://github.com/andrewtheguy/iroh.git", branch = "ezvpn-send-backpressure-1.1.0" }
8 changes: 5 additions & 3 deletions docs/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -704,7 +704,9 @@ sequenceDiagram

### Relay Watchdog (Server, Custom Relays)

Implemented in `src/transport/relay_watchdog.rs`, driven by the serve loop in
Implemented in the shared
[flexaccess-iroh](https://github.com/flexaccessdev/flexaccess-iroh) crate
(`flexaccess_iroh::relay_watchdog`), driven by the serve loop in
`VpnServer::run`. A custom-relay server is dialable from off the LAN only while
it is **registered on its home relay** (n0 address lookup is off; clients dial
by relay hint, and a relay forwards QUIC Initials only to endpoints connected to
Expand Down Expand Up @@ -746,8 +748,8 @@ on demand and dropped after a minute idle, which is normal and never counts as
an outage. With the default relays the watchdog is not armed: reachability
there rests on n0 publishing/resolution, not on one relay registration.

The same watchdog lives in flextunnel (`transport::relay_watchdog`); keep the
two in sync.
The watchdog is shared with flextunnel through that crate: fix it there, tag a
release, and bump the tag here.

### Client Network Consistency Check (Reconnect)

Expand Down
220 changes: 46 additions & 174 deletions src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
//! Public-key authentication for iroh VPN tunnel connections.
//! Public-key authentication for ezvpn client connections.
//!
//! Key management is delegated to the
//! [flexaccess-keys](https://github.com/flexaccessdev/flexaccess-keys)
//! repository: the shared `ed25519-sec:` / `ed25519-pub:` token format, key
//! files, authorized-keys parsing, and the `generate-auth-key` /
//! `show-auth-key` CLI all live there. This module owns only ezvpn's
//! domain-separated authentication transcript and its authorization decision.
//! The transcript — sign the client's own ephemeral endpoint id, verify it
//! against the connection's TLS-authenticated `remote_id()` and the
//! authorized-keys file — is the shared [`flexaccess_iroh::auth`] one, and the
//! key format and files are
//! [flexaccess-keys](https://github.com/flexaccessdev/flexaccess-keys). This
//! module owns only what makes it ezvpn's: the domain-separation context, the
//! key-file loaders, and the authorization decision in the server's handshake
//! (`VpnServer::verify_client_auth`).
//!
//! ## Handshake
//! The client's iroh endpoint id stays ephemeral. In its [`VpnHandshake`] the
Expand All @@ -20,104 +22,39 @@
//!
//! [`VpnHandshake`]: crate::tunnel::signaling::VpnHandshake

use anyhow::{Context, Result};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use flexaccess_keys::{PrivateKey, PublicKey};
use anyhow::Result;
use flexaccess_keys::PublicKey;
use iroh::EndpointId;
use std::path::Path;

pub use flexaccess_keys::AuthorizedKeys;
pub use flexaccess_iroh::auth::{AuthorizedKeys, ClientKey};

/// Domain-separation context prepended to the signed message, so a client-auth
/// signature can never be confused with any other ed25519 signature made by
/// the same key — including one made for another FlexAccess application
/// sharing the key format.
/// Domain-separation context prepended to the signed message, so an ezvpn
/// client-auth signature can never be confused with any other ed25519
/// signature made by the same key — including one made for another FlexAccess
/// application sharing the key format and transcript.
const AUTH_CONTEXT: &[u8] = b"ezvpn-client-auth-v1";

/// A client authentication keypair: a shared-format [`PrivateKey`] bound to
/// ezvpn's signing transcript.
#[derive(Clone)]
pub struct ClientKey {
private: PrivateKey,
/// Sign the client-auth message binding `endpoint_id` (this client's own
/// ephemeral iroh id) under ezvpn's context, returning the base64url
/// signature.
pub fn sign_endpoint_id(key: &ClientKey, endpoint_id: &EndpointId) -> String {
key.sign_endpoint_id(AUTH_CONTEXT, endpoint_id)
}

/// `Debug` shows only the public half — the secret must never leak into
/// logs or error context.
impl std::fmt::Debug for ClientKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientKey")
.field("public", &self.public_str())
.finish_non_exhaustive()
}
}

impl From<PrivateKey> for ClientKey {
fn from(private: PrivateKey) -> Self {
Self { private }
}
}

impl ClientKey {
/// Generate a fresh random keypair. Fails only when the system RNG is
/// unavailable — fallible rather than panicking because the FFI surfaces
/// call this, and a panic there aborts the host app process.
pub fn generate() -> Result<Self> {
let private = PrivateKey::generate()
.map_err(anyhow::Error::from)
.context("Failed to generate an authentication keypair")?;
Ok(private.into())
}

/// Parse an encoded secret key (`ed25519-sec:...`).
pub fn from_secret_str(s: &str) -> Result<Self> {
let private = s
.parse::<PrivateKey>()
.map_err(anyhow::Error::from)
.context("Invalid authentication private key")?;
Ok(private.into())
}

/// The encoded secret key (`ed25519-sec:...`).
pub fn secret_str(&self) -> String {
self.private.to_token()
}

/// The encoded public key (`ed25519-pub:...`).
pub fn public_str(&self) -> String {
self.private.public_key().to_token()
}

/// The verifying half of this keypair.
pub fn public_key(&self) -> PublicKey {
self.private.public_key()
}

/// Sign the client-auth message binding `endpoint_id` (this client's own
/// ephemeral iroh id), returning the base64url signature.
pub fn sign_endpoint_id(&self, endpoint_id: &EndpointId) -> String {
let sig = self.private.sign(&auth_message(endpoint_id));
URL_SAFE_NO_PAD.encode(sig)
}
}

/// The signed message: domain-separation context + the raw endpoint-id bytes.
fn auth_message(endpoint_id: &EndpointId) -> Vec<u8> {
let mut msg = Vec::with_capacity(AUTH_CONTEXT.len() + 32);
msg.extend_from_slice(AUTH_CONTEXT);
msg.extend_from_slice(endpoint_id.as_bytes());
msg
}

/// Verify a base64url client-auth signature over `endpoint_id` under `public`.
/// Verify a base64url client-auth signature over `endpoint_id` under `public`
/// and ezvpn's context.
pub fn verify_endpoint_id_signature(
public: &PublicKey,
endpoint_id: &EndpointId,
signature_b64: &str,
) -> bool {
let Ok(bytes) = URL_SAFE_NO_PAD.decode(signature_b64) else {
return false;
};
public.verify(&auth_message(endpoint_id), &bytes)
flexaccess_iroh::auth::verify_endpoint_id_signature(
public,
AUTH_CONTEXT,
endpoint_id,
signature_b64,
)
}

/// Load a client secret key from a shared-format key file (a bare
Expand All @@ -137,40 +74,27 @@ pub fn load_authorized_keys(path: &Path) -> Result<AuthorizedKeys> {
#[cfg(test)]
mod tests {
use super::*;
use flexaccess_keys::{PRIVATE_KEY_PREFIX, PUBLIC_KEY_PREFIX};
use iroh::SecretKey;
use std::io::Write;
use tempfile::NamedTempFile;

fn ephemeral_endpoint_id() -> EndpointId {
let bytes: [u8; 32] = rand::random();
SecretKey::from_bytes(&bytes).public()
}

#[test]
fn keypair_roundtrip() {
fn signature_is_bound_to_ezvpn_context() {
let key = ClientKey::generate().unwrap();
let secret = key.secret_str();
assert!(secret.starts_with(PRIVATE_KEY_PREFIX));
let public = key.public_str();
assert!(public.starts_with(PUBLIC_KEY_PREFIX));
let id = SecretKey::generate().public();
let sig = sign_endpoint_id(&key, &id);
assert!(verify_endpoint_id_signature(&key.public_key(), &id, &sig));

let reparsed = ClientKey::from_secret_str(&secret).unwrap();
assert_eq!(reparsed.public_str(), public);
assert_eq!(public.parse::<PublicKey>().unwrap(), key.public_key());
// The same key and id signed under another application's context
// (flextunnel shares the key format and transcript) is not an ezvpn
// credential.
let foreign = key.sign_endpoint_id(b"flextunnel-client-auth-v1", &id);
assert!(!verify_endpoint_id_signature(&key.public_key(), &id, &foreign));
}

#[test]
fn secret_str_rejects_bad_inputs() {
// Wrong prefix (a public key is not a secret key).
let key = ClientKey::generate().unwrap();
assert!(ClientKey::from_secret_str(&key.public_str()).is_err());
// Bad base64.
assert!(ClientKey::from_secret_str("ed25519-sec:!!!").is_err());
// Wrong length.
let short = format!("{}{}", PRIVATE_KEY_PREFIX, URL_SAFE_NO_PAD.encode([0u8; 16]));
assert!(ClientKey::from_secret_str(&short).is_err());
// The retired ezvpn auth-token format is rejected, not migrated.
fn retired_auth_token_format_is_rejected() {
// The pre-keypair ezvpn auth token is rejected, not migrated.
assert!(
ClientKey::from_secret_str("vmfNFxTPDKB3jsM1Q8kzAvZnQHbmJ1W49Rk8i1S2Jzrze9Q").is_err()
);
Expand All @@ -188,79 +112,27 @@ mod tests {
file.write_all(contents.as_bytes()).unwrap();
let loaded = load_client_key_from_file(file.path()).unwrap();
assert_eq!(loaded.public_str(), key.public_str());
}

#[test]
fn key_file_without_secret_is_rejected() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "# only comments here").unwrap();
assert!(load_client_key_from_file(file.path()).is_err());

let mut bad = NamedTempFile::new().unwrap();
writeln!(bad, "not-a-key").unwrap();
writeln!(bad, "# only comments here").unwrap();
assert!(load_client_key_from_file(bad.path()).is_err());
}

#[test]
fn signature_binds_endpoint_id() {
let key = ClientKey::generate().unwrap();
let id = ephemeral_endpoint_id();
let sig = key.sign_endpoint_id(&id);
assert!(verify_endpoint_id_signature(&key.public_key(), &id, &sig));

// A different endpoint id (replay from another endpoint) fails.
let other_id = ephemeral_endpoint_id();
assert!(!verify_endpoint_id_signature(
&key.public_key(),
&other_id,
&sig
));

// A different key fails.
let other_key = ClientKey::generate().unwrap();
assert!(!verify_endpoint_id_signature(
&other_key.public_key(),
&id,
&sig
));

// Garbage signatures fail instead of erroring.
assert!(!verify_endpoint_id_signature(&key.public_key(), &id, "!!!"));
assert!(!verify_endpoint_id_signature(&key.public_key(), &id, ""));
}

#[test]
fn authorized_keys_parsing() {
fn authorized_keys_file_parses_and_rejects_secrets() {
let a = ClientKey::generate().unwrap();
let b = ClientKey::generate().unwrap();
let c = ClientKey::generate().unwrap();

let mut file = NamedTempFile::new().unwrap();
writeln!(file, "# Authorized client keys").unwrap();
writeln!(file).unwrap();
writeln!(file, "{}", a.public_str()).unwrap();
writeln!(file, "# Authorized client keys\n\n{}", a.public_str()).unwrap();
writeln!(file, "{} alice laptop", b.public_str()).unwrap();
writeln!(file, " {} build server ", c.public_str()).unwrap();

let keys = load_authorized_keys(file.path()).unwrap();
assert_eq!(keys.len(), 3);
assert_eq!(keys.len(), 2);
assert!(keys.contains(&a.public_key()));
assert!(keys.contains(&b.public_key()));
assert!(keys.contains(&c.public_key()));
assert_eq!(keys.comment(&b.public_key()), Some("alice laptop"));
}

#[test]
fn authorized_keys_invalid_key_is_rejected() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "# header").unwrap();
writeln!(file, "ed25519-pub:short").unwrap();
let err = load_authorized_keys(file.path()).unwrap_err();
assert!(err.to_string().contains(":2"), "{err}");

// A secret key pasted into the authorized-keys file is rejected too.
// A secret key pasted into the authorized-keys file is rejected.
let mut wrong = NamedTempFile::new().unwrap();
writeln!(wrong, "{}", ClientKey::generate().unwrap().secret_str()).unwrap();
writeln!(wrong, "{}", a.secret_str()).unwrap();
assert!(load_authorized_keys(wrong.path()).is_err());
}
}
Loading
Loading