crates/gunnar-core/src/sshsig.rs
//! **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)
}