Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

TLS Layer

This chapter documents the internal implementation of Akāmu’s native TLS server, covering the crypto backend selection, certificate loading, composite ML-DSA scheme wiring, and the connection acceptance loop.

Module layout

src/tls/
  mod.rs              TLS module re-exports; build_rustls_server_config and
                      build_admin_rustls_server_config entry points; leaf_cert_der helper
  init.rs             tls::init::load_or_generate — certificate bootstrap
  loader.rs           PEM loading helpers (pem_to_der, BackendPrivateKey::from_pem)
  schemes.rs          Composite ML-DSA+classical code points (COMPOSITE_SCHEMES)
  verifier.rs         SyntaClientCertVerifier — rustls ClientCertVerifier impl
  channel_binding.rs  RFC 5929 tls-server-end-point channel binding computation

TLS is optional. When config.tls.enabled is false, the server uses a plain axum::serve call and the entire src/tls/ subsystem is never entered.

Crypto provider: rustls-native-ossl

The rustls ServerConfig is constructed with the rustls-native-ossl default provider, which delegates all cryptographic operations to the system OpenSSL library:

#![allow(unused)]
fn main() {
let provider = Arc::new(rustls_native_ossl::default_provider());
let builder = rustls::ServerConfig::builder_with_provider(provider)
    .with_protocol_versions(&versions)?;
}

rustls-native-ossl handles all classical TLS signature schemes (ECDSA, RSA-PSS, RSA-PKCS1, EdDSA) for both server certificate verification and client certificate CertificateVerify in TLS 1.2.

Composite ML-DSA+classical CertificateVerify messages in TLS 1.3 are routed through the same native-ossl OpenSSL backend via a dedicated dispatch path (see Composite scheme verification below).

tls::init::load_or_generate (src/tls/init.rs)

Called once at startup when config.tls.enabled is true. It mirrors the logic of ca::init::load_or_generate:

cert_file existskey_file existsAction
NoNoGenerate server key + CA-signed cert; write both files
YesYesReturn immediately — caller has supplied its own cert
YesNo (or No/Yes)Return Err — partial state rejected

When generating:

  1. ca::init::generate_backend_key(&tls.bootstrap_key_type) generates a fresh server key.
  2. ca::issue::sign_server_cert(&tls.server_name, &server_key, ca) produces a CA-signed certificate DER.
  3. server_key.to_pem(None) serialises the private key PEM; written to key_file first via crate::util::write_key_file.
  4. synta_certificate::der_to_pem("CERTIFICATE", &cert_der) converts the certificate to PEM.
  5. The PEM chain written to cert_file is leaf cert + CA cert (PEM-concatenated) so TLS clients see a complete chain without needing the CA cert separately.

The function signature is:

#![allow(unused)]
fn main() {
pub fn load_or_generate(tls: &TlsConfig, ca: &CaState) -> Result<(), String>
}

PEM loading (src/tls/loader.rs)

All PEM-to-DER conversion uses synta_certificate::pem_to_der — the same helper used throughout the server and CA subsystems. This avoids a second PEM parser dependency.

load_server_cert_chain

#![allow(unused)]
fn main() {
pub fn load_server_cert_chain(path: &str) -> Result<Vec<CertificateDer<'static>>, String>
}

Reads the file, calls pem_to_der, and maps each DER blob to rustls::pki_types::CertificateDer. Returns an error if the file contains no PEM blocks.

load_server_private_key

#![allow(unused)]
fn main() {
pub fn load_server_private_key(path: &str) -> Result<PrivateKeyDer<'static>, String>
}

Reads the PEM file and calls BackendPrivateKey::from_pem(&pem, None) to parse it — the same synta_certificate primitive used to load the CA key. The resulting BackendPrivateKey is then serialised to PKCS#8 DER via .to_der() and wrapped in rustls::pki_types::PrivateKeyDer::Pkcs8. This accepts both unencrypted PKCS#8 (-----BEGIN PRIVATE KEY-----) and SEC1 EC keys (-----BEGIN EC PRIVATE KEY-----).

load_ca_certs

#![allow(unused)]
fn main() {
pub fn load_ca_certs(ca_files: &[String]) -> Result<Vec<Vec<u8>>, String>
}

Iterates the configured CA PEM files, calls pem_to_der for each, and returns a flat Vec of DER blobs for the SyntaClientCertVerifier trust store.

SyntaClientCertVerifier (src/tls/verifier.rs)

Implements rustls::server::danger::ClientCertVerifier using synta-x509-verification for chain validation. Trust anchors are parsed once at startup via OwnedStore::try_new and reused across all connections with no DER re-parsing per handshake.

Construction

#![allow(unused)]
fn main() {
let verifier = SyntaClientCertVerifier::new(&ca_ders, client_auth_config)?;
}

OwnedStore::try_new parses each CA DER blob into an owned in-process trust store. The DN hints (root_hint_subjects) are also pre-computed once by parsing the subject Name from each CA DER using synta::Decoder.

verify_client_cert

On each TLS handshake, rustls calls this method. It:

  1. Clones the DER bytes out of the short-lived CertificateDer borrows into owned Vec<u8> allocations.
  2. Parses the leaf and each intermediate via synta::Decoder::decode::<Certificate>().
  3. Builds a PolicyDefinition via PolicyDefinition::new_client(OpensslSignatureVerifier, validation_time), then applies the configured profile, depth, minimum RSA modulus, and algorithm sets.
  4. Calls self.owned_store.verify(&leaf_vc, &inter_vcs, &policy, RevocationChecks::default()) — no re-parsing of trust anchors.

Algorithm sets are chosen based on allow_post_quantum:

allow_post_quantumSPKI algorithmsSignature algorithms
falseWEBPKI_PERMITTED_SPKI_ALGORITHMSWEBPKI_PERMITTED_SIGNATURE_ALGORITHMS
trueWEBPKI_PERMITTED_SPKI_ALGORITHMS_WITH_PQWEBPKI_PERMITTED_SIGNATURE_ALGORITHMS_WITH_PQ

verify_tls12_signature

All TLS 1.2 CertificateVerify schemes delegate to the rustls-native-ossl provider via the provider field cached at construction time — no new default_provider() call per handshake:

#![allow(unused)]
fn main() {
rustls::crypto::verify_tls12_signature(
    message, cert, dss,
    &self.provider.signature_verification_algorithms,
)
}

Composite ML-DSA schemes are TLS 1.3 only and never appear here.

verify_tls13_signature

TLS 1.3 CertificateVerify dispatch:

#![allow(unused)]
fn main() {
if crate::tls::schemes::is_composite(dss.scheme) {
    verify_composite_tls13_signature(message, cert, dss)
} else {
    rustls::crypto::verify_tls13_signature(
        message, cert, dss,
        &self.provider.signature_verification_algorithms,
    )
}
}

Classical schemes go to rustls-native-ossl; composite ML-DSA schemes go to the native-ossl EVP path. The provider is stored as Arc<rustls::crypto::CryptoProvider> in the verifier struct (built once at SyntaClientCertVerifier::new), so a single rustls_native_ossl::default_provider() call is shared across every connection.

Composite scheme code points (src/tls/schemes.rs)

#![allow(unused)]
fn main() {
pub const MLDSA44_ECDSA_P256_SHA256:     u16 = 0x0901;
pub const MLDSA44_RSA2048_PKCS15_SHA256: u16 = 0x0902;
// … 11 entries total
pub const MLDSA87_ED448_SHAKE256:        u16 = 0x090C;
}

These are provisional code points from draft-reddy-tls-composite-mldsa (all TBD pending IANA allocation). The X.509 OIDs for the same algorithm combinations are defined in the companion draft-ietf-lamps-pq-composite-sigs. They are advertised as SignatureScheme::Unknown(code) values because rustls does not have built-in named variants for these provisional code points.

COMPOSITE_SCHEMES is a &[SignatureScheme] slice of all 11 entries, returned by supported_verify_schemes when allow_post_quantum = true.

is_composite(scheme: SignatureScheme) -> bool checks whether a scheme’s code point is in COMPOSITE_SCHEMES:

#![allow(unused)]
fn main() {
pub fn is_composite(scheme: SignatureScheme) -> bool {
    if let SignatureScheme::Unknown(code) = scheme {
        COMPOSITE_SCHEMES.contains(&SignatureScheme::Unknown(code))
    } else {
        false
    }
}
}

Composite scheme verification (native-ossl)

When is_composite returns true, verification is routed to:

#![allow(unused)]
fn main() {
fn verify_composite_tls13_signature(
    message: &[u8],
    cert: &CertificateDer<'_>,
    dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TlsError>
}

This function:

  1. Extracts the composite SubjectPublicKeyInfo DER from the raw certificate bytes using synta_certificate::cert_byte_ranges to get the exact SPKI TLV byte range — avoiding a full certificate re-parse.
  2. Calls verify_composite_via_openssl(dss.scheme, message, spki_der, dss.signature()).

verify_composite_via_openssl uses native-ossl:

#![allow(unused)]
fn main() {
use native_ossl::pkey::{Pkey, Public, SignInit, Verifier};

let pkey = Pkey::<Public>::from_der(spki_der)?;
let digest = composite_digest(scheme)?;
let mut verifier = Verifier::new(&pkey, &SignInit { digest: Some(&digest), params: None })?;
verifier.update(message)?;
verifier.verify(sig_bytes)?
}

Pkey::<Public>::from_der loads the composite SubjectPublicKeyInfo DER via OpenSSL’s d2i_PUBKEY, which understands both the classical and ML-DSA components of the composite key. Verifier::verify dispatches to the OpenSSL provider, which applies “and” semantics — both the classical and ML-DSA components must verify.

composite_digest maps each code point to the correct hash algorithm name for native_ossl::digest::DigestAlg::fetch:

Code pointConstantHash
0x0901MLDSA44_ECDSA_P256_SHA256SHA2-256
0x0902MLDSA44_RSA2048_PKCS15_SHA256SHA2-256
0x0903MLDSA44_RSA2048_PSS_SHA256SHA2-256
0x0904MLDSA44_ED25519_SHA512SHA2-512
0x0905MLDSA65_ECDSA_P256_SHA512SHA2-512
0x0906MLDSA65_ECDSA_P384_SHA512SHA2-512
0x0907MLDSA65_RSA3072_PKCS15_SHA512SHA2-512
0x0908MLDSA65_RSA3072_PSS_SHA512SHA2-512
0x0909MLDSA65_ED25519_SHA512SHA2-512
0x090AMLDSA87_ECDSA_P384_SHA512SHA2-512
0x090CMLDSA87_ED448_SHAKE256SHAKE256

Channel binding (src/tls/channel_binding.rs)

Implements RFC 5929 §4 tls-server-end-point channel binding, used by the GSSAPI authentication layer to bind Kerberos tokens to the TLS session.

TlsServerEndpointBinding

#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct TlsServerEndpointBinding(pub Vec<u8>);
}

A typed request extension injected per-connection. Contains the raw binding bytes (the hash of the leaf certificate DER per RFC 5929 §4). Absent when the server certificate uses an algorithm with no defined hash (ML-DSA pure or composite, Ed448, or any unrecognised algorithm) — in those cases the field is not inserted and the GSSAPI layer passes None channel bindings.

tls_server_endpoint_binding

#![allow(unused)]
fn main() {
pub fn tls_server_endpoint_binding(cert_der: &[u8]) -> Option<Vec<u8>>
}

Parses the leaf certificate DER with synta::Decoder, extracts the signature algorithm OID, and selects the appropriate hash:

Signature algorithmHash used
ecdsa-with-SHA256 / sha256WithRSAEncryptionSHA-256
md5WithRSAEncryption / sha1WithRSAEncryptionSHA-256 (RFC 5929 §4 override)
id-RSASSA-PSS with SHA-1 or SHA-256 paramsSHA-256 (SHA-1 overridden)
id-RSASSA-PSS with SHA-384 paramsSHA-384
id-RSASSA-PSS with SHA-512 paramsSHA-512
ecdsa-with-SHA384 / sha384WithRSAEncryptionSHA-384
ecdsa-with-SHA512 / sha512WithRSAEncryption / id-Ed25519SHA-512
ML-DSA pure (FIPS 204), Composite ML-DSA, id-Ed448None — no canonical hash

Returns None for unsupported algorithms; the caller logs an informational message and disables GSSAPI channel bindings for that server certificate.

TLS connection acceptance loop (src/main.rs)

When config.tls.enabled is true, the server does not use axum::serve. Instead it runs a manual accept loop:

#![allow(unused)]
fn main() {
let mut server_cfg = akamu::tls::build_rustls_server_config(&config.tls)?;
server_cfg.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_cfg));

// Pre-compute RFC 5929 tls-server-end-point channel binding once at startup.
let tls_channel_binding: Option<Arc<Vec<u8>>> = { ... };

loop {
    tokio::select! {
        _ = &mut shutdown => { break; }
        result = listener.accept() => {
            let (stream, peer_addr) = result?;
            let acceptor = acceptor.clone();
            let router = router.clone();
            let tls_channel_binding = tls_channel_binding.clone();
            tokio::spawn(async move {
                let tls = match acceptor.accept(stream).await {
                    Ok(s) => s,
                    Err(e) => { tracing::warn!("TLS handshake failed: {e}"); return; }
                };
                let io = hyper_util::rt::TokioIo::new(tls);
                let svc = hyper::service::service_fn(move |mut req| {
                    // Inject peer address so axum::extract::ConnectInfo works.
                    req.extensions_mut().insert(axum::extract::ConnectInfo(peer_addr));
                    // Inject pre-computed channel binding if available.
                    if let Some(ref b) = tls_channel_binding {
                        req.extensions_mut().insert(TlsServerEndpointBinding(b.as_ref().clone()));
                    }
                    ...
                });
                hyper_util::server::conn::auto::Builder::new(TokioExecutor::new())
                    .serve_connection(io, svc)
                    .await
            });
        }
    }
}
}

Each accepted TCP connection is handed to tokio_rustls::TlsAcceptor::accept, which completes the TLS handshake (including client certificate verification if client_auth is configured). TLS handshake failures log a warning via tracing::warn! and the task returns without serving any HTTP.

For the plain HTTP path, axum::serve(listener, router.into_make_service_with_connect_info::<SocketAddr>()).await is used without modification.

ALPN protocols ["h2", "http/1.1"] are negotiated; hyper’s auto::Builder handles both HTTP/1.1 and HTTP/2.

ConnectInfo is available in the TLS path. The accept loop explicitly inserts axum::extract::ConnectInfo(peer_addr) into each request’s extensions before routing, so handlers can use axum’s ConnectInfo<SocketAddr> extractor normally regardless of whether TLS is enabled.

Channel binding injection. The tls-server-end-point binding bytes (see Channel binding above) are pre-computed once at startup from the leaf certificate DER and stored as Option<Arc<Vec<u8>>>. Each spawned connection task clones the Arc and injects a TlsServerEndpointBinding extension into the request so GSSAPI handlers can access it without re-reading the certificate.

build_rustls_server_config (src/tls/mod.rs)

The central assembly function for the ACME listener:

#![allow(unused)]
fn main() {
pub fn build_rustls_server_config(
    tls: &crate::config::TlsConfig,
) -> Result<rustls::ServerConfig, String>
}
  1. Calls loader::load_server_cert_chain and loader::load_server_private_key.
  2. Builds the provider: Arc::new(rustls_native_ossl::default_provider()).
  3. Filters tls.protocols to &rustls::version::TLS12 and/or &rustls::version::TLS13. Returns Err if the resulting list is empty.
  4. If tls.client_auth is present: builds SyntaClientCertVerifier and calls .with_client_cert_verifier(verifier).
  5. If absent: calls .with_no_client_auth().
  6. Calls .with_single_cert(certs, key) to install the server certificate and key.

build_admin_rustls_server_config (src/tls/mod.rs)

A parallel function for the dedicated admin listener:

#![allow(unused)]
fn main() {
pub fn build_admin_rustls_server_config(
    admin: &crate::config::AdminConfig,
) -> Result<rustls::ServerConfig, String>
}

Differences from build_rustls_server_config:

  • Always enables both TLS 1.2 and TLS 1.3 (not configurable via protocols).
  • Client auth is optional: if admin.ca_certs is empty, with_no_client_auth() is used; otherwise a SyntaClientCertVerifier is built with required = false so the same listener serves both mTLS (cert path) and GSSAPI (no cert presented) connections.
  • Uses a fixed ClientAuthConfig with profile = "rfc5280", max_chain_depth = 5, minimum_rsa_modulus = 2048, and allow_post_quantum = false.
  • Admin ALPN is ["http/1.1"] only (set by the caller after this function returns).

leaf_cert_der (src/tls/mod.rs)

#![allow(unused)]
fn main() {
pub fn leaf_cert_der(tls: &crate::config::TlsConfig) -> Result<Vec<u8>, String>
}

Returns the DER bytes of the first (leaf) certificate in the configured cert_file. Used at startup to pre-compute the tls-server-end-point channel binding without keeping a parsed certificate in memory.