Skip to main content

cratefield_push_auth/
es256.rs

1//! ES256 (ECDSA P-256 + SHA-256) provider JWTs: APNs today, VAPID next.
2
3use base64::Engine;
4use base64::engine::general_purpose::URL_SAFE_NO_PAD;
5use p256::ecdsa::SigningKey;
6use p256::ecdsa::signature::Signer as _;
7use p256::pkcs8::DecodePrivateKey as _;
8use serde_json::Value;
9
10use crate::{KeyError, signing_input};
11
12/// Signs ES256 JWTs with a P-256 private key.
13///
14/// The nonce is RFC 6979 deterministic, so the same key over the same input
15/// always yields the same token — which is what lets the APNs adapter's
16/// cache be a plain "same token until it ages out", and lets a test assert
17/// an exact token string.
18pub struct Es256Signer {
19    key: SigningKey,
20}
21
22impl Es256Signer {
23    /// Parses a PKCS#8 PEM P-256 private key.
24    ///
25    /// # Errors
26    ///
27    /// [`KeyError::Parse`] if the PEM is not a PKCS#8 P-256 private key.
28    pub fn from_pkcs8_pem(pem: &str) -> Result<Self, KeyError> {
29        SigningKey::from_pkcs8_pem(pem)
30            .map(|key| Self { key })
31            .map_err(|err| KeyError::parse("ES256", err))
32    }
33
34    /// Apple's name for the same thing: the `.p8` downloaded from the
35    /// developer portal is a PKCS#8 PEM.
36    ///
37    /// # Errors
38    ///
39    /// [`KeyError::Parse`] if the `.p8` is not a PKCS#8 P-256 private key.
40    pub fn from_p8_pem(pem: &str) -> Result<Self, KeyError> {
41        Self::from_pkcs8_pem(pem)
42    }
43
44    /// The bare 32-byte P-256 private scalar, with no PKCS#8 wrapper around
45    /// it — which is how a VAPID key is usually handed over (issue #180).
46    /// Every generator in that ecosystem prints the scalar base64url and
47    /// calls it `VAPID_PRIVATE_KEY`; the caller decodes it and passes the
48    /// bytes.
49    ///
50    /// # Errors
51    ///
52    /// [`KeyError::Parse`] if the scalar is zero or not below the curve
53    /// order, neither of which is a usable private key.
54    pub fn from_scalar(scalar: &[u8; 32]) -> Result<Self, KeyError> {
55        SigningKey::from_slice(scalar)
56            .map(|key| Self { key })
57            .map_err(|err| KeyError::parse("ES256", err))
58    }
59
60    /// Signs `header`/`claims` into a compact JWS.
61    ///
62    /// The caller owns the claims because they differ per provider: APNs
63    /// wants `iss`/`iat`, VAPID wants `aud`/`sub`/`exp`. `alg` and `kid`
64    /// belong in `header`.
65    #[must_use]
66    pub fn sign_jwt(&self, header: &Value, claims: &Value) -> String {
67        let input = signing_input(header, claims);
68        // ES256 is a fixed 64-byte r||s signature — exactly JWS's encoding,
69        // so there is no DER to unwrap.
70        let signature: p256::ecdsa::Signature = self.key.sign(input.as_bytes());
71        format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature.to_bytes()))
72    }
73
74    /// The public key as an uncompressed SEC1 point (`0x04 || x || y`), the
75    /// form VAPID sends as `k=` and a JWKS splits into `x`/`y`.
76    #[must_use]
77    pub fn public_key_uncompressed(&self) -> [u8; 65] {
78        let point = self.key.verifying_key().to_sec1_point(false);
79        let mut out = [0u8; 65];
80        // An uncompressed P-256 point is always 65 bytes.
81        out.copy_from_slice(point.as_bytes());
82        out
83    }
84}
85
86impl std::fmt::Debug for Es256Signer {
87    /// Never prints the key.
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.write_str("Es256Signer { .. }")
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use p256::ecdsa::signature::Verifier as _;
97    use serde_json::json;
98
99    // A throwaway P-256 key, generated for these tests only — NOT an Apple
100    // key. The same one `crates/adapter-apns/tests/apns.rs` uses, so the two
101    // suites assert the same bytes.
102    const TEST_P8: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcXMgRpW+eLn7ZvCx\nIuTdd8csWMZ69azlRzS0dy2FN6GhRANCAATJ6GazR2lhWcC3JYsazLR0uWOyDKrC\nmeP4HPWghRmfoa4z3Ux7mG3Ylz+auRaBukKGicSdSvVG+jGeQwr3fNag\n-----END PRIVATE KEY-----";
103
104    #[test]
105    fn rejects_a_pem_that_is_not_a_key() {
106        let error = Es256Signer::from_p8_pem("not a key").unwrap_err();
107        assert!(error.to_string().contains("ES256"), "{error}");
108    }
109
110    #[test]
111    fn signs_a_verifiable_jwt() {
112        let signer = Es256Signer::from_p8_pem(TEST_P8).unwrap();
113        let jwt = signer.sign_jwt(
114            &json!({ "alg": "ES256", "kid": "ABC1234567" }),
115            &json!({ "iss": "TEAM987654", "iat": 1_700_000_000 }),
116        );
117
118        let parts: Vec<&str> = jwt.split('.').collect();
119        assert_eq!(parts.len(), 3, "header.claims.signature");
120
121        // The signature verifies against the key's own public half.
122        let verifying = signer.key.verifying_key();
123        let signature = p256::ecdsa::Signature::from_slice(
124            &URL_SAFE_NO_PAD.decode(parts[2]).expect("base64url"),
125        )
126        .expect("64-byte r||s");
127        let input = format!("{}.{}", parts[0], parts[1]);
128        verifying
129            .verify(input.as_bytes(), &signature)
130            .expect("the JWT verifies under its own key");
131    }
132
133    /// RFC 6979 makes ES256 deterministic here, so the token is a fixed
134    /// string — the strongest form of "this did not silently change".
135    #[test]
136    fn the_token_is_a_fixed_string_for_a_fixed_key_and_claims() {
137        let signer = Es256Signer::from_p8_pem(TEST_P8).unwrap();
138        let jwt = signer.sign_jwt(
139            &json!({ "alg": "ES256", "kid": "ABC1234567" }),
140            &json!({ "iss": "TEAM987654", "iat": 1_700_000_000 }),
141        );
142        assert_eq!(
143            jwt,
144            "eyJhbGciOiJFUzI1NiIsImtpZCI6IkFCQzEyMzQ1NjcifQ.\
145             eyJpc3MiOiJURUFNOTg3NjU0IiwiaWF0IjoxNzAwMDAwMDAwfQ.\
146             FLaMDTVoUcLQhdV3iub8kZEpb2FRTQcrjNgvBvt8mIdsVi6hnRSrdAgbM06sQEsGKInnfX_M1sknBYJIsrAH0Q"
147        );
148    }
149
150    /// The PKCS#8 wrapper carries the same scalar, so a signer built from
151    /// the bare scalar must be the *same* signer — that is what lets a VAPID
152    /// key be configured in either form (issue #180).
153    #[test]
154    fn a_bare_scalar_and_its_pkcs8_wrapper_are_the_same_key() {
155        let from_pem = Es256Signer::from_p8_pem(TEST_P8).unwrap();
156        let scalar: [u8; 32] = from_pem.key.to_bytes().into();
157        let from_scalar = Es256Signer::from_scalar(&scalar).unwrap();
158
159        assert_eq!(
160            from_scalar.public_key_uncompressed(),
161            from_pem.public_key_uncompressed()
162        );
163        let header = json!({ "alg": "ES256" });
164        let claims = json!({ "sub": "mailto:ops@example.test" });
165        assert_eq!(
166            from_scalar.sign_jwt(&header, &claims),
167            from_pem.sign_jwt(&header, &claims)
168        );
169    }
170
171    #[test]
172    fn a_scalar_that_is_not_a_private_key_is_refused() {
173        // Zero is not a valid P-256 scalar, and neither is the curve order
174        // itself (n, from FIPS 186-4 D.1.2.3) or anything above it.
175        let error = Es256Signer::from_scalar(&[0u8; 32]).unwrap_err();
176        assert!(error.to_string().contains("ES256"), "{error}");
177        let order = [
178            0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
179            0xff, 0xff, 0xbc, 0xe6, 0xfa, 0xad, 0xa7, 0x17, 0x9e, 0x84, 0xf3, 0xb9, 0xca, 0xc2,
180            0xfc, 0x63, 0x25, 0x51,
181        ];
182        assert!(Es256Signer::from_scalar(&order).is_err());
183    }
184
185    #[test]
186    fn the_public_key_is_an_uncompressed_sec1_point() {
187        let signer = Es256Signer::from_p8_pem(TEST_P8).unwrap();
188        let point = signer.public_key_uncompressed();
189        assert_eq!(point.len(), 65);
190        assert_eq!(point[0], 0x04, "uncompressed marker");
191        // It is the public half of this key: signing with the private key
192        // verifies under a key rebuilt from these bytes.
193        let verifying =
194            p256::ecdsa::VerifyingKey::from_sec1_bytes(&point).expect("valid SEC1 point");
195        assert_eq!(&verifying, signer.key.verifying_key());
196    }
197}