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 exists | key_file exists | Action |
|---|---|---|
| No | No | Generate server key + CA-signed cert; write both files |
| Yes | Yes | Return immediately — caller has supplied its own cert |
| Yes | No (or No/Yes) | Return Err — partial state rejected |
When generating:
ca::init::generate_backend_key(&tls.bootstrap_key_type)generates a fresh server key.ca::issue::sign_server_cert(&tls.server_name, &server_key, ca)produces a CA-signed certificate DER.server_key.to_pem(None)serialises the private key PEM; written tokey_filefirst viacrate::util::write_key_file.synta_certificate::der_to_pem("CERTIFICATE", &cert_der)converts the certificate to PEM.- The PEM chain written to
cert_fileisleaf 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:
- Clones the DER bytes out of the short-lived
CertificateDerborrows into ownedVec<u8>allocations. - Parses the leaf and each intermediate via
synta::Decoder::decode::<Certificate>(). - Builds a
PolicyDefinitionviaPolicyDefinition::new_client(OpensslSignatureVerifier, validation_time), then applies the configured profile, depth, minimum RSA modulus, and algorithm sets. - 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_quantum | SPKI algorithms | Signature algorithms |
|---|---|---|
false | WEBPKI_PERMITTED_SPKI_ALGORITHMS | WEBPKI_PERMITTED_SIGNATURE_ALGORITHMS |
true | WEBPKI_PERMITTED_SPKI_ALGORITHMS_WITH_PQ | WEBPKI_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:
- Extracts the composite SubjectPublicKeyInfo DER from the raw certificate bytes using
synta_certificate::cert_byte_rangesto get the exact SPKI TLV byte range — avoiding a full certificate re-parse. - 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 point | Constant | Hash |
|---|---|---|
0x0901 | MLDSA44_ECDSA_P256_SHA256 | SHA2-256 |
0x0902 | MLDSA44_RSA2048_PKCS15_SHA256 | SHA2-256 |
0x0903 | MLDSA44_RSA2048_PSS_SHA256 | SHA2-256 |
0x0904 | MLDSA44_ED25519_SHA512 | SHA2-512 |
0x0905 | MLDSA65_ECDSA_P256_SHA512 | SHA2-512 |
0x0906 | MLDSA65_ECDSA_P384_SHA512 | SHA2-512 |
0x0907 | MLDSA65_RSA3072_PKCS15_SHA512 | SHA2-512 |
0x0908 | MLDSA65_RSA3072_PSS_SHA512 | SHA2-512 |
0x0909 | MLDSA65_ED25519_SHA512 | SHA2-512 |
0x090A | MLDSA87_ECDSA_P384_SHA512 | SHA2-512 |
0x090C | MLDSA87_ED448_SHAKE256 | SHAKE256 |
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 algorithm | Hash used |
|---|---|
| ecdsa-with-SHA256 / sha256WithRSAEncryption | SHA-256 |
| md5WithRSAEncryption / sha1WithRSAEncryption | SHA-256 (RFC 5929 §4 override) |
| id-RSASSA-PSS with SHA-1 or SHA-256 params | SHA-256 (SHA-1 overridden) |
| id-RSASSA-PSS with SHA-384 params | SHA-384 |
| id-RSASSA-PSS with SHA-512 params | SHA-512 |
| ecdsa-with-SHA384 / sha384WithRSAEncryption | SHA-384 |
| ecdsa-with-SHA512 / sha512WithRSAEncryption / id-Ed25519 | SHA-512 |
| ML-DSA pure (FIPS 204), Composite ML-DSA, id-Ed448 | None — 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>
}
- Calls
loader::load_server_cert_chainandloader::load_server_private_key. - Builds the provider:
Arc::new(rustls_native_ossl::default_provider()). - Filters
tls.protocolsto&rustls::version::TLS12and/or&rustls::version::TLS13. ReturnsErrif the resulting list is empty. - If
tls.client_authis present: buildsSyntaClientCertVerifierand calls.with_client_cert_verifier(verifier). - If absent: calls
.with_no_client_auth(). - 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_certsis empty,with_no_client_auth()is used; otherwise aSyntaClientCertVerifieris built withrequired = falseso the same listener serves both mTLS (cert path) and GSSAPI (no cert presented) connections. - Uses a fixed
ClientAuthConfigwithprofile = "rfc5280",max_chain_depth = 5,minimum_rsa_modulus = 2048, andallow_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.