//! Secrets for OxygenFlow jobs (hub spec §13.2, oxygenhub#107): named //! values kept per site, organisation and repository, sealed at rest //! under `keys/secrets.key`. A job gets only the secrets its pipeline //! declares with `secret "name" { mount-path … }`, as files Flow mounts //! (OxygenFlow spec §9), never in the environment; the hub masks each //! value in the job's log as it arrives. Inner levels override outer by //! name, as variables do. use crate::db::{now_ms, Pool}; use crate::levels::{self, Scope}; use crate::Shared; use sqlx::FromRow; /// How many a level may hold. pub const MAX_PER_LEVEL: usize = 100; /// The longest value, in bytes. pub const MAX_VALUE: usize = 64 * 1024; /// Values shorter than this are not masked in logs: too many false hits. const MIN_MASKED: usize = 4; #[derive(Clone, Debug, FromRow)] pub struct Secret { pub scope: String, pub scope_id: i64, pub name: String, /// Sealed; `App.secret_key` opens it. pub value: Vec, pub updated_ms: i64, } /// A secret's name, as a pipeline names it: 1 to 64 of `A-Z a-z 0-9 . _ -`. pub fn name_ok(name: &str) -> bool { !name.is_empty() && name.len() <= 64 && name.bytes().all(|b| b.is_ascii_alphanumeric() || b".-_".contains(&b)) } pub async fn list(pool: &Pool, scope: Scope) -> sqlx::Result> { sqlx::query_as("SELECT * FROM flow_secrets WHERE scope = ? AND scope_id = ? ORDER BY name") .bind(scope.kind()) .bind(scope.id()) .fetch_all(pool) .await } /// Set a secret at a level, new or changed; `sealed` is the value sealed. pub async fn set(pool: &Pool, scope: Scope, name: &str, sealed: &[u8]) -> sqlx::Result<()> { sqlx::query( "INSERT INTO flow_secrets (scope, scope_id, name, value, updated_ms) VALUES (?, ?, ?, ?, ?) \ ON CONFLICT (scope, scope_id, name) DO UPDATE SET value = excluded.value, updated_ms = excluded.updated_ms", ) .bind(scope.kind()) .bind(scope.id()) .bind(name) .bind(sealed) .bind(now_ms()) .execute(pool) .await?; Ok(()) } /// Remove a secret from a level; false when it was not there. pub async fn delete(pool: &Pool, scope: Scope, name: &str) -> sqlx::Result { let r = sqlx::query("DELETE FROM flow_secrets WHERE scope = ? AND scope_id = ? AND name = ?") .bind(scope.kind()) .bind(scope.id()) .bind(name) .execute(pool) .await?; Ok(r.rows_affected() == 1) } /// Forget a level's secrets, when the organisation or repository goes. pub async fn delete_scope(pool: &Pool, scope: Scope) -> sqlx::Result<()> { sqlx::query("DELETE FROM flow_secrets WHERE scope = ? AND scope_id = ?").bind(scope.kind()).bind(scope.id()).execute(pool).await?; Ok(()) } /// A secret as a level sees it: where from, and whether an inner level /// sets the same name. The value stays sealed. #[derive(Clone, Debug)] pub struct Seen { pub secret: Secret, /// `the site`, `the acme organisation`, or empty for the level's own. pub from: String, pub overridden: bool, } /// Every secret a level sees, outermost first. pub async fn seen(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result> { let mut lvls = levels::above(pool, scope, owner).await?; lvls.push((scope, String::new())); let mut out: Vec = vec![]; for (level, from) in lvls { for s in list(pool, level).await? { for o in out.iter_mut().filter(|o| o.secret.name == s.name) { o.overridden = true; } out.push(Seen { secret: s, from: from.clone(), overridden: false }); } } Ok(out) } /// The values of the named secrets as a repository's jobs get them: /// `(name, value)` for each that is set and opens; names not set are /// left out, as are values sealed under another key. pub async fn for_job(app: &Shared, repo: &crate::db::repos::RepoRow, names: &[String]) -> sqlx::Result> { let all = seen(&app.db, Scope::Repo(repo.id), &repo.owner).await?; let mut out = vec![]; for name in names { if let Some(s) = all.iter().find(|s| !s.overridden && s.secret.name == *name) { if let Some(value) = app.secret_key.open(&s.secret.value) { out.push((name.clone(), value)); } } } Ok(out) } /// `text` with every secret value (and its standard base64) replaced by /// `***`, for a job's log. pub fn mask(text: &str, values: &[String]) -> String { let mut out = text.to_string(); for v in values { if v.len() < MIN_MASKED { continue; } out = out.replace(v.as_str(), "***"); let b64 = base64_standard(v.as_bytes()); if b64.len() >= MIN_MASKED { out = out.replace(&b64, "***"); } } out } fn base64_standard(bytes: &[u8]) -> String { const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); for chunk in bytes.chunks(3) { let n = chunk.iter().fold(0u32, |acc, b| (acc << 8) | *b as u32) << (8 * (3 - chunk.len())); for i in 0..4 { if i <= chunk.len() { out.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char); } else { out.push('='); } } } out } #[cfg(test)] mod tests { use super::*; #[test] fn values_and_their_base64_are_masked_in_logs() { assert_eq!(base64_standard(b"hunter22"), "aHVudGVyMjI="); let line = "token=hunter22 b64=aHVudGVyMjI= short=ab done"; assert_eq!(mask(line, &["hunter22".into(), "ab".into()]), "token=*** b64=*** short=ab done"); assert!(name_ok("deploy-token") && name_ok("aws.key_1") && !name_ok("") && !name_ok("a b") && !name_ok(&"x".repeat(65))); } }