//! 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. //! //! An agent asks with a grant token instead (step 5): its owner signed an //! Oxygen grant letting `agent:NAME@ACCOUNT` act for them (spec §14.7), //! and every certificate issued under it carries that grant, so the //! agent's fresh keys sign in the owner's name with nothing kept on disk //! but the token. Revoking the grant stops the certificates. use crate::api::ApiError; use crate::{db, Shared}; use axum::extract::Path; use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; use axum::Json; use oxy_core::cert::{certify_challenge, Certificate, SignedCertificate, SignedGrant}; 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; /// What an agent holds in place of a key: `oxyagent_` and 32 random bytes. pub const GRANT_TOKEN_PREFIX: &str = "oxyagent_"; /// 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, /// The most the certificate may give. allowed: Scope, /// The owner's grant the certificate carries, for an agent. grant: Option, } 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), allowed: Scope::CHANGES | Scope::REFS | Scope::SEAL, grant: None, }) } /// 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, allowed: Scope::CHANGES | Scope::REFS | Scope::SEAL, grant: None, }) } /// An agent's grant token: the owner's live grant. async fn grant_proof(app: &Shared, token: &str, now: i64) -> Result { let invalid = || bad(StatusCode::UNAUTHORIZED, "this agent grant is not valid (revoked, ended or unknown)"); let g = db::accounts::agent_grant_by_token(&app.db, &crate::auth::token_hash(token)) .await .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .filter(|g| g.live(now)) .ok_or_else(invalid)?; let signed = SignedGrant::decode(&g.signed).map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; db::accounts::touch_agent_grant(&app.db, g.id).await.ok(); Ok(Proven { subject: g.subject.clone(), claims: vec![("agent".to_string(), g.agent.clone()), ("grant".to_string(), g.id.to_string())], until_ms: g.expires_ms, target: g.subject, allowed: signed.grant.scope, grant: Some(signed), }) } /// `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 if token.starts_with(GRANT_TOKEN_PREFIX) { grant_proof(&app, &token, now).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}")))?; // What was asked, within what the credential allows (an agent's grant // may leave out `refs`, say): a key asks for all it might sign. let asked = Scope::parse(&req.scope).map_err(|e| bad(StatusCode::BAD_REQUEST, e))?; let scope = asked.names().into_iter().filter_map(|n| Scope::parse(n).ok()).filter(|s| proven.allowed.has(*s)).fold(Scope::NONE, |a, s| a | s); if scope.is_empty() { return Err(bad(StatusCode::FORBIDDEN, format!("{} may be certified for {} only", proven.subject, proven.allowed))); } 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, grant: proven.grant, }; 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(); // Every certificate is logged (#238), as Fulcio's are in Rekor. crate::tlog::log( &app, vec![crate::tlog::Pending { kind: "certificate", repo: [0; 16], repo_row: None, object: signed.cert.hash(), signers: vec![app.hub_key.key], }], ) .await; Ok(Json(CertifyResponse { certificate: oxy_core::dsse::base64(&signed.encode()), subject: proven.subject, expires_ms: expires })) } #[derive(Deserialize)] pub struct GrantRequest { /// The signed Oxygen grant (`[grant, signature]`), base64. pub grant: String, } #[derive(Serialize)] pub struct GrantView { pub id: i64, pub agent: String, pub subject: String, pub grantor: String, pub scope: String, pub created_ms: i64, pub expires_ms: i64, pub revoked_ms: Option, pub last_used_ms: Option, /// Shown once, when the grant is made. #[serde(skip_serializing_if = "Option::is_none")] pub token: Option, } impl From for GrantView { fn from(g: db::accounts::AgentGrant) -> Self { GrantView { id: g.id, agent: g.agent, subject: g.subject, grantor: g.grantor, scope: g.scope, created_ms: g.created_ms, expires_ms: g.expires_ms, revoked_ms: g.revoked_ms, last_used_ms: g.last_used_ms, token: None, } } } /// The account behind a personal token. A grant needs no token scope: /// what makes it is the owner's signature, by a key linked to the account. async fn owner(app: &Shared, headers: &HeaderMap) -> Result { let c = crate::api::caller(app, headers).await?; // Grants are their owner's to make and end, not an agent's. if c.agent { return Err(bad(StatusCode::FORBIDDEN, "an agent's token cannot manage agent grants: use your own")); } c.account.ok_or_else(|| bad(StatusCode::UNAUTHORIZED, "a personal access token is required")) } /// An agent's name: what follows `agent:` and precedes `@`. fn agent_name_ok(name: &str) -> bool { !name.is_empty() && name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') } /// `POST /api/v1/agents/grants`: keep an owner's signed grant to one of /// their agents and give the agent its token. pub async fn grants_create( State(app): State, headers: HeaderMap, Json(req): Json, ) -> Result<(StatusCode, Json), ApiError> { let account = owner(&app, &headers).await?; let bytes = oxy_core::dsse::base64_decode(req.grant.trim()).ok_or_else(|| bad(StatusCode::BAD_REQUEST, "the grant is not base64"))?; let signed = SignedGrant::decode(&bytes).map_err(|e| bad(StatusCode::BAD_REQUEST, format!("grant: {e}")))?; let g = &signed.grant; let suffix = format!("@{}", account.username); let agent = g .subject .strip_prefix("agent:") .and_then(|r| r.strip_suffix(&suffix)) .filter(|n| agent_name_ok(n)) .ok_or_else(|| bad(StatusCode::BAD_REQUEST, format!("a grant here is for agent:NAME{suffix}, not {}", g.subject)))? .to_string(); if g.issuer != app.hub_key.key { return Err(bad(StatusCode::BAD_REQUEST, "the grant names another issuer than this hub's key (/.well-known/oxyhub-key)")); } let grantor = g.grantor.to_hex(); let linked = db::accounts::identity_by_key(&app.db, &grantor) .await .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .filter(|i| i.account_id == account.id) .ok_or_else(|| bad(StatusCode::FORBIDDEN, format!("key {} is not linked to your account: link it first", g.grantor.short(12))))?; let identity = Identity::decode(&linked.object).map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; signed.verify(&identity).map_err(|e| bad(StatusCode::BAD_REQUEST, format!("the grant does not verify: {e}")))?; let now = db::now_ms(); if (g.expires as i64) <= now { return Err(bad(StatusCode::BAD_REQUEST, "the grant has already ended")); } let token = format!("{GRANT_TOKEN_PREFIX}{}", crate::auth::random_hex(32)); let hash = crate::auth::token_hash(&token); let scope = g.scope.to_string(); let id = db::accounts::create_agent_grant( &app.db, db::accounts::NewAgentGrant { account_id: account.id, agent: &agent, subject: &g.subject, grantor: &grantor, scope: &scope, signed: &bytes, token_hash: &hash, expires_ms: g.expires as i64, }, ) .await .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; db::accounts::audit(&app.db, Some(&account), "agent.granted", &g.subject, &format!("grant {id}, {scope}, until {}", g.expires)) .await .ok(); Ok(( StatusCode::CREATED, Json(GrantView { id, agent, subject: g.subject.clone(), grantor, scope, created_ms: now, expires_ms: g.expires as i64, revoked_ms: None, last_used_ms: None, token: Some(token), }), )) } /// `GET /api/v1/agents/grants`: the caller's grants, without tokens. pub async fn grants_list(State(app): State, headers: HeaderMap) -> Result>, ApiError> { let account = owner(&app, &headers).await?; let grants = db::accounts::agent_grants_of(&app.db, account.id).await.map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(grants.into_iter().map(GrantView::from).collect())) } /// `DELETE /api/v1/agents/grants/{id}`: stop certifying under a grant. pub async fn grants_revoke(State(app): State, headers: HeaderMap, Path(id): Path) -> Result { let account = owner(&app, &headers).await?; let done = db::accounts::revoke_agent_grant(&app.db, account.id, id) .await .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if !done { return Err(bad(StatusCode::NOT_FOUND, "no such live grant of yours")); } db::accounts::audit(&app.db, Some(&account), "agent.revoked", &format!("grant {id}"), "").await.ok(); Ok(StatusCode::NO_CONTENT) }