ppoppo-token 0.2.0

JWT (RFC 9068, EdDSA) issuance + verification engine for the Ppoppo ecosystem. Single deep module with a small interface (issue, verify) hiding RFC 8725 mitigations M01-M45, JWKS handling, and substrate ports (epoch, session, replay).
Documentation
//! Opaque Ed25519 signing key with associated `kid`.
//!
//! Wraps `jsonwebtoken::EncodingKey` so the public surface stays free of
//! `jsonwebtoken::*` (M51 boundary). The `kid` travels with the key
//! because JWT kids are issuer-chosen — there's no library-derived
//! identifier the way PASETO has PASERK pid. Pairing kid+key here lets
//! the engine reject mismatched configurations (`IssueError::KeyMismatch`)
//! before any encoding work happens.
//!
//! Companion: [`ed25519_public_from_pem`] derives the 32-byte public key
//! half from the same PEM that builds a `SigningKey`. PAS uses it at boot
//! to populate the JWKS document without re-encoding the key material.

use jsonwebtoken::{DecodingKey, EncodingKey};

use crate::access_token::IssueError;
use crate::KeySet;

pub struct SigningKey {
    inner: EncodingKey,
    kid: String,
}

impl SigningKey {
    /// Parse an Ed25519 private key from PEM and pair it with the given
    /// `kid`. Returns `KeyParse` on any decode failure — the variant
    /// name is the audit signal; the wrapped string carries the
    /// library's diagnostic for incident response.
    pub fn from_ed25519_pem(pem: &[u8], kid: impl Into<String>) -> Result<Self, IssueError> {
        let inner =
            EncodingKey::from_ed_pem(pem).map_err(|e| IssueError::KeyParse(e.to_string()))?;
        Ok(Self {
            inner,
            kid: kid.into(),
        })
    }

    /// Returns the `kid` associated with this signer.
    pub fn kid(&self) -> &str {
        &self.kid
    }

    /// Test-only constructor — returns a `(SigningKey, KeySet)` pair
    /// where the KeySet already carries the matching decoding key under
    /// the same kid. Round-trip integration tests rely on this contract:
    /// `issue` with the signing half and `verify` against the key set
    /// must agree without manual wiring.
    ///
    /// The PEM constants are deterministic across runs (checked-in test
    /// material; same key as `tests/jwt_negative.rs::TEST_PRIVATE_KEY_PEM`),
    /// so failures are reproducible. The private key has no production
    /// value.
    #[allow(clippy::expect_used)]
    pub fn test_pair() -> (Self, KeySet) {
        const TEST_PRIVATE_KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIG+00IvEd4uv6IWtGFVUEBVdqnXiuI/ESQHu6rmcDvAs
-----END PRIVATE KEY-----
";
        const TEST_PUBLIC_KEY_PEM: &[u8] = b"-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAh//e6j3It3xhjghg8Kpn2pM0jMCH/cvemGu4vv7D1Q4=
-----END PUBLIC KEY-----
";
        const TEST_KID: &str = "k4.test.0";

        let signer = Self::from_ed25519_pem(TEST_PRIVATE_KEY_PEM, TEST_KID)
            .expect("checked-in test PEM should always parse");
        let mut key_set = KeySet::new();
        let dec = DecodingKey::from_ed_pem(TEST_PUBLIC_KEY_PEM)
            .expect("checked-in test PEM should always parse");
        key_set.insert(TEST_KID, dec);
        (signer, key_set)
    }

    #[allow(dead_code)] // wired up by `engine::encode::issue` in commit 3.3
    pub(crate) fn encoding(&self) -> &EncodingKey {
        &self.inner
    }
}

/// Derive the 32-byte Ed25519 public key from a PKCS8-encoded private
/// PEM. Used by PAS at boot to populate `/.well-known/jwks.json` from the
/// same key material that produces issuance signatures, so issuer and
/// publisher cannot drift.
///
/// Internals: `ed25519-compact` parses the PEM and exposes the matching
/// public key. We avoid `jsonwebtoken`'s own EncodingKey here because it
/// does not expose pubkey extraction on its public API. `ed25519-compact`
/// is the smallest pure-Rust path; it carries no `unsafe`, no I/O, no
/// global state.
///
/// Errors as `IssueError::KeyParse` to match `from_ed25519_pem`'s error
/// shape (operators see one variant for "private PEM didn't load");
/// the wrapped string carries the underlying library's diagnostic.
pub fn ed25519_public_from_pem(pem: &[u8]) -> Result<[u8; 32], IssueError> {
    let pem_str = std::str::from_utf8(pem)
        .map_err(|e| IssueError::KeyParse(format!("PEM utf8: {e}")))?;
    let secret = ed25519_compact::SecretKey::from_pem(pem_str)
        .map_err(|e| IssueError::KeyParse(format!("ed25519 pem decode: {e}")))?;
    Ok(*secret.public_key())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn public_from_pem_matches_test_pair_public_key() {
        // The test_pair public key bytes come from a known DER blob in
        // signing_key.rs. The PEM-side derivation must agree with the
        // public PEM that test_pair already encodes — this is the
        // round-trip that PAS depends on at boot for JWKS construction.
        const TEST_PRIVATE_KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIG+00IvEd4uv6IWtGFVUEBVdqnXiuI/ESQHu6rmcDvAs
-----END PRIVATE KEY-----
";
        // SPKI DER for the matching public key. Last 32 bytes = raw pk.
        const TEST_PUBLIC_KEY_SPKI_B64: &str =
            "MCowBQYDK2VwAyEAh//e6j3It3xhjghg8Kpn2pM0jMCH/cvemGu4vv7D1Q4=";

        use base64::Engine as _;
        let spki = base64::engine::general_purpose::STANDARD
            .decode(TEST_PUBLIC_KEY_SPKI_B64)
            .expect("test SPKI must decode");
        let expected: [u8; 32] = spki[12..].try_into().expect("SPKI carries 32-byte pk");

        let derived = ed25519_public_from_pem(TEST_PRIVATE_KEY_PEM)
            .expect("checked-in test PEM must derive");
        assert_eq!(
            derived, expected,
            "PEM-derived public key must match the test_pair fixture",
        );
    }

    #[test]
    fn public_from_pem_rejects_non_pem() {
        let err = ed25519_public_from_pem(b"not a pem at all").expect_err("garbage must reject");
        match err {
            IssueError::KeyParse(_) => {}
            other => panic!("expected KeyParse, got {other:?}"),
        }
    }
}