OXYGENHUB
# fbd7c8551932 oxygenhub / crates / hub / src / secrets.rs
5.8 KiB · Rust · last changed Postgres, step 1: every query numbers its placeholders $1, $2, ... 19 days ago source blame history raw
1
//! Secrets for OxygenFlow jobs (hub spec §13.2, oxygenhub#107): named
2
//! values kept per site, organisation and repository, sealed at rest
3
//! under `keys/secrets.key`. A job gets only the secrets its pipeline
4
//! declares with `secret "name" { mount-path … }`, as files Flow mounts
5
//! (OxygenFlow spec §9), never in the environment; the hub masks each
6
//! value in the job's log as it arrives. Inner levels override outer by
7
//! name, as variables do.
8
9
use crate::db::{now_ms, Pool};
10
use crate::levels::{self, Scope};
11
use crate::Shared;
12
use sqlx::FromRow;
13
14
/// How many a level may hold.
15
pub const MAX_PER_LEVEL: usize = 100;
16
/// The longest value, in bytes.
17
pub const MAX_VALUE: usize = 64 * 1024;
18
/// Values shorter than this are not masked in logs: too many false hits.
19
const MIN_MASKED: usize = 4;
20
21
#[derive(Clone, Debug, FromRow)]
22
pub struct Secret {
23
    pub scope: String,
24
    pub scope_id: i64,
25
    pub name: String,
26
    /// Sealed; `App.secret_key` opens it.
27
    pub value: Vec<u8>,
28
    pub updated_ms: i64,
29
}
30
31
/// A secret's name, as a pipeline names it: 1 to 64 of `A-Z a-z 0-9 . _ -`.
32
pub fn name_ok(name: &str) -> bool {
33
    !name.is_empty() && name.len() <= 64 && name.bytes().all(|b| b.is_ascii_alphanumeric() || b".-_".contains(&b))
34
}
35
36
pub async fn list(pool: &Pool, scope: Scope) -> sqlx::Result<Vec<Secret>> {
37
    sqlx::query_as("SELECT * FROM flow_secrets WHERE scope = $1 AND scope_id = $2 ORDER BY name")
38
        .bind(scope.kind())
39
        .bind(scope.id())
40
        .fetch_all(pool)
41
        .await
42
}
43
44
/// Set a secret at a level, new or changed; `sealed` is the value sealed.
45
pub async fn set(pool: &Pool, scope: Scope, name: &str, sealed: &[u8]) -> sqlx::Result<()> {
46
    sqlx::query(
47
        "INSERT INTO flow_secrets (scope, scope_id, name, value, updated_ms) VALUES ($1, $2, $3, $4, $5) \
48
         ON CONFLICT (scope, scope_id, name) DO UPDATE SET value = excluded.value, updated_ms = excluded.updated_ms",
49
    )
50
    .bind(scope.kind())
51
    .bind(scope.id())
52
    .bind(name)
53
    .bind(sealed)
54
    .bind(now_ms())
55
    .execute(pool)
56
    .await?;
57
    Ok(())
58
}
59
60
/// Remove a secret from a level; false when it was not there.
61
pub async fn delete(pool: &Pool, scope: Scope, name: &str) -> sqlx::Result<bool> {
62
    let r = sqlx::query("DELETE FROM flow_secrets WHERE scope = $1 AND scope_id = $2 AND name = $3")
63
        .bind(scope.kind())
64
        .bind(scope.id())
65
        .bind(name)
66
        .execute(pool)
67
        .await?;
68
    Ok(r.rows_affected() == 1)
69
}
70
71
/// Forget a level's secrets, when the organisation or repository goes.
72
pub async fn delete_scope(pool: &Pool, scope: Scope) -> sqlx::Result<()> {
73
    sqlx::query("DELETE FROM flow_secrets WHERE scope = $1 AND scope_id = $2").bind(scope.kind()).bind(scope.id()).execute(pool).await?;
74
    Ok(())
75
}
76
77
/// A secret as a level sees it: where from, and whether an inner level
78
/// sets the same name. The value stays sealed.
79
#[derive(Clone, Debug)]
80
pub struct Seen {
81
    pub secret: Secret,
82
    /// `the site`, `the acme organisation`, or empty for the level's own.
83
    pub from: String,
84
    pub overridden: bool,
85
}
86
87
/// Every secret a level sees, outermost first.
88
pub async fn seen(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<Seen>> {
89
    let mut lvls = levels::above(pool, scope, owner).await?;
90
    lvls.push((scope, String::new()));
91
    let mut out: Vec<Seen> = vec![];
92
    for (level, from) in lvls {
93
        for s in list(pool, level).await? {
94
            for o in out.iter_mut().filter(|o| o.secret.name == s.name) {
95
                o.overridden = true;
96
            }
97
            out.push(Seen { secret: s, from: from.clone(), overridden: false });
98
        }
99
    }
100
    Ok(out)
101
}
102
103
/// The values of the named secrets as a repository's jobs get them:
104
/// `(name, value)` for each that is set and opens; names not set are
105
/// left out, as are values sealed under another key.
106
pub async fn for_job(app: &Shared, repo: &crate::db::repos::RepoRow, names: &[String]) -> sqlx::Result<Vec<(String, String)>> {
107
    let all = seen(&app.db, Scope::Repo(repo.id), &repo.owner).await?;
108
    let mut out = vec![];
109
    for name in names {
110
        if let Some(s) = all.iter().find(|s| !s.overridden && s.secret.name == *name) {
111
            if let Some(value) = app.secret_key.open(&s.secret.value) {
112
                out.push((name.clone(), value));
113
            }
114
        }
115
    }
116
    Ok(out)
117
}
118
119
/// `text` with every secret value (and its standard base64) replaced by
120
/// `***`, for a job's log.
121
pub fn mask(text: &str, values: &[String]) -> String {
122
    let mut out = text.to_string();
123
    for v in values {
124
        if v.len() < MIN_MASKED {
125
            continue;
126
        }
127
        out = out.replace(v.as_str(), "***");
128
        let b64 = base64_standard(v.as_bytes());
129
        if b64.len() >= MIN_MASKED {
130
            out = out.replace(&b64, "***");
131
        }
132
    }
133
    out
134
}
135
136
fn base64_standard(bytes: &[u8]) -> String {
137
    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
138
    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
139
    for chunk in bytes.chunks(3) {
140
        let n = chunk.iter().fold(0u32, |acc, b| (acc << 8) | *b as u32) << (8 * (3 - chunk.len()));
141
        for i in 0..4 {
142
            if i <= chunk.len() {
143
                out.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char);
144
            } else {
145
                out.push('=');
146
            }
147
        }
148
    }
149
    out
150
}
151
152
#[cfg(test)]
153
mod tests {
154
    use super::*;
155
156
    #[test]
157
    fn values_and_their_base64_are_masked_in_logs() {
158
        assert_eq!(base64_standard(b"hunter22"), "aHVudGVyMjI=");
159
        let line = "token=hunter22 b64=aHVudGVyMjI= short=ab done";
160
        assert_eq!(mask(line, &["hunter22".into(), "ab".into()]), "token=*** b64=*** short=ab done");
161
        assert!(name_ok("deploy-token") && name_ok("aws.key_1") && !name_ok("") && !name_ok("a b") && !name_ok(&"x".repeat(65)));
162
    }
163
}