atagen

malice

public
Star0
Watch0
4ac384dcce6c4c0b2d9806e2fa8512728546430d
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
crates/malice-push/src/vapid.rsRust147 lines5.3 KB
1
use std::{
2
    io::{ErrorKind, Write},
3
    path::Path,
4
    time::{SystemTime, UNIX_EPOCH},
5
};
6
7
use aws_lc_rs::{
8
    rand::SystemRandom,
9
    signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair},
10
};
11
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
12
13
const TOKEN_LIFETIME_SECS: u64 = 12 * 60 * 60;
14
15
/// The server's VAPID identity (RFC 8292), an ES256 key kept on disk.
16
pub struct Vapid {
17
    key: EcdsaKeyPair,
18
    public: String,
19
}
20
21
impl Vapid {
22
    /// Reads the PKCS#8 key at `path`, or creates it owner-only.
23
    pub fn load_or_create(path: &Path) -> Result<Self, String> {
24
        match std::fs::read(path) {
25
            Ok(bytes) => return Self::from_pkcs8(&bytes),
26
            Err(error) if error.kind() != ErrorKind::NotFound => {
27
                return Err(format!("reading the VAPID key: {error}"));
28
            }
29
            Err(_) => {}
30
        }
31
        let document =
32
            EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &SystemRandom::new())
33
                .map_err(|_| "generating the VAPID key failed".to_owned())?;
34
        let mut options = std::fs::OpenOptions::new();
35
        options.write(true).create_new(true);
36
        #[cfg(unix)]
37
        {
38
            use std::os::unix::fs::OpenOptionsExt;
39
            options.mode(0o600);
40
        }
41
        match options.open(path) {
42
            Ok(mut file) => {
43
                if let Err(error) = file
44
                    .write_all(document.as_ref())
45
                    .and_then(|_| file.sync_all())
46
                {
47
                    let _ = std::fs::remove_file(path);
48
                    return Err(format!("writing the VAPID key: {error}"));
49
                }
50
                Self::from_pkcs8(document.as_ref())
51
            }
52
            Err(error) if error.kind() == ErrorKind::AlreadyExists => {
53
                let bytes = std::fs::read(path)
54
                    .map_err(|error| format!("reading the VAPID key: {error}"))?;
55
                Self::from_pkcs8(&bytes)
56
            }
57
            Err(error) => Err(format!("creating the VAPID key: {error}")),
58
        }
59
    }
60
61
    fn from_pkcs8(bytes: &[u8]) -> Result<Self, String> {
62
        let key = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, bytes)
63
            .map_err(|_| "the VAPID key file is not a P-256 PKCS#8 key".to_owned())?;
64
        let public = URL_SAFE_NO_PAD.encode(key.public_key().as_ref());
65
        Ok(Self { key, public })
66
    }
67
68
    /// The uncompressed public point, base64url, as `applicationServerKey` takes it.
69
    pub fn public_key(&self) -> &str {
70
        &self.public
71
    }
72
73
    /// The `Authorization` header for a push service at `audience` (its origin).
74
    pub fn authorization(&self, audience: &str, contact: &str) -> Result<String, String> {
75
        let now = SystemTime::now()
76
            .duration_since(UNIX_EPOCH)
77
            .map_err(|_| "the clock is before 1970".to_owned())?
78
            .as_secs();
79
        let header = URL_SAFE_NO_PAD.encode(br#"{"typ":"JWT","alg":"ES256"}"#);
80
        let claims = serde_json::json!({
81
            "aud": audience,
82
            "exp": now + TOKEN_LIFETIME_SECS,
83
            "sub": contact,
84
        });
85
        let claims = URL_SAFE_NO_PAD.encode(claims.to_string());
86
        let signing_input = format!("{header}.{claims}");
87
        let signature = self
88
            .key
89
            .sign(&SystemRandom::new(), signing_input.as_bytes())
90
            .map_err(|_| "signing the VAPID token failed".to_owned())?;
91
        Ok(format!(
92
            "vapid t={signing_input}.{}, k={}",
93
            URL_SAFE_NO_PAD.encode(signature.as_ref()),
94
            self.public
95
        ))
96
    }
97
}
98
99
#[cfg(test)]
100
mod tests {
101
    use aws_lc_rs::signature::{ECDSA_P256_SHA256_FIXED, UnparsedPublicKey};
102
103
    use super::*;
104
105
    #[test]
106
    fn key_persists_owner_only_and_signs_verifiable_tokens() {
107
        let dir = tempfile::tempdir().unwrap();
108
        let path = dir.path().join("vapid.pk8");
109
        let first = Vapid::load_or_create(&path).unwrap();
110
        let again = Vapid::load_or_create(&path).unwrap();
111
        assert_eq!(first.public_key(), again.public_key());
112
        let public = URL_SAFE_NO_PAD.decode(first.public_key()).unwrap();
113
        assert_eq!(public.len(), 65);
114
        assert_eq!(public[0], 4);
115
        #[cfg(unix)]
116
        {
117
            use std::os::unix::fs::PermissionsExt;
118
            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
119
            assert_eq!(mode & 0o777, 0o600);
120
        }
121
122
        let header = first
123
            .authorization("https://fcm.googleapis.com", "mailto:a@example.org")
124
            .unwrap();
125
        let (token, key) = header
126
            .strip_prefix("vapid t=")
127
            .unwrap()
128
            .split_once(", k=")
129
            .unwrap();
130
        assert_eq!(key, first.public_key());
131
        let (input, signature) = token.rsplit_once('.').unwrap();
132
        UnparsedPublicKey::new(&ECDSA_P256_SHA256_FIXED, &public)
133
            .verify(
134
                input.as_bytes(),
135
                &URL_SAFE_NO_PAD.decode(signature).unwrap(),
136
            )
137
            .unwrap();
138
        let claims: serde_json::Value = serde_json::from_slice(
139
            &URL_SAFE_NO_PAD
140
                .decode(input.split_once('.').unwrap().1)
141
                .unwrap(),
142
        )
143
        .unwrap();
144
        assert_eq!(claims["aud"], "https://fcm.googleapis.com");
145
        assert_eq!(claims["sub"], "mailto:a@example.org");
146
    }
147
}