OXYGENHUB

Coverage / crates/hub/src/keyless.rs 88.2%

307 of 348 lines run, 42 of 134 functions · run #515 on main · run missed

1
//! Certificates for short-lived keys (oxygenhub#241, docs/keyless.md,
2
//! Oxygen spec §14.7): the hub vouches, for ten minutes, for a fresh key
3
//! whose holder proves who it is with a credential the hub issued. A Flow
4
//! job asks with its job token; anything else with one of the hub's OIDC
5
//! tokens for the keyless audience. The key signs, then is thrown away:
6
//! nothing long-lived sits on a runner.
7
//!
8
//! An agent asks with a grant token instead (step 5): its owner signed an
9
//! Oxygen grant letting `agent:NAME@ACCOUNT` act for them (spec §14.7),
10
//! and every certificate issued under it carries that grant, so the
11
//! agent's fresh keys sign in the owner's name with nothing kept on disk
12
//! but the token. Revoking the grant stops the certificates.
13
14
use crate::api::ApiError;
15
use crate::{db, Shared};
16
use axum::extract::Path;
17
use axum::extract::State;
18
use axum::http::{HeaderMap, StatusCode};
19
use axum::Json;
20
use oxy_core::cert::{certify_challenge, Certificate, SignedCertificate, SignedGrant};
21
use oxy_core::identity::{verify_challenge, Identity, Scope, Signature};
22
use serde::{Deserialize, Serialize};
23
24
/// How long a certificate lasts.
25
pub const LIFETIME_MS: i64 = 10 * 60 * 1000;
26
/// How far back a certificate starts, for clocks a little behind.
27
const SKEW_MS: i64 = 30 * 1000;
28
/// What an agent holds in place of a key: `oxyagent_` and 32 random bytes.
29
pub const GRANT_TOKEN_PREFIX: &str = "oxyagent_";
30
31
/// The OIDC audience a token must have to be exchanged here.
323×
pub fn audience(base_url: &str) -> String {
333×
    format!("{}/keyless", base_url.trim_end_matches('/'))
343×
}
35
36
#[derive(Deserialize)]
37
pub struct CertifyRequest {
38
    /// The fresh key's identity object, base64.
39
    pub identity: String,
40
    /// The key's signature over `certify_challenge(credential)`, base64 CBOR.
41
    pub proof: String,
42
    /// `changes,refs` and so on.
43
    pub scope: String,
44
}
45
46
#[derive(Serialize)]
47
pub struct CertifyResponse {
48
    /// The signed certificate, base64 (`[certificate, signature]`).
49
    pub certificate: String,
50
    pub subject: String,
51
    pub expires_ms: i64,
52
    /// For an agent's certificate, the identity that signed its grant
53
    /// (base64), so a repository that never saw the owner's current key can
54
    /// check the grant, and pass it on with its push (oxygen#134).
55
    #[serde(skip_serializing_if = "Option::is_none")]
56
    pub grantor_identity: Option<String>,
57
    /// For an agent's certificate, the grant's owner's newest linked
58
    /// identity and its predecessors back to the grant's key (base64,
59
    /// newest first, the grant's key's own left out): a home with
60
    /// `author.for` authors as the first (oxygen#82). Empty when the
61
    /// grant's key is the newest.
62
    #[serde(skip_serializing_if = "Vec::is_empty")]
63
    pub author_chain: Vec<String>,
64
}
65
66
/// The newest identity linked to `account_id` whose `previous` chain
67
/// reaches `grantor`, with the chain down to (not including) it, newest
68
/// first (oxygen#82). Empty when none is newer.
694×
pub async fn author_chain(app: &Shared, account_id: i64, grantor: oxy_core::hash::KeyId) -> Vec<Identity> {
704×
    let linked = db::accounts::identities_of(&app.db, account_id).await.unwrap_or_default();
714×
    let ids: std::collections::HashMap<oxy_core::hash::KeyId, Identity> =
726×
        linked.iter().filter_map(|l| Identity::decode(&l.object).ok()).map(|i| (i.hash(), i)).collect();
734×
    let mut best: Vec<Identity> = Vec::new();
746×
    for head in ids.values() {
756×
        let mut chain = vec![head.clone()];
766×
        let mut cur = head;
776×
        let reached = loop {
782×
            match cur.previous {
792×
                Some(p) if p == grantor => break true,
800×
                Some(p) => match ids.get(&p) {
810×
                    Some(prev) if chain.len() < 64 => {
820×
                        chain.push(prev.clone());
830×
                        cur = prev;
840×
                    }
850×
                    _ => break false,
86
                },
874×
                None => break false,
88
            }
89
        };
906×
        if reached && best.first().is_none_or(|b| head.created > b.created) {
912×
            best = chain;
924×
        }
93
    }
944×
    best
954×
}
96
97
/// Who a credential says its holder is.
98
struct Proven {
99
    subject: String,
100
    claims: Vec<(String, String)>,
101
    /// The credential's own end, which a certificate never outlives.
102
    until_ms: i64,
103
    target: String,
104
    /// The most the certificate may give.
105
    allowed: Scope,
106
    /// The owner's grant the certificate carries, for an agent.
107
    grant: Option<SignedGrant>,
108
}
109
1106×
fn bad(status: StatusCode, why: impl Into<String>) -> ApiError {
1116×
    ApiError(status, why.into())
1126×
}
113
114
/// A running job's token: its repository, bookmark, run and job.
1156×
async fn job_proof(app: &Shared, token: &str) -> Result<Proven, ApiError> {
1166×
    let invalid = || bad(StatusCode::UNAUTHORIZED, "this job token is not valid (the job must be running)");
1176×
    let hash = crate::auth::token_hash(token);
1186×
    let job = db::flow::job_by_token(&app.db, &hash, crate::flow::JOB_TOKEN_MAX_AGE_MS)
1196×
        .await
1206×
        .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
1216×
        .ok_or_else(invalid)?;
1225×
    let run = db::flow::run_by_id(&app.db, job.run_id)
1235×
        .await
1245×
        .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
1255×
        .ok_or_else(invalid)?;
1265×
    let repo = db::repos::get_by_id(&app.db, run.repo_id)
1275×
        .await
1285×
        .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
1295×
        .ok_or_else(invalid)?;
1305×
    let mut claims = vec![
1315×
        ("repository".to_string(), format!("{}/{}", repo.owner, repo.name)),
1325×
        ("bookmark".to_string(), run.bookmark.clone()),
1335×
        ("run".to_string(), run.number.to_string()),
1345×
        ("job".to_string(), job.name.clone()),
1355×
        ("runner".to_string(), job.runner.clone()),
136
    ];
1375×
    if !job.environment.is_empty() && !job.approved_by.is_empty() {
1380×
        claims.push(("environment".to_string(), job.environment.clone()));
1390×
        claims.push(("approved_by".to_string(), job.approved_by.clone()));
1405×
    }
1415×
    Ok(Proven {
1425×
        subject: format!("repo:{}/{}:bookmark:{}", repo.owner, repo.name, run.bookmark),
1435×
        claims,
1445×
        until_ms: job.started_ms.unwrap_or(0) + crate::flow::JOB_TOKEN_MAX_AGE_MS,
1455×
        target: format!("{}/{}", repo.owner, repo.name),
1465×
        allowed: Scope::CHANGES | Scope::REFS | Scope::SEAL,
1475×
        grant: None,
1485×
    })
1496×
}
150
151
/// One of this hub's OIDC tokens for the keyless audience.
1522×
async fn oidc_proof(app: &Shared, token: &str, now: i64) -> Result<Proven, ApiError> {
1532×
    let issuer = app.oidc().await.map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
1542×
    let aud = audience(app.config.base_url());
1552×
    let claims = issuer
1562×
        .verify(token, &aud, now)
1572×
        .ok_or_else(|| bad(StatusCode::UNAUTHORIZED, format!("the token is not one of this hub's, for audience {aud}, still valid")))?;
1581×
    let subject = claims["sub"].as_str().unwrap_or("").to_string();
1591×
    if subject.is_empty() {
1600×
        return Err(bad(StatusCode::UNAUTHORIZED, "the token names no subject"));
1611×
    }
1621×
    let keep = ["repository", "bookmark", "run_number", "job", "runner", "environment", "approved_by"];
1631×
    let mut out = Vec::new();
1647×
    for k in keep {
1657×
        match &claims[k] {
1660×
            serde_json::Value::String(s) => out.push((k.to_string(), s.clone())),
1670×
            serde_json::Value::Number(n) => out.push((k.to_string(), n.to_string())),
1687×
            _ => {}
169
        }
170
    }
1711×
    Ok(Proven {
1721×
        target: claims["repository"].as_str().unwrap_or(&subject).to_string(),
1731×
        subject,
1741×
        claims: out,
1751×
        until_ms: claims["exp"].as_i64().unwrap_or(0) * 1000,
1761×
        allowed: Scope::CHANGES | Scope::REFS | Scope::SEAL,
1771×
        grant: None,
1781×
    })
1792×
}
180
181
/// An agent's grant token: the owner's live grant.
1824×
async fn grant_proof(app: &Shared, token: &str, now: i64) -> Result<Proven, ApiError> {
1834×
    let invalid = || bad(StatusCode::UNAUTHORIZED, "this agent grant is not valid (revoked, ended or unknown)");
1844×
    let g = db::accounts::agent_grant_by_token(&app.db, &crate::auth::token_hash(token))
1854×
        .await
1864×
        .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
1874×
        .filter(|g| g.live(now))
1884×
        .ok_or_else(invalid)?;
1894×
    let signed = SignedGrant::decode(&g.signed).map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
1904×
    db::accounts::touch_agent_grant(&app.db, g.id).await.ok();
1914×
    Ok(Proven {
1924×
        subject: g.subject.clone(),
1934×
        claims: vec![("agent".to_string(), g.agent.clone()), ("grant".to_string(), g.id.to_string())],
1944×
        until_ms: g.expires_ms,
1954×
        target: g.subject,
1964×
        allowed: signed.grant.scope,
1974×
        grant: Some(signed),
1984×
    })
1994×
}
200
201
/// `POST /api/v1/keyless/certify`: a ten-minute certificate for a fresh
202
/// key, for whoever the bearer credential proves.
20312×
pub async fn certify(
20412×
    State(app): State<Shared>,
20512×
    headers: HeaderMap,
20612×
    Json(req): Json<CertifyRequest>,
20712×
) -> Result<Json<CertifyResponse>, ApiError> {
20812×
    let token = oxy_server::bearer_token(&headers)
20912×
        .ok_or_else(|| bad(StatusCode::UNAUTHORIZED, "ask with a job token or one of this hub's OIDC tokens as the bearer"))?
21012×
        .to_string();
21112×
    let now = db::now_ms();
21212×
    let proven = if token.starts_with(crate::flow::JOB_TOKEN_PREFIX) {
2136×
        job_proof(&app, &token).await?
2146×
    } else if token.starts_with(GRANT_TOKEN_PREFIX) {
2154×
        grant_proof(&app, &token, now).await?
216
    } else {
2172×
        oidc_proof(&app, &token, now).await?
218
    };
21920×
    let decode = |what: &str, b64: &str| {
22020×
        oxy_core::dsse::base64_decode(b64.trim()).ok_or_else(|| bad(StatusCode::BAD_REQUEST, format!("{what} is not base64")))
22120×
    };
22210×
    let identity =
22310×
        Identity::decode(&decode("identity", &req.identity)?).map_err(|e| bad(StatusCode::BAD_REQUEST, format!("identity: {e}")))?;
22410×
    let proof = oxy_core::cbor::decode(&decode("proof", &req.proof)?)
22510×
        .map_err(|e| e.to_string())
22610×
        .and_then(|v| Signature::from_value(&v).map_err(|e| e.to_string()))
22710×
        .map_err(|e| bad(StatusCode::BAD_REQUEST, format!("proof: {e}")))?;
22810×
    verify_challenge(&identity, &proof, &certify_challenge(&token))
22910×
        .map_err(|e| bad(StatusCode::UNAUTHORIZED, format!("the proof does not show this key's holder asked: {e}")))?;
230
    // What was asked, within what the credential allows (an agent's grant
231
    // may leave out `refs`, say): a key asks for all it might sign.
2329×
    let asked = Scope::parse(&req.scope).map_err(|e| bad(StatusCode::BAD_REQUEST, e))?;
2339×
    let scope =
23419×
        asked.names().into_iter().filter_map(|n| Scope::parse(n).ok()).filter(|s| proven.allowed.has(*s)).fold(Scope::NONE, |a, s| a | s);
2359×
    if scope.is_empty() {
2360×
        return Err(bad(StatusCode::FORBIDDEN, format!("{} may be certified for {} only", proven.subject, proven.allowed)));
2379×
    }
2389×
    let expires = (now + LIFETIME_MS).min(proven.until_ms);
2399×
    if expires <= now {
2400×
        return Err(bad(StatusCode::UNAUTHORIZED, "the credential is about to expire: ask with a fresh one"));
2419×
    }
2429×
    let (key, secret) =
2439×
        crate::keys::load_secret(&app.config.keys_dir()).map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
2449×
    let cert = Certificate {
2459×
        key: identity,
2469×
        issuer: key,
2479×
        subject: proven.subject.clone(),
2489×
        claims: proven.claims,
2499×
        scope,
2509×
        not_before: (now - SKEW_MS) as u64,
2519×
        expires: expires as u64,
2529×
        grant: proven.grant,
2539×
    };
254
    // The grant's signer, as linked to its account (the grant was checked
255
    // against it when it was made).
2569×
    let grantor = match &cert.grant {
2574×
        Some(g) => db::accounts::identity_by_key(&app.db, &g.grant.grantor.to_hex()).await.ok().flatten(),
2585×
        None => None,
259
    };
2609×
    let grantor_account = grantor.as_ref().map(|l| l.account_id);
2619×
    let grantor_identity = grantor.map(|l| oxy_core::dsse::base64(&l.object));
2629×
    let author_chain = match (&cert.grant, &grantor_account) {
2634×
        (Some(g), Some(account)) => {
2644×
            author_chain(&app, *account, g.grant.grantor).await.iter().map(|i| oxy_core::dsse::base64(&i.encode())).collect()
265
        }
2665×
        _ => Vec::new(),
267
    };
2689×
    let signed = SignedCertificate::sign(cert, &app.hub_key.identity, &secret);
2699×
    let detail = format!("{} for {} until {}", signed.cert.key_id().short(12), proven.subject, expires);
2709×
    db::accounts::audit(&app.db, None, "keyless.certified", &proven.target, &detail).await.ok();
271
    // Every certificate is logged (#238), as Fulcio's are in Rekor.
2729×
    crate::tlog::log(
2739×
        &app,
2749×
        vec![crate::tlog::Pending {
2759×
            kind: "certificate",
2769×
            repo: [0; 16],
2779×
            repo_row: None,
2789×
            object: signed.cert.hash(),
2799×
            signers: vec![app.hub_key.key],
2809×
        }],
2819×
    )
2829×
    .await;
2839×
    Ok(Json(CertifyResponse {
2849×
        certificate: oxy_core::dsse::base64(&signed.encode()),
2859×
        subject: proven.subject,
2869×
        expires_ms: expires,
2879×
        grantor_identity,
2889×
        author_chain,
2899×
    }))
29012×
}
291
292
#[derive(Deserialize)]
293
pub struct GrantRequest {
294
    /// The signed Oxygen grant (`[grant, signature]`), base64.
295
    pub grant: String,
296
}
297
298
#[derive(Serialize)]
299
pub struct GrantView {
300
    pub id: i64,
301
    pub agent: String,
302
    pub subject: String,
303
    pub grantor: String,
304
    pub scope: String,
305
    pub created_ms: i64,
306
    pub expires_ms: i64,
307
    pub revoked_ms: Option<i64>,
308
    pub last_used_ms: Option<i64>,
309
    /// Shown once, when the grant is made.
310
    #[serde(skip_serializing_if = "Option::is_none")]
311
    pub token: Option<String>,
312
}
313
314
impl From<db::accounts::AgentGrant> for GrantView {
3151×
    fn from(g: db::accounts::AgentGrant) -> Self {
3161×
        GrantView {
3171×
            id: g.id,
3181×
            agent: g.agent,
3191×
            subject: g.subject,
3201×
            grantor: g.grantor,
3211×
            scope: g.scope,
3221×
            created_ms: g.created_ms,
3231×
            expires_ms: g.expires_ms,
3241×
            revoked_ms: g.revoked_ms,
3251×
            last_used_ms: g.last_used_ms,
3261×
            token: None,
3271×
        }
3281×
    }
329
}
330
331
/// The account behind a personal token. A grant needs no token scope:
332
/// what makes it is the owner's signature, by a key linked to the account.
3336×
async fn owner(app: &Shared, headers: &HeaderMap) -> Result<db::accounts::Account, ApiError> {
3346×
    let c = crate::api::caller(app, headers).await?;
335
    // Grants are their owner's to make and end, not an agent's.
3366×
    if c.agent {
3371×
        return Err(bad(StatusCode::FORBIDDEN, "an agent's token cannot manage agent grants: use your own"));
3385×
    }
3395×
    c.account.ok_or_else(|| bad(StatusCode::UNAUTHORIZED, "a personal access token is required"))
3406×
}
341
342
/// An agent's name: what follows `agent:` and precedes `@`.
3432×
fn agent_name_ok(name: &str) -> bool {
34412×
    !name.is_empty() && name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
3452×
}
346
347
/// `POST /api/v1/agents/grants`: keep an owner's signed grant to one of
348
/// their agents and give the agent its token.
3493×
pub async fn grants_create(
3503×
    State(app): State<Shared>,
3513×
    headers: HeaderMap,
3523×
    Json(req): Json<GrantRequest>,
3533×
) -> Result<(StatusCode, Json<GrantView>), ApiError> {
3543×
    let account = owner(&app, &headers).await?;
3553×
    let bytes = oxy_core::dsse::base64_decode(req.grant.trim()).ok_or_else(|| bad(StatusCode::BAD_REQUEST, "the grant is not base64"))?;
3563×
    let signed = SignedGrant::decode(&bytes).map_err(|e| bad(StatusCode::BAD_REQUEST, format!("grant: {e}")))?;
3573×
    let g = &signed.grant;
3583×
    let suffix = format!("@{}", account.username);
3593×
    let agent = g
3603×
        .subject
3613×
        .strip_prefix("agent:")
3623×
        .and_then(|r| r.strip_suffix(&suffix))
3633×
        .filter(|n| agent_name_ok(n))
3643×
        .ok_or_else(|| bad(StatusCode::BAD_REQUEST, format!("a grant here is for agent:NAME{suffix}, not {}", g.subject)))?
3652×
        .to_string();
3662×
    if g.issuer != app.hub_key.key {
3670×
        return Err(bad(StatusCode::BAD_REQUEST, "the grant names another issuer than this hub's key (/.well-known/oxyhub-key)"));
3682×
    }
3692×
    let grantor = g.grantor.to_hex();
3702×
    let linked = db::accounts::identity_by_key(&app.db, &grantor)
3712×
        .await
3722×
        .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
3732×
        .filter(|i| i.account_id == account.id)
3742×
        .ok_or_else(|| bad(StatusCode::FORBIDDEN, format!("key {} is not linked to your account: link it first", g.grantor.short(12))))?;
3751×
    let identity = Identity::decode(&linked.object).map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
3761×
    signed.verify(&identity).map_err(|e| bad(StatusCode::BAD_REQUEST, format!("the grant does not verify: {e}")))?;
3771×
    let now = db::now_ms();
3781×
    if (g.expires as i64) <= now {
3790×
        return Err(bad(StatusCode::BAD_REQUEST, "the grant has already ended"));
3801×
    }
3811×
    let token = format!("{GRANT_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
3821×
    let hash = crate::auth::token_hash(&token);
3831×
    let scope = g.scope.to_string();
3841×
    let id = db::accounts::create_agent_grant(
3851×
        &app.db,
3861×
        db::accounts::NewAgentGrant {
3871×
            account_id: account.id,
3881×
            agent: &agent,
3891×
            subject: &g.subject,
3901×
            grantor: &grantor,
3911×
            scope: &scope,
3921×
            signed: &bytes,
3931×
            token_hash: &hash,
3941×
            expires_ms: g.expires as i64,
3951×
        },
3961×
    )
3971×
    .await
3981×
    .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
3991×
    db::accounts::audit(&app.db, Some(&account), "agent.granted", &g.subject, &format!("grant {id}, {scope}, until {}", g.expires))
4001×
        .await
4011×
        .ok();
4021×
    Ok((
4031×
        StatusCode::CREATED,
4041×
        Json(GrantView {
4051×
            id,
4061×
            agent,
4071×
            subject: g.subject.clone(),
4081×
            grantor,
4091×
            scope,
4101×
            created_ms: now,
4111×
            expires_ms: g.expires as i64,
4121×
            revoked_ms: None,
4131×
            last_used_ms: None,
4141×
            token: Some(token),
4151×
        }),
4161×
    ))
4173×
}
418
419
/// `GET /api/v1/agents/grants`: the caller's grants, without tokens.
4202×
pub async fn grants_list(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<GrantView>>, ApiError> {
4212×
    let account = owner(&app, &headers).await?;
4221×
    let grants =
4231×
        db::accounts::agent_grants_of(&app.db, account.id).await.map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
4241×
    Ok(Json(grants.into_iter().map(GrantView::from).collect()))
4252×
}
426
427
/// `DELETE /api/v1/agents/grants/{id}`: stop certifying under a grant.
4281×
pub async fn grants_revoke(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<i64>) -> Result<StatusCode, ApiError> {
4291×
    let account = owner(&app, &headers).await?;
4301×
    let done = db::accounts::revoke_agent_grant(&app.db, account.id, id)
4311×
        .await
4321×
        .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
4331×
    if !done {
4340×
        return Err(bad(StatusCode::NOT_FOUND, "no such live grant of yours"));
4351×
    }
4361×
    db::accounts::audit(&app.db, Some(&account), "agent.revoked", &format!("grant {id}"), "").await.ok();
4371×
    Ok(StatusCode::NO_CONTENT)
4381×
}