alktls 0.1.0

Shared TLS setup types: server and client rustls configs, cert resolvers, verifiers, and ACME state-machine wiring, transport-agnostic and shareable across transports.
Documentation
//! Identity types: [`TlsIdentity`], [`Ed25519SecretKey`], [`AcmeDirectory`]
//! (ADR-005, moved from alknet-core `config.rs`).
//!
//! The three types are the identity half of config construction: they
//! describe *what* a server presents and *what* a client presents, and they
//! are consumed by the server and client config builders. Auth-layer types
//! (`PeerEntry`, `AuthPolicy`, fingerprint → peer-id resolution) are
//! deliberately out — ADR-005's carve-out.

use std::path::PathBuf;

/// Ed25519 signing key backed by [`ed25519_dalek::SigningKey`].
///
/// The byte surface (`from_bytes(&[u8; 32])` / `as_bytes() -> [u8; 32]`)
/// is load-bearing: iroh's `iroh_base::SecretKey` consumes the 32 raw
/// bytes (OQ-TLS-07, verified against iroh 1.1 in Phase 0), and the
/// `Ed25519SigningKey` helper signs through it.
#[derive(Clone)]
pub struct Ed25519SecretKey(ed25519_dalek::SigningKey);

impl Ed25519SecretKey {
    /// Generate a new key from OS randomness.
    pub fn generate() -> Self {
        Self(ed25519_dalek::SigningKey::generate(&mut rand_core::OsRng))
    }

    /// Rebuild a key from its 32 raw bytes (the iroh interop surface).
    pub fn from_bytes(bytes: &[u8; 32]) -> Self {
        Self(ed25519_dalek::SigningKey::from_bytes(bytes))
    }

    /// The 32 raw secret bytes.
    pub fn as_bytes(&self) -> [u8; 32] {
        self.0.to_bytes()
    }

    /// The matching public key.
    pub fn public(&self) -> ed25519_dalek::VerifyingKey {
        self.0.verifying_key()
    }

    /// Sign a message.
    pub fn sign(&self, message: &[u8]) -> ed25519_dalek::Signature {
        use ed25519_dalek::Signer;
        self.0.sign(message)
    }
}

impl std::fmt::Debug for Ed25519SecretKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Ed25519SecretKey").finish_non_exhaustive()
    }
}

/// ACME directory choice: the pinned Let's Encrypt endpoints or a custom
/// URL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AcmeDirectory {
    /// The Let's Encrypt production directory.
    Production,
    /// The Let's Encrypt staging directory (rate limits are relaxed;
    /// certificates it issues are not trusted by browsers).
    Staging,
    /// A custom ACME directory URL. The URL is passed to rustls-acme
    /// verbatim and **must be an `https://` ACME directory URL** — an
    /// `http://` URL would run ACME (token-bearing) over plaintext.
    /// No runtime validation is applied (a non-https test directory
    /// must stay usable); this is a caller contract.
    Custom(String),
}

impl AcmeDirectory {
    /// The directory URL: pinned strings for `Production` / `Staging`,
    /// the stored URL for [`AcmeDirectory::Custom`].
    pub fn url(&self) -> &str {
        match self {
            AcmeDirectory::Production => "https://acme-v02.api.letsencrypt.org/directory",
            AcmeDirectory::Staging => "https://acme-staging-v02.api.letsencrypt.org/directory",
            AcmeDirectory::Custom(url) => url,
        }
    }
}

/// What identity a TLS side presents and how its cert material is
/// obtained (alknet ADR-027's identity model).
#[derive(Debug, Clone)]
pub enum TlsIdentity {
    /// X.509 cert + key PEM files on disk.
    X509 {
        /// Path to the certificate chain PEM file.
        cert: PathBuf,
        /// Path to the private key PEM file.
        key: PathBuf,
    },
    /// A raw Ed25519 key: the cert is synthesized at config-construction
    /// time and peers are identified by the public key's fingerprint.
    RawKey(Ed25519SecretKey),
    /// A generated self-signed dev cert (server side). On the **client**
    /// path this identity presents nothing — `NoClientCertResolver`, the
    /// OQ-TLS-02 resolution: `SelfSigned` as a *local* identity meaning
    /// "present nothing" is coherent, and presenting a self-signed
    /// client cert would add nothing the fingerprint path uses.
    SelfSigned,
    /// An ACME-managed cert (Let's Encrypt or a custom directory). This
    /// is a **server-only** identity: using it for client auth is a
    /// config error (`TlsError::AcmeConfig` on the client path) — there
    /// is no client-auth cert to present while the order is pending.
    Acme {
        /// The domains the certificate covers.
        domains: Vec<String>,
        /// Directory where issued certificates are cached.
        cache_dir: PathBuf,
        /// Which ACME directory to order from.
        directory: AcmeDirectory,
        /// ACME contact addresses (e.g. `mailto:` URIs).
        contact: Vec<String>,
    },
}

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

    use ed25519_dalek::Verifier;

    #[test]
    fn ed25519_secret_key_round_trips_bytes() {
        let key = Ed25519SecretKey::generate();
        let bytes = key.as_bytes();
        let restored = Ed25519SecretKey::from_bytes(&bytes);
        assert_eq!(restored.as_bytes(), bytes);
    }

    #[test]
    fn ed25519_secret_key_sign_verifies_against_public_key() {
        let key = Ed25519SecretKey::generate();
        let public = key.public();
        let message = b"alktls identity coverage check";
        let signature: ed25519_dalek::Signature = key.sign(message);
        assert_eq!(signature.to_bytes().len(), 64);
        assert!(
            public.verify(message, &signature).is_ok(),
            "signature produced by Ed25519SecretKey::sign must verify under its public key"
        );
    }

    #[test]
    fn ed25519_secret_key_sign_rejects_tampered_message() {
        let key = Ed25519SecretKey::generate();
        let public = key.public();
        let signature: ed25519_dalek::Signature = key.sign(b"original message");
        assert!(
            public.verify(b"tampered message", &signature).is_err(),
            "signature must not verify against a different message"
        );
    }

    #[test]
    fn ed25519_secret_key_debug_does_not_leak_material() {
        let key = Ed25519SecretKey::generate();
        let dbg = format!("{key:?}");
        assert!(dbg.contains("Ed25519SecretKey"));
        assert!(!dbg.contains("SigningKey"));
        let raw = hex::encode(key.as_bytes());
        assert!(
            !dbg.contains(&raw),
            "Debug output must not contain the raw key bytes"
        );
    }

    #[test]
    fn ed25519_secret_key_public_matches_underlying_signing_key() {
        let key = Ed25519SecretKey::generate();
        let public = key.public();
        assert_eq!(public.to_bytes().len(), 32);
    }

    #[test]
    fn acme_directory_urls_are_pinned() {
        assert_eq!(
            AcmeDirectory::Production.url(),
            "https://acme-v02.api.letsencrypt.org/directory"
        );
        assert_eq!(
            AcmeDirectory::Staging.url(),
            "https://acme-staging-v02.api.letsencrypt.org/directory"
        );
        assert_eq!(
            AcmeDirectory::Custom("https://acme.example/dir".to_string()).url(),
            "https://acme.example/dir"
        );
    }

    #[test]
    fn tls_identity_x509_construct() {
        let id = TlsIdentity::X509 {
            cert: PathBuf::from("/etc/cert.pem"),
            key: PathBuf::from("/etc/key.pem"),
        };
        match id {
            TlsIdentity::X509 { cert, key } => {
                assert_eq!(cert, PathBuf::from("/etc/cert.pem"));
                assert_eq!(key, PathBuf::from("/etc/key.pem"));
            }
            _ => panic!("expected X509"),
        }
    }

    #[test]
    fn tls_identity_self_signed() {
        let id = TlsIdentity::SelfSigned;
        let s = format!("{id:?}");
        assert!(s.contains("SelfSigned"));
    }

    #[test]
    fn tls_identity_acme_construct() {
        let id = TlsIdentity::Acme {
            domains: vec!["example.com".to_string()],
            cache_dir: PathBuf::from("/var/cache/alktls-acme"),
            directory: AcmeDirectory::Staging,
            contact: vec!["mailto:ops@example.com".to_string()],
        };
        match id {
            TlsIdentity::Acme {
                domains,
                cache_dir,
                directory,
                contact,
            } => {
                assert_eq!(domains, vec!["example.com".to_string()]);
                assert_eq!(cache_dir, PathBuf::from("/var/cache/alktls-acme"));
                assert_eq!(directory, AcmeDirectory::Staging);
                assert_eq!(contact, vec!["mailto:ops@example.com".to_string()]);
            }
            _ => panic!("expected Acme"),
        }
    }
}