yog 0.0.5

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! **The `openssl` half of the mint** (REMOTE §1.4, §8; bl-ae05): the tool
//! invocations, and the two facts a certificate carries that are yog's to
//! decide — the subject alternative name and the extended key usage.
//!
//! Split from [`provision`](super) at the seam the 300-line cap and the §12
//! pre-split band both name: **which artifacts a box needs** is a question
//! about the box, and **what one `openssl` run says** is a question about
//! X.509. Nothing here reads the directory's state; nothing there spells a
//! flag.
//!
//! yog links no certificate library (AGENTS.md rule 6) — this shells to the
//! tool an operator would use, through the crate's one command constructor.

use super::{ANCHORS, CA_KEY, CURVE, DAYS, LOOPBACK, private};
use crate::git_env;
use crate::wire::material::Role;
use std::net::IpAddr;
use std::path::Path;

/// The extension file's suffix. Scratch, deleted with the request it rode
/// beside.
const EXT: &str = "ext";
/// The section `-extensions` names inside it. A name rather than the unnamed
/// default section, because "which section" is then stated rather than
/// inferred by two different tools' defaults.
const SECTION: &str = "leaf";
/// The serial counter `-CAcreateserial` derives from the anchor's name — the
/// `openssl` convention, which is why it is spelled here and not beside
/// [`ANCHORS`].
const SERIAL: &str = "ca.srl";

/// The self-signed operator CA both ends verify against.
pub(super) fn ca(dir: &Path) -> Result<(), String> {
    let key = dir.join(CA_KEY);
    tool(&[
        "req",
        "-x509",
        "-newkey",
        "ec",
        "-pkeyopt",
        CURVE,
        "-nodes",
        "-sha256",
        "-days",
        DAYS,
        "-subj",
        "/CN=yog-ca",
        "-keyout",
        &key.to_string_lossy(),
        "-out",
        &dir.join(ANCHORS).to_string_lossy(),
    ])?;
    private(&key, 0o600);
    Ok(())
}

/// One of the three roles' leaves: [`Role`] derives the basename it is written
/// under, the common name it carries — which **is** the client identity the
/// engine reads back off the presented certificate (REMOTE §2) — and the two
/// X.509 facts below.
pub(super) fn leaf(dir: &Path, role: Role, host: &str) -> Result<(), String> {
    issue(
        dir,
        &role.leaf(),
        &role.common_name(),
        &san(role, host),
        eku(role),
    )
}

/// A client leaf under a **stated** common name (REMOTE §8.2, bl-64a7): the
/// host half of provisioning an entry, and [`Role::Client`]'s own recipe with
/// the operator's name where the role's would be — the client EKU, and a SAN
/// naming the leaf itself, because nothing dials a client. No host is named
/// anywhere in it, which is what lets the pair be carried to whichever box the
/// operator hands it to.
///
/// The pair is written under the common name itself (`<cn>.pem`/`<cn>.key`),
/// because a directory holding several must say which is which. That basename
/// is a filing convenience and nothing more: the name **inside** is the
/// identity (REMOTE §2), and on the client box the pair is placed into
/// `wire/workspaces/<leaf>/` as `client.pem`/`client.key` (§8.2) without
/// changing what it authenticates as.
pub(super) fn stated_leaf(dir: &Path, cn: &str) -> Result<(), String> {
    issue(dir, cn, cn, &format!("DNS:{cn}"), eku(Role::Client))
}

/// The issuance itself: a key, a bare request, then the signature that carries
/// the SAN and EKU it was handed.
///
/// **The extensions are the issuer's, and they are handed over in a file**
/// (bl-8626). The obvious spelling — `req -addext` to put them in the request
/// and `x509 -copy_extensions copy` to carry them across — is OpenSSL-only:
/// macOS ships LibreSSL as `openssl`, whose `x509` has no `-copy_extensions`
/// and refuses the whole invocation (`Unrecognized flag copy_extensions`),
/// which is every wire test on that platform. `-extfile`/`-extensions` is the
/// spelling both toolsets have had for decades, and it is the more honest
/// model besides: what a certificate asserts is decided by whoever signs it,
/// not by whoever asked. One recipe, both toolsets — never a second recipe and
/// never a platform gate.
fn issue(dir: &Path, name: &str, cn: &str, san: &str, eku: &str) -> Result<(), String> {
    let key = dir.join(format!("{name}.key"));
    let csr = dir.join(format!("{name}.csr"));
    let ext = dir.join(format!("{name}.{EXT}"));
    let body = format!("[{SECTION}]\nsubjectAltName={san}\nextendedKeyUsage={eku}\n");
    std::fs::write(&ext, body).map_err(|e| format!("{}: {e}", ext.display()))?;
    tool(&[
        "req",
        "-new",
        "-newkey",
        "ec",
        "-pkeyopt",
        CURVE,
        "-nodes",
        "-sha256",
        "-subj",
        &format!("/CN={cn}"),
        "-keyout",
        &key.to_string_lossy(),
        "-out",
        &csr.to_string_lossy(),
    ])?;
    tool(&[
        "x509",
        "-req",
        "-sha256",
        "-days",
        DAYS,
        "-extfile",
        &ext.to_string_lossy(),
        "-extensions",
        SECTION,
        "-in",
        &csr.to_string_lossy(),
        "-CA",
        &dir.join(ANCHORS).to_string_lossy(),
        "-CAkey",
        &dir.join(CA_KEY).to_string_lossy(),
        // LibreSSL's `x509` refuses to sign when the CA's serial file is
        // absent and it was not told it may make one; OpenSSL 3 accepts the
        // flag and does the same thing. Portable, and the file is scratch.
        "-CAcreateserial",
        "-out",
        &dir.join(format!("{name}.pem")).to_string_lossy(),
    ])?;
    // Issuance scratch, not material: the request, the extension file and the
    // serial counter are all inputs to one signature and nothing reads them
    // afterwards, so the directory is left holding exactly what
    // [`artifacts`](super::artifacts) names. Dropping the counter means each
    // leaf is issued under a freshly drawn serial rather than a running one,
    // which is what `-CAcreateserial` writes when it finds no file.
    for scratch in [&csr, &ext, &dir.join(SERIAL)] {
        let _ = std::fs::remove_file(scratch);
    }
    private(&key, 0o600);
    Ok(())
}

/// A leaf's subject alternative name. The **server**'s is derived from the
/// address, because that is the name a seat verifies against what it dialled —
/// an IP literal is an IP identity and anything else a DNS one, the same rule
/// [`client`](super::client) reads it back by. A client leaf's names itself:
/// nothing dials a client.
///
/// **Loopback is always on the server leaf** (bl-ae05). The local window is a
/// client of `127.0.0.1` unconditionally — that is what the ruling means by the
/// front door — so a server certificate that only named an operator's public
/// host would refuse the one seat that is certain to be there. It costs one
/// SAN entry and removes a whole class of "the window cannot reach its own
/// engine".
pub(super) fn san(role: Role, host: &str) -> String {
    let loopback = format!("IP:{LOOPBACK}");
    match role {
        Role::Server if host.parse::<IpAddr>().is_ok() => {
            let named = format!("IP:{host}");
            if named == loopback {
                named
            } else {
                format!("{named},{loopback}")
            }
        }
        Role::Server => format!("DNS:{host},{loopback}"),
        _ => format!("DNS:{}", role.common_name()),
    }
}

/// A leaf's extended key usage: the server end authenticates as a server, and
/// both client ends as clients.
pub(super) fn eku(role: Role) -> &'static str {
    match role {
        Role::Server => "serverAuth",
        _ => "clientAuth",
    }
}

/// One `openssl` run. The tool is named once, here — [`run`] takes it as a
/// parameter only so a test can drive the two failure paths without
/// uninstalling anything.
pub(super) fn tool(args: &[&str]) -> Result<(), String> {
    run(Path::new("openssl"), args)
}

/// One run of `program`, through the crate's one command constructor
/// (`rules/no-bare-command.yml`). Its stderr is the refusal's text, trimmed:
/// an operator whose mint failed needs the tool's own sentence.
pub(super) fn run(program: &Path, args: &[&str]) -> Result<(), String> {
    let out = git_env::output(git_env::command(program).args(args)).map_err(|e| {
        format!(
            "{}: {e} — the wire's certificates need it",
            program.display()
        )
    })?;
    if out.status.success() {
        return Ok(());
    }
    let said = String::from_utf8_lossy(&out.stderr);
    Err(format!(
        "openssl {}: {}",
        args.first().copied().unwrap_or_default(),
        said.trim()
    ))
}