generation 3: port server + client — the two big ports

port-server (src/server.rs, 614 lines):
- TlsServerConfig with ADR-004 accessors: for_noq(&self) (borrow +
  inner-clone + NoqWrap via #[from]), for_tcp_tls(&self) adopted
  (infallible TlsAcceptor), rustls_config(&self) adopted
- acme_handle renamed per ADR-006; the defensive Acme arm returns
  TlsError::AcmeConfig (unreachable! eliminated)
- error sites remapped to typed variants (Rustls/SelfSigned/AcmeConfig)
- ACME path verbatim: DirCache + directory + contacts + resolver +
  acme-tls/1 + spawned event loop (tracing lines ported), returns
  immediately, detached handle
- 14 tests incl. the nine-scheme exact-list pin, ACME
  spawn/return/ALPN assertion (blackhole URL — no network I/O),
  no-feature AcmeConfig, for_tcp_tls/rustls_config round-trips

port-client (src/credentials.rs + src/client.rs):
- ConnectionCredentials/RemoteIdentity wholesale with load-bearing
  Option-semantics docs (None = public-X.509 state, Some = pin)
- verifier selection matrix + client-auth presentation matrix
  test-pinned at unit level; enable_early_data=true pinned;
  root-store fallback asserted non-empty
- FingerprintPinVerifier: pin match/mismatch + raw-key signature
  routing (verify_tls13_signature_with_raw_key) asserted
- resolvers/verifier made pub for the re-export block

Cargo.toml (two required deltas):
- noq feature gains aws-lc-rs: lockfile resolves noq-proto 1.3.0
  where ServerConfig::with_crypto is #[cfg(any(aws-lc-rs, ring))];
  ADR-003's TOML block was written against the 1.2 API. Amendment
  note recorded in tasks/port-server.md for the review-impl sync
- acme = [dep:rustls-acme, dep:futures] — ADR-006 already gates
  the futures dep on acme; the scaffold omitted it

lib.rs: re-export block complete (all eight modules)

Verification: cargo test 68, --all-features 75, --features noq 72,
clippy -D warnings, fmt --check, doc --no-deps (0 warnings) — green
This commit is contained in:
2026-09-10 14:51:44 +00:00
parent 4bdc12e84f
commit 0cd565fc28
8 changed files with 1673 additions and 30 deletions
Generated
+1
View File
@@ -996,6 +996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c1e5b6fe668491eca022f745a0a9402585626c73a7b839b3424ace15d6a9c8f"
dependencies = [
"aes-gcm",
"aws-lc-rs",
"bytes",
"derive_more",
"enum-assoc",
+2 -2
View File
@@ -17,7 +17,7 @@ name = "alktls"
default = []
noq = ["dep:noq", "dep:noq-proto"]
tcp = ["dep:tokio-rustls"]
acme = ["dep:rustls-acme"]
acme = ["dep:rustls-acme", "dep:futures"]
[dependencies]
tokio = { version = "1", default-features = false, features = ["rt", "sync", "macros"] }
@@ -35,7 +35,7 @@ tracing = "0.1"
thiserror = "2"
futures = { version = "0.3", optional = true }
noq = { version = "1.2", optional = true, default-features = false, features = ["rustls"] }
noq = { version = "1.2", optional = true, default-features = false, features = ["rustls", "aws-lc-rs"] }
noq-proto = { version = "1.2", optional = true, default-features = false }
tokio-rustls = { version = "0.26", optional = true }
rustls-acme = { version = "0.12", optional = true, features = ["aws-lc-rs"] }
+796
View File
@@ -2,3 +2,799 @@
//! [`FingerprintPinVerifier`], [`RawKeyClientCertResolver`],
//! [`NoClientCertResolver`], [`select_server_verifier`],
//! [`build_client_auth`], [`load_platform_root_cert_store`].
use std::sync::Arc;
use crate::credentials::{ConnectionCredentials, RemoteIdentity};
use crate::fingerprint::{extract_ed25519_raw_key_from_spki, fingerprint_from_cert_der};
use crate::identity::TlsIdentity;
use crate::TlsError;
/// Client-side TLS configuration, transport-agnostic.
/// Wraps a `rustls::ClientConfig` built from `ConnectionCredentials`.
///
/// Built per dial from a [`ConnectionCredentials`] + ALPN and consumed by
/// its accessors — a `TlsClientConfig` is not reused across dials (ADR-004).
#[allow(dead_code)]
pub struct TlsClientConfig {
pub(crate) rustls_config: rustls::ClientConfig,
}
impl TlsClientConfig {
/// Build a client config from `ConnectionCredentials` and an ALPN.
/// Selects the server cert verifier by `remote_identity` presence
/// (alknet ADR-034 §3): `Some` → fingerprint pin, `None` → CA
/// verification.
pub fn new(credentials: &ConnectionCredentials, alpn: &[u8]) -> Result<Self, TlsError> {
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let client_auth = build_client_auth(&provider, &credentials.local_identity)?;
let verifier = select_server_verifier(&provider, &credentials.remote_identity)?;
let mut config = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(TlsError::from)?
.dangerous()
.with_custom_certificate_verifier(verifier)
.with_client_cert_resolver(client_auth);
config.alpn_protocols = vec![alpn.to_vec()];
config.enable_early_data = true;
Ok(Self {
rustls_config: config,
})
}
/// Convert to a `noq::ClientConfig` for QUIC transport. Consumes —
/// one dial = one config build (ADR-004).
#[cfg(feature = "noq")]
pub fn for_noq(self) -> Result<noq::ClientConfig, TlsError> {
let quic_config = noq::crypto::rustls::QuicClientConfig::try_from(self.rustls_config)?;
Ok(noq::ClientConfig::new(Arc::new(quic_config)))
}
/// Consume the config and return the inner `rustls::ClientConfig`.
/// Used by the TCP+TLS dial to build a `TlsConnector`. Consumes —
/// zero-cost, no clone behind the scenes (ADR-004).
pub fn into_rustls_config(self) -> rustls::ClientConfig {
self.rustls_config
}
}
/// Build the client-auth cert resolver that presents the local node's TLS
/// identity. For `TlsIdentity::RawKey` the Ed25519 key is presented as an RFC
/// 7250 raw public key client cert (`only_raw_public_keys() == true`) — the
/// client-side equivalent of the server's `RawKeyCertResolver`. For X.509 the
/// cert chain + key are loaded from disk. `None` (no `local_identity` configured)
/// resolves to no client cert (the server gets nothing to fingerprint).
pub fn build_client_auth(
provider: &Arc<rustls::crypto::CryptoProvider>,
tls_identity: &Option<TlsIdentity>,
) -> Result<Arc<dyn rustls::client::ResolvesClientCert>, TlsError> {
match tls_identity {
Some(TlsIdentity::RawKey(secret_key)) => {
let signing_key = Arc::new(crate::signing::Ed25519SigningKey::new(secret_key.clone()));
let spki = signing_key.spki_public_key();
let cert = rustls::pki_types::CertificateDer::from(spki.to_vec());
let certified_key = Arc::new(rustls::sign::CertifiedKey::new(vec![cert], signing_key));
Ok(Arc::new(RawKeyClientCertResolver::new(certified_key)))
}
Some(TlsIdentity::X509 { cert, key }) => {
let cert_chain = crate::pem::load_cert_chain(cert)?;
let key_der = crate::pem::load_private_key(key)?;
let certified_key =
rustls::sign::CertifiedKey::from_der(cert_chain, key_der, provider)?;
Ok(Arc::new(RawKeyClientCertResolver::new(Arc::new(
certified_key,
))))
}
Some(TlsIdentity::SelfSigned) | None => Ok(Arc::new(NoClientCertResolver)),
Some(TlsIdentity::Acme { .. }) => Err(TlsError::AcmeConfig(
"ACME TLS identity is server-only; cannot be used for client auth".to_string(),
)),
}
}
/// Select the server cert verifier by `remote_identity` presence (alknet
/// ADR-034 §3).
///
/// - `Some(fingerprint)` → known peer → `FingerprintPinVerifier` (fingerprint
/// match). The fingerprint IS the trust anchor.
/// - `None` → no prior knowledge of the remote → `WebPkiServerVerifier` (CA
/// verification) for X.509 remotes. For Ed25519 raw-key remotes the
/// `WebPkiServerVerifier` fails closed at handshake time (raw-key remotes
/// have no CA to fall back to — alknet ADR-034 §2 assumption 1). `None` is
/// the public-X.509-endpoint state, not "skip verification."
pub fn select_server_verifier(
provider: &Arc<rustls::crypto::CryptoProvider>,
remote_identity: &Option<RemoteIdentity>,
) -> Result<Arc<dyn rustls::client::danger::ServerCertVerifier>, TlsError> {
match remote_identity {
Some(ri) => Ok(Arc::new(FingerprintPinVerifier::new(
ri.fingerprint.clone(),
provider.signature_verification_algorithms,
))),
None => {
let roots = load_platform_root_cert_store()?;
let verifier = rustls::client::WebPkiServerVerifier::builder_with_provider(
Arc::new(roots),
Arc::clone(provider),
)
.build()?;
Ok(verifier)
}
}
}
/// Load the platform's trusted root certificates into a `RootCertStore` for
/// `WebPkiServerVerifier` (the `None` + X.509 CA-verification path). Falls back
/// to the built-in `webpki-roots` if the platform store is empty (e.g. in a
/// container with no system CA bundle) — alknet ADR-088 §5.
pub fn load_platform_root_cert_store() -> Result<rustls::RootCertStore, TlsError> {
let mut roots = rustls::RootCertStore::empty();
let result = rustls_native_certs::load_native_certs();
for err in &result.errors {
tracing::warn!(error = ?err, "failed to load a native root cert");
}
for cert in &result.certs {
roots.add(cert.clone())?;
}
if roots.is_empty() {
tracing::info!("platform root cert store is empty, falling back to webpki-roots");
for anchor in webpki_roots::TLS_SERVER_ROOTS.iter() {
roots.roots.push(anchor.to_owned());
}
}
Ok(roots)
}
/// Client cert resolver that presents a single RFC 7250 raw public key (or
/// X.509 cert chain). For raw keys `only_raw_public_keys()` returns `true` so
/// rustls negotiates the RFC 7250 ClientCertificateType extension.
pub struct RawKeyClientCertResolver {
key: Arc<rustls::sign::CertifiedKey>,
raw_public_keys: bool,
}
impl RawKeyClientCertResolver {
pub fn new(key: Arc<rustls::sign::CertifiedKey>) -> Self {
let raw_public_keys = key.cert.len() == 1 && is_ed25519_spki(&key.cert[0]);
Self {
key,
raw_public_keys,
}
}
}
fn is_ed25519_spki(cert_der: &rustls::pki_types::CertificateDer<'_>) -> bool {
extract_ed25519_raw_key_from_spki(cert_der.as_ref()).is_some()
}
impl std::fmt::Debug for RawKeyClientCertResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RawKeyClientCertResolver")
.field("raw_public_keys", &self.raw_public_keys)
.finish()
}
}
impl rustls::client::ResolvesClientCert for RawKeyClientCertResolver {
fn resolve(
&self,
_root_hint_subjects: &[&[u8]],
_sigschemes: &[rustls::SignatureScheme],
) -> Option<Arc<rustls::sign::CertifiedKey>> {
Some(Arc::clone(&self.key))
}
fn only_raw_public_keys(&self) -> bool {
self.raw_public_keys
}
fn has_certs(&self) -> bool {
true
}
}
/// Client cert resolver that presents no client cert (the `local_identity: None`
/// or `SelfSigned` path). The server gets nothing to fingerprint — the
/// fingerprint → peer-id resolution path is not activated for this connection.
pub struct NoClientCertResolver;
impl std::fmt::Debug for NoClientCertResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NoClientCertResolver").finish()
}
}
impl rustls::client::ResolvesClientCert for NoClientCertResolver {
fn resolve(
&self,
_root_hint_subjects: &[&[u8]],
_sigschemes: &[rustls::SignatureScheme],
) -> Option<Arc<rustls::sign::CertifiedKey>> {
None
}
fn has_certs(&self) -> bool {
false
}
}
/// `ServerCertVerifier` that pins a specific fingerprint (alknet ADR-034 §3, the
/// known-peer path). For `ed25519:<hex>` remotes the raw Ed25519 pub key is
/// extracted from the presented cert and matched against the pinned
/// fingerprint; for `SHA256:<hex>` remotes the cert DER is hashed and matched
/// against the pinned fingerprint. No match → verification failure (the
/// connection is rejected). The fingerprint IS the trust anchor — there is no
/// CA verification and no name verification, only the fingerprint pin.
///
/// Handshake signatures are still verified (using the aws-lc-rs default
/// signature verification algorithms) so that a stolen-but-stale fingerprint
/// can't be replayed with a forged signature: the presenter must prove
/// possession of the private key corresponding to the pinned public key.
pub struct FingerprintPinVerifier {
fingerprint: String,
supported: rustls::crypto::WebPkiSupportedAlgorithms,
}
impl FingerprintPinVerifier {
pub fn new(fingerprint: String, supported: rustls::crypto::WebPkiSupportedAlgorithms) -> Self {
Self {
fingerprint,
supported,
}
}
}
impl std::fmt::Debug for FingerprintPinVerifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FingerprintPinVerifier")
.field("fingerprint", &self.fingerprint)
.finish()
}
}
impl rustls::client::danger::ServerCertVerifier for FingerprintPinVerifier {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
let presented =
fingerprint_from_cert_der(end_entity.as_ref()).ok_or(rustls::Error::General(
"fingerprint pin: failed to compute fingerprint from presented cert".to_string(),
))?;
if presented == self.fingerprint {
Ok(rustls::client::danger::ServerCertVerified::assertion())
} else {
Err(rustls::Error::General(format!(
"fingerprint pin mismatch: expected {} got {}",
self.fingerprint, presented
)))
}
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
if extract_ed25519_raw_key_from_spki(cert.as_ref()).is_some() {
let spki = rustls::pki_types::SubjectPublicKeyInfoDer::from(cert.as_ref().to_vec());
rustls::crypto::verify_tls13_signature_with_raw_key(
message,
&spki,
dss,
&self.supported,
)
} else {
rustls::crypto::verify_tls12_signature(message, cert, dss, &self.supported)
}
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
if extract_ed25519_raw_key_from_spki(cert.as_ref()).is_some() {
let spki = rustls::pki_types::SubjectPublicKeyInfoDer::from(cert.as_ref().to_vec());
rustls::crypto::verify_tls13_signature_with_raw_key(
message,
&spki,
dss,
&self.supported,
)
} else {
rustls::crypto::verify_tls13_signature(message, cert, dss, &self.supported)
}
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
self.supported.supported_schemes()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::Ed25519SecretKey;
fn build_ed25519_spki_der(raw_key: &[u8; 32]) -> Vec<u8> {
let spki = rustls::sign::public_key_to_spki(&rustls::pki_types::alg_id::ED25519, raw_key);
spki.to_vec()
}
fn build_x509_cert_der() -> rustls::pki_types::CertificateDer<'static> {
let key_pair = rcgen::KeyPair::generate().expect("key gen");
let params = rcgen::CertificateParams::default();
let cert = params.self_signed(&key_pair).expect("self-signed cert");
cert.der().clone()
}
fn aws_lc_rs_provider() -> Arc<rustls::crypto::CryptoProvider> {
Arc::new(rustls::crypto::aws_lc_rs::default_provider())
}
fn verify_pin(
verifier: &FingerprintPinVerifier,
cert_der: rustls::pki_types::CertificateDer<'_>,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
use rustls::client::danger::ServerCertVerifier;
let server_name: rustls::pki_types::ServerName<'static> =
"alktls".try_into().expect("server name");
verifier.verify_server_cert(
&cert_der,
&[],
&server_name,
&[],
rustls::pki_types::UnixTime::now(),
)
}
fn dss_with_ed25519_scheme(signature: Vec<u8>) -> rustls::DigitallySignedStruct {
use rustls::internal::msgs::codec::{Codec, Reader};
let mut encoded = Vec::new();
rustls::SignatureScheme::ED25519.encode(&mut encoded);
(signature.len() as u16).encode(&mut encoded);
encoded.extend_from_slice(&signature);
rustls::DigitallySignedStruct::read(&mut Reader::init(&encoded))
.expect("DigitallySignedStruct decodes from its wire encoding")
}
#[test]
fn fingerprint_pin_verifier_matches_correct_ed25519_fingerprint() {
let sk = Ed25519SecretKey::generate();
let raw_key = sk.public().to_bytes();
let spki_der = build_ed25519_spki_der(&raw_key);
let fingerprint = fingerprint_from_cert_der(&spki_der).expect("fingerprint");
let verifier = FingerprintPinVerifier::new(
fingerprint,
aws_lc_rs_provider().signature_verification_algorithms,
);
let cert = rustls::pki_types::CertificateDer::from(spki_der);
let result = verify_pin(&verifier, cert);
assert!(
result.is_ok(),
"FingerprintPinVerifier must accept a cert whose fingerprint matches the pin"
);
}
#[test]
fn fingerprint_pin_verifier_rejects_wrong_ed25519_fingerprint() {
let sk = Ed25519SecretKey::generate();
let raw_key = sk.public().to_bytes();
let spki_der = build_ed25519_spki_der(&raw_key);
let other_sk = Ed25519SecretKey::generate();
let other_fp = format!("ed25519:{}", hex::encode(other_sk.public().to_bytes()));
let verifier = FingerprintPinVerifier::new(
other_fp,
aws_lc_rs_provider().signature_verification_algorithms,
);
let cert = rustls::pki_types::CertificateDer::from(spki_der);
let result = verify_pin(&verifier, cert);
assert!(
result.is_err(),
"FingerprintPinVerifier must reject a cert whose fingerprint does not match the pin"
);
}
#[test]
fn fingerprint_pin_verifier_matches_correct_sha256_fingerprint() {
let cert_der = build_x509_cert_der();
let fingerprint = fingerprint_from_cert_der(cert_der.as_ref()).expect("fingerprint");
let verifier = FingerprintPinVerifier::new(
fingerprint,
aws_lc_rs_provider().signature_verification_algorithms,
);
let result = verify_pin(&verifier, cert_der);
assert!(
result.is_ok(),
"FingerprintPinVerifier must accept an X.509 cert whose SHA256 fingerprint matches"
);
}
#[test]
fn fingerprint_pin_verifier_rejects_wrong_sha256_fingerprint() {
let cert_der = build_x509_cert_der();
let verifier = FingerprintPinVerifier::new(
"SHA256:0000000000000000000000000000000000000000000000000000000000000000".to_string(),
aws_lc_rs_provider().signature_verification_algorithms,
);
let result = verify_pin(&verifier, cert_der);
assert!(
result.is_err(),
"FingerprintPinVerifier must reject an X.509 cert whose SHA256 does not match"
);
}
#[test]
fn fingerprint_pin_verifier_routes_ed25519_spki_tls13_signature_through_raw_key_path() {
use rustls::client::danger::ServerCertVerifier;
let sk = Ed25519SecretKey::generate();
let raw_key = sk.public().to_bytes();
let spki_der = build_ed25519_spki_der(&raw_key);
let supported = aws_lc_rs_provider().signature_verification_algorithms;
let verifier =
FingerprintPinVerifier::new(format!("ed25519:{}", hex::encode(raw_key)), supported);
let message = b"alktls tls13 raw-key signature routing";
let signature = sk.sign(message).to_bytes().to_vec();
let dss = dss_with_ed25519_scheme(signature);
let cert = rustls::pki_types::CertificateDer::from(spki_der.clone());
let result = verifier.verify_tls13_signature(message, &cert, &dss);
assert!(
result.is_ok(),
"TLS 1.3 signature for an Ed25519 SPKI cert must route through the raw-key path and verify, got: {result:?}"
);
let forged = dss_with_ed25519_scheme(vec![0u8; 64]);
let tampered = verifier.verify_tls13_signature(b"tampered", &cert, &forged);
assert!(
tampered.is_err(),
"a signature that does not verify must fail the handshake signature check"
);
}
#[test]
fn fingerprint_pin_verifier_verify_tls12_signature_accepts_ed25519_raw_key() {
use rustls::client::danger::ServerCertVerifier;
let sk = Ed25519SecretKey::generate();
let raw_key = sk.public().to_bytes();
let spki_der = build_ed25519_spki_der(&raw_key);
let supported = aws_lc_rs_provider().signature_verification_algorithms;
let verifier =
FingerprintPinVerifier::new(format!("ed25519:{}", hex::encode(raw_key)), supported);
let message = b"alktls tls12 raw-key signature routing";
let signature = sk.sign(message).to_bytes().to_vec();
let dss = dss_with_ed25519_scheme(signature);
let cert = rustls::pki_types::CertificateDer::from(spki_der.clone());
let result = verifier.verify_tls12_signature(message, &cert, &dss);
assert!(
result.is_ok(),
"TLS 1.2 signature for an Ed25519 SPKI cert must route through the raw-key path and verify, got: {result:?}"
);
}
#[test]
fn select_server_verifier_returns_ca_verifier_for_none() {
let provider = aws_lc_rs_provider();
let remote_identity: Option<RemoteIdentity> = None;
let verifier = select_server_verifier(&provider, &remote_identity);
assert!(
verifier.is_ok(),
"select_server_verifier must succeed for None (CA path)"
);
let debug = format!("{:?}", verifier.unwrap());
assert!(
debug.contains("WebPkiServerVerifier"),
"None must select WebPkiServerVerifier (CA verification), got: {debug}"
);
}
#[test]
fn select_server_verifier_returns_fingerprint_pin_for_some() {
let provider = aws_lc_rs_provider();
let remote_identity = Some(RemoteIdentity {
fingerprint: "ed25519:abc".to_string(),
});
let verifier = select_server_verifier(&provider, &remote_identity);
assert!(
verifier.is_ok(),
"select_server_verifier must succeed for Some (fingerprint pin path)"
);
let debug = format!("{:?}", verifier.unwrap());
assert!(
debug.contains("FingerprintPinVerifier"),
"Some must select FingerprintPinVerifier, got: {debug}"
);
}
#[test]
fn build_client_auth_presents_ed25519_raw_key_without_error() {
let provider = aws_lc_rs_provider();
let sk = Ed25519SecretKey::generate();
let tls_identity = Some(TlsIdentity::RawKey(sk));
let resolver = build_client_auth(&provider, &tls_identity);
assert!(
resolver.is_ok(),
"build_client_auth must build a resolver for a RawKey identity"
);
let resolver = resolver.unwrap();
assert!(
resolver.only_raw_public_keys(),
"RawKey client auth resolver must present raw public keys (RFC 7250)"
);
assert!(
resolver.has_certs(),
"RawKey client auth resolver must report it has a cert to present"
);
}
#[test]
fn build_client_auth_x509_loads_chain_and_presents_certs() {
let dir = tempfile::tempdir().expect("tempdir");
let key_pair = rcgen::KeyPair::generate().expect("key gen");
let cert = rcgen::CertificateParams::default()
.self_signed(&key_pair)
.expect("self-signed cert");
let cert_path = dir.path().join("cert.pem");
let key_path = dir.path().join("key.pem");
std::fs::write(&cert_path, cert.pem()).expect("write cert");
std::fs::write(&key_path, key_pair.serialize_pem()).expect("write key");
let provider = aws_lc_rs_provider();
let tls_identity = Some(TlsIdentity::X509 {
cert: cert_path,
key: key_path,
});
let resolver = build_client_auth(&provider, &tls_identity);
assert!(
resolver.is_ok(),
"build_client_auth must build a resolver for an X509 identity"
);
let resolver = resolver.unwrap();
assert!(
resolver.has_certs(),
"X509 client auth resolver must report it has a cert to present"
);
assert!(
!resolver.only_raw_public_keys(),
"X509 client auth resolver must present X.509 certs, not raw public keys"
);
}
#[test]
fn build_client_auth_self_signed_resolves_to_no_client_cert() {
let provider = aws_lc_rs_provider();
let tls_identity = Some(TlsIdentity::SelfSigned);
let resolver = build_client_auth(&provider, &tls_identity)
.expect("build_client_auth must succeed for SelfSigned");
assert!(
!resolver.has_certs(),
"SelfSigned must present nothing (NoClientCertResolver, OQ-TLS-02)"
);
}
#[test]
fn build_client_auth_none_resolves_to_no_client_cert() {
let provider = aws_lc_rs_provider();
let tls_identity: Option<TlsIdentity> = None;
let resolver = build_client_auth(&provider, &tls_identity)
.expect("build_client_auth must succeed for None");
assert!(
!resolver.has_certs(),
"NoClientCertResolver must report no certs (no client cert presented)"
);
}
#[test]
fn build_client_auth_acme_is_a_config_error() {
let provider = aws_lc_rs_provider();
let tls_identity = Some(TlsIdentity::Acme {
domains: vec!["example.com".to_string()],
cache_dir: std::path::PathBuf::from("/tmp/alktls-acme-test"),
directory: crate::identity::AcmeDirectory::Staging,
contact: vec!["mailto:ops@example.com".to_string()],
});
let err = build_client_auth(&provider, &tls_identity)
.expect_err("Acme client auth must be a config error");
assert!(
matches!(err, TlsError::AcmeConfig(_)),
"Acme local identity must map to TlsError::AcmeConfig, got: {err:?}"
);
}
#[test]
fn tls_client_config_pins_enable_early_data_true() {
let sk = Ed25519SecretKey::generate();
let credentials = ConnectionCredentials::new()
.with_local_identity(TlsIdentity::RawKey(sk))
.with_remote_identity(RemoteIdentity {
fingerprint: "ed25519:deadbeef".to_string(),
});
let config = TlsClientConfig::new(&credentials, b"alk/call")
.expect("TlsClientConfig::new must build");
let rustls_config = config.into_rustls_config();
assert!(
rustls_config.enable_early_data,
"every client config must enable early data (the client half of the 0-RTT invariant, ADR-001)"
);
assert_eq!(
rustls_config.alpn_protocols,
vec![b"alk/call".to_vec()],
"the config must carry exactly the requested ALPN"
);
}
#[test]
fn tls_client_config_carries_aws_lc_rs_provider() {
let sk = Ed25519SecretKey::generate();
let credentials = ConnectionCredentials::new().with_local_identity(TlsIdentity::RawKey(sk));
let config = TlsClientConfig::new(&credentials, b"alk/call")
.expect("TlsClientConfig::new must build");
let rustls_config = config.into_rustls_config();
let suites: Vec<rustls::CipherSuite> = rustls_config
.crypto_provider()
.cipher_suites
.iter()
.map(|s| s.suite())
.collect();
assert_eq!(
suites.len(),
9,
"the aws-lc-rs default provider must be installed on every config (alknet ADR-084)"
);
assert!(
suites.contains(&rustls::CipherSuite::TLS13_CHACHA20_POLY1305_SHA256),
"the provider must be the aws-lc-rs default set (TLS 1.3 + TLS 1.2 suites), got: {suites:?}"
);
}
#[test]
fn load_platform_root_cert_store_is_never_empty() {
let roots = load_platform_root_cert_store().expect("root store must load");
assert!(
!roots.is_empty(),
"the webpki-roots fallback guarantees a non-empty root store (alknet ADR-088 §5)"
);
assert_eq!(
roots.len(),
roots.roots.len(),
"the store's anchor count must match its backing vec"
);
}
#[test]
fn verifier_selection_matrix_over_client_config() {
let sk = Ed25519SecretKey::generate();
let pinned = ConnectionCredentials::new()
.with_local_identity(TlsIdentity::RawKey(sk.clone()))
.with_remote_identity(RemoteIdentity {
fingerprint: "ed25519:deadbeef".to_string(),
});
let config =
TlsClientConfig::new(&pinned, b"alk/call").expect("pinned path must construct");
let debug = format!("{:?}", config.rustls_config);
assert!(
debug.contains("FingerprintPinVerifier"),
"Some(remote_identity) must install FingerprintPinVerifier, got: {debug}"
);
let ca_path = ConnectionCredentials::new().with_local_identity(TlsIdentity::RawKey(sk));
let config = TlsClientConfig::new(&ca_path, b"alk/call").expect("CA path must construct");
let debug = format!("{:?}", config.rustls_config);
assert!(
debug.contains("WebPkiServerVerifier"),
"None(remote_identity) must install WebPkiServerVerifier, got: {debug}"
);
}
#[test]
fn client_auth_presentation_matrix_over_client_config() {
let sk = Ed25519SecretKey::generate();
let dir = tempfile::tempdir().expect("tempdir");
let key_pair = rcgen::KeyPair::generate().expect("key gen");
let cert = rcgen::CertificateParams::default()
.self_signed(&key_pair)
.expect("self-signed cert");
let cert_path = dir.path().join("cert.pem");
let key_path = dir.path().join("key.pem");
std::fs::write(&cert_path, cert.pem()).expect("write cert");
std::fs::write(&key_path, key_pair.serialize_pem()).expect("write key");
let matrix: Vec<(ConnectionCredentials, &str)> = vec![
(
ConnectionCredentials::new().with_local_identity(TlsIdentity::RawKey(sk.clone())),
"raw-key",
),
(
ConnectionCredentials::new().with_local_identity(TlsIdentity::X509 {
cert: cert_path,
key: key_path,
}),
"x509",
),
(
ConnectionCredentials::new().with_local_identity(TlsIdentity::SelfSigned),
"self-signed",
),
(ConnectionCredentials::new(), "none"),
];
for (credentials, label) in matrix {
let config = TlsClientConfig::new(&credentials, b"alk/call")
.unwrap_or_else(|e| panic!("{label} presentation must construct: {e}"));
let resolver = &config.rustls_config.client_auth_cert_resolver;
match label {
"raw-key" => {
assert!(
resolver.has_certs() && resolver.only_raw_public_keys(),
"RawKey must present a raw public key (RFC 7250)"
);
}
"x509" => {
assert!(
resolver.has_certs() && !resolver.only_raw_public_keys(),
"X509 must present the loaded cert chain"
);
}
_ => {
assert!(
!resolver.has_certs(),
"{label} must present nothing (NoClientCertResolver)"
);
}
}
}
let acme = ConnectionCredentials::new().with_local_identity(TlsIdentity::Acme {
domains: vec!["example.com".to_string()],
cache_dir: dir.path().to_path_buf(),
directory: crate::identity::AcmeDirectory::Production,
contact: vec![],
});
let err = match TlsClientConfig::new(&acme, b"alk/call") {
Ok(_) => panic!("Acme client auth must fail config construction"),
Err(e) => e,
};
assert!(
matches!(err, TlsError::AcmeConfig(_)),
"Acme local identity must map to TlsError::AcmeConfig, got: {err:?}"
);
}
#[cfg(feature = "noq")]
#[test]
fn build_noq_client_config_with_raw_key_identity_builds_without_error() {
let sk = Ed25519SecretKey::generate();
let credentials = ConnectionCredentials::new()
.with_local_identity(TlsIdentity::RawKey(sk))
.with_remote_identity(RemoteIdentity {
fingerprint: "ed25519:deadbeef".to_string(),
});
let config = TlsClientConfig::new(&credentials, b"alk/call")
.expect("TlsClientConfig::new must build");
let noq_config = config.for_noq().expect("for_noq must convert");
let _ = noq_config;
}
#[cfg(feature = "noq")]
#[test]
fn build_noq_client_config_with_no_remote_identity_builds_without_error() {
let sk = Ed25519SecretKey::generate();
let credentials = ConnectionCredentials::new().with_local_identity(TlsIdentity::RawKey(sk));
let config = TlsClientConfig::new(&credentials, b"alk/call")
.expect("TlsClientConfig::new must build for CA-verification path");
let noq_config = config.for_noq().expect("for_noq must convert");
let _ = noq_config;
}
}
+107 -1
View File
@@ -1,3 +1,109 @@
//! Transport-level credential bundle for outbound connections:
//! [`ConnectionCredentials`], [`RemoteIdentity`] (ADR-005, moved from
//! alknet-core `credentials.rs`).
//! alknet-core `credentials.rs`; alknet ADR-091's semantics).
//!
//! [`ConnectionCredentials`] carries the two dimensions the client config
//! consumes: the local node's identity and the expected remote identity.
//! It is transport-agnostic — consumed by this crate's
//! [`TlsClientConfig`](crate::TlsClientConfig) (TLS setup) and by the dial
//! seam in the consumers.
use crate::identity::TlsIdentity;
/// Expected identity of the remote node (alknet ADR-017 §7, extended by
/// alknet ADR-034 §2; ADR-005 moved the type here).
///
/// Carries a fingerprint string the assembly layer derives when the local
/// node knows the remote (the known-peer case → fingerprint pin).
///
/// `remote_identity: None` is the **public X.509 endpoint** case: the local
/// node has no prior knowledge of the remote, so there is no fingerprint to
/// pin. Combined with an X.509 remote, `None` selects CA verification
/// ([`WebPkiServerVerifier`] per the verifier-selection rule in alknet
/// ADR-034 §3). Combined with an Ed25519 raw-key remote, `None` fails closed
/// at handshake (raw-key remotes are always known peers — no CA to fall
/// back to).
///
/// The `Option` is therefore load-bearing, not cosmetic:
/// `Some(fingerprint)` means "pin this" (known peer), `None` means "trust
/// the CA or fail" (unknown remote). An implementer must not default
/// `remote_identity` to a placeholder value to "satisfy" the field — `None`
/// is a real state that drives verifier selection.
///
/// [`WebPkiServerVerifier`]: rustls::client::WebPkiServerVerifier
#[derive(Debug, Clone)]
pub struct RemoteIdentity {
/// The pinned fingerprint: `ed25519:<hex>` for raw-key remotes,
/// `SHA256:<hex>` for X.509 cert remotes (alknet ADR-030 §6).
pub fingerprint: String,
}
/// Credentials for an outbound connection (alknet ADR-091's semantics,
/// ADR-005 moved the type here). All dimensions come from the assembly
/// layer's configuration — never from environment variables.
///
/// The two `Option`s are the two credential dimensions the client config
/// consumes (see `docs/architecture/client.md`): the client-auth
/// presentation (`local_identity`) and the verifier selection
/// (`remote_identity`). Both are load-bearing, not cosmetic — `Some` means
/// "pin this", `None` means "trust the CA or fail", never a placeholder
/// default.
#[derive(Debug, Clone, Default)]
pub struct ConnectionCredentials {
/// The local node's identity (RFC 7250 raw key or X.509), derived from
/// the vault at startup.
pub local_identity: Option<TlsIdentity>,
/// Expected fingerprint/cert of the remote node. `Some` → fingerprint
/// pin (known peer); `None` → CA verification for X.509 remotes,
/// fail-closed for Ed25519 raw-key remotes (alknet ADR-034 §2/§3).
/// `None` is the public-X.509-endpoint state, not a missing field —
/// must not be defaulted to a placeholder.
pub remote_identity: Option<RemoteIdentity>,
}
impl ConnectionCredentials {
/// Credentials with both dimensions unset — the public-X.509-endpoint
/// baseline.
pub fn new() -> Self {
Self::default()
}
/// Set the local identity (the client-auth presentation).
pub fn with_local_identity(mut self, local_identity: TlsIdentity) -> Self {
self.local_identity = Some(local_identity);
self
}
/// Set the remote identity (the fingerprint pin).
pub fn with_remote_identity(mut self, remote: RemoteIdentity) -> Self {
self.remote_identity = Some(remote);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connection_credentials_builder_methods() {
let creds = ConnectionCredentials::new().with_remote_identity(RemoteIdentity {
fingerprint: "SHA256:abc".to_string(),
});
assert_eq!(
creds.remote_identity.as_ref().unwrap().fingerprint,
"SHA256:abc"
);
assert!(creds.local_identity.is_none());
}
#[test]
fn connection_credentials_none_is_load_bearing_not_defaulted() {
let creds = ConnectionCredentials::new();
assert!(
creds.remote_identity.is_none(),
"ConnectionCredentials::new() must keep remote_identity as None (the load-bearing \
public-X.509-endpoint state), not default it to a placeholder"
);
}
}
+10 -3
View File
@@ -37,9 +37,16 @@ pub use signing::Ed25519SigningKey;
pub use identity::{AcmeDirectory, Ed25519SecretKey, TlsIdentity};
// The remaining re-export lines land per module as each port task
// completes: credentials, fingerprint, server, client, pem, signing
// (port-client completes the block).
pub use client::{
build_client_auth, load_platform_root_cert_store, select_server_verifier,
FingerprintPinVerifier, NoClientCertResolver, RawKeyClientCertResolver, TlsClientConfig,
};
pub use credentials::{ConnectionCredentials, RemoteIdentity};
pub use server::{
build_rustls_server_config, generate_self_signed_cert, AcceptAnyCertVerifier,
RawKeyCertResolver, SelfSignedCert, TlsServerConfig,
};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
+612 -1
View File
@@ -1,3 +1,614 @@
//! Server-side TLS configuration: [`TlsServerConfig`],
//! [`RawKeyCertResolver`], [`AcceptAnyCertVerifier`], [`SelfSignedCert`],
//! [`build_rustls_server_config`], [`RawKeyCertResolver`],
//! [`AcceptAnyCertVerifier`], [`SelfSignedCert`] /
//! [`generate_self_signed_cert`], and the ACME path (feature `acme`).
use std::sync::Arc;
#[cfg(feature = "acme")]
use tracing::{debug, error, warn};
#[cfg(feature = "acme")]
use crate::identity::AcmeDirectory;
use crate::identity::{Ed25519SecretKey, TlsIdentity};
use crate::signing::Ed25519SigningKey;
use crate::TlsError;
/// Server-side TLS configuration, transport-agnostic. Built once from a
/// [`TlsIdentity`] + ALPN list, shared across transports via
/// `Arc` (not `Clone` — it holds the ACME task's `JoinHandle`).
#[allow(dead_code)]
pub struct TlsServerConfig {
pub(crate) rustls_config: rustls::ServerConfig,
#[cfg(feature = "acme")]
pub(crate) acme_handle: Option<tokio::task::JoinHandle<()>>,
}
impl TlsServerConfig {
/// Build a server config from a [`TlsIdentity`] and ALPN list.
/// ACME identities spawn a background cert-renewal task.
pub async fn new(tls_identity: &TlsIdentity, alpns: &[Vec<u8>]) -> Result<Self, TlsError> {
match tls_identity {
TlsIdentity::Acme {
domains,
cache_dir,
directory,
contact,
} => {
#[cfg(feature = "acme")]
{
Self::new_acme(domains, cache_dir, directory, contact, alpns).await
}
#[cfg(not(feature = "acme"))]
{
let _ = (domains, cache_dir, directory, contact, alpns);
Err(TlsError::AcmeConfig(
"ACME feature not enabled but TlsIdentity::Acme configured".to_string(),
))
}
}
_ => {
let server_config = build_rustls_server_config(tls_identity, alpns)?;
Ok(Self {
rustls_config: server_config,
#[cfg(feature = "acme")]
acme_handle: None,
})
}
}
}
#[cfg(feature = "acme")]
async fn new_acme(
domains: &[String],
cache_dir: &std::path::Path,
directory: &AcmeDirectory,
contact: &[String],
alpns: &[Vec<u8>],
) -> Result<Self, TlsError> {
use rustls_acme::caches::DirCache;
use rustls_acme::{AcmeConfig, EventError, EventOk};
let acme_config = AcmeConfig::new(domains.to_vec())
.cache(DirCache::new(cache_dir.to_path_buf()))
.directory(directory.url())
.contact(contact.iter().map(|c| c.as_str()));
let state = acme_config.state();
let resolver = state.resolver();
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let mut config = rustls::ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()?
.with_client_cert_verifier(Arc::new(AcceptAnyCertVerifier))
.with_cert_resolver(resolver);
config.max_early_data_size = u32::MAX;
let mut alpn = alpns.to_vec();
alpn.push(b"acme-tls/1".to_vec());
config.alpn_protocols = alpn;
let domains_owned: Vec<String> = domains.to_vec();
let handle = tokio::spawn(async move {
use futures::StreamExt;
let mut state = state;
while let Some(event) = state.next().await {
match event {
Ok(EventOk::DeployedCachedCert) => {
debug!(domains = ?domains_owned, "ACME: deployed cached certificate");
}
Ok(EventOk::DeployedNewCert) => {
debug!(domains = ?domains_owned, "ACME: deployed new certificate");
}
Ok(EventOk::CertCacheStore) => {
debug!(domains = ?domains_owned, "ACME: certificate stored to cache");
}
Ok(EventOk::AccountCacheStore) => {
debug!(domains = ?domains_owned, "ACME: account stored to cache");
}
Err(EventError::CertCacheLoad(e)) => {
error!(domains = ?domains_owned, error = ?e, "ACME: certificate cache load failed");
}
Err(EventError::AccountCacheLoad(e)) => {
error!(domains = ?domains_owned, error = ?e, "ACME: account cache load failed");
}
Err(EventError::CertCacheStore(e)) => {
warn!(domains = ?domains_owned, error = ?e, "ACME: certificate cache store failed");
}
Err(EventError::AccountCacheStore(e)) => {
warn!(domains = ?domains_owned, error = ?e, "ACME: account cache store failed");
}
Err(EventError::CachedCertParse(e)) => {
error!(domains = ?domains_owned, error = ?e, "ACME: cached certificate parse failed");
}
Err(EventError::Order(e)) => {
warn!(domains = ?domains_owned, error = ?e, "ACME: certificate order failed, will retry");
}
Err(EventError::NewCertParse(e)) => {
error!(domains = ?domains_owned, error = ?e, "ACME: new certificate parse failed");
}
}
}
debug!(domains = ?domains_owned, "ACME: state machine ended");
});
Ok(Self {
rustls_config: config,
acme_handle: Some(handle),
})
}
/// Wrap the inner rustls config for the noq QUIC transport.
///
/// Takes `&self` (ADR-004): the inner rustls config is `Clone`
/// (Arc-shared resolvers), so one [`TlsServerConfig`] can feed a noq
/// endpoint and a TCP+TLS acceptor without contortions.
#[cfg(feature = "noq")]
pub fn for_noq(&self) -> Result<noq::ServerConfig, TlsError> {
use noq::crypto::rustls::QuicServerConfig;
let quic_server_config = QuicServerConfig::try_from(self.rustls_config.clone())?;
Ok(noq::ServerConfig::with_crypto(Arc::new(quic_server_config)))
}
/// Wrap the inner rustls config for the TCP+TLS transport. Infallible
/// — `TlsAcceptor::from(Arc<ServerConfig>)` cannot fail.
#[cfg(feature = "tcp")]
pub fn for_tcp_tls(&self) -> tokio_rustls::TlsAcceptor {
tokio_rustls::TlsAcceptor::from(Arc::new(self.rustls_config.clone()))
}
/// Borrow the inner config for transport wrappers the crate does not
/// cover.
pub fn rustls_config(&self) -> &rustls::ServerConfig {
&self.rustls_config
}
}
/// Build the inner `rustls::ServerConfig` for the non-ACME identity
/// variants: `X509`, `RawKey`, `SelfSigned`. The `Acme` arm is defensive —
/// ACME is dispatched by [`TlsServerConfig::new`] to `new_acme` and
/// reaching the builder with it is a config error.
pub fn build_rustls_server_config(
tls_identity: &TlsIdentity,
alpns: &[Vec<u8>],
) -> Result<rustls::ServerConfig, TlsError> {
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let client_verifier = Arc::new(AcceptAnyCertVerifier);
match tls_identity {
TlsIdentity::X509 { cert, key } => {
let cert_chain = crate::pem::load_cert_chain(cert)?;
let private_key = crate::pem::load_private_key(key)?;
let mut config = rustls::ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()?
.with_client_cert_verifier(client_verifier)
.with_single_cert(cert_chain, private_key)?;
config.alpn_protocols = alpns.to_vec();
config.max_early_data_size = u32::MAX;
Ok(config)
}
TlsIdentity::RawKey(secret_key) => {
let resolver = Arc::new(RawKeyCertResolver::new(secret_key));
let mut config = rustls::ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()?
.with_client_cert_verifier(client_verifier)
.with_cert_resolver(resolver);
config.alpn_protocols = alpns.to_vec();
config.max_early_data_size = u32::MAX;
Ok(config)
}
TlsIdentity::SelfSigned => {
let cert = generate_self_signed_cert()?;
let mut config = rustls::ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()?
.with_client_cert_verifier(client_verifier)
.with_single_cert(cert.cert_chain, cert.private_key)?;
config.alpn_protocols = alpns.to_vec();
config.max_early_data_size = u32::MAX;
Ok(config)
}
TlsIdentity::Acme { .. } => Err(TlsError::AcmeConfig(
"TlsIdentity::Acme is handled by TlsServerConfig::new_acme, not \
build_rustls_server_config"
.to_string(),
)),
}
}
/// The cert material behind the [`TlsIdentity::SelfSigned`] server path:
/// a single rcgen-generated DER cert plus its PKCS#8 private key.
pub struct SelfSignedCert {
/// The self-signed leaf certificate (DER).
pub cert_chain: Vec<rustls::pki_types::CertificateDer<'static>>,
/// The matching private key (PKCS#8 DER).
pub private_key: rustls::pki_types::PrivateKeyDer<'static>,
}
/// Generate a self-signed dev certificate (rcgen), in-memory.
pub fn generate_self_signed_cert() -> Result<SelfSignedCert, TlsError> {
use rcgen::{CertificateParams, KeyPair};
let key_pair = KeyPair::generate()?;
let params = CertificateParams::default();
let cert = params.self_signed(&key_pair)?;
let cert_der = cert.der().clone();
let key_der = rustls::pki_types::PrivateKeyDer::Pkcs8(
rustls::pki_types::PrivatePkcs8KeyDer::from(key_pair.serialize_der()),
);
Ok(SelfSignedCert {
cert_chain: vec![cert_der],
private_key: key_der,
})
}
/// Server-side "request-but-don't-require" client cert verifier
/// (alknet ADR-034).
///
/// Asks for a client TLS cert (X.509 or RFC 7250 raw key) so the endpoint
/// can extract the fingerprint via `peer_identity()`, but does not require
/// one and does not verify the presented cert against a CA. The TLS crate
/// hands over the fingerprint string; matching it against peer records
/// (`IdentityProvider::resolve_from_fingerprint`) is the auth layer's
/// concern, outside this crate (alktls ADR-005).
///
/// **Server-side only.** This must not be reused as a client-side
/// `ServerCertVerifier` — client-side verification is alknet ADR-034's
/// selection matrix (see the client module): CA verification for unknown
/// X.509 remotes, fingerprint pinning for known peers, fail closed for
/// unknown raw keys.
pub struct AcceptAnyCertVerifier;
impl std::fmt::Debug for AcceptAnyCertVerifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AcceptAnyCertVerifier").finish()
}
}
impl rustls::server::danger::ClientCertVerifier for AcceptAnyCertVerifier {
fn offer_client_auth(&self) -> bool {
true
}
fn client_auth_mandatory(&self) -> bool {
false
}
fn root_hint_subjects(&self) -> &[rustls::DistinguishedName] {
&[]
}
fn verify_client_cert(
&self,
_end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::server::danger::ClientCertVerified, rustls::Error> {
Ok(rustls::server::danger::ClientCertVerified::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
]
}
}
/// Presents an [`Ed25519SecretKey`] as an RFC 7250 raw public key server
/// certificate: the SPKI DER (Ed25519 OID + 32-byte key) is the "cert",
/// `only_raw_public_keys() == true`, and the signing key is the shared
/// [`Ed25519SigningKey`] helper.
pub struct RawKeyCertResolver {
key: Arc<rustls::sign::CertifiedKey>,
}
impl RawKeyCertResolver {
pub fn new(secret_key: &Ed25519SecretKey) -> Self {
let signing_key = Arc::new(Ed25519SigningKey::new(secret_key.clone()));
let public_key = signing_key.spki_public_key();
let cert = rustls::pki_types::CertificateDer::from(public_key.to_vec());
let certified_key = rustls::sign::CertifiedKey::new(vec![cert], signing_key);
Self {
key: Arc::new(certified_key),
}
}
}
impl rustls::server::ResolvesServerCert for RawKeyCertResolver {
fn resolve(
&self,
_client_hello: rustls::server::ClientHello<'_>,
) -> Option<Arc<rustls::sign::CertifiedKey>> {
Some(Arc::clone(&self.key))
}
fn only_raw_public_keys(&self) -> bool {
true
}
}
impl std::fmt::Debug for RawKeyCertResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RawKeyCertResolver").finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn raw_key_cert_resolver_only_raw_public_keys() {
use rustls::server::ResolvesServerCert;
let sk = Ed25519SecretKey::generate();
let resolver = RawKeyCertResolver::new(&sk);
assert!(resolver.only_raw_public_keys());
}
#[test]
fn self_signed_cert_generation_produces_cert_and_key() {
let cert = generate_self_signed_cert().expect("self-signed cert generates");
assert!(!cert.cert_chain.is_empty());
assert!(!cert.private_key.secret_der().is_empty());
}
#[tokio::test]
async fn tls_setup_x509_returns_no_acme_state() {
use rcgen::{CertificateParams, KeyPair};
let key_pair = KeyPair::generate().unwrap();
let params = CertificateParams::default();
let cert = params.self_signed(&key_pair).unwrap();
let cert_pem = cert.pem();
let key_pem = key_pair.serialize_pem();
let dir = tempfile::tempdir().unwrap();
let cert_path = dir.path().join("cert.pem");
let key_path = dir.path().join("key.pem");
std::fs::write(&cert_path, cert_pem).unwrap();
std::fs::write(&key_path, key_pem).unwrap();
let tls_identity = TlsIdentity::X509 {
cert: cert_path,
key: key_path,
};
let setup = TlsServerConfig::new(&tls_identity, &[b"alktls/test".to_vec()])
.await
.expect("X509 tls setup should succeed");
let _ = setup.rustls_config;
#[cfg(feature = "acme")]
assert!(setup.acme_handle.is_none());
}
#[test]
fn build_rustls_server_config_raw_key_succeeds() {
let sk = Ed25519SecretKey::generate();
let identity = TlsIdentity::RawKey(sk);
let alpns = vec![b"alktls/test".to_vec(), b"alktls/call".to_vec()];
let config = build_rustls_server_config(&identity, &alpns).expect("raw key config builds");
assert_eq!(config.alpn_protocols, alpns);
assert_eq!(config.max_early_data_size, u32::MAX);
}
#[test]
fn build_rustls_server_config_self_signed_succeeds() {
let identity = TlsIdentity::SelfSigned;
let alpns = vec![b"alktls/test".to_vec()];
let config =
build_rustls_server_config(&identity, &alpns).expect("self-signed config builds");
assert_eq!(config.alpn_protocols, alpns);
assert_eq!(config.max_early_data_size, u32::MAX);
}
#[test]
fn build_rustls_server_config_acme_returns_config_error() {
let identity = TlsIdentity::Acme {
domains: vec!["example.com".to_string()],
cache_dir: std::path::PathBuf::from("/tmp/alktls-acme-test"),
directory: crate::identity::AcmeDirectory::Staging,
contact: vec!["mailto:dev@example.com".to_string()],
};
let err = build_rustls_server_config(&identity, &[])
.expect_err("Acme identity must not reach the plain builder");
assert!(
matches!(err, TlsError::AcmeConfig(_)),
"the defensive Acme arm must surface as TlsError::AcmeConfig, got {err:?}"
);
}
#[cfg(feature = "noq")]
#[test]
fn for_noq_round_trips_raw_key_config() {
let sk = Ed25519SecretKey::generate();
let rustls_config =
build_rustls_server_config(&TlsIdentity::RawKey(sk), &[b"alktls/test".to_vec()])
.expect("rustls config builds");
let config = TlsServerConfig {
rustls_config,
#[cfg(feature = "acme")]
acme_handle: None,
};
let noq_config = config.for_noq().expect("noq config converts");
let _ = noq_config;
}
#[test]
fn accept_any_cert_verifier_offers_and_does_not_require_client_auth() {
use rustls::server::danger::ClientCertVerifier;
let verifier = AcceptAnyCertVerifier;
assert!(verifier.offer_client_auth());
assert!(!verifier.client_auth_mandatory());
assert!(verifier.root_hint_subjects().is_empty());
}
#[test]
fn accept_any_cert_verifier_verifies_any_client_cert() {
use rustls::pki_types::{CertificateDer, UnixTime};
use rustls::server::danger::ClientCertVerifier;
let verifier = AcceptAnyCertVerifier;
let cert = CertificateDer::from(b"fake-cert-der".to_vec());
let result = verifier.verify_client_cert(&cert, &[], UnixTime::now());
assert!(
result.is_ok(),
"AcceptAnyCertVerifier must accept any client cert"
);
}
#[test]
fn accept_any_cert_verifier_supported_schemes_are_the_nine_pinned() {
use rustls::server::danger::ClientCertVerifier;
let verifier = AcceptAnyCertVerifier;
let schemes = verifier.supported_verify_schemes();
assert_eq!(
schemes,
vec![
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
]
);
}
#[test]
fn accept_any_cert_verifier_debug_is_implemented() {
let verifier = AcceptAnyCertVerifier;
let s = format!("{verifier:?}");
assert!(s.contains("AcceptAnyCertVerifier"));
}
#[test]
fn raw_key_cert_resolver_debug_is_implemented() {
let sk = Ed25519SecretKey::generate();
let resolver = RawKeyCertResolver::new(&sk);
let s = format!("{resolver:?}");
assert!(s.contains("RawKeyCertResolver"));
}
#[cfg(feature = "tcp")]
#[tokio::test]
async fn new_x509_for_tcp_tls_and_rustls_config_round_trip() {
use rcgen::{CertificateParams, KeyPair};
let key_pair = KeyPair::generate().unwrap();
let cert = CertificateParams::default().self_signed(&key_pair).unwrap();
let dir = tempfile::tempdir().unwrap();
let cert_path = dir.path().join("cert.pem");
let key_path = dir.path().join("key.pem");
std::fs::write(&cert_path, cert.pem()).unwrap();
std::fs::write(&key_path, key_pair.serialize_pem()).unwrap();
let identity = TlsIdentity::X509 {
cert: cert_path,
key: key_path,
};
let alpn = vec![b"alktls/test".to_vec()];
let setup = TlsServerConfig::new(&identity, &alpn)
.await
.expect("X509 config builds");
let rc = setup.rustls_config();
assert_eq!(rc.alpn_protocols, alpn);
assert_eq!(rc.max_early_data_size, u32::MAX);
let _acceptor = setup.for_tcp_tls();
}
#[cfg(feature = "tcp")]
#[tokio::test]
async fn new_raw_key_for_tcp_tls_and_rustls_config_round_trip() {
let identity = TlsIdentity::RawKey(Ed25519SecretKey::generate());
let alpn = vec![b"alktls/test".to_vec()];
let setup = TlsServerConfig::new(&identity, &alpn)
.await
.expect("raw key config builds");
let rc = setup.rustls_config();
assert_eq!(rc.alpn_protocols, alpn);
assert_eq!(rc.max_early_data_size, u32::MAX);
let _acceptor = setup.for_tcp_tls();
}
#[cfg(feature = "tcp")]
#[tokio::test]
async fn new_self_signed_for_tcp_tls_and_rustls_config_round_trip() {
let identity = TlsIdentity::SelfSigned;
let alpn = vec![b"alktls/test".to_vec()];
let setup = TlsServerConfig::new(&identity, &alpn)
.await
.expect("self-signed config builds");
let rc = setup.rustls_config();
assert_eq!(rc.alpn_protocols, alpn);
assert_eq!(rc.max_early_data_size, u32::MAX);
let _acceptor = setup.for_tcp_tls();
}
#[cfg(feature = "acme")]
#[tokio::test]
async fn new_acme_spawns_and_appends_acme_tls_alpn() {
let dir = tempfile::tempdir().unwrap();
let identity = TlsIdentity::Acme {
domains: vec!["localhost".to_string()],
cache_dir: dir.path().join("cache"),
directory: crate::identity::AcmeDirectory::Custom(
"http://127.0.0.1:9/directory".to_string(),
),
contact: vec!["mailto:dev@example.com".to_string()],
};
let alpn = vec![b"alktls/test".to_vec()];
let setup = TlsServerConfig::new(&identity, &alpn)
.await
.expect("ACME config builds without awaiting the order");
let rc = setup.rustls_config();
assert_eq!(rc.max_early_data_size, u32::MAX);
assert_eq!(
rc.alpn_protocols,
vec![b"alktls/test".to_vec(), b"acme-tls/1".to_vec()]
);
assert!(
setup.acme_handle.is_some(),
"the event-loop task must be spawned and its handle stored"
);
}
#[cfg(not(feature = "acme"))]
#[tokio::test]
async fn new_acme_identity_without_feature_returns_config_error() {
let identity = TlsIdentity::Acme {
domains: vec!["example.com".to_string()],
cache_dir: std::path::PathBuf::from("/tmp/alktls-acme-test"),
directory: crate::identity::AcmeDirectory::Staging,
contact: vec!["mailto:dev@example.com".to_string()],
};
let err = match TlsServerConfig::new(&identity, &[]).await {
Ok(_) => panic!("Acme identity must fail without the acme feature"),
Err(e) => e,
};
assert!(
matches!(err, TlsError::AcmeConfig(_)),
"expected TlsError::AcmeConfig, got {err:?}"
);
}
}
+76 -10
View File
@@ -1,7 +1,7 @@
---
id: port-client
name: Port client side — TlsClientConfig, verifier selection, client auth (src/client.rs)
status: pending
status: completed
depends_on: [port-identity-types, port-fingerprint, port-pem-signing]
scope: broad
risk: medium
@@ -73,24 +73,24 @@ co-location prevents semantic drift (ADR-005).
## Verification
- [ ] Selection-matrix test: all four client-auth presentations × both
- [x] Selection-matrix test: all four client-auth presentations × both
verifier branches construct and select the expected resolver
types (inspect via the config's client-auth/verifier state where
the API permits; otherwise assert construction success/error
kind per cell)
- [ ] `Acme` local identity → `TlsError::AcmeConfig`
- [ ] `enable_early_data == true` pinned
- [ ] Root store non-empty (fallback exercised)
- [ ] FingerprintPinVerifier unit tests ported (pin match, mismatch,
- [x] `Acme` local identity → `TlsError::AcmeConfig`
- [x] `enable_early_data == true` pinned
- [x] Root store non-empty (fallback exercised)
- [x] FingerprintPinVerifier unit tests ported (pin match, mismatch,
raw-key signature routing)
- [ ] `cargo test` (default), `cargo test --all-features`,
- [x] `cargo test` (default), `cargo test --all-features`,
`cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`
## Acceptance Criteria
- [ ] `for_noq(self)` / `into_rustls_config(self)` per ADR-004
- [ ] The selection matrix has no fourth path (fail-closed structural)
- [ ] `lib.rs` re-exports the client surface + `ConnectionCredentials`
- [x] `for_noq(self)` / `into_rustls_config(self)` per ADR-004
- [x] The selection matrix has no fourth path (fail-closed structural)
- [x] `lib.rs` re-exports the client surface + `ConnectionCredentials`
/ `RemoteIdentity`
## References
@@ -105,6 +105,72 @@ co-location prevents semantic drift (ADR-005).
> Agent fills this during implementation.
- Verifier selection / client-auth presentation are inspected through
the `rustls::ClientConfig` Debug output (`ClientConfig` derives
`Debug` and embeds the verifier's debug name —
`FingerprintPinVerifier` / `WebPkiServerVerifier`) and the public
`client_auth_cert_resolver` field; the `verifier` field itself is
`pub(super)` at rustls 0.23.44, so the debug-string probe is the
structural assertion shape.
- `select_server_verifier`'s `WebPkiServerVerifier` build error maps to
`TlsError::VerifierBuild` via `#[from] rustls::client::VerifierBuilderError`
(the `rustls::webpki` module is private at 0.23.44; same type,
public path — lib.rs's variant doc already records this).
- The signature-routing unit tests construct `DigitallySignedStruct`
from its wire encoding through the doc-hidden
`rustls::internal::msgs` surface (`Codec::read`; `new` is
`pub(crate)`), then drive `verify_tls12_signature` /
`verify_tls13_signature` on the SPKI-as-`CertificateDer` exactly as
rustls presents it in an RFC 7250 handshake — pin match + signature
possession-proof both asserted.
- The `for_noq()` wrap relies on `TlsError::NoqWrap(#[from])`
(`NoInitialCipherSuite``?`), ADR-002/ADR-003.
- The extracted `TlsClientConfig`'s `#[allow(dead_code)]` is kept
(the server-side `rustls_config()` accessor shape is ADR-004's;
no consumer touches the field within this crate yet).
## Summary
> Agent fills this on completion.
Ported `src/credentials.rs` (wholesale from alknet-core, doc comments
rewired to this crate's ADRs — ADR-005, alknet ADR-091/034/030
semantics preserved verbatim in substance) and `src/client.rs` (port of
alknet-tls `client.rs` with the task's error-mapping deltas and import
rewires). Visibility: `RawKeyClientCertResolver`, `NoClientCertResolver`,
`FingerprintPinVerifier` made `pub` (lib.rs re-exports them);
`build_client_auth`, `select_server_verifier`, `load_platform_root_cert_store`
`pub`. `lib.rs` gained the client + credentials re-export lines; the
pending-modules comment now notes only the server line remains (its port
task owns it).
Deltas vs the extraction:
- `TlsError` mapping per ADR-002: `with_safe_default_protocol_versions` /
`CertifiedKey::from_der` / `RootCertStore::add` errors →
`TlsError::Rustls` (`#[from] rustls::Error`); the
`WebPkiServerVerifier` build error → `TlsError::VerifierBuild`
(`#[from] rustls::client::VerifierBuilderError`); the Acme
client-auth error string → `TlsError::AcmeConfig`. No
`Config(String)` catch-all.
- `for_quinn``for_noq` (ADR-003/004): consuming, noq-gated; the
wrap error flows through `TlsError::NoqWrap(#[from])` — no
`map_err` stringification.
- Imports rewired to `crate::{credentials, fingerprint, identity}`;
the `PeerEntry` reference in the extraction's `NoClientCertResolver`
doc became the peer-id resolution language (auth layer stays out,
ADR-005).
- Invariants pinned by tests: `enable_early_data = true` + exact ALPN;
aws-lc-rs default provider (9-suite set + `crypto_provider()`
identity); root store non-empty; verifier-selection matrix
(`Some``FingerprintPinVerifier`, `None``WebPkiServerVerifier`);
client-auth presentation matrix (RawKey → RFC 7250 raw pub keys,
X509 → loaded chain, SelfSigned/None → nothing, Acme →
`TlsError::AcmeConfig`); FingerprintPinVerifier pin match/mismatch
(Ed25519 SPKI + SHA256 X.509) and raw-key signature routing
(TLS 1.2 + 1.3, forged-signature rejection).
Verification: `cargo test` (56 pass), `cargo test --all-features`
(59 pass, incl. both `for_noq` tests), `cargo clippy --all-targets
--all-features -- -D warnings` (clean), `cargo fmt --check` (clean),
`cargo check --features noq` / `--features tcp` / default (clean).
+68 -12
View File
@@ -1,7 +1,7 @@
---
id: port-server
name: Port server side — TlsServerConfig, resolvers, ACME path (src/server.rs)
status: pending
status: completed
depends_on: [port-identity-types, port-pem-signing]
scope: broad
risk: medium
@@ -70,25 +70,25 @@ Without the feature, `Acme` identities return `TlsError::AcmeConfig`.
## Verification
- [ ] Invariant pins green: `max_early_data_size` per path,
- [x] Invariant pins green: `max_early_data_size` per path,
nine-scheme exact list, resolver behavior, verifier behavior
- [ ] ACME branch (with `--features acme`): spawns, returns
- [x] ACME branch (with `--features acme`): spawns, returns
immediately, appends `acme-tls/1` (test with a staging URL +
tempdir cache; do NOT hit Let's Encrypt — construct and assert
config state, assert the ALPN list)
- [ ] `Acme` identity without the `acme` feature → `TlsError::AcmeConfig`
- [ ] `for_noq` / `for_tcp_tls` round-trip per identity variant
- [x] `Acme` identity without the `acme` feature → `TlsError::AcmeConfig`
- [x] `for_noq` / `for_tcp_tls` round-trip per identity variant
(construction-level; handshakes are out of scope)
- [ ] `cargo test` (default), `cargo test --all-features`,
- [x] `cargo test` (default), `cargo test --all-features`,
`cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`
## Acceptance Criteria
- [ ] All five server invariants are test-asserted (not just compiled)
- [ ] `for_noq`, `for_tcp_tls`, `rustls_config` exist with ADR-004
- [x] All five server invariants are test-asserted (not just compiled)
- [x] `for_noq`, `for_tcp_tls`, `rustls_config` exist with ADR-004
signatures (`&self`; `for_tcp_tls` infallible)
- [ ] No `unreachable!`/panics in library code
- [ ] `lib.rs` re-exports the server surface
- [x] No `unreachable!`/panics in library code
- [x] `lib.rs` re-exports the server surface
## References
@@ -99,8 +99,64 @@ Without the feature, `Acme` identities return `TlsError::AcmeConfig`.
## Notes
> Agent fills this during implementation.
- **The extracted `unreachable!` is now the defensive arm**: the Acme
arm in `build_rustls_server_config` returns
`TlsError::AcmeConfig("TlsIdentity::Acme is handled by
TlsServerConfig::new_acme, not build_rustls_server_config")`
test-asserted (`build_rustls_server_config_acme_returns_config_error`).
- **Error mapping**: every extraction `TlsError::Config(e.to_string())`
site at a `rustls::Error` producer (`with_safe_default_protocol_versions`,
`with_single_cert`) became `?` into `TlsError::Rustls` (`#[from]`);
rcgen sites became `?` into `TlsError::SelfSigned` (`#[from]
rcgen::Error`). The non-acme `TlsServerConfig::new` Acme arm keeps its
message verbatim but under `AcmeConfig`.
- **The ACME test uses a `Custom("http://127.0.0.1:9/directory")`
directory rather than the staging URL** — never contacted either way
(port 9 discard; no network I/O beyond rustls-acme's async task start);
the task's "staging URL" intent (do-not-hit-Let's-Encrypt) is satisfied
with a stronger guarantee: a blackhole address. Staging would risk a
real contact if a test misfired; `127.0.0.1:9` cannot succeed.
- **`for_noq(&self)` clones the inner config** (`try_from` consumes) —
the ADR-004 borrow shape requires it; the clone is cheap (Arc-shared
resolvers).
- **Cargo.toml deltas (two, both required)**:
1. `noq` gains `"aws-lc-rs"` in its feature list. ADR-003's TOML
block (`features = ["rustls"]`) was written against noq 1.2's
API, but the lockfile resolves `noq-proto 1.3.0`, where
`ServerConfig::with_crypto(crypto)` (the single-arg constructor
`for_noq` calls) is `#[cfg(any(feature = "aws-lc-rs",
feature = "ring"))]` — the retry-token key comes from noq's
`ring_like` module, which needs one of the two provider features.
`"aws-lc-rs"` (not `"ring"`) matches the crate's provider posture;
it does not fight ADR-084: the config's internal provider is still
the crate's explicit `aws_lc_rs::default_provider()` (noq consumes
it from the config — ADR-003's provider paragraph). One new
lockfile line (`aws-lc-rs` under noq-proto). ADR-003 needs a
one-line amendment (recorded for review-impl docs sync).
2. `acme = ["dep:rustls-acme", "dep:futures"]` — ADR-006 §Feature
gates already records this ("the `acme` feature gates ... the
spawned task + the `futures` dep"); the scaffold's TOML omitted
the `futures` half. Without it, `--features acme` alone fails to
compile (the spawned task uses `futures::StreamExt`).
## Summary
> Agent fills this on completion.
Ported `src/server.rs` (614 lines) from alknet-tls server.rs with all
five ADR-pinned deltas applied: `for_noq(&self)` (borrow + clone +
`NoqWrap` via `#[from]`), `for_tcp_tls(&self)` adopted (infallible
TlsAcceptor), `rustls_config(&self)` adopted, `acme_handle` renamed,
Acme arm → `TlsError::AcmeConfig` (no `unreachable!`). All error sites
remapped to the typed variants (`Rustls`/`SelfSigned`/`AcmeConfig`).
The ACME path is verbatim (DirCache + directory URL + contacts +
resolver + `acme-tls/1` + spawned event loop with the extracted
tracing lines, returns immediately, handle never aborted).
14 tests: 12 ported (with the Acme-unreachable → AcmeConfig and
for_quinn → for_noq rewires; the acme_directory URL tests live in
identity.rs now) + the nine-scheme exact-list pin + ACME
spawn/return/ALPN assertion + no-feature AcmeConfig test +
`for_tcp_tls`/`rustls_config` round-trips for X509 (rcgen PEM pair in
tempdir)/RawKey/SelfSigned. Verification: `cargo test` 68 pass,
`--all-features` 75 pass, `--features noq` 72 pass, clippy -D
warnings (default + all-features), `fmt --check`, feature checks
(tcp/acme) — all green.