Skip to main content

actpub_httpsig/key/
mod.rs

1//! Cryptographic key abstractions.
2//!
3//! This module hides the backend (currently `aws-lc-rs`) behind a single
4//! [`SigningKey`] / [`VerifyingKey`] pair that the rest of the crate uses
5//! without concerning itself with the algorithm. Conversion to and from
6//! PEM / PKCS#8 / FEP-521a Multikey lives under submodules.
7
8mod ed25519;
9mod multikey;
10mod pem;
11mod rsa;
12
13use std::fmt;
14
15pub use self::ed25519::{Ed25519PublicKey, Ed25519SigningKey};
16pub use self::multikey::Multikey;
17use self::pem::{
18    ed25519_public_key_from_pem, ed25519_public_key_to_pem, ed25519_signing_key_from_pem,
19    ed25519_signing_key_to_pem, rsa_public_key_from_pem, rsa_public_key_to_pem,
20    rsa_signing_key_from_pem, rsa_signing_key_to_pem,
21};
22pub use self::rsa::{RsaBits, RsaPublicKey, RsaSigningKey};
23use crate::error::Error;
24
25/// Algorithm identifier for a signing / verifying key.
26///
27/// The lexical `name()` strings match the values that appear in the
28/// `algorithm=` parameter of a Cavage `Signature:` header.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30#[non_exhaustive]
31pub enum Algorithm {
32    /// RSA PKCS#1 v1.5 with SHA-256, used by Mastodon's default actor key.
33    RsaSha256,
34    /// Ed25519 (`EdDSA` over Curve25519).
35    Ed25519,
36}
37
38impl Algorithm {
39    /// Returns the Cavage-compatible lexical name for this algorithm.
40    #[must_use]
41    pub const fn name(self) -> &'static str {
42        match self {
43            Self::RsaSha256 => "rsa-sha256",
44            Self::Ed25519 => "ed25519",
45        }
46    }
47
48    /// Parses a Cavage / RFC 9421-compatible name back into an
49    /// [`Algorithm`].
50    ///
51    /// Accepts both naming conventions in use across the Fediverse:
52    ///
53    /// - Cavage draft-12 `rsa-sha256`, `ed25519`, `ed25519-sha512`
54    /// - RFC 9421 §3.3.2 `rsa-v1_5-sha256`
55    /// - Legacy `hs2019` (Mastodon), which requests auto-detection
56    ///   from the key itself — returned as `Ok(None)`.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`Error::UnsupportedAlgorithm`] for anything else.
61    pub fn parse(name: &str) -> Result<Option<Self>, Error> {
62        match name {
63            "rsa-sha256" | "rsa-v1_5-sha256" => Ok(Some(Self::RsaSha256)),
64            "ed25519" | "ed25519-sha512" => Ok(Some(Self::Ed25519)),
65            "hs2019" => Ok(None),
66            other => Err(Error::UnsupportedAlgorithm(other.to_owned())),
67        }
68    }
69}
70
71/// A key capable of producing detached signatures.
72#[non_exhaustive]
73pub enum SigningKey {
74    /// Ed25519 backend.
75    Ed25519(Ed25519SigningKey),
76    /// RSA PKCS#1 v1.5 backend.
77    Rsa(RsaSigningKey),
78}
79
80impl SigningKey {
81    /// Generates a fresh Ed25519 signing key using the system RNG.
82    ///
83    /// # Panics
84    ///
85    /// Panics if `aws-lc-rs` cannot draw bytes from the operating system.
86    /// Every platform we support (Linux, macOS, Windows, the major BSDs)
87    /// guarantees this succeeds, so a panic here indicates a broken host.
88    /// Callers that prefer to handle this failure gracefully can call
89    /// [`Ed25519SigningKey::generate`] directly.
90    #[must_use]
91    pub fn generate_ed25519() -> Self {
92        #[allow(
93            clippy::expect_used,
94            reason = "the system RNG is a hard dependency of every supported platform; a failure here indicates a broken host and is unrecoverable"
95        )]
96        let key = Ed25519SigningKey::generate().expect("system RNG must be available for Ed25519");
97        Self::Ed25519(key)
98    }
99
100    /// Generates a fresh RSA signing key of the requested width.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`Error::KeyGeneration`] on RNG or key-scheduling failure.
105    pub fn generate_rsa(bits: RsaBits) -> Result<Self, Error> {
106        RsaSigningKey::generate(bits).map(Self::Rsa)
107    }
108
109    /// Loads a signing key from a PEM document, autodetecting the
110    /// algorithm from the embedded OID.
111    ///
112    /// # Errors
113    ///
114    /// Returns [`Error::InvalidPem`], [`Error::UnexpectedPemLabel`] or
115    /// [`Error::UnsupportedAlgorithm`] as appropriate.
116    pub fn from_pem(pem_text: &str) -> Result<Self, Error> {
117        // Try Ed25519 first — its OID is tiny (3 bytes) so the substring
118        // match in `pem` is unambiguous; fall back to RSA on
119        // UnsupportedAlgorithm.
120        match ed25519_signing_key_from_pem(pem_text) {
121            Ok(k) => Ok(Self::Ed25519(k)),
122            Err(Error::UnsupportedAlgorithm(_) | Error::UnexpectedPemLabel(_, _)) => {
123                rsa_signing_key_from_pem(pem_text).map(Self::Rsa)
124            }
125            Err(e) => Err(e),
126        }
127    }
128
129    /// Encodes the signing key as a PKCS#8 `PRIVATE KEY` PEM.
130    #[must_use]
131    pub fn to_pem(&self) -> String {
132        match self {
133            Self::Ed25519(k) => ed25519_signing_key_to_pem(k),
134            Self::Rsa(k) => rsa_signing_key_to_pem(k),
135        }
136    }
137
138    /// Returns the algorithm identifier for this key.
139    #[must_use]
140    pub const fn algorithm(&self) -> Algorithm {
141        match self {
142            Self::Ed25519(_) => Algorithm::Ed25519,
143            Self::Rsa(_) => Algorithm::RsaSha256,
144        }
145    }
146
147    /// Returns the verifying half of this key pair.
148    #[must_use]
149    pub fn verifying_key(&self) -> VerifyingKey {
150        match self {
151            Self::Ed25519(k) => VerifyingKey::Ed25519(k.public_key()),
152            Self::Rsa(k) => VerifyingKey::Rsa(k.public_key()),
153        }
154    }
155
156    /// Signs `message` and returns the raw signature bytes.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`Error::Crypto`] if the underlying primitive fails.
161    pub fn sign(&self, message: &[u8]) -> Result<Vec<u8>, Error> {
162        match self {
163            Self::Ed25519(k) => Ok(k.sign(message)),
164            Self::Rsa(k) => k.sign(message),
165        }
166    }
167}
168
169impl fmt::Debug for SigningKey {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        f.debug_tuple("SigningKey")
172            .field(&self.algorithm())
173            .finish()
174    }
175}
176
177/// A key capable of verifying detached signatures.
178#[derive(Debug, Clone, PartialEq, Eq)]
179#[non_exhaustive]
180pub enum VerifyingKey {
181    /// Ed25519 backend.
182    Ed25519(Ed25519PublicKey),
183    /// RSA PKCS#1 v1.5 backend.
184    Rsa(RsaPublicKey),
185}
186
187impl VerifyingKey {
188    /// Loads a public key from a PEM `PUBLIC KEY` document.
189    ///
190    /// # Errors
191    ///
192    /// Returns [`Error::InvalidPem`], [`Error::UnexpectedPemLabel`], or
193    /// [`Error::UnsupportedAlgorithm`] as appropriate.
194    pub fn from_pem(pem_text: &str) -> Result<Self, Error> {
195        match ed25519_public_key_from_pem(pem_text) {
196            Ok(k) => Ok(Self::Ed25519(k)),
197            Err(Error::UnsupportedAlgorithm(_) | Error::UnexpectedPemLabel(_, _)) => {
198                rsa_public_key_from_pem(pem_text).map(Self::Rsa)
199            }
200            Err(e) => Err(e),
201        }
202    }
203
204    /// Encodes the public key as a `SubjectPublicKeyInfo` PEM.
205    #[must_use]
206    pub fn to_pem(&self) -> String {
207        match self {
208            Self::Ed25519(k) => ed25519_public_key_to_pem(k),
209            Self::Rsa(k) => rsa_public_key_to_pem(k),
210        }
211    }
212
213    /// Returns the algorithm identifier for this key.
214    #[must_use]
215    pub const fn algorithm(&self) -> Algorithm {
216        match self {
217            Self::Ed25519(_) => Algorithm::Ed25519,
218            Self::Rsa(_) => Algorithm::RsaSha256,
219        }
220    }
221
222    /// Verifies a detached signature of `message`.
223    ///
224    /// # Errors
225    ///
226    /// Returns [`Error::VerificationFailed`] if the signature is
227    /// malformed or does not match the message.
228    pub fn verify(&self, message: &[u8], signature: &[u8]) -> Result<(), Error> {
229        match self {
230            Self::Ed25519(k) => k.verify(message, signature),
231            Self::Rsa(k) => k.verify(message, signature),
232        }
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use pretty_assertions::assert_eq;
239
240    use super::*;
241
242    #[test]
243    fn ed25519_pem_roundtrip_through_top_level_enum() {
244        let key = SigningKey::generate_ed25519();
245        let pem = key.to_pem();
246        let reloaded = SigningKey::from_pem(&pem).expect("reload");
247        assert_eq!(reloaded.algorithm(), Algorithm::Ed25519);
248        assert_eq!(
249            key.verifying_key(),
250            reloaded.verifying_key(),
251            "verifying keys must match after PEM roundtrip",
252        );
253    }
254
255    #[test]
256    fn rsa_pem_roundtrip_through_top_level_enum() {
257        let key = SigningKey::generate_rsa(RsaBits::Rsa2048).expect("rng");
258        let pem = key.to_pem();
259        let reloaded = SigningKey::from_pem(&pem).expect("reload");
260        assert_eq!(reloaded.algorithm(), Algorithm::RsaSha256);
261    }
262
263    #[test]
264    fn sign_and_verify_through_enum_dispatch() {
265        for key in [
266            SigningKey::generate_ed25519(),
267            SigningKey::generate_rsa(RsaBits::Rsa2048).expect("rng"),
268        ] {
269            let msg = b"payload";
270            let sig = key.sign(msg).expect("sign");
271            key.verifying_key()
272                .verify(msg, &sig)
273                .expect("verify must succeed for the matching key");
274        }
275    }
276
277    #[test]
278    fn algorithm_parse_handles_known_names() {
279        assert_eq!(
280            Algorithm::parse("rsa-sha256").expect("parse"),
281            Some(Algorithm::RsaSha256),
282        );
283        // RFC 9421 §3.3.2 name for the same primitive.
284        assert_eq!(
285            Algorithm::parse("rsa-v1_5-sha256").expect("parse"),
286            Some(Algorithm::RsaSha256),
287        );
288        assert_eq!(
289            Algorithm::parse("ed25519").expect("parse"),
290            Some(Algorithm::Ed25519),
291        );
292        assert_eq!(
293            Algorithm::parse("ed25519-sha512").expect("parse"),
294            Some(Algorithm::Ed25519),
295        );
296        // Legacy `hs2019` requests autodetection.
297        assert_eq!(Algorithm::parse("hs2019").expect("parse"), None);
298    }
299
300    #[test]
301    fn algorithm_parse_rejects_unknown_names() {
302        let err = Algorithm::parse("hmac-sha256").expect_err("unknown algorithm");
303        assert!(matches!(err, Error::UnsupportedAlgorithm(_)));
304    }
305}