Skip to content
Draft
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
234 changes: 231 additions & 3 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ members = [
"crates/commons-tests",
"crates/commons-types",
"crates/database",
"crates/frond-server",
"crates/jobs",
"crates/private-server",
"crates/public-server",
Expand Down
33 changes: 33 additions & 0 deletions crates/frond-server/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
[package]
publish = false
name = "frond-server"
version = "0.1.0"
edition = "2024"
resolver = "3"
license = "GPL-3.0-or-later"
authors = [
"Félix Saparelli <felix@passcod.name>",
"BES Developers <contact@bes.au>",
]

[[bin]]
name = "frond-server"
required-features = ["cli"]

[dependencies]
clap = { workspace = true, optional = true, features = ["derive", "env"] }
ed25519-dalek = { version = "2.2.0", features = ["pkcs8", "rand_core"] }
lloggs = { workspace = true, optional = true, features = ["miette-7"] }
miette = { workspace = true, features = ["fancy"] }
quinn = "0.11.9"
rand_core = { version = "0.6", features = ["getrandom"] }
rustls = { version = "0.23.40", default-features = false, features = ["ring", "std"] }
rustls-pki-types = "1.14.1"
sha2 = "0.11.0"
subtle = "2.6.1"
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] }
tracing.workspace = true

[features]
default = ["cli"]
cli = ["dep:clap", "dep:lloggs"]
40 changes: 40 additions & 0 deletions crates/frond-server/src/keys.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
use ed25519_dalek::SigningKey;
use rand_core::OsRng;
use sha2::{Digest, Sha256};

/// Generate a fresh Ed25519 keypair in memory.
///
/// TODO: persist this in the database and load on startup so that the
/// server's SPKI is stable across restarts.
pub fn generate_ephemeral() -> SigningKey {
SigningKey::generate(&mut OsRng)
}

/// Build the SubjectPublicKeyInfo (SPKI) DER encoding for an Ed25519 key.
///
/// ```text
/// SEQUENCE {
/// SEQUENCE { OID 1.3.101.112 } -- Ed25519
/// BIT STRING { 0x00 || 32-byte key }
/// }
/// ```
pub fn spki_der(key: &SigningKey) -> Vec<u8> {
const PREFIX: [u8; 12] = [
0x30, 0x2a, // SEQUENCE 42 bytes
0x30, 0x05, // SEQUENCE 5 bytes
0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112
0x03, 0x21, 0x00, // BIT STRING 33 bytes, 0 unused bits
];
let mut out = Vec::with_capacity(44);
out.extend_from_slice(&PREFIX);
out.extend_from_slice(key.verifying_key().as_bytes());
out
}

/// SHA-256 fingerprint of a byte slice as lowercase hex.
pub fn fingerprint(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
13 changes: 13 additions & 0 deletions crates/frond-server/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// frond-server: a QUIC endpoint for canopy speaking the bes.canopy/1 protocol.
//
// See `docs/plans/frond-server.md` for the staged build-out.

pub mod keys;
pub mod server;
pub mod tls;

pub use server::{accept_loop, bind};

/// ALPN negotiated for frond-server QUIC connections. Bumping this string is
/// the lever for incompatible protocol revisions.
pub const ALPN: &[u8] = b"bes.canopy/1";
45 changes: 45 additions & 0 deletions crates/frond-server/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
use std::net::{Ipv6Addr, SocketAddr, SocketAddrV6};

use clap::Parser;
use lloggs::{LoggingArgs, PreArgs};
use miette::IntoDiagnostic;

#[derive(Debug, Parser)]
struct Args {
#[command(flatten)]
logging: LoggingArgs,

#[arg(long, short, default_value = "7899", env = "PORT")]
port: u16,

#[arg(long, env = "BIND_ADDRESS", conflicts_with = "port")]
bind: Option<SocketAddr>,
}

#[tokio::main]
async fn main() -> miette::Result<()> {
let mut _guard = PreArgs::parse_with_env("CANOPY_LOG").setup()?;
let args = Args::parse();
if _guard.is_none() {
_guard = Some(args.logging.setup(|v| match v {
0 => "info",
1 => "debug",
_ => "trace",
})?);
}

rustls::crypto::ring::default_provider()
.install_default()
.expect("ring crypto provider already installed");

let addr = args
.bind
.unwrap_or_else(|| SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::LOCALHOST, args.port, 0, 0)));

let endpoint = frond_server::bind(addr)?;
let local = endpoint.local_addr().into_diagnostic()?;
tracing::info!(%local, "frond-server listening");

frond_server::accept_loop(endpoint).await;
Ok(())
}
58 changes: 58 additions & 0 deletions crates/frond-server/src/server.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
use std::{net::SocketAddr, sync::Arc};

use miette::{Context, IntoDiagnostic, Result};
use quinn::Endpoint;

use crate::{keys, tls};

/// Bind a fresh frond-server endpoint on `addr`.
///
/// Generates an ephemeral Ed25519 keypair, builds the rustls + quinn config,
/// and returns the listening endpoint. The caller drives the accept loop.
pub fn bind(addr: SocketAddr) -> Result<Endpoint> {
let key = keys::generate_ephemeral();
let spki = keys::spki_der(&key);
let fp = keys::fingerprint(&spki);
tracing::info!(server_fingerprint = %fp, "frond-server identity (ephemeral; TODO: persist)");

let tls = tls::build_server_config(&key, spki)
.map_err(|e| miette::miette!("building TLS config: {e}"))?;
let quic = quinn::crypto::rustls::QuicServerConfig::try_from(tls)
.into_diagnostic()
.wrap_err("converting rustls config to quinn QuicServerConfig")?;

let server_config = quinn::ServerConfig::with_crypto(Arc::new(quic));
Endpoint::server(server_config, addr)
.into_diagnostic()
.wrap_err_with(|| format!("binding QUIC endpoint on {addr}"))
}

/// Run the accept loop until the endpoint is closed.
///
/// Phase 2 stub: each accepted connection is logged and immediately closed.
/// Real stream handling lands in a later phase.
pub async fn accept_loop(endpoint: Endpoint) {
while let Some(incoming) = endpoint.accept().await {
tokio::spawn(handle_incoming(incoming));
}
}

async fn handle_incoming(incoming: quinn::Incoming) {
let peer = incoming.remote_address();
match incoming.await {
Ok(conn) => {
let alpn = conn
.handshake_data()
.and_then(|d| {
d.downcast::<quinn::crypto::rustls::HandshakeData>()
.ok()
.and_then(|d| d.protocol.clone())
})
.map(|p| String::from_utf8_lossy(&p).into_owned())
.unwrap_or_default();
tracing::info!(%peer, %alpn, "accepted connection (Phase 2 stub: closing)");
conn.close(0u32.into(), b"phase 2 stub");
}
Err(e) => tracing::warn!(%peer, "incoming connection failed: {e}"),
}
}
111 changes: 111 additions & 0 deletions crates/frond-server/src/tls.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
use std::sync::Arc;

use ed25519_dalek::{SigningKey, pkcs8::EncodePrivateKey};
use rustls::{
DigitallySignedStruct, DistinguishedName, ServerConfig, SignatureScheme,
client::danger::HandshakeSignatureValid,
server::{
AlwaysResolvesServerRawPublicKeys,
danger::{ClientCertVerified, ClientCertVerifier},
},
sign::CertifiedKey,
};
use rustls_pki_types::{
CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, SubjectPublicKeyInfoDer, UnixTime,
};

use crate::ALPN;

/// Build the rustls server config used by frond-server.
///
/// `key` is the server's signing key (paired with `spki`, its DER-encoded
/// SubjectPublicKeyInfo). The resulting config:
///
/// - Presents `spki` as a raw public key (RFC 7250) instead of an X.509 cert.
/// - Requires clients to do the same.
/// - Negotiates only the `bes.canopy/1` ALPN.
/// - Uses [`PermissiveClientVerifier`] so any well-formed client RPK is
/// accepted. Phase 4 swaps this for an allowlist verifier.
pub fn build_server_config(
key: &SigningKey,
spki: Vec<u8>,
) -> Result<ServerConfig, Box<dyn std::error::Error + Send + Sync>> {
let pkcs8 = key.to_pkcs8_der()?;
let private_key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(pkcs8.as_bytes().to_vec()));
let signing_key = rustls::crypto::ring::sign::any_supported_type(&private_key)?;

let cert = CertificateDer::from(spki);
let certified_key = Arc::new(CertifiedKey::new(vec![cert], signing_key));
let resolver = Arc::new(AlwaysResolvesServerRawPublicKeys::new(certified_key));

let client_verifier: Arc<dyn ClientCertVerifier> = Arc::new(PermissiveClientVerifier);

let mut tls = ServerConfig::builder()
.with_client_cert_verifier(client_verifier)
.with_cert_resolver(resolver);

tls.alpn_protocols = vec![ALPN.to_vec()];
Ok(tls)
}

/// Phase 2 stand-in: accepts any well-formed client raw public key without
/// allowlist or DB lookup. Phase 4 of `docs/plans/frond-server.md` replaces
/// this with a verifier that pins a specific SPKI from `identity.pub.pem`,
/// and a later phase swaps in a `device_keys` lookup.
#[derive(Debug)]
pub struct PermissiveClientVerifier;

impl ClientCertVerifier for PermissiveClientVerifier {
fn root_hint_subjects(&self) -> &[DistinguishedName] {
&[]
}

fn verify_client_cert(
&self,
end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_now: UnixTime,
) -> Result<ClientCertVerified, rustls::Error> {
let fp = crate::keys::fingerprint(end_entity.as_ref());
tracing::debug!(client_fingerprint = %fp, "permissive verifier accepted client");
Ok(ClientCertVerified::assertion())
}

fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}

fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature_with_raw_key(
message,
&SubjectPublicKeyInfoDer::from(cert.as_ref()),
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}

fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}

fn requires_raw_public_keys(&self) -> bool {
true
}
}
Loading
Loading