Skip to main content

khive_runtime/credentials/
config.rs

1use std::collections::BTreeSet;
2
3use serde::{de::DeserializeOwned, Deserialize};
4
5use super::CredentialError;
6use crate::engine_config::KhiveConfig;
7
8/// Reads `[[credentials]]` and `[visibility_receipts]` apart from the rest of the config.
9/// A refusal names the table or entry and a fixed reason. It never carries a value, a key
10/// or a line of the file, because a secret written inline is what these tables refuse.
11pub(crate) fn read_tables(raw: &str, config: &mut KhiveConfig) -> Result<(), CredentialError> {
12    let mut document: toml::Table = toml::from_str(raw).map_err(|_| refused("config".into()))?;
13    if let Some(value) = document.remove("credentials") {
14        let toml::Value::Array(entries) = value else {
15            return Err(refused("credentials".into()));
16        };
17        config.credentials = entries
18            .into_iter()
19            .enumerate()
20            .map(|(index, entry)| closed(entry, || format!("credentials[{index}]")))
21            .collect::<Result<_, _>>()?;
22    }
23    if let Some(value) = document.remove("visibility_receipts") {
24        config.visibility_receipts = Some(closed(value, || "visibility_receipts".into())?);
25    }
26    Ok(())
27}
28
29fn closed<T: DeserializeOwned>(
30    value: toml::Value,
31    name: impl FnOnce() -> String,
32) -> Result<T, CredentialError> {
33    value.try_into().map_err(|_| refused(name()))
34}
35
36fn refused(name: String) -> CredentialError {
37    CredentialError::InvalidConfig {
38        name,
39        reason: "unknown, missing or invalid field; values are never shown",
40    }
41}
42
43#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "snake_case")]
45pub enum CredentialKind {
46    Header,
47    Basic,
48    CookieJar,
49    SigningKey,
50}
51
52/// Closed named references. Material is resolved by providers and never stored in config.
53#[derive(Debug, Clone, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct CredentialConfig {
56    pub name: String,
57    pub kind: CredentialKind,
58    pub provider: String,
59    pub env_var: Option<String>,
60    pub header: Option<String>,
61}
62
63impl CredentialConfig {
64    pub fn validate_all(entries: &[Self]) -> Result<(), CredentialError> {
65        let mut names = BTreeSet::new();
66        for entry in entries {
67            let invalid = |reason| CredentialError::InvalidConfig {
68                name: entry.name.clone(),
69                reason,
70            };
71            if entry.name.is_empty() || !names.insert(&entry.name) {
72                return Err(invalid("credential names must be nonempty and unique"));
73            }
74            if entry.provider.is_empty() {
75                return Err(invalid("provider must be nonempty"));
76            }
77            if entry.provider == "env" {
78                if entry
79                    .env_var
80                    .as_deref()
81                    .is_none_or(|name| name.is_empty() || name.contains(['=', '\0']))
82                {
83                    return Err(invalid("env provider requires a valid env_var name"));
84                }
85            } else if entry.env_var.is_some() {
86                return Err(invalid("env_var is only supported by the env provider"));
87            }
88            match (entry.kind, entry.header.as_deref()) {
89                (CredentialKind::Header, Some(header)) if valid_header_name(header) => {}
90                (CredentialKind::Header, _) => {
91                    return Err(invalid("header credentials require a valid header name"));
92                }
93                (_, Some(_)) => return Err(invalid("header is only valid for header credentials")),
94                (_, None) => {}
95            }
96        }
97        Ok(())
98    }
99}
100
101fn valid_header_name(name: &str) -> bool {
102    !name.is_empty()
103        && name
104            .bytes()
105            .all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte))
106}
107
108/// Receipt key IDs and credential references; this table contains no key bytes.
109#[derive(Debug, Clone, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct VisibilityReceiptConfig {
112    #[serde(default)]
113    pub keys: Vec<VisibilityReceiptKeyConfig>,
114}
115
116#[derive(Debug, Clone, Deserialize)]
117#[serde(deny_unknown_fields)]
118pub struct VisibilityReceiptKeyConfig {
119    pub id: String,
120    pub credential: String,
121    #[serde(default)]
122    pub encrypt: bool,
123}
124
125impl VisibilityReceiptConfig {
126    pub fn validate(&self, credentials: &[CredentialConfig]) -> Result<(), CredentialError> {
127        CredentialConfig::validate_all(credentials)?;
128        let mut ids = BTreeSet::new();
129        let mut encrypting = 0;
130        for key in &self.keys {
131            let invalid = |reason| CredentialError::InvalidConfig {
132                name: "visibility_receipts".to_owned(),
133                reason,
134            };
135            if !(1..=64).contains(&key.id.len())
136                || !key
137                    .id
138                    .bytes()
139                    .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
140            {
141                return Err(invalid(
142                    "key IDs require 1..64 ASCII letters, digits, '.', '_' or '-'",
143                ));
144            }
145            if !ids.insert(&key.id) {
146                return Err(invalid("key IDs must be unique"));
147            }
148            let Some(credential) = credentials
149                .iter()
150                .find(|entry| entry.name == key.credential)
151            else {
152                return Err(CredentialError::UnknownCredential {
153                    name: key.credential.clone(),
154                });
155            };
156            if credential.kind != CredentialKind::SigningKey {
157                return Err(invalid(
158                    "receipt keys must reference signing_key credentials",
159                ));
160            }
161            encrypting += usize::from(key.encrypt);
162        }
163        if encrypting != 1 {
164            return Err(CredentialError::InvalidConfig {
165                name: "visibility_receipts".to_owned(),
166                reason: "exactly one key must have encrypt = true",
167            });
168        }
169        Ok(())
170    }
171}