use std::fmt;
use std::time::{SystemTime, UNIX_EPOCH};
use aws_lc_rs::rand::SystemRandom;
use aws_lc_rs::signature::{RSA_PKCS1_SHA256, RsaKeyPair};
use base64::Engine;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use vgi_forge::async_trait;
use vgi_forge::{ForgeError, Result};
use zeroize::Zeroizing;
use crate::secret::Secret;
pub const JWT_BACKDATE_SECS: u64 = 60;
pub const JWT_LIFETIME_SECS: u64 = 9 * 60;
#[async_trait]
pub trait AppKeySigner: Send + Sync {
async fn sign_rs256(&self, message: &[u8]) -> Result<Vec<u8>>;
}
pub struct InProcessKey {
key: RsaKeyPair,
}
impl InProcessKey {
pub fn from_pem(pem: &str) -> Result<Self> {
let (label, der) = decode_pem(pem)?;
let key = match label.as_str() {
"RSA PRIVATE KEY" => RsaKeyPair::from_der(&der),
"PRIVATE KEY" => RsaKeyPair::from_pkcs8(&der),
other => {
return Err(ForgeError::Config(format!(
"expected an RSA private key PEM (`RSA PRIVATE KEY` or `PRIVATE KEY`), got \
`{other}`"
)));
}
}
.map_err(|e| ForgeError::Config(format!("the App private key was rejected: {e}")))?;
Ok(InProcessKey { key })
}
pub fn from_secret(pem: &Secret) -> Result<Self> {
Self::from_pem(pem.expose())
}
}
impl fmt::Debug for InProcessKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("InProcessKey(<redacted>)")
}
}
#[async_trait]
impl AppKeySigner for InProcessKey {
async fn sign_rs256(&self, message: &[u8]) -> Result<Vec<u8>> {
let mut sig = vec![0u8; self.key.public_modulus_len()];
self.key
.sign(&RSA_PKCS1_SHA256, &SystemRandom::new(), message, &mut sig)
.map_err(|_| ForgeError::Config("RS256 signing failed".into()))?;
Ok(sig)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AppClaims {
pub iat: u64,
pub exp: u64,
pub iss: String,
}
impl AppClaims {
pub fn at(now: u64, issuer: &str) -> Self {
AppClaims {
iat: now.saturating_sub(JWT_BACKDATE_SECS),
exp: now + JWT_LIFETIME_SECS,
iss: issuer.to_string(),
}
}
}
pub async fn app_jwt_at(signer: &dyn AppKeySigner, issuer: &str, now: u64) -> Result<Secret> {
let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"RS256","typ":"JWT"}"#);
let claims = serde_json::to_vec(&AppClaims::at(now, issuer))
.map_err(|e| ForgeError::Protocol(e.to_string()))?;
let signing_input = format!("{header}.{}", URL_SAFE_NO_PAD.encode(claims));
let signature = signer.sign_rs256(signing_input.as_bytes()).await?;
Ok(Secret::new(format!(
"{signing_input}.{}",
URL_SAFE_NO_PAD.encode(signature)
)))
}
pub async fn app_jwt(signer: &dyn AppKeySigner, issuer: &str) -> Result<Secret> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| ForgeError::Config("system clock is before 1970".into()))?
.as_secs();
app_jwt_at(signer, issuer, now).await
}
fn decode_pem(pem: &str) -> Result<(String, Zeroizing<Vec<u8>>)> {
let bad = |m: &str| ForgeError::Config(format!("App private key: {m}"));
let pem = pem.trim();
let first = pem.lines().next().unwrap_or_default().trim();
let label = first
.strip_prefix("-----BEGIN ")
.and_then(|l| l.strip_suffix("-----"))
.ok_or_else(|| bad("not a PEM block"))?
.to_string();
if pem.contains("ENCRYPTED") || pem.contains("Proc-Type:") {
return Err(bad(
"encrypted keys are not supported; supply the key GitHub issued",
));
}
let end = format!("-----END {label}-----");
let body: Zeroizing<String> = Zeroizing::new(
pem.lines()
.skip(1)
.take_while(|l| l.trim() != end)
.map(str::trim)
.collect(),
);
if !pem.lines().any(|l| l.trim() == end) {
return Err(bad("missing the END line"));
}
let der = Zeroizing::new(
STANDARD
.decode(body.as_bytes())
.map_err(|_| bad("the PEM body is not valid base64"))?,
);
Ok((label, der))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn claims_backdate_iat_and_stay_under_ten_minutes() {
let c = AppClaims::at(1_000_000, "Iv1.abc");
assert_eq!(c.iat, 1_000_000 - 60);
assert_eq!(c.exp, 1_000_000 + 540);
assert!(c.exp - 1_000_000 <= 600);
assert_eq!(c.iss, "Iv1.abc");
}
#[test]
fn pem_errors_are_specific() {
let e = InProcessKey::from_pem("not a key").unwrap_err();
assert!(e.to_string().contains("not a PEM block"), "{e}");
let e =
InProcessKey::from_pem("-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----")
.unwrap_err();
assert!(e.to_string().contains("CERTIFICATE"), "{e}");
let e = InProcessKey::from_pem(
"-----BEGIN ENCRYPTED PRIVATE KEY-----\nAAAA\n-----END ENCRYPTED PRIVATE KEY-----",
)
.unwrap_err();
assert!(e.to_string().contains("encrypted"), "{e}");
let e = InProcessKey::from_pem("-----BEGIN RSA PRIVATE KEY-----\nAAAA\n").unwrap_err();
assert!(e.to_string().contains("END"), "{e}");
}
}