gunnar-server 3.0.0

The gunnar git server: SSH and smart-HTTP git transports and the gRPC control plane. The appliance half, extracted from gunnar-cli so the server code lives in its own crate.
Documentation
//! **The appliance's actor-ticket key: an OpenSSH ed25519 keypair that makes
//! RAW Ed25519 signatures.**
//!
//! [`ActorTicketKey`] is the one thing in this repository that signs with a key
//! outside SSH's own framing, and this module exists so that fact lives in one
//! place with the reason attached rather than being spelled out wherever a
//! signature is needed.
//!
//! # Stored as OpenSSH, signed as raw Ed25519
//!
//! The key is minted by [`gunnar_core::mint_ed25519`] and kept in the secret
//! store as an OpenSSH private key, byte for byte like the host key, the
//! transparency key, the provenance key and the webhook key — one mint, one
//! encoding, one thing to back up, and a key an operator can read with ordinary
//! tools if they ever have to.
//!
//! What it produces is not `sshsig`. The verifier on the other side of the seam
//! is `monetize_embed::ticket::verify_ticket`, which holds an
//! `ed25519_dalek::VerifyingKey` and checks a 64-byte signature over the
//! canonical bytes that crate defines. gunnar writes no second canonical form
//! and wraps it in no envelope of its own, because a second encoder is a second
//! thing that can disagree with the verifier, and the whole point of a ticket
//! is that a party gunnar does not run can check it without asking.
//!
//! # Why the private half never leaves this type
//!
//! [`ActorTicketKey`] hands out a signature and a public key and nothing else.
//! The service that mints tickets lives in `gunnar-grpc`, on the far side of
//! the control-plane seam, and it asks this half to sign — it is never handed
//! key material to sign with itself. That keeps the number of places that hold
//! a private key at the number it already was.

use anyhow::{anyhow, Context, Result};
use ed25519_dalek::{Signer, SigningKey};

/// The appliance's actor-ticket signing key.
///
/// `Debug` prints the public half only. Private key material must not be one
/// `{:?}` away from a log file — the same rule `gunnar_core::HostKey` keeps,
/// and for the same reason.
#[derive(Clone)]
pub struct ActorTicketKey {
    signing: SigningKey,
    public_hex: String,
}

impl ActorTicketKey {
    /// Adopt an OpenSSH ed25519 private key as an actor-ticket signer.
    ///
    /// Refuses anything that is not ed25519 rather than falling back: the
    /// ticket format names one algorithm, and a key of another type here could
    /// only ever produce signatures the verifier will not check.
    pub fn from_openssh(private: &gunnar_core::ssh_key::PrivateKey) -> Result<ActorTicketKey> {
        let pair = private.key_data().ed25519().ok_or_else(|| {
            anyhow!(
                "the stored actor-ticket key is {} and the ticket format is ed25519",
                private.algorithm().as_str()
            )
        })?;
        // `ssh_key` keeps the 32-byte seed; `ed25519_dalek` derives the public
        // half from it. Deriving rather than trusting the stored public half
        // means a store that was edited to swap one of the two cannot produce
        // a key that advertises one public half and signs under another.
        let signing = SigningKey::from_bytes(pair.private.as_ref());
        let public_hex = signing
            .verifying_key()
            .to_bytes()
            .iter()
            .map(|byte| format!("{byte:02x}"))
            .collect();
        Ok(ActorTicketKey {
            signing,
            public_hex,
        })
    }

    /// The public half, 64 lowercase hex characters.
    ///
    /// This is the string `Billing.ApplianceIdentity` serves and an operator
    /// pastes into their configuration. Hex and not base64: it is copied
    /// between two config files by a human, and hex has no alphabet variant, no
    /// padding, and no `+/` versus `-_` question to get wrong on the way.
    pub fn public_key_hex(&self) -> &str {
        &self.public_hex
    }

    /// Sign `message` — raw Ed25519, 64 bytes, no envelope.
    ///
    /// `message` is `monetize_embed::ticket::ticket_message` of an unsigned
    /// ticket in the one use this has. It is a `&[u8]` and not an `ActorTicket`
    /// deliberately: this crate is the half that holds the key, not the half
    /// that knows the ticket format, and giving it an opinion about the format
    /// would be a second place the format is written down.
    pub fn sign(&self, message: &[u8]) -> [u8; 64] {
        self.signing.sign(message).to_bytes()
    }
}

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

/// Parse the 64 hex characters [`ActorTicketKey::public_key_hex`] renders back
/// into the 32 bytes a verifier needs.
///
/// Here rather than at each call site because both halves of the contract need
/// it — the appliance renders, a verifier parses — and two spellings of the
/// same decode is how one of them ends up accepting an odd-length string.
pub fn public_key_from_hex(hex: &str) -> Result<[u8; 32]> {
    if hex.len() != 64 {
        return Err(anyhow!(
            "an ed25519 public key is 64 hex characters and this is {}",
            hex.len()
        ));
    }
    let mut out = [0u8; 32];
    for (slot, pair) in out.iter_mut().zip(hex.as_bytes().chunks(2)) {
        let pair = std::str::from_utf8(pair).context("the key is not ascii")?;
        *slot = u8::from_str_radix(pair, 16).context("the key is not hex")?;
    }
    Ok(out)
}