1use std::fmt;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use aws_lc_rs::rand::SystemRandom;
14use aws_lc_rs::signature::{RSA_PKCS1_SHA256, RsaKeyPair};
15use base64::Engine;
16use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
17use vgi_forge::async_trait;
18use vgi_forge::{ForgeError, Result};
19use zeroize::Zeroizing;
20
21use crate::secret::Secret;
22
23pub const JWT_BACKDATE_SECS: u64 = 60;
26
27pub const JWT_LIFETIME_SECS: u64 = 9 * 60;
30
31#[async_trait]
36pub trait AppKeySigner: Send + Sync {
37 async fn sign_rs256(&self, message: &[u8]) -> Result<Vec<u8>>;
39}
40
41pub struct InProcessKey {
43 key: RsaKeyPair,
44}
45
46impl InProcessKey {
47 pub fn from_pem(pem: &str) -> Result<Self> {
50 let (label, der) = decode_pem(pem)?;
51 let key = match label.as_str() {
52 "RSA PRIVATE KEY" => RsaKeyPair::from_der(&der),
53 "PRIVATE KEY" => RsaKeyPair::from_pkcs8(&der),
54 other => {
55 return Err(ForgeError::Config(format!(
56 "expected an RSA private key PEM (`RSA PRIVATE KEY` or `PRIVATE KEY`), got \
57 `{other}`"
58 )));
59 }
60 }
61 .map_err(|e| ForgeError::Config(format!("the App private key was rejected: {e}")))?;
62 Ok(InProcessKey { key })
63 }
64
65 pub fn from_secret(pem: &Secret) -> Result<Self> {
67 Self::from_pem(pem.expose())
68 }
69}
70
71impl fmt::Debug for InProcessKey {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 f.write_str("InProcessKey(<redacted>)")
74 }
75}
76
77#[async_trait]
78impl AppKeySigner for InProcessKey {
79 async fn sign_rs256(&self, message: &[u8]) -> Result<Vec<u8>> {
80 let mut sig = vec![0u8; self.key.public_modulus_len()];
81 self.key
82 .sign(&RSA_PKCS1_SHA256, &SystemRandom::new(), message, &mut sig)
83 .map_err(|_| ForgeError::Config("RS256 signing failed".into()))?;
84 Ok(sig)
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
91pub struct AppClaims {
92 pub iat: u64,
94 pub exp: u64,
96 pub iss: String,
98}
99
100impl AppClaims {
101 pub fn at(now: u64, issuer: &str) -> Self {
103 AppClaims {
104 iat: now.saturating_sub(JWT_BACKDATE_SECS),
105 exp: now + JWT_LIFETIME_SECS,
106 iss: issuer.to_string(),
107 }
108 }
109}
110
111pub async fn app_jwt_at(signer: &dyn AppKeySigner, issuer: &str, now: u64) -> Result<Secret> {
113 let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"RS256","typ":"JWT"}"#);
114 let claims = serde_json::to_vec(&AppClaims::at(now, issuer))
115 .map_err(|e| ForgeError::Protocol(e.to_string()))?;
116 let signing_input = format!("{header}.{}", URL_SAFE_NO_PAD.encode(claims));
117 let signature = signer.sign_rs256(signing_input.as_bytes()).await?;
118 Ok(Secret::new(format!(
119 "{signing_input}.{}",
120 URL_SAFE_NO_PAD.encode(signature)
121 )))
122}
123
124pub async fn app_jwt(signer: &dyn AppKeySigner, issuer: &str) -> Result<Secret> {
126 let now = SystemTime::now()
127 .duration_since(UNIX_EPOCH)
128 .map_err(|_| ForgeError::Config("system clock is before 1970".into()))?
129 .as_secs();
130 app_jwt_at(signer, issuer, now).await
131}
132
133fn decode_pem(pem: &str) -> Result<(String, Zeroizing<Vec<u8>>)> {
135 let bad = |m: &str| ForgeError::Config(format!("App private key: {m}"));
136 let pem = pem.trim();
137 let first = pem.lines().next().unwrap_or_default().trim();
138 let label = first
139 .strip_prefix("-----BEGIN ")
140 .and_then(|l| l.strip_suffix("-----"))
141 .ok_or_else(|| bad("not a PEM block"))?
142 .to_string();
143 if pem.contains("ENCRYPTED") || pem.contains("Proc-Type:") {
144 return Err(bad(
145 "encrypted keys are not supported; supply the key GitHub issued",
146 ));
147 }
148 let end = format!("-----END {label}-----");
149 let body: Zeroizing<String> = Zeroizing::new(
150 pem.lines()
151 .skip(1)
152 .take_while(|l| l.trim() != end)
153 .map(str::trim)
154 .collect(),
155 );
156 if !pem.lines().any(|l| l.trim() == end) {
157 return Err(bad("missing the END line"));
158 }
159 let der = Zeroizing::new(
160 STANDARD
161 .decode(body.as_bytes())
162 .map_err(|_| bad("the PEM body is not valid base64"))?,
163 );
164 Ok((label, der))
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn claims_backdate_iat_and_stay_under_ten_minutes() {
173 let c = AppClaims::at(1_000_000, "Iv1.abc");
174 assert_eq!(c.iat, 1_000_000 - 60);
175 assert_eq!(c.exp, 1_000_000 + 540);
176 assert!(c.exp - 1_000_000 <= 600);
177 assert_eq!(c.iss, "Iv1.abc");
178 }
179
180 #[test]
181 fn pem_errors_are_specific() {
182 let e = InProcessKey::from_pem("not a key").unwrap_err();
183 assert!(e.to_string().contains("not a PEM block"), "{e}");
184 let e =
185 InProcessKey::from_pem("-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----")
186 .unwrap_err();
187 assert!(e.to_string().contains("CERTIFICATE"), "{e}");
188 let e = InProcessKey::from_pem(
189 "-----BEGIN ENCRYPTED PRIVATE KEY-----\nAAAA\n-----END ENCRYPTED PRIVATE KEY-----",
190 )
191 .unwrap_err();
192 assert!(e.to_string().contains("encrypted"), "{e}");
193 let e = InProcessKey::from_pem("-----BEGIN RSA PRIVATE KEY-----\nAAAA\n").unwrap_err();
194 assert!(e.to_string().contains("END"), "{e}");
195 }
196}