via-cli 0.1.0

Run commands and API requests with 1Password-backed credentials without exposing secrets to your shell
Documentation
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use base64::Engine as _;
use ring::rand::SystemRandom;
use ring::signature::{RsaKeyPair, RSA_PKCS1_SHA256};
use serde_json::Value;

use crate::error::ViaError;

pub fn sign_rs256(claims: &Value, private_key_pem: &str) -> Result<String, ViaError> {
    let header = serde_json::json!({
        "alg": "RS256",
        "typ": "JWT",
    });
    let header = encode_json(&header)?;
    let claims = encode_json(claims)?;
    let signing_input = format!("{header}.{claims}");
    let key_pair = rsa_key_pair_from_pem(private_key_pem)?;
    let rng = SystemRandom::new();
    let mut signature = vec![0; key_pair.public().modulus_len()];
    key_pair
        .sign(
            &RSA_PKCS1_SHA256,
            &rng,
            signing_input.as_bytes(),
            &mut signature,
        )
        .map_err(|_| ViaError::InvalidConfig("failed to sign JWT".to_owned()))?;
    Ok(format!(
        "{signing_input}.{}",
        URL_SAFE_NO_PAD.encode(signature)
    ))
}

fn encode_json(value: &Value) -> Result<String, ViaError> {
    let raw = serde_json::to_vec(value)?;
    Ok(URL_SAFE_NO_PAD.encode(raw))
}

fn rsa_key_pair_from_pem(pem: &str) -> Result<RsaKeyPair, ViaError> {
    let der = decode_private_key_pem(pem)?;

    RsaKeyPair::from_der(&der)
        .or_else(|_| RsaKeyPair::from_pkcs8(&der))
        .map_err(|_| {
            ViaError::InvalidConfig(
                "GitHub App private_key must be an RSA private key in PEM format".to_owned(),
            )
        })
}

fn decode_private_key_pem(pem: &str) -> Result<Vec<u8>, ViaError> {
    let mut body = String::new();
    let mut in_key = false;

    for line in pem.lines() {
        let line = line.trim();
        if line.starts_with("-----BEGIN ") && line.ends_with(" PRIVATE KEY-----") {
            in_key = true;
            continue;
        }
        if line.starts_with("-----END ") && line.ends_with(" PRIVATE KEY-----") {
            break;
        }
        if in_key {
            body.push_str(line);
        }
    }

    if body.is_empty() {
        return Err(ViaError::InvalidConfig(
            "GitHub App private_key must be PEM encoded".to_owned(),
        ));
    }

    STANDARD.decode(body).map_err(|_| {
        ViaError::InvalidConfig("GitHub App private_key PEM body is not valid base64".to_owned())
    })
}

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

    const TEST_RSA_PRIVATE_KEY: &str =
        include_str!("../../tests/fixtures/rsa-private-key.pkcs1.pem");

    #[test]
    fn rejects_non_pem_private_key() {
        let error = sign_rs256(&serde_json::json!({"iss": "test"}), "not a key").unwrap_err();

        assert!(
            matches!(error, ViaError::InvalidConfig(message) if message.contains("PEM encoded"))
        );
    }

    #[test]
    fn signs_rs256_jwt() {
        let jwt = sign_rs256(
            &serde_json::json!({
                "iss": "client-id",
                "iat": 1,
                "exp": 2,
            }),
            TEST_RSA_PRIVATE_KEY,
        )
        .unwrap();

        let parts = jwt.split('.').collect::<Vec<_>>();
        assert_eq!(parts.len(), 3);
        assert_eq!(
            parts[0], "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
            "header should be stable"
        );
    }
}