gunnar-core 3.0.0

gunnar's repository model, identity table, ACL and path sandbox. gunnar-ssh, gunnar-grpc and gunnar-cli all sit on it.
Documentation
//! **One signer and one verifier for gunnar's signed event logs** (one implementation).
//!
//! The forge log ([`crate::forge`]) and the membership log
//! ([`crate::membership`]) are the same shape: a line of fixed-order bytes,
//! signed by the actor's own key with an `sshsig` under the log's **own
//! namespace**, verified later by anybody against the actor's registered public
//! key — `ssh-keygen -Y verify` with no shared secret. The two differ only in
//! *what* they put in the bytes and *which* namespace they bind; the arithmetic
//! that turns those into a signature, and that checks one, is identical.
//!
//! So it lives here once. A second copy of it in each log is exactly the twin
//! one implementation forbids: the day one copy gained a check the other
//! missed — a
//! namespace comparison, say — a signature refused by one log would be accepted
//! by the other, and the whole point of a per-log namespace is that it cannot.
//!
//! The namespace is passed in and **bound into what is signed** (`ssh_key`
//! writes it inside the signature blob and [`verify`] both re-derives it from
//! the offered key and refuses a blob whose namespace is not the one asked
//! for). That is what stops a membership signature being replayed as a forge
//! approval, or a commit signature (`git`) as either: a signature made under
//! one namespace does not verify under another, and this module is the single
//! place that binds it.

/// The hash `ssh_key` signs an `sshsig` with. `Sha512`, matching every other
/// signed log in this crate, so one log's key can never be told from another's
/// by the hash it used.
pub const HASH_ALG: ssh_key::HashAlg = ssh_key::HashAlg::Sha512;

/// Why a signed event did not verify.
///
/// Shared by every log so one refusal reads the same everywhere. The three
/// arms name three different failures on purpose — a caller that collapsed them
/// would report "the key you offered is not the one that signed this" and "the
/// signature is forged" with the same sentence.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EventProblem {
    /// The stored form could not be read at all — not a well-formed `sshsig`,
    /// or made under a namespace this log does not sign under.
    Malformed(String),
    /// The signature is over a different key than the one offered to check it.
    WrongKey {
        /// The fingerprint the event claims.
        signed_by: String,
        /// The fingerprint of the key given to check it.
        offered: String,
    },
    /// Well formed, right key, right namespace — and the signature does not
    /// check out.
    NotVerified,
}

impl std::fmt::Display for EventProblem {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            EventProblem::Malformed(why) => {
                write!(
                    f,
                    "the event's signature is not a well-formed sshsig: {why}"
                )
            }
            EventProblem::WrongKey { signed_by, offered } => write!(
                f,
                "the event says it was signed by {signed_by} and was checked against {offered}"
            ),
            EventProblem::NotVerified => write!(f, "the event's signature does not verify"),
        }
    }
}

/// The `SHA256:…` fingerprint of a key, spelled exactly as every log stores it.
pub fn fingerprint(key: &ssh_key::PublicKey) -> String {
    key.fingerprint(ssh_key::HashAlg::Sha256).to_string()
}

/// Sign `signing_bytes` under `namespace` with the actor's own key.
///
/// Returns the actor's `SHA256:…` fingerprint and the PEM-armoured `sshsig`,
/// the two fields every `SignedEvent` in this crate carries beside the event.
pub fn sign(
    key: &ssh_key::PrivateKey,
    namespace: &str,
    signing_bytes: &[u8],
) -> Result<(String, String), ssh_key::Error> {
    let signature = key.sign(namespace, HASH_ALG, signing_bytes)?;
    Ok((
        fingerprint(key.public_key()),
        signature.to_pem(ssh_key::LineEnding::LF)?,
    ))
}

/// Verify a signed event against a public key, under exactly `namespace`.
///
/// Three refusals, in the order that keeps them honest: the fingerprint must be
/// the offered key's (so the answer is about *this* key), the blob must parse
/// **and** carry this namespace (so a signature made elsewhere — another log,
/// or a `git` commit — is `Malformed` here rather than silently checked), and
/// only then is the signature itself verified.
pub fn verify(
    public: &ssh_key::PublicKey,
    namespace: &str,
    signing_bytes: &[u8],
    key_fingerprint: &str,
    signature_pem: &str,
) -> Result<(), EventProblem> {
    let offered = fingerprint(public);
    if offered != key_fingerprint {
        return Err(EventProblem::WrongKey {
            signed_by: key_fingerprint.to_owned(),
            offered,
        });
    }
    let signature = ssh_key::SshSig::from_pem(signature_pem.as_bytes())
        .map_err(|err| EventProblem::Malformed(err.to_string()))?;
    if signature.namespace() != namespace {
        return Err(EventProblem::Malformed(format!(
            "namespace is {:?}, not {namespace:?}",
            signature.namespace()
        )));
    }
    public
        .verify(namespace, signing_bytes, &signature)
        .map_err(|_| EventProblem::NotVerified)
}