Skip to main content

sofie_auth/
signing.rs

1use ring::{hkdf, hmac};
2use sofie_core::encoding::base64::{self, URL_SAFE};
3
4use crate::SigningKeys;
5use crate::session::KEY_LENGTH;
6
7const SIGNING_PURPOSE: &[u8] = b"sofie-signature-v1";
8
9/// Each purpose signs with its own derived key, so one purpose's signature never passes for another's.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum Signed {
12    Link,
13    StoredFile,
14    PrincipalToken,
15    UploadSeal,
16}
17
18const PURPOSES: [Signed; 4] = [
19    Signed::Link,
20    Signed::StoredFile,
21    Signed::PrincipalToken,
22    Signed::UploadSeal,
23];
24
25impl Signed {
26    const fn info(self) -> &'static [u8] {
27        match self {
28            Self::Link => b"link",
29            Self::StoredFile => b"stored-file",
30            Self::PrincipalToken => b"principal-token",
31            Self::UploadSeal => b"upload-seal",
32        }
33    }
34}
35
36pub(crate) struct PurposeKeys([hmac::Key; PURPOSES.len()]);
37
38impl PurposeKeys {
39    fn of(&self, purpose: Signed) -> &hmac::Key {
40        &self.0[purpose as usize]
41    }
42}
43
44impl SigningKeys {
45    #[must_use]
46    pub fn sign(&self, purpose: Signed, message: &[u8]) -> String {
47        let tag = hmac::sign(self.first().signing.of(purpose), message);
48        base64::encode(&URL_SAFE, tag.as_ref())
49    }
50
51    /// Compared in constant time.
52    #[must_use]
53    pub fn verifies(&self, purpose: Signed, message: &[u8], signature: &str) -> bool {
54        base64::decode(&URL_SAFE, signature).is_some_and(|tag| {
55            self.all()
56                .any(|key| hmac::verify(key.signing.of(purpose), message, &tag).is_ok())
57        })
58    }
59}
60
61pub(crate) fn purpose_keys(key: &[u8; KEY_LENGTH]) -> PurposeKeys {
62    let secret = hkdf::Salt::new(hkdf::HKDF_SHA256, &[]).extract(key);
63    PurposeKeys(PURPOSES.map(|purpose| {
64        secret
65            .expand(&[SIGNING_PURPOSE, purpose.info()], hmac::HMAC_SHA256)
66            .map(hmac::Key::from)
67            .expect("HKDF-SHA256 expands to the 32 bytes of an HMAC-SHA256 key")
68    }))
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use crate::session::tests::{claims, keys};
75
76    #[test]
77    fn a_signature_verifies_only_its_message_under_a_key_still_held() {
78        let message = b"/files/1?expires=1700000000";
79        let signature = keys(1).sign(Signed::Link, message);
80        let rotated = SigningKeys::new(&[[2; KEY_LENGTH], [1; KEY_LENGTH]]).unwrap();
81
82        assert!(keys(1).verifies(Signed::Link, message, &signature));
83        assert!(rotated.verifies(Signed::Link, message, &signature));
84        assert!(!keys(1).verifies(Signed::Link, b"/files/2?expires=1700000000", &signature));
85        assert!(!keys(1).verifies(Signed::StoredFile, message, &signature));
86        assert!(!keys(2).verifies(Signed::Link, message, &signature));
87        assert!(!keys(1).verifies(Signed::Link, message, "not base64!"));
88        assert!(!keys(1).verifies(Signed::Link, message, &claims().seal(&keys(1)).unwrap()));
89    }
90}