ppoppo_token/signing_key.rs
1//! Opaque Ed25519 signing key with associated `kid`.
2//!
3//! Wraps `jsonwebtoken::EncodingKey` so the public surface stays free of
4//! `jsonwebtoken::*` (M51 boundary). The `kid` travels with the key
5//! because JWT kids are issuer-chosen — there's no library-derived
6//! identifier the way PASETO has PASERK pid. Pairing kid+key here lets
7//! the engine reject mismatched configurations (`IssueError::KeyMismatch`)
8//! before any encoding work happens.
9//!
10//! Companion: [`ed25519_public_from_pem`] derives the 32-byte public key
11//! half from the same PEM that builds a `SigningKey`. PAS uses it at boot
12//! to populate the JWKS document without re-encoding the key material.
13
14use jsonwebtoken::{DecodingKey, EncodingKey};
15
16use crate::KeySet;
17use crate::access_token::IssueError;
18
19pub struct SigningKey {
20 inner: EncodingKey,
21 kid: String,
22}
23
24/// Debug shows the `kid` only — never the `EncodingKey` material.
25impl std::fmt::Debug for SigningKey {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 f.debug_struct("SigningKey")
28 .field("kid", &self.kid)
29 .finish_non_exhaustive()
30 }
31}
32
33impl SigningKey {
34 /// Parse an Ed25519 private key from PEM and pair it with the given
35 /// `kid`. Returns `KeyParse` on any decode failure — the variant
36 /// name is the audit signal; the wrapped string carries the
37 /// library's diagnostic for incident response.
38 pub fn from_ed25519_pem(pem: &[u8], kid: impl Into<String>) -> Result<Self, IssueError> {
39 let inner =
40 EncodingKey::from_ed_pem(pem).map_err(|e| IssueError::KeyParse(e.to_string()))?;
41 Ok(Self {
42 inner,
43 kid: kid.into(),
44 })
45 }
46
47 /// Returns the `kid` associated with this signer.
48 pub fn kid(&self) -> &str {
49 &self.kid
50 }
51
52 /// Test-only constructor — returns a `(SigningKey, KeySet)` pair
53 /// where the KeySet already carries the matching decoding key under
54 /// the same kid. Round-trip integration tests rely on this contract:
55 /// `issue` with the signing half and `verify` against the key set
56 /// must agree without manual wiring.
57 ///
58 /// The PEM constants are deterministic across runs (checked-in test
59 /// material; same key as `tests/jwt_negative.rs::TEST_PRIVATE_KEY_PEM`),
60 /// so failures are reproducible. The private key has no production
61 /// value.
62 #[allow(clippy::expect_used)]
63 pub fn test_pair() -> (Self, KeySet) {
64 const TEST_PRIVATE_KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
65MC4CAQAwBQYDK2VwBCIEIG+00IvEd4uv6IWtGFVUEBVdqnXiuI/ESQHu6rmcDvAs
66-----END PRIVATE KEY-----
67";
68 const TEST_PUBLIC_KEY_PEM: &[u8] = b"-----BEGIN PUBLIC KEY-----
69MCowBQYDK2VwAyEAh//e6j3It3xhjghg8Kpn2pM0jMCH/cvemGu4vv7D1Q4=
70-----END PUBLIC KEY-----
71";
72 const TEST_KID: &str = "k4.test.0";
73
74 let signer = Self::from_ed25519_pem(TEST_PRIVATE_KEY_PEM, TEST_KID)
75 .expect("checked-in test PEM should always parse");
76 let mut key_set = KeySet::new();
77 let dec = DecodingKey::from_ed_pem(TEST_PUBLIC_KEY_PEM)
78 .expect("checked-in test PEM should always parse");
79 key_set.insert(TEST_KID, dec);
80 (signer, key_set)
81 }
82
83 #[allow(dead_code)] // wired up by `engine::encode::issue` in commit 3.3
84 pub(crate) fn encoding(&self) -> &EncodingKey {
85 &self.inner
86 }
87}
88
89/// Derive the 32-byte Ed25519 public key from a PKCS8-encoded private
90/// PEM. Used by PAS at boot to populate `/.well-known/jwks.json` from the
91/// same key material that produces issuance signatures, so issuer and
92/// publisher cannot drift.
93///
94/// Internals: `ed25519-compact` parses the PEM and exposes the matching
95/// public key. We avoid `jsonwebtoken`'s own EncodingKey here because it
96/// does not expose pubkey extraction on its public API. `ed25519-compact`
97/// is the smallest pure-Rust path; it carries no `unsafe`, no I/O, no
98/// global state.
99///
100/// Errors as `IssueError::KeyParse` to match `from_ed25519_pem`'s error
101/// shape (operators see one variant for "private PEM didn't load");
102/// the wrapped string carries the underlying library's diagnostic.
103pub fn ed25519_public_from_pem(pem: &[u8]) -> Result<[u8; 32], IssueError> {
104 let pem_str =
105 std::str::from_utf8(pem).map_err(|e| IssueError::KeyParse(format!("PEM utf8: {e}")))?;
106 let secret = ed25519_compact::SecretKey::from_pem(pem_str)
107 .map_err(|e| IssueError::KeyParse(format!("ed25519 pem decode: {e}")))?;
108 Ok(*secret.public_key())
109}
110
111#[cfg(test)]
112mod tests {
113 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
114 use super::*;
115
116 #[test]
117 fn public_from_pem_matches_test_pair_public_key() {
118 // The test_pair public key bytes come from a known DER blob in
119 // signing_key.rs. The PEM-side derivation must agree with the
120 // public PEM that test_pair already encodes — this is the
121 // round-trip that PAS depends on at boot for JWKS construction.
122 const TEST_PRIVATE_KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
123MC4CAQAwBQYDK2VwBCIEIG+00IvEd4uv6IWtGFVUEBVdqnXiuI/ESQHu6rmcDvAs
124-----END PRIVATE KEY-----
125";
126 // SPKI DER for the matching public key. Last 32 bytes = raw pk.
127 const TEST_PUBLIC_KEY_SPKI_B64: &str =
128 "MCowBQYDK2VwAyEAh//e6j3It3xhjghg8Kpn2pM0jMCH/cvemGu4vv7D1Q4=";
129
130 use base64::Engine as _;
131 let spki = base64::engine::general_purpose::STANDARD
132 .decode(TEST_PUBLIC_KEY_SPKI_B64)
133 .expect("test SPKI must decode");
134 let expected: [u8; 32] = spki[12..].try_into().expect("SPKI carries 32-byte pk");
135
136 let derived =
137 ed25519_public_from_pem(TEST_PRIVATE_KEY_PEM).expect("checked-in test PEM must derive");
138 assert_eq!(
139 derived, expected,
140 "PEM-derived public key must match the test_pair fixture",
141 );
142 }
143
144 #[test]
145 fn public_from_pem_rejects_non_pem() {
146 let err = ed25519_public_from_pem(b"not a pem at all").expect_err("garbage must reject");
147 match err {
148 IssueError::KeyParse(_) => {}
149 other => panic!("expected KeyParse, got {other:?}"),
150 }
151 }
152}