OXYGENHUB
# ca0c443c30cf oxygenhub / crates / hub / src / keyless.rs
15.6 KiB · Rust · last changed The hub's transparency log (step 1 of docs/transparency.md) 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
//! 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.
32
pub fn audience(base_url: &str) -> String {
33
    format!("{}/keyless", base_url.trim_end_matches('/'))
34
}
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
}
53
54
/// Who a credential says its holder is.
55
struct Proven {
56
    subject: String,
57
    claims: Vec<(String, String)>,
58
    /// The credential's own end, which a certificate never outlives.
59
    until_ms: i64,
60
    target: String,
61
    /// The most the certificate may give.
62
    allowed: Scope,
63
    /// The owner's grant the certificate carries, for an agent.
64
    grant: Option<SignedGrant>,
65
}
66
67
fn bad(status: StatusCode, why: impl Into<String>) -> ApiError {
68
    ApiError(status, why.into())
69
}
70
71
/// A running job's token: its repository, bookmark, run and job.
72
async fn job_proof(app: &Shared, token: &str) -> Result<Proven, ApiError> {
73
    let invalid = || bad(StatusCode::UNAUTHORIZED, "this job token is not valid (the job must be running)");
74
    let hash = crate::auth::token_hash(token);
75
    let job = db::flow::job_by_token(&app.db, &hash, crate::flow::JOB_TOKEN_MAX_AGE_MS)
76
        .await
77
        .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
78
        .ok_or_else(invalid)?;
79
    let run = db::flow::run_by_id(&app.db, job.run_id)
80
        .await
81
        .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
82
        .ok_or_else(invalid)?;
83
    let repo = db::repos::get_by_id(&app.db, run.repo_id)
84
        .await
85
        .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
86
        .ok_or_else(invalid)?;
87
    let mut claims = vec![
88
        ("repository".to_string(), format!("{}/{}", repo.owner, repo.name)),
89
        ("bookmark".to_string(), run.bookmark.clone()),
90
        ("run".to_string(), run.number.to_string()),
91
        ("job".to_string(), job.name.clone()),
92
        ("runner".to_string(), job.runner.clone()),
93
    ];
94
    if !job.environment.is_empty() && !job.approved_by.is_empty() {
95
        claims.push(("environment".to_string(), job.environment.clone()));
96
        claims.push(("approved_by".to_string(), job.approved_by.clone()));
97
    }
98
    Ok(Proven {
99
        subject: format!("repo:{}/{}:bookmark:{}", repo.owner, repo.name, run.bookmark),
100
        claims,
101
        until_ms: job.started_ms.unwrap_or(0) + crate::flow::JOB_TOKEN_MAX_AGE_MS,
102
        target: format!("{}/{}", repo.owner, repo.name),
103
        allowed: Scope::CHANGES | Scope::REFS | Scope::SEAL,
104
        grant: None,
105
    })
106
}
107
108
/// One of this hub's OIDC tokens for the keyless audience.
109
async fn oidc_proof(app: &Shared, token: &str, now: i64) -> Result<Proven, ApiError> {
110
    let issuer = app.oidc().await.map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
111
    let aud = audience(app.config.base_url());
112
    let claims = issuer
113
        .verify(token, &aud, now)
114
        .ok_or_else(|| bad(StatusCode::UNAUTHORIZED, format!("the token is not one of this hub's, for audience {aud}, still valid")))?;
115
    let subject = claims["sub"].as_str().unwrap_or("").to_string();
116
    if subject.is_empty() {
117
        return Err(bad(StatusCode::UNAUTHORIZED, "the token names no subject"));
118
    }
119
    let keep = ["repository", "bookmark", "run_number", "job", "runner", "environment", "approved_by"];
120
    let mut out = Vec::new();
121
    for k in keep {
122
        match &claims[k] {
123
            serde_json::Value::String(s) => out.push((k.to_string(), s.clone())),
124
            serde_json::Value::Number(n) => out.push((k.to_string(), n.to_string())),
125
            _ => {}
126
        }
127
    }
128
    Ok(Proven {
129
        target: claims["repository"].as_str().unwrap_or(&subject).to_string(),
130
        subject,
131
        claims: out,
132
        until_ms: claims["exp"].as_i64().unwrap_or(0) * 1000,
133
        allowed: Scope::CHANGES | Scope::REFS | Scope::SEAL,
134
        grant: None,
135
    })
136
}
137
138
/// An agent's grant token: the owner's live grant.
139
async fn grant_proof(app: &Shared, token: &str, now: i64) -> Result<Proven, ApiError> {
140
    let invalid = || bad(StatusCode::UNAUTHORIZED, "this agent grant is not valid (revoked, ended or unknown)");
141
    let g = db::accounts::agent_grant_by_token(&app.db, &crate::auth::token_hash(token))
142
        .await
143
        .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
144
        .filter(|g| g.live(now))
145
        .ok_or_else(invalid)?;
146
    let signed = SignedGrant::decode(&g.signed).map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
147
    db::accounts::touch_agent_grant(&app.db, g.id).await.ok();
148
    Ok(Proven {
149
        subject: g.subject.clone(),
150
        claims: vec![("agent".to_string(), g.agent.clone()), ("grant".to_string(), g.id.to_string())],
151
        until_ms: g.expires_ms,
152
        target: g.subject,
153
        allowed: signed.grant.scope,
154
        grant: Some(signed),
155
    })
156
}
157
158
/// `POST /api/v1/keyless/certify`: a ten-minute certificate for a fresh
159
/// key, for whoever the bearer credential proves.
160
pub async fn certify(
161
    State(app): State<Shared>,
162
    headers: HeaderMap,
163
    Json(req): Json<CertifyRequest>,
164
) -> Result<Json<CertifyResponse>, ApiError> {
165
    let token = oxy_server::bearer_token(&headers)
166
        .ok_or_else(|| bad(StatusCode::UNAUTHORIZED, "ask with a job token or one of this hub's OIDC tokens as the bearer"))?
167
        .to_string();
168
    let now = db::now_ms();
169
    let proven = if token.starts_with(crate::flow::JOB_TOKEN_PREFIX) {
170
        job_proof(&app, &token).await?
171
    } else if token.starts_with(GRANT_TOKEN_PREFIX) {
172
        grant_proof(&app, &token, now).await?
173
    } else {
174
        oidc_proof(&app, &token, now).await?
175
    };
176
    let decode = |what: &str, b64: &str| {
177
        oxy_core::dsse::base64_decode(b64.trim()).ok_or_else(|| bad(StatusCode::BAD_REQUEST, format!("{what} is not base64")))
178
    };
179
    let identity =
180
        Identity::decode(&decode("identity", &req.identity)?).map_err(|e| bad(StatusCode::BAD_REQUEST, format!("identity: {e}")))?;
181
    let proof = oxy_core::cbor::decode(&decode("proof", &req.proof)?)
182
        .map_err(|e| e.to_string())
183
        .and_then(|v| Signature::from_value(&v).map_err(|e| e.to_string()))
184
        .map_err(|e| bad(StatusCode::BAD_REQUEST, format!("proof: {e}")))?;
185
    verify_challenge(&identity, &proof, &certify_challenge(&token))
186
        .map_err(|e| bad(StatusCode::UNAUTHORIZED, format!("the proof does not show this key's holder asked: {e}")))?;
187
    // What was asked, within what the credential allows (an agent's grant
188
    // may leave out `refs`, say): a key asks for all it might sign.
189
    let asked = Scope::parse(&req.scope).map_err(|e| bad(StatusCode::BAD_REQUEST, e))?;
190
    let scope =
191
        asked.names().into_iter().filter_map(|n| Scope::parse(n).ok()).filter(|s| proven.allowed.has(*s)).fold(Scope::NONE, |a, s| a | s);
192
    if scope.is_empty() {
193
        return Err(bad(StatusCode::FORBIDDEN, format!("{} may be certified for {} only", proven.subject, proven.allowed)));
194
    }
195
    let expires = (now + LIFETIME_MS).min(proven.until_ms);
196
    if expires <= now {
197
        return Err(bad(StatusCode::UNAUTHORIZED, "the credential is about to expire: ask with a fresh one"));
198
    }
199
    let (key, secret) =
200
        crate::keys::load_secret(&app.config.keys_dir()).map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
201
    let cert = Certificate {
202
        key: identity,
203
        issuer: key,
204
        subject: proven.subject.clone(),
205
        claims: proven.claims,
206
        scope,
207
        not_before: (now - SKEW_MS) as u64,
208
        expires: expires as u64,
209
        grant: proven.grant,
210
    };
211
    let signed = SignedCertificate::sign(cert, &app.hub_key.identity, &secret);
212
    let detail = format!("{} for {} until {}", signed.cert.key_id().short(12), proven.subject, expires);
213
    db::accounts::audit(&app.db, None, "keyless.certified", &proven.target, &detail).await.ok();
214
    // Every certificate is logged (#238), as Fulcio's are in Rekor.
215
    crate::tlog::log(
216
        &app,
217
        vec![crate::tlog::Pending {
218
            kind: "certificate",
219
            repo: [0; 16],
220
            repo_row: None,
221
            object: signed.cert.hash(),
222
            signers: vec![app.hub_key.key],
223
        }],
224
    )
225
    .await;
226
    Ok(Json(CertifyResponse { certificate: oxy_core::dsse::base64(&signed.encode()), subject: proven.subject, expires_ms: expires }))
227
}
228
229
#[derive(Deserialize)]
230
pub struct GrantRequest {
231
    /// The signed Oxygen grant (`[grant, signature]`), base64.
232
    pub grant: String,
233
}
234
235
#[derive(Serialize)]
236
pub struct GrantView {
237
    pub id: i64,
238
    pub agent: String,
239
    pub subject: String,
240
    pub grantor: String,
241
    pub scope: String,
242
    pub created_ms: i64,
243
    pub expires_ms: i64,
244
    pub revoked_ms: Option<i64>,
245
    pub last_used_ms: Option<i64>,
246
    /// Shown once, when the grant is made.
247
    #[serde(skip_serializing_if = "Option::is_none")]
248
    pub token: Option<String>,
249
}
250
251
impl From<db::accounts::AgentGrant> for GrantView {
252
    fn from(g: db::accounts::AgentGrant) -> Self {
253
        GrantView {
254
            id: g.id,
255
            agent: g.agent,
256
            subject: g.subject,
257
            grantor: g.grantor,
258
            scope: g.scope,
259
            created_ms: g.created_ms,
260
            expires_ms: g.expires_ms,
261
            revoked_ms: g.revoked_ms,
262
            last_used_ms: g.last_used_ms,
263
            token: None,
264
        }
265
    }
266
}
267
268
/// The account behind a personal token. A grant needs no token scope:
269
/// what makes it is the owner's signature, by a key linked to the account.
270
async fn owner(app: &Shared, headers: &HeaderMap) -> Result<db::accounts::Account, ApiError> {
271
    let c = crate::api::caller(app, headers).await?;
272
    c.account.ok_or_else(|| bad(StatusCode::UNAUTHORIZED, "a personal access token is required"))
273
}
274
275
/// An agent's name: what follows `agent:` and precedes `@`.
276
fn agent_name_ok(name: &str) -> bool {
277
    !name.is_empty() && name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
278
}
279
280
/// `POST /api/v1/agents/grants`: keep an owner's signed grant to one of
281
/// their agents and give the agent its token.
282
pub async fn grants_create(
283
    State(app): State<Shared>,
284
    headers: HeaderMap,
285
    Json(req): Json<GrantRequest>,
286
) -> Result<(StatusCode, Json<GrantView>), ApiError> {
287
    let account = owner(&app, &headers).await?;
288
    let bytes = oxy_core::dsse::base64_decode(req.grant.trim()).ok_or_else(|| bad(StatusCode::BAD_REQUEST, "the grant is not base64"))?;
289
    let signed = SignedGrant::decode(&bytes).map_err(|e| bad(StatusCode::BAD_REQUEST, format!("grant: {e}")))?;
290
    let g = &signed.grant;
291
    let suffix = format!("@{}", account.username);
292
    let agent = g
293
        .subject
294
        .strip_prefix("agent:")
295
        .and_then(|r| r.strip_suffix(&suffix))
296
        .filter(|n| agent_name_ok(n))
297
        .ok_or_else(|| bad(StatusCode::BAD_REQUEST, format!("a grant here is for agent:NAME{suffix}, not {}", g.subject)))?
298
        .to_string();
299
    if g.issuer != app.hub_key.key {
300
        return Err(bad(StatusCode::BAD_REQUEST, "the grant names another issuer than this hub's key (/.well-known/oxyhub-key)"));
301
    }
302
    let grantor = g.grantor.to_hex();
303
    let linked = db::accounts::identity_by_key(&app.db, &grantor)
304
        .await
305
        .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
306
        .filter(|i| i.account_id == account.id)
307
        .ok_or_else(|| bad(StatusCode::FORBIDDEN, format!("key {} is not linked to your account: link it first", g.grantor.short(12))))?;
308
    let identity = Identity::decode(&linked.object).map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
309
    signed.verify(&identity).map_err(|e| bad(StatusCode::BAD_REQUEST, format!("the grant does not verify: {e}")))?;
310
    let now = db::now_ms();
311
    if (g.expires as i64) <= now {
312
        return Err(bad(StatusCode::BAD_REQUEST, "the grant has already ended"));
313
    }
314
    let token = format!("{GRANT_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
315
    let hash = crate::auth::token_hash(&token);
316
    let scope = g.scope.to_string();
317
    let id = db::accounts::create_agent_grant(
318
        &app.db,
319
        db::accounts::NewAgentGrant {
320
            account_id: account.id,
321
            agent: &agent,
322
            subject: &g.subject,
323
            grantor: &grantor,
324
            scope: &scope,
325
            signed: &bytes,
326
            token_hash: &hash,
327
            expires_ms: g.expires as i64,
328
        },
329
    )
330
    .await
331
    .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
332
    db::accounts::audit(&app.db, Some(&account), "agent.granted", &g.subject, &format!("grant {id}, {scope}, until {}", g.expires))
333
        .await
334
        .ok();
335
    Ok((
336
        StatusCode::CREATED,
337
        Json(GrantView {
338
            id,
339
            agent,
340
            subject: g.subject.clone(),
341
            grantor,
342
            scope,
343
            created_ms: now,
344
            expires_ms: g.expires as i64,
345
            revoked_ms: None,
346
            last_used_ms: None,
347
            token: Some(token),
348
        }),
349
    ))
350
}
351
352
/// `GET /api/v1/agents/grants`: the caller's grants, without tokens.
353
pub async fn grants_list(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<GrantView>>, ApiError> {
354
    let account = owner(&app, &headers).await?;
355
    let grants =
356
        db::accounts::agent_grants_of(&app.db, account.id).await.map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
357
    Ok(Json(grants.into_iter().map(GrantView::from).collect()))
358
}
359
360
/// `DELETE /api/v1/agents/grants/{id}`: stop certifying under a grant.
361
pub async fn grants_revoke(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<i64>) -> Result<StatusCode, ApiError> {
362
    let account = owner(&app, &headers).await?;
363
    let done = db::accounts::revoke_agent_grant(&app.db, account.id, id)
364
        .await
365
        .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
366
    if !done {
367
        return Err(bad(StatusCode::NOT_FOUND, "no such live grant of yours"));
368
    }
369
    db::accounts::audit(&app.db, Some(&account), "agent.revoked", &format!("grant {id}"), "").await.ok();
370
    Ok(StatusCode::NO_CONTENT)
371
}