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
35 changes: 1 addition & 34 deletions Cargo.lock

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

3 changes: 1 addition & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "flexaccess-iroh"
version = "0.0.2"
version = "0.0.3"
edition = "2024"
description = "Shared iroh transport layer for FlexAccess applications: relay configuration and probing, endpoint building and rebuilding, the home-relay watchdog, and the endpoint-bound public-key auth transcript"
repository = "https://github.com/flexaccessdev/flexaccess-iroh"
Expand Down Expand Up @@ -37,5 +37,4 @@ iroh-mdns-address-lookup = { version = "0.5", optional = true }
# Test double for iroh's `Watcher`-based status APIs (the relay watchdog tests
# drive a plain `Watchable`); the same crate iroh itself re-exports `Watcher` from.
n0-watcher = "1"
tempfile = "3"
tokio = { version = "1", features = ["full", "test-util"] }
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,26 @@ hand into every repo.
| Module | Contents |
|---|---|
| `relay` | `RelayConfig` (default vs custom relays, which also decides whether n0 internet discovery is on), the shared relay auth token, the strict per-relay startup probe |
| `endpoint` | the common endpoint builder, `create_endpoint` (strict first creation) vs `rebuild_endpoint` (tolerant mid-run replacement), `RebuildableEndpoint`, the persistent secret-key file loader |
| `endpoint` | the common endpoint builder, `create_endpoint` (strict first creation) vs `rebuild_endpoint` (tolerant mid-run replacement), `RebuildableEndpoint` |
| `relay_watchdog` | the server-side home-relay watchdog: nudge with `network_change()`, then ask for a rebuild |
| `auth` | the endpoint-bound public-key auth transcript over the [flexaccess-keys] format; each application passes its own domain-separation context |

Deliberately **not** in it: ALPNs, handshake wire formats, QUIC transport
tuning, connection-path status UIs, and anything else product-specific. Those
stay in each application.
stay in each application. Nor does it load anything: identity and auth key
files — paths, formats, error hints, permissions — are the application's
(client key files come through [flexaccess-keys]' own loaders), and the crate
takes the resulting `iroh::SecretKey` / `flexaccess_keys` values.

[flexaccess-keys]: https://github.com/flexaccessdev/flexaccess-keys

## Depending on it

```toml
[dependencies]
flexaccess-iroh = { git = "https://github.com/flexaccessdev/flexaccess-iroh", tag = "v0.0.1" }
flexaccess-iroh = { git = "https://github.com/flexaccessdev/flexaccess-iroh", tag = "v0.0.3" }
# or, with mDNS local-network discovery on every endpoint (compiled out on iOS):
flexaccess-iroh = { git = "...", tag = "v0.0.1", features = ["mdns"] }
flexaccess-iroh = { git = "...", tag = "v0.0.3", features = ["mdns"] }
```

The `flexaccess_keys` crate is re-exported so a consumer signs and verifies
Expand Down
85 changes: 5 additions & 80 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
//! Key management lives in
//! [flexaccess-keys](https://github.com/flexaccessdev/flexaccess-keys): the
//! `ed25519-sec:` / `ed25519-pub:` token format, key files, authorized-keys
//! parsing, and the `generate-auth-key` / `show-auth-key` CLI. This module
//! owns the one transcript every FlexAccess program uses to prove a keypair
//! over an iroh connection; the application supplies only its
//! domain-separation context.
//! parsing, and the `generate-auth-key` / `show-auth-key` CLI. Reading key
//! files is the application's job (with `flexaccess_keys::load_private_key`
//! and `flexaccess_keys::load_authorized_keys`); this module owns only the one
//! transcript every FlexAccess program uses to prove a keypair over an iroh
//! connection, and the application supplies its domain-separation context.
//!
//! ## Transcript
//! The client's iroh endpoint id stays ephemeral. In its handshake the client
Expand All @@ -26,7 +27,6 @@ use anyhow::{Context, Result};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use flexaccess_keys::{PrivateKey, PublicKey};
use iroh::EndpointId;
use std::path::Path;

pub use flexaccess_keys::AuthorizedKeys;

Expand Down Expand Up @@ -120,27 +120,11 @@ pub fn verify_endpoint_id_signature(
public.verify(&auth_message(context, endpoint_id), &bytes)
}

/// Load a client secret key from a shared-format key file (a bare
/// `ed25519-sec:...` token, or the token preceded by `#` header lines).
pub fn load_client_key_from_file(path: &Path) -> Result<ClientKey> {
let private = flexaccess_keys::load_private_key(path).map_err(anyhow::Error::from)?;
Ok(private.into())
}

/// Load a server's authorized client public keys (shared authorized-keys
/// document: one `ed25519-pub:...` per line, optional trailing comment, `#`
/// lines and blank lines ignored).
pub fn load_authorized_keys(path: &Path) -> Result<AuthorizedKeys> {
flexaccess_keys::load_authorized_keys(path).map_err(anyhow::Error::from)
}

#[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;

const CONTEXT: &[u8] = b"test-client-auth-v1";

Expand Down Expand Up @@ -181,31 +165,6 @@ mod tests {
assert!(ClientKey::from_secret_str(&short).is_err());
}

#[test]
fn shared_key_file_reloads() {
let key = ClientKey::generate().unwrap();
let contents = format!(
"# Ed25519 authentication key\n# Public key: {} alice laptop\n{}\n",
key.public_str(),
key.secret_str()
);
let mut file = NamedTempFile::new().unwrap();
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();
assert!(load_client_key_from_file(bad.path()).is_err());
}

#[test]
fn signature_binds_endpoint_id_and_context() {
let key = ClientKey::generate().unwrap();
Expand Down Expand Up @@ -235,38 +194,4 @@ mod tests {
assert!(!verify_endpoint_id_signature(&key.public_key(), CONTEXT, &id, ""));
}

#[test]
fn authorized_keys_parsing() {
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, "{} 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!(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.
let mut wrong = NamedTempFile::new().unwrap();
writeln!(wrong, "{}", ClientKey::generate().unwrap().secret_str()).unwrap();
assert!(load_authorized_keys(wrong.path()).is_err());
}
}
107 changes: 20 additions & 87 deletions src/endpoint.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
//! Endpoint construction shared by every FlexAccess program: the common
//! builder, the creation-vs-rebuild policy, a rebuildable endpoint handle, and
//! the persistent secret-key file loader.
//! builder, the creation-vs-rebuild policy, and a rebuildable endpoint handle.
//!
//! Identity is the application's: it reads and decodes its own secret-key
//! file (or generates an ephemeral key) and binds the resulting
//! [`iroh::SecretKey`] on the builder itself.
//!
//! Applications layer their own ALPNs, hooks, identity, and QUIC transport
//! tuning onto the [`iroh::endpoint::Builder`] returned by
Expand All @@ -9,15 +12,13 @@

use crate::relay::{RELAY_CONNECT_TIMEOUT, RelayConfig, probe_custom_relays};
use anyhow::{Context, Result};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use futures::future::BoxFuture;
use iroh::{
Endpoint, EndpointId, SecretKey,
Endpoint, EndpointId,
address_lookup::{DnsAddressLookup, PkarrPublisher},
endpoint::{Builder as EndpointBuilder, QuicTransportConfig, presets},
};
use log::info;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;

Expand All @@ -35,6 +36,12 @@ pub struct EndpointOptions {
/// resolve it by id; a client that only dials out should not advertise
/// itself.
pub publish_address: bool,
/// Reach peers **only** through the configured relays: the direct IP
/// transports are dropped and no address lookup of any kind (n0 internet
/// discovery, mDNS) is added, so nothing can ever produce a direct path.
/// A testing and reference mode for a self-hosted relay deployment; only
/// meaningful with custom relays (the default relays are rate-limited).
pub relay_only: bool,
}

/// Create a base endpoint builder with the common configuration.
Expand All @@ -54,6 +61,9 @@ pub struct EndpointOptions {
///
/// With the `mdns` feature, mDNS local-network discovery is added independent
/// of the relay mode (except on iOS, where it is compiled out).
///
/// [`EndpointOptions::relay_only`] overrides all of that: the IP transports
/// are cleared and no address lookup at all is added.
pub fn endpoint_builder(relay_config: &RelayConfig, options: EndpointOptions) -> EndpointBuilder {
// iroh 1.x requires the crypto provider to be set explicitly on the
// builder when starting from the `Empty` preset — the `tls-ring` feature
Expand All @@ -63,6 +73,11 @@ pub fn endpoint_builder(relay_config: &RelayConfig, options: EndpointOptions) ->
.transport_config(options.transport_config)
.crypto_provider(Arc::new(rustls::crypto::ring::default_provider()));

if options.relay_only {
info!("Relay-only mode: no direct paths and no address lookup");
return builder.clear_ip_transports();
}

if relay_config.is_custom() {
info!("Internet discovery disabled (custom relays configured)");
} else {
Expand Down Expand Up @@ -249,91 +264,9 @@ impl RebuildableEndpoint {
}
}

/// Load a persistent iroh secret key from its key file: the base64 secret on
/// the first line that is neither blank nor a `#` comment (generated key files
/// carry `# created:` / `# public key:` headers above the secret).
///
/// A missing file is an error naming the path; the application adds its own
/// "generate one with ..." hint as context.
pub fn load_secret(path: &Path) -> Result<SecretKey> {
if !path.exists() {
anyhow::bail!("Secret key file not found: {}", path.display());
}
let content = std::fs::read_to_string(path).context("Failed to read secret key file")?;
let Some(line) = content
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with('#'))
else {
anyhow::bail!(
"No secret key found in {} (only blank lines or `#` comments)",
path.display()
);
};
load_secret_from_string(line)
}

/// Load a secret key from a base64-encoded string.
pub fn load_secret_from_string(base64_key: &str) -> Result<SecretKey> {
let bytes = BASE64
.decode(base64_key)
.context("Invalid base64 in secret key")?;
SecretKey::try_from(&bytes[..]).context("Invalid secret key (must be 32 bytes)")
}

/// The endpoint id (public key) a secret key gives an endpoint.
pub fn secret_to_endpoint_id(secret: &SecretKey) -> EndpointId {
secret.public()
}

#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;

#[test]
fn load_secret_skips_comment_header() {
let secret = SecretKey::generate();
let encoded = BASE64.encode(secret.to_bytes());
let mut file = tempfile::NamedTempFile::new().unwrap();
writeln!(
file,
"# created: 2026-08-13T01:02:03Z\n# public key: {}\n\n{}",
secret.public(),
encoded
)
.unwrap();
let loaded = load_secret(file.path()).unwrap();
assert_eq!(loaded.public(), secret.public());

// A bare secret with no comments still loads.
let mut bare = tempfile::NamedTempFile::new().unwrap();
writeln!(bare, "{encoded}").unwrap();
assert_eq!(load_secret(bare.path()).unwrap().public(), secret.public());

// Comments only — no secret line — is a hard error.
let mut empty = tempfile::NamedTempFile::new().unwrap();
writeln!(empty, "# created: 2026-08-13T01:02:03Z").unwrap();
let err = load_secret(empty.path()).unwrap_err();
assert!(err.to_string().contains("No secret key found"), "{err}");
}

#[test]
fn load_secret_missing_file_names_the_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("missing.key");
let err = load_secret(&path).unwrap_err();
assert!(err.to_string().contains("missing.key"), "{err}");
}

#[test]
fn load_secret_from_string_rejects_bad_input() {
assert!(load_secret_from_string("!!!").is_err());
assert!(load_secret_from_string(&BASE64.encode([0u8; 16])).is_err());
let secret = SecretKey::generate();
let ok = load_secret_from_string(&BASE64.encode(secret.to_bytes())).unwrap();
assert_eq!(secret_to_endpoint_id(&ok), secret.public());
}

#[tokio::test]
async fn rebuildable_endpoint_swaps_and_closes_the_old_one() {
Expand Down
Loading
Loading