cratefield-push-auth 0.1.1

Provider-JWT signing for push adapters: ES256 (APNs, VAPID) and RS256 (Google service accounts), with a keyed token cache
Documentation
//! ES256 (ECDSA P-256 + SHA-256) provider JWTs: APNs today, VAPID next.

use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use p256::ecdsa::SigningKey;
use p256::ecdsa::signature::Signer as _;
use p256::pkcs8::DecodePrivateKey as _;
use serde_json::Value;

use crate::{KeyError, signing_input};

/// Signs ES256 JWTs with a P-256 private key.
///
/// The nonce is RFC 6979 deterministic, so the same key over the same input
/// always yields the same token — which is what lets the APNs adapter's
/// cache be a plain "same token until it ages out", and lets a test assert
/// an exact token string.
pub struct Es256Signer {
    key: SigningKey,
}

impl Es256Signer {
    /// Parses a PKCS#8 PEM P-256 private key.
    ///
    /// # Errors
    ///
    /// [`KeyError::Parse`] if the PEM is not a PKCS#8 P-256 private key.
    pub fn from_pkcs8_pem(pem: &str) -> Result<Self, KeyError> {
        SigningKey::from_pkcs8_pem(pem)
            .map(|key| Self { key })
            .map_err(|err| KeyError::parse("ES256", err))
    }

    /// Apple's name for the same thing: the `.p8` downloaded from the
    /// developer portal is a PKCS#8 PEM.
    ///
    /// # Errors
    ///
    /// [`KeyError::Parse`] if the `.p8` is not a PKCS#8 P-256 private key.
    pub fn from_p8_pem(pem: &str) -> Result<Self, KeyError> {
        Self::from_pkcs8_pem(pem)
    }

    /// Signs `header`/`claims` into a compact JWS.
    ///
    /// The caller owns the claims because they differ per provider: APNs
    /// wants `iss`/`iat`, VAPID wants `aud`/`sub`/`exp`. `alg` and `kid`
    /// belong in `header`.
    #[must_use]
    pub fn sign_jwt(&self, header: &Value, claims: &Value) -> String {
        let input = signing_input(header, claims);
        // ES256 is a fixed 64-byte r||s signature — exactly JWS's encoding,
        // so there is no DER to unwrap.
        let signature: p256::ecdsa::Signature = self.key.sign(input.as_bytes());
        format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature.to_bytes()))
    }

    /// The public key as an uncompressed SEC1 point (`0x04 || x || y`), the
    /// form VAPID sends as `k=` and a JWKS splits into `x`/`y`.
    #[must_use]
    pub fn public_key_uncompressed(&self) -> [u8; 65] {
        let point = self.key.verifying_key().to_encoded_point(false);
        let mut out = [0u8; 65];
        // An uncompressed P-256 point is always 65 bytes.
        out.copy_from_slice(point.as_bytes());
        out
    }
}

impl std::fmt::Debug for Es256Signer {
    /// Never prints the key.
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("Es256Signer { .. }")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use p256::ecdsa::signature::Verifier as _;
    use serde_json::json;

    // A throwaway P-256 key, generated for these tests only — NOT an Apple
    // key. The same one `crates/adapter-apns/tests/apns.rs` uses, so the two
    // suites assert the same bytes.
    const TEST_P8: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcXMgRpW+eLn7ZvCx\nIuTdd8csWMZ69azlRzS0dy2FN6GhRANCAATJ6GazR2lhWcC3JYsazLR0uWOyDKrC\nmeP4HPWghRmfoa4z3Ux7mG3Ylz+auRaBukKGicSdSvVG+jGeQwr3fNag\n-----END PRIVATE KEY-----";

    #[test]
    fn rejects_a_pem_that_is_not_a_key() {
        let error = Es256Signer::from_p8_pem("not a key").unwrap_err();
        assert!(error.to_string().contains("ES256"), "{error}");
    }

    #[test]
    fn signs_a_verifiable_jwt() {
        let signer = Es256Signer::from_p8_pem(TEST_P8).unwrap();
        let jwt = signer.sign_jwt(
            &json!({ "alg": "ES256", "kid": "ABC1234567" }),
            &json!({ "iss": "TEAM987654", "iat": 1_700_000_000 }),
        );

        let parts: Vec<&str> = jwt.split('.').collect();
        assert_eq!(parts.len(), 3, "header.claims.signature");

        // The signature verifies against the key's own public half.
        let verifying = signer.key.verifying_key();
        let signature = p256::ecdsa::Signature::from_slice(
            &URL_SAFE_NO_PAD.decode(parts[2]).expect("base64url"),
        )
        .expect("64-byte r||s");
        let input = format!("{}.{}", parts[0], parts[1]);
        verifying
            .verify(input.as_bytes(), &signature)
            .expect("the JWT verifies under its own key");
    }

    /// RFC 6979 makes ES256 deterministic here, so the token is a fixed
    /// string — the strongest form of "this did not silently change".
    #[test]
    fn the_token_is_a_fixed_string_for_a_fixed_key_and_claims() {
        let signer = Es256Signer::from_p8_pem(TEST_P8).unwrap();
        let jwt = signer.sign_jwt(
            &json!({ "alg": "ES256", "kid": "ABC1234567" }),
            &json!({ "iss": "TEAM987654", "iat": 1_700_000_000 }),
        );
        assert_eq!(
            jwt,
            "eyJhbGciOiJFUzI1NiIsImtpZCI6IkFCQzEyMzQ1NjcifQ.\
             eyJpc3MiOiJURUFNOTg3NjU0IiwiaWF0IjoxNzAwMDAwMDAwfQ.\
             FLaMDTVoUcLQhdV3iub8kZEpb2FRTQcrjNgvBvt8mIdsVi6hnRSrdAgbM06sQEsGKInnfX_M1sknBYJIsrAH0Q"
        );
    }

    #[test]
    fn the_public_key_is_an_uncompressed_sec1_point() {
        let signer = Es256Signer::from_p8_pem(TEST_P8).unwrap();
        let point = signer.public_key_uncompressed();
        assert_eq!(point.len(), 65);
        assert_eq!(point[0], 0x04, "uncompressed marker");
        // It is the public half of this key: signing with the private key
        // verifies under a key rebuilt from these bytes.
        let verifying =
            p256::ecdsa::VerifyingKey::from_sec1_bytes(&point).expect("valid SEC1 point");
        assert_eq!(&verifying, signer.key.verifying_key());
    }
}