Skip to main content

cratefield_push_auth/
rs256.rs

1//! RS256 (RSASSA-PKCS1-v1_5 + SHA-256) service-account JWTs: Google's
2//! OAuth 2.0 server-to-server flow, which FCM v1 needs for its bearer token.
3
4use base64::Engine;
5use base64::engine::general_purpose::URL_SAFE_NO_PAD;
6use rsa::pkcs1::DecodeRsaPrivateKey as _;
7use rsa::pkcs1v15::SigningKey;
8use rsa::pkcs8::DecodePrivateKey as _;
9use rsa::sha2::Sha256;
10use rsa::signature::{SignatureEncoding as _, Signer as _};
11use serde_json::Value;
12
13use crate::{KeyError, signing_input};
14
15/// Signs RS256 JWTs with an RSA private key.
16///
17/// Google's service-account JSON ships its key as PKCS#8
18/// (`-----BEGIN PRIVATE KEY-----`); older exports and other providers use the
19/// legacy PKCS#1 form (`-----BEGIN RSA PRIVATE KEY-----`). [`Self::from_pem`]
20/// takes either, so a venture never has to know which it was handed.
21///
22/// PKCS#1 v1.5 signing is deterministic and takes no RNG, which is what lets
23/// this run on a Workers isolate. That is a portability property, not a
24/// security one: `rsa` 0.9 blinds the private-key modexp only in
25/// `sign_with_rng`, so signing here is **unblinded** and carries no timing
26/// countermeasure. It is safe in this crate because there is no decryption
27/// oracle, the claims are ours rather than an attacker's, and the JWT is
28/// minted off the request path where nobody can time it — see the
29/// RUSTSEC-2023-0071 acceptance in `deny.toml`. Signing attacker-supplied
30/// bytes, or signing where the duration can be observed, re-opens that.
31pub struct Rs256Signer {
32    key: SigningKey<Sha256>,
33}
34
35impl Rs256Signer {
36    /// Parses a PKCS#8 PEM RSA private key — the form Google's
37    /// service-account JSON carries in `private_key`.
38    ///
39    /// # Errors
40    ///
41    /// [`KeyError::Parse`] if the PEM is not a PKCS#8 RSA private key.
42    pub fn from_pkcs8_pem(pem: &str) -> Result<Self, KeyError> {
43        rsa::RsaPrivateKey::from_pkcs8_pem(pem)
44            .map(|key| Self {
45                key: SigningKey::new(key),
46            })
47            .map_err(|err| KeyError::parse("RS256", err))
48    }
49
50    /// Parses the legacy PKCS#1 PEM form
51    /// (`-----BEGIN RSA PRIVATE KEY-----`).
52    ///
53    /// # Errors
54    ///
55    /// [`KeyError::Parse`] if the PEM is not a PKCS#1 RSA private key.
56    pub fn from_pkcs1_pem(pem: &str) -> Result<Self, KeyError> {
57        rsa::RsaPrivateKey::from_pkcs1_pem(pem)
58            .map(|key| Self {
59                key: SigningKey::new(key),
60            })
61            .map_err(|err| KeyError::parse("RS256", err))
62    }
63
64    /// Parses either PEM form, choosing by the label so the error a caller
65    /// sees names the format they actually supplied.
66    ///
67    /// # Errors
68    ///
69    /// [`KeyError::Parse`] if the PEM is neither a PKCS#8 nor a PKCS#1 RSA
70    /// private key.
71    pub fn from_pem(pem: &str) -> Result<Self, KeyError> {
72        if pem.contains("BEGIN RSA PRIVATE KEY") {
73            Self::from_pkcs1_pem(pem)
74        } else {
75            Self::from_pkcs8_pem(pem)
76        }
77    }
78
79    /// Signs `header`/`claims` into a compact JWS.
80    #[must_use]
81    pub fn sign_jwt(&self, header: &Value, claims: &Value) -> String {
82        let input = signing_input(header, claims);
83        let signature = self.key.sign(input.as_bytes());
84        format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature.to_bytes()))
85    }
86}
87
88impl std::fmt::Debug for Rs256Signer {
89    /// Never prints the key.
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        f.write_str("Rs256Signer { .. }")
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use rsa::sha2::Digest as _;
99    use rsa::traits::PublicKeyParts as _;
100    use serde_json::json;
101
102    // Throwaway 2048-bit RSA keys, generated for these tests only. The two
103    // PEMs are the SAME key in the two encodings Google and its older
104    // exports use, so `from_pem` can be asserted to reach one signer.
105    const TEST_PKCS8: &str = "-----BEGIN PRIVATE KEY-----
106MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCgQ/qAORkSKZjW
107xMCbrhGXqbVCHpslcKpb3Ew/5qpRclIDXmJcaXehqY4swKuYdyqcQloRH5inZ/6U
108DR/p72s+6Gu5ExAje64hw2AChozIdmKA/xZYxPjQ5AtFXLofTglg1VeudH+6/bDw
109sXKGsaqRPWghb0lc1cai0ETHZDfP9frfJKjJRm2HlZqp8CKwwl1Jy6CQOGrjKlQX
110ThV07JKPpRPTZFscZTMzjG9fIfYCcxWt7c7HcPN4G7QUrx4oEX/jkuJcA2zgQ0WL
1110gJ4IKUc1BiNDP67UniFq8bxlcqePQc8MN3bBDrPKktX24u9ZJJZjx6otgBIWCaB
112nIKyuyfzAgMBAAECggEAS0Af+tzUfMazUQSJO4/8Cq5QwX8Fcgr4srE5zDdOeXeo
113MpS6spGC7pFihHjjGW+6viwZhjjDwLb/vhx7g6g7Pwp6qifdSAvms0u9ZPIwYF/V
1142KPtpji2a77n2+WyLsjBdoo15WAmKXK9BgcLs1rwr8mZfzl1xPVLk18fLFBONIKY
115ySiiGRQC2NZXfAEZF/pDMaBT5+my7hjZkw07XMrV//DnR71Gre2IPj0SNPWjS+Fd
116qMQq5UvxaJxLP6dDswYqXNvoWC7YAlsnf9ySD3ykLThATh4yUCL6y97wvKEdU9yb
1171mruLpOGNCnjCDqx+spY49qt+3uWBcj2BXjAPUlQoQKBgQDPOLZSz772jquTiyk0
1184OiUkCofmhLdpKU/vJW6TWGjGWv3MSUvi1wA5q/AByljCtr+/lwzkj1c9/6Hzo0N
119zv0kagaVRmb5INhTEu+1957lBP/WucHOvKPhiUvLjiH0YTqFSl+uB5ZbzbIxMOko
120T86cq9/qFkOl3BhR8CFoRWwbbwKBgQDF/avnjCFOEVzluU+FnNepR36mZWlhQPNt
121xT/wlavbPVQbIYbUJ4vKaU2OjIRE0fPJiC/XXEdD74LeX3gfoufau/M3hSmUDLoq
122DZCng5zGKtOlPMLQkf0Q2xVesM5eCJ2dPWZDR8jgkoXaLRa1YIFUofl24AkeabqO
123u56pgEwJvQKBgAaN+65o5dh0sNas6zPB/XldigeP3xLlt1hpxa6r7e+zySd7hXqY
124hON+aIbBczyvxjeUoiP7dzdunL18+hc6ueUh+W1VWcJ9mHogOjbeS0dhPhpzq763
125VtO2fRBGQaqyPKCktpwRn17uBbnqmyVsSNPJ1/5Wj/M6IAbPeq8Kqx2/AoGAPy6u
126lxu+3RzpWl4CpI7iu6CXKB6gvGpvxI3305zP1Q0DNA1E65sbHyLvnxf0dcnSVHPj
127YISQMXvTdYdd3Cqudr0X5pXWKOrO1fCyQuLbOtob5FU5jjmoWqKvdSJTGOsC8VTQ
128t5PG5POdR3ywDH2ZiBqQc4EXJ99xq27wOQM6QLkCgYB9f9GrE+DQIYBzb7hf1SMz
129ELvz+3ijy43njvIg4CihiUAzIuc0VJaVCKZcxIMZxLoDB6roeLoYBS+GhVrMoHUj
130mbv4voW/HXdaIlZUbMa0y9q0cg3Q8oaw5bwSzrSZxEkadudn4+MFKbLRvmYFX7We
1316OmL9MfIa/kocbiIIu0TSA==
132-----END PRIVATE KEY-----";
133
134    const TEST_PKCS1: &str = "-----BEGIN RSA PRIVATE KEY-----
135MIIEogIBAAKCAQEAoEP6gDkZEimY1sTAm64Rl6m1Qh6bJXCqW9xMP+aqUXJSA15i
136XGl3oamOLMCrmHcqnEJaER+Yp2f+lA0f6e9rPuhruRMQI3uuIcNgAoaMyHZigP8W
137WMT40OQLRVy6H04JYNVXrnR/uv2w8LFyhrGqkT1oIW9JXNXGotBEx2Q3z/X63ySo
138yUZth5WaqfAisMJdScugkDhq4ypUF04VdOySj6UT02RbHGUzM4xvXyH2AnMVre3O
139x3DzeBu0FK8eKBF/45LiXANs4ENFi9ICeCClHNQYjQz+u1J4havG8ZXKnj0HPDDd
1402wQ6zypLV9uLvWSSWY8eqLYASFgmgZyCsrsn8wIDAQABAoIBAEtAH/rc1HzGs1EE
141iTuP/AquUMF/BXIK+LKxOcw3Tnl3qDKUurKRgu6RYoR44xlvur4sGYY4w8C2/74c
142e4OoOz8Keqon3UgL5rNLvWTyMGBf1dij7aY4tmu+59vlsi7IwXaKNeVgJilyvQYH
143C7Na8K/JmX85dcT1S5NfHyxQTjSCmMkoohkUAtjWV3wBGRf6QzGgU+fpsu4Y2ZMN
144O1zK1f/w50e9Rq3tiD49EjT1o0vhXajEKuVL8WicSz+nQ7MGKlzb6Fgu2AJbJ3/c
145kg98pC04QE4eMlAi+sve8LyhHVPcm9Zq7i6ThjQp4wg6sfrKWOParft7lgXI9gV4
146wD1JUKECgYEAzzi2Us++9o6rk4spNODolJAqH5oS3aSlP7yVuk1hoxlr9zElL4tc
147AOavwAcpYwra/v5cM5I9XPf+h86NDc79JGoGlUZm+SDYUxLvtfee5QT/1rnBzryj
1484YlLy44h9GE6hUpfrgeWW82yMTDpKE/OnKvf6hZDpdwYUfAhaEVsG28CgYEAxf2r
14954whThFc5blPhZzXqUd+pmVpYUDzbcU/8JWr2z1UGyGG1CeLymlNjoyERNHzyYgv
15011xHQ++C3l94H6Ln2rvzN4UplAy6Kg2Qp4OcxirTpTzC0JH9ENsVXrDOXgidnT1m
151Q0fI4JKF2i0WtWCBVKH5duAJHmm6jrueqYBMCb0CgYAGjfuuaOXYdLDWrOszwf15
152XYoHj98S5bdYacWuq+3vs8kne4V6mITjfmiGwXM8r8Y3lKIj+3c3bpy9fPoXOrnl
153IfltVVnCfZh6IDo23ktHYT4ac6u+t1bTtn0QRkGqsjygpLacEZ9e7gW56pslbEjT
154ydf+Vo/zOiAGz3qvCqsdvwKBgD8urpcbvt0c6VpeAqSO4ruglygeoLxqb8SN99Oc
155z9UNAzQNROubGx8i758X9HXJ0lRz42CEkDF703WHXdwqrna9F+aV1ijqztXwskLi
1562zraG+RVOY45qFqir3UiUxjrAvFU0LeTxuTznUd8sAx9mYgakHOBFyffcatu8DkD
157OkC5AoGAfX/RqxPg0CGAc2+4X9UjMxC78/t4o8uN547yIOAooYlAMyLnNFSWlQim
158XMSDGcS6Aweq6Hi6GAUvhoVazKB1I5m7+L6Fvx13WiJWVGzGtMvatHIN0PKGsOW8
159Es60mcRJGnbnZ+PjBSmy0b5mBV+1nujpi/THyGv5KHG4iCLtE0g=
160-----END RSA PRIVATE KEY-----";
161
162    fn claims() -> (Value, Value) {
163        (
164            json!({ "alg": "RS256", "typ": "JWT" }),
165            json!({
166                "iss": "svc@project.iam.gserviceaccount.com",
167                "scope": "https://www.googleapis.com/auth/firebase.messaging",
168                "aud": "https://oauth2.googleapis.com/token",
169                "iat": 1_700_000_000,
170                "exp": 1_700_003_600,
171            }),
172        )
173    }
174
175    #[test]
176    fn signs_a_jwt_that_verifies_under_pkcs1v15() {
177        let signer = Rs256Signer::from_pkcs8_pem(TEST_PKCS8).unwrap();
178        let (header, payload) = claims();
179        let jwt = signer.sign_jwt(&header, &payload);
180
181        let parts: Vec<&str> = jwt.split('.').collect();
182        assert_eq!(parts.len(), 3, "header.claims.signature");
183        let signature = URL_SAFE_NO_PAD.decode(parts[2]).expect("base64url");
184        let public = rsa::RsaPublicKey::from(signer.key.as_ref());
185        assert_eq!(signature.len(), public.size(), "a full-width RSA signature");
186
187        // Verified the long way round — `Pkcs1v15Sign` over the SHA-256
188        // digest of the signing input — so the test does not just re-run the
189        // code it is testing.
190        let digest = rsa::sha2::Sha256::digest(format!("{}.{}", parts[0], parts[1]).as_bytes());
191        public
192            .verify(
193                rsa::Pkcs1v15Sign::new::<rsa::sha2::Sha256>(),
194                &digest,
195                &signature,
196            )
197            .expect("the JWT verifies under the matching public key");
198    }
199
200    #[test]
201    fn both_pem_encodings_parse_to_the_same_key() {
202        let (header, payload) = claims();
203        let from_pkcs8 = Rs256Signer::from_pem(TEST_PKCS8).unwrap();
204        let from_pkcs1 = Rs256Signer::from_pem(TEST_PKCS1).unwrap();
205        // PKCS#1 v1.5 is deterministic, so the same key over the same input
206        // is the same token, byte for byte.
207        assert_eq!(
208            from_pkcs8.sign_jwt(&header, &payload),
209            from_pkcs1.sign_jwt(&header, &payload)
210        );
211    }
212
213    #[test]
214    fn rejects_a_pem_that_is_not_a_key() {
215        let error = Rs256Signer::from_pem(
216            "-----BEGIN RSA PRIVATE KEY-----\nnope\n-----END RSA PRIVATE KEY-----",
217        )
218        .unwrap_err();
219        assert!(error.to_string().contains("RS256"), "{error}");
220        let error = Rs256Signer::from_pem("not a key at all").unwrap_err();
221        assert!(error.to_string().contains("RS256"), "{error}");
222    }
223}