OXYGENHUB
# 3eb491e9489b oxygenhub / crates / hub / src / keyless.rs
7.2 KiB · Rust · last changed Keyless step 2: the hub certifies fresh keys for ten minutes 18 days ago source blame history raw
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
use crate::api::ApiError;
9
use crate::{db, Shared};
10
use axum::extract::State;
11
use axum::http::{HeaderMap, StatusCode};
12
use axum::Json;
13
use oxy_core::cert::{certify_challenge, Certificate, SignedCertificate};
14
use oxy_core::identity::{verify_challenge, Identity, Scope, Signature};
15
use serde::{Deserialize, Serialize};
16
17
/// How long a certificate lasts.
18
pub const LIFETIME_MS: i64 = 10 * 60 * 1000;
19
/// How far back a certificate starts, for clocks a little behind.
20
const SKEW_MS: i64 = 30 * 1000;
21
22
/// The OIDC audience a token must have to be exchanged here.
23
pub fn audience(base_url: &str) -> String {
24
    format!("{}/keyless", base_url.trim_end_matches('/'))
25
}
26
27
#[derive(Deserialize)]
28
pub struct CertifyRequest {
29
    /// The fresh key's identity object, base64.
30
    pub identity: String,
31
    /// The key's signature over `certify_challenge(credential)`, base64 CBOR.
32
    pub proof: String,
33
    /// `changes,refs` and so on.
34
    pub scope: String,
35
}
36
37
#[derive(Serialize)]
38
pub struct CertifyResponse {
39
    /// The signed certificate, base64 (`[certificate, signature]`).
40
    pub certificate: String,
41
    pub subject: String,
42
    pub expires_ms: i64,
43
}
44
45
/// Who a credential says its holder is.
46
struct Proven {
47
    subject: String,
48
    claims: Vec<(String, String)>,
49
    /// The credential's own end, which a certificate never outlives.
50
    until_ms: i64,
51
    target: String,
52
}
53
54
fn bad(status: StatusCode, why: impl Into<String>) -> ApiError {
55
    ApiError(status, why.into())
56
}
57
58
/// A running job's token: its repository, bookmark, run and job.
59
async fn job_proof(app: &Shared, token: &str) -> Result<Proven, ApiError> {
60
    let invalid = || bad(StatusCode::UNAUTHORIZED, "this job token is not valid (the job must be running)");
61
    let hash = crate::auth::token_hash(token);
62
    let job = db::flow::job_by_token(&app.db, &hash, crate::flow::JOB_TOKEN_MAX_AGE_MS)
63
        .await
64
        .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
65
        .ok_or_else(invalid)?;
66
    let run = db::flow::run_by_id(&app.db, job.run_id)
67
        .await
68
        .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
69
        .ok_or_else(invalid)?;
70
    let repo = db::repos::get_by_id(&app.db, run.repo_id)
71
        .await
72
        .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
73
        .ok_or_else(invalid)?;
74
    let mut claims = vec![
75
        ("repository".to_string(), format!("{}/{}", repo.owner, repo.name)),
76
        ("bookmark".to_string(), run.bookmark.clone()),
77
        ("run".to_string(), run.number.to_string()),
78
        ("job".to_string(), job.name.clone()),
79
        ("runner".to_string(), job.runner.clone()),
80
    ];
81
    if !job.environment.is_empty() && !job.approved_by.is_empty() {
82
        claims.push(("environment".to_string(), job.environment.clone()));
83
        claims.push(("approved_by".to_string(), job.approved_by.clone()));
84
    }
85
    Ok(Proven {
86
        subject: format!("repo:{}/{}:bookmark:{}", repo.owner, repo.name, run.bookmark),
87
        claims,
88
        until_ms: job.started_ms.unwrap_or(0) + crate::flow::JOB_TOKEN_MAX_AGE_MS,
89
        target: format!("{}/{}", repo.owner, repo.name),
90
    })
91
}
92
93
/// One of this hub's OIDC tokens for the keyless audience.
94
async fn oidc_proof(app: &Shared, token: &str, now: i64) -> Result<Proven, ApiError> {
95
    let issuer = app.oidc().await.map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
96
    let aud = audience(app.config.base_url());
97
    let claims = issuer
98
        .verify(token, &aud, now)
99
        .ok_or_else(|| bad(StatusCode::UNAUTHORIZED, format!("the token is not one of this hub's, for audience {aud}, still valid")))?;
100
    let subject = claims["sub"].as_str().unwrap_or("").to_string();
101
    if subject.is_empty() {
102
        return Err(bad(StatusCode::UNAUTHORIZED, "the token names no subject"));
103
    }
104
    let keep = ["repository", "bookmark", "run_number", "job", "runner", "environment", "approved_by"];
105
    let mut out = Vec::new();
106
    for k in keep {
107
        match &claims[k] {
108
            serde_json::Value::String(s) => out.push((k.to_string(), s.clone())),
109
            serde_json::Value::Number(n) => out.push((k.to_string(), n.to_string())),
110
            _ => {}
111
        }
112
    }
113
    Ok(Proven {
114
        target: claims["repository"].as_str().unwrap_or(&subject).to_string(),
115
        subject,
116
        claims: out,
117
        until_ms: claims["exp"].as_i64().unwrap_or(0) * 1000,
118
    })
119
}
120
121
/// `POST /api/v1/keyless/certify`: a ten-minute certificate for a fresh
122
/// key, for whoever the bearer credential proves.
123
pub async fn certify(
124
    State(app): State<Shared>,
125
    headers: HeaderMap,
126
    Json(req): Json<CertifyRequest>,
127
) -> Result<Json<CertifyResponse>, ApiError> {
128
    let token = oxy_server::bearer_token(&headers)
129
        .ok_or_else(|| bad(StatusCode::UNAUTHORIZED, "ask with a job token or one of this hub's OIDC tokens as the bearer"))?
130
        .to_string();
131
    let now = db::now_ms();
132
    let proven = if token.starts_with(crate::flow::JOB_TOKEN_PREFIX) {
133
        job_proof(&app, &token).await?
134
    } else {
135
        oidc_proof(&app, &token, now).await?
136
    };
137
    let decode = |what: &str, b64: &str| {
138
        oxy_core::dsse::base64_decode(b64.trim()).ok_or_else(|| bad(StatusCode::BAD_REQUEST, format!("{what} is not base64")))
139
    };
140
    let identity =
141
        Identity::decode(&decode("identity", &req.identity)?).map_err(|e| bad(StatusCode::BAD_REQUEST, format!("identity: {e}")))?;
142
    let proof = oxy_core::cbor::decode(&decode("proof", &req.proof)?)
143
        .map_err(|e| e.to_string())
144
        .and_then(|v| Signature::from_value(&v).map_err(|e| e.to_string()))
145
        .map_err(|e| bad(StatusCode::BAD_REQUEST, format!("proof: {e}")))?;
146
    verify_challenge(&identity, &proof, &certify_challenge(&token))
147
        .map_err(|e| bad(StatusCode::UNAUTHORIZED, format!("the proof does not show this key's holder asked: {e}")))?;
148
    let scope = Scope::parse(&req.scope).map_err(|e| bad(StatusCode::BAD_REQUEST, e))?;
149
    let expires = (now + LIFETIME_MS).min(proven.until_ms);
150
    if expires <= now {
151
        return Err(bad(StatusCode::UNAUTHORIZED, "the credential is about to expire: ask with a fresh one"));
152
    }
153
    let (key, secret) =
154
        crate::keys::load_secret(&app.config.keys_dir()).map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
155
    let cert = Certificate {
156
        key: identity,
157
        issuer: key,
158
        subject: proven.subject.clone(),
159
        claims: proven.claims,
160
        scope,
161
        not_before: (now - SKEW_MS) as u64,
162
        expires: expires as u64,
163
    };
164
    let signed = SignedCertificate::sign(cert, &app.hub_key.identity, &secret);
165
    let detail = format!("{} for {} until {}", signed.cert.key_id().short(12), proven.subject, expires);
166
    db::accounts::audit(&app.db, None, "keyless.certified", &proven.target, &detail).await.ok();
167
    Ok(Json(CertifyResponse { certificate: oxy_core::dsse::base64(&signed.encode()), subject: proven.subject, expires_ms: expires }))
168
}