Skip to main content

gunnar_core/
sshsig.rs

1//! **One signer and one verifier for gunnar's signed event logs** (one implementation).
2//!
3//! The forge log ([`crate::forge`]) and the membership log
4//! ([`crate::membership`]) are the same shape: a line of fixed-order bytes,
5//! signed by the actor's own key with an `sshsig` under the log's **own
6//! namespace**, verified later by anybody against the actor's registered public
7//! key — `ssh-keygen -Y verify` with no shared secret. The two differ only in
8//! *what* they put in the bytes and *which* namespace they bind; the arithmetic
9//! that turns those into a signature, and that checks one, is identical.
10//!
11//! So it lives here once. A second copy of it in each log is exactly the twin
12//! one implementation forbids: the day one copy gained a check the other
13//! missed — a
14//! namespace comparison, say — a signature refused by one log would be accepted
15//! by the other, and the whole point of a per-log namespace is that it cannot.
16//!
17//! The namespace is passed in and **bound into what is signed** (`ssh_key`
18//! writes it inside the signature blob and [`verify`] both re-derives it from
19//! the offered key and refuses a blob whose namespace is not the one asked
20//! for). That is what stops a membership signature being replayed as a forge
21//! approval, or a commit signature (`git`) as either: a signature made under
22//! one namespace does not verify under another, and this module is the single
23//! place that binds it.
24
25/// The hash `ssh_key` signs an `sshsig` with. `Sha512`, matching every other
26/// signed log in this crate, so one log's key can never be told from another's
27/// by the hash it used.
28pub const HASH_ALG: ssh_key::HashAlg = ssh_key::HashAlg::Sha512;
29
30/// Why a signed event did not verify.
31///
32/// Shared by every log so one refusal reads the same everywhere. The three
33/// arms name three different failures on purpose — a caller that collapsed them
34/// would report "the key you offered is not the one that signed this" and "the
35/// signature is forged" with the same sentence.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum EventProblem {
38    /// The stored form could not be read at all — not a well-formed `sshsig`,
39    /// or made under a namespace this log does not sign under.
40    Malformed(String),
41    /// The signature is over a different key than the one offered to check it.
42    WrongKey {
43        /// The fingerprint the event claims.
44        signed_by: String,
45        /// The fingerprint of the key given to check it.
46        offered: String,
47    },
48    /// Well formed, right key, right namespace — and the signature does not
49    /// check out.
50    NotVerified,
51}
52
53impl std::fmt::Display for EventProblem {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            EventProblem::Malformed(why) => {
57                write!(f, "the event's signature is not a well-formed sshsig: {why}")
58            }
59            EventProblem::WrongKey { signed_by, offered } => write!(
60                f,
61                "the event says it was signed by {signed_by} and was checked against {offered}"
62            ),
63            EventProblem::NotVerified => write!(f, "the event's signature does not verify"),
64        }
65    }
66}
67
68/// The `SHA256:…` fingerprint of a key, spelled exactly as every log stores it.
69pub fn fingerprint(key: &ssh_key::PublicKey) -> String {
70    key.fingerprint(ssh_key::HashAlg::Sha256).to_string()
71}
72
73/// Sign `signing_bytes` under `namespace` with the actor's own key.
74///
75/// Returns the actor's `SHA256:…` fingerprint and the PEM-armoured `sshsig`,
76/// the two fields every `SignedEvent` in this crate carries beside the event.
77pub fn sign(
78    key: &ssh_key::PrivateKey,
79    namespace: &str,
80    signing_bytes: &[u8],
81) -> Result<(String, String), ssh_key::Error> {
82    let signature = key.sign(namespace, HASH_ALG, signing_bytes)?;
83    Ok((
84        fingerprint(key.public_key()),
85        signature.to_pem(ssh_key::LineEnding::LF)?,
86    ))
87}
88
89/// Verify a signed event against a public key, under exactly `namespace`.
90///
91/// Three refusals, in the order that keeps them honest: the fingerprint must be
92/// the offered key's (so the answer is about *this* key), the blob must parse
93/// **and** carry this namespace (so a signature made elsewhere — another log,
94/// or a `git` commit — is `Malformed` here rather than silently checked), and
95/// only then is the signature itself verified.
96pub fn verify(
97    public: &ssh_key::PublicKey,
98    namespace: &str,
99    signing_bytes: &[u8],
100    key_fingerprint: &str,
101    signature_pem: &str,
102) -> Result<(), EventProblem> {
103    let offered = fingerprint(public);
104    if offered != key_fingerprint {
105        return Err(EventProblem::WrongKey {
106            signed_by: key_fingerprint.to_owned(),
107            offered,
108        });
109    }
110    let signature = ssh_key::SshSig::from_pem(signature_pem.as_bytes())
111        .map_err(|err| EventProblem::Malformed(err.to_string()))?;
112    if signature.namespace() != namespace {
113        return Err(EventProblem::Malformed(format!(
114            "namespace is {:?}, not {namespace:?}",
115            signature.namespace()
116        )));
117    }
118    public
119        .verify(namespace, signing_bytes, &signature)
120        .map_err(|_| EventProblem::NotVerified)
121}