//! Certificates for short-lived keys (oxygenhub#241, docs/keyless.md, //! Oxygen spec ยง14.7): the hub vouches, for ten minutes, for a fresh key //! whose holder proves who it is with a credential the hub issued. A Flow //! job asks with its job token; anything else with one of the hub's OIDC //! tokens for the keyless audience. The key signs, then is thrown away: //! nothing long-lived sits on a runner. use crate::api::ApiError; use crate::{db, Shared}; use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; use axum::Json; use oxy_core::cert::{certify_challenge, Certificate, SignedCertificate}; use oxy_core::identity::{verify_challenge, Identity, Scope, Signature}; use serde::{Deserialize, Serialize}; /// How long a certificate lasts. pub const LIFETIME_MS: i64 = 10 * 60 * 1000; /// How far back a certificate starts, for clocks a little behind. const SKEW_MS: i64 = 30 * 1000; /// The OIDC audience a token must have to be exchanged here. pub fn audience(base_url: &str) -> String { format!("{}/keyless", base_url.trim_end_matches('/')) } #[derive(Deserialize)] pub struct CertifyRequest { /// The fresh key's identity object, base64. pub identity: String, /// The key's signature over `certify_challenge(credential)`, base64 CBOR. pub proof: String, /// `changes,refs` and so on. pub scope: String, } #[derive(Serialize)] pub struct CertifyResponse { /// The signed certificate, base64 (`[certificate, signature]`). pub certificate: String, pub subject: String, pub expires_ms: i64, } /// Who a credential says its holder is. struct Proven { subject: String, claims: Vec<(String, String)>, /// The credential's own end, which a certificate never outlives. until_ms: i64, target: String, } fn bad(status: StatusCode, why: impl Into) -> ApiError { ApiError(status, why.into()) } /// A running job's token: its repository, bookmark, run and job. async fn job_proof(app: &Shared, token: &str) -> Result { let invalid = || bad(StatusCode::UNAUTHORIZED, "this job token is not valid (the job must be running)"); let hash = crate::auth::token_hash(token); let job = db::flow::job_by_token(&app.db, &hash, crate::flow::JOB_TOKEN_MAX_AGE_MS) .await .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or_else(invalid)?; let run = db::flow::run_by_id(&app.db, job.run_id) .await .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or_else(invalid)?; let repo = db::repos::get_by_id(&app.db, run.repo_id) .await .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or_else(invalid)?; let mut claims = vec![ ("repository".to_string(), format!("{}/{}", repo.owner, repo.name)), ("bookmark".to_string(), run.bookmark.clone()), ("run".to_string(), run.number.to_string()), ("job".to_string(), job.name.clone()), ("runner".to_string(), job.runner.clone()), ]; if !job.environment.is_empty() && !job.approved_by.is_empty() { claims.push(("environment".to_string(), job.environment.clone())); claims.push(("approved_by".to_string(), job.approved_by.clone())); } Ok(Proven { subject: format!("repo:{}/{}:bookmark:{}", repo.owner, repo.name, run.bookmark), claims, until_ms: job.started_ms.unwrap_or(0) + crate::flow::JOB_TOKEN_MAX_AGE_MS, target: format!("{}/{}", repo.owner, repo.name), }) } /// One of this hub's OIDC tokens for the keyless audience. async fn oidc_proof(app: &Shared, token: &str, now: i64) -> Result { let issuer = app.oidc().await.map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let aud = audience(app.config.base_url()); let claims = issuer .verify(token, &aud, now) .ok_or_else(|| bad(StatusCode::UNAUTHORIZED, format!("the token is not one of this hub's, for audience {aud}, still valid")))?; let subject = claims["sub"].as_str().unwrap_or("").to_string(); if subject.is_empty() { return Err(bad(StatusCode::UNAUTHORIZED, "the token names no subject")); } let keep = ["repository", "bookmark", "run_number", "job", "runner", "environment", "approved_by"]; let mut out = Vec::new(); for k in keep { match &claims[k] { serde_json::Value::String(s) => out.push((k.to_string(), s.clone())), serde_json::Value::Number(n) => out.push((k.to_string(), n.to_string())), _ => {} } } Ok(Proven { target: claims["repository"].as_str().unwrap_or(&subject).to_string(), subject, claims: out, until_ms: claims["exp"].as_i64().unwrap_or(0) * 1000, }) } /// `POST /api/v1/keyless/certify`: a ten-minute certificate for a fresh /// key, for whoever the bearer credential proves. pub async fn certify( State(app): State, headers: HeaderMap, Json(req): Json, ) -> Result, ApiError> { let token = oxy_server::bearer_token(&headers) .ok_or_else(|| bad(StatusCode::UNAUTHORIZED, "ask with a job token or one of this hub's OIDC tokens as the bearer"))? .to_string(); let now = db::now_ms(); let proven = if token.starts_with(crate::flow::JOB_TOKEN_PREFIX) { job_proof(&app, &token).await? } else { oidc_proof(&app, &token, now).await? }; let decode = |what: &str, b64: &str| { oxy_core::dsse::base64_decode(b64.trim()).ok_or_else(|| bad(StatusCode::BAD_REQUEST, format!("{what} is not base64"))) }; let identity = Identity::decode(&decode("identity", &req.identity)?).map_err(|e| bad(StatusCode::BAD_REQUEST, format!("identity: {e}")))?; let proof = oxy_core::cbor::decode(&decode("proof", &req.proof)?) .map_err(|e| e.to_string()) .and_then(|v| Signature::from_value(&v).map_err(|e| e.to_string())) .map_err(|e| bad(StatusCode::BAD_REQUEST, format!("proof: {e}")))?; verify_challenge(&identity, &proof, &certify_challenge(&token)) .map_err(|e| bad(StatusCode::UNAUTHORIZED, format!("the proof does not show this key's holder asked: {e}")))?; let scope = Scope::parse(&req.scope).map_err(|e| bad(StatusCode::BAD_REQUEST, e))?; let expires = (now + LIFETIME_MS).min(proven.until_ms); if expires <= now { return Err(bad(StatusCode::UNAUTHORIZED, "the credential is about to expire: ask with a fresh one")); } let (key, secret) = crate::keys::load_secret(&app.config.keys_dir()).map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let cert = Certificate { key: identity, issuer: key, subject: proven.subject.clone(), claims: proven.claims, scope, not_before: (now - SKEW_MS) as u64, expires: expires as u64, }; let signed = SignedCertificate::sign(cert, &app.hub_key.identity, &secret); let detail = format!("{} for {} until {}", signed.cert.key_id().short(12), proven.subject, expires); db::accounts::audit(&app.db, None, "keyless.certified", &proven.target, &detail).await.ok(); Ok(Json(CertifyResponse { certificate: oxy_core::dsse::base64(&signed.encode()), subject: proven.subject, expires_ms: expires })) }