orbit_runtime/
identity.rs1use serde::Deserialize;
26use sha2::{Digest, Sha256};
27use std::path::Path;
28
29#[derive(Debug, Clone, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct WorkerIdentity {
32 pub id: String,
33 #[serde(default)]
35 pub token_sha256: Option<String>,
36 #[serde(default)]
38 pub client_cert: Option<String>,
39 pub tasks: Vec<String>,
41 #[serde(default)]
42 pub runtime: Option<String>,
43}
44
45impl WorkerIdentity {
46 pub fn may_serve(&self, task: &str) -> bool {
47 self.tasks.iter().any(|p| pattern_matches(p, task))
48 }
49}
50
51pub fn pattern_matches(pattern: &str, task: &str) -> bool {
52 match pattern.strip_suffix('*') {
53 Some(prefix) => task.starts_with(prefix),
54 None => pattern == task,
55 }
56}
57
58#[derive(Debug, Default, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct WorkersFile {
61 #[serde(default, rename = "worker")]
62 pub workers: Vec<WorkerIdentity>,
63}
64
65#[derive(Debug, Default)]
66pub struct Identities {
67 workers: Vec<WorkerIdentity>,
68}
69
70pub fn sha256_hex(s: &str) -> String {
71 Sha256::digest(s.as_bytes())
72 .iter()
73 .map(|b| format!("{b:02x}"))
74 .collect()
75}
76
77fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
78 a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
79}
80
81impl Identities {
82 pub fn load(path: &Path) -> Result<Identities, String> {
83 let text = std::fs::read_to_string(path)
84 .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
85 Self::parse(&text).map_err(|e| format!("{}: {e}", path.display()))
86 }
87
88 pub fn parse(text: &str) -> Result<Identities, String> {
89 let file: WorkersFile = toml::from_str(text).map_err(|e| e.to_string())?;
90 let mut seen = std::collections::HashSet::new();
91 for w in &file.workers {
92 if w.id.trim().is_empty() {
93 return Err("a worker has an empty `id`".into());
94 }
95 if !seen.insert(w.id.clone()) {
96 return Err(format!("worker `{}` is declared twice", w.id));
97 }
98 if w.token_sha256.is_none() && w.client_cert.is_none() {
99 return Err(format!(
100 "worker `{}` needs `token_sha256` or `client_cert`",
101 w.id
102 ));
103 }
104 if let Some(h) = &w.token_sha256 {
105 if h.len() != 64 || !h.bytes().all(|b| b.is_ascii_hexdigit()) {
106 return Err(format!(
107 "worker `{}`: `token_sha256` must be 64 hex characters (use `orbit token`)",
108 w.id
109 ));
110 }
111 }
112 if w.tasks.is_empty() {
113 return Err(format!(
114 "worker `{}` must list at least one task pattern",
115 w.id
116 ));
117 }
118 }
119 Ok(Identities {
120 workers: file.workers,
121 })
122 }
123
124 pub fn is_empty(&self) -> bool {
125 self.workers.is_empty()
126 }
127
128 pub fn len(&self) -> usize {
129 self.workers.len()
130 }
131
132 pub fn by_token(&self, token: &str) -> Option<&WorkerIdentity> {
134 let h = sha256_hex(token);
135 self.workers.iter().find(|w| {
136 w.token_sha256
137 .as_ref()
138 .is_some_and(|t| constant_time_eq(t.to_ascii_lowercase().as_bytes(), h.as_bytes()))
139 })
140 }
141
142 pub fn by_certificate(&self, der: &[u8]) -> Option<&WorkerIdentity> {
145 let names = certificate_names(der);
146 self.workers.iter().find(|w| {
147 w.client_cert
148 .as_ref()
149 .is_some_and(|c| names.iter().any(|n| n.eq_ignore_ascii_case(c)))
150 })
151 }
152}
153
154pub fn certificate_names(der: &[u8]) -> Vec<String> {
156 let Ok((_, cert)) = x509_parser::parse_x509_certificate(der) else {
157 return vec![];
158 };
159 let mut names: Vec<String> = cert
160 .subject()
161 .iter_common_name()
162 .filter_map(|cn| cn.as_str().ok().map(str::to_string))
163 .collect();
164 if let Ok(Some(san)) = cert.subject_alternative_name() {
165 for n in &san.value.general_names {
166 if let x509_parser::extensions::GeneralName::DNSName(d) = n {
167 names.push(d.to_string());
168 }
169 }
170 }
171 names
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn parses_and_matches() {
180 let token_hash = sha256_hex("s3cret");
181 let ids = Identities::parse(&format!(
182 r#"
183 [[worker]]
184 id = "pay"
185 token_sha256 = "{token_hash}"
186 tasks = ["payments.*", "audit.log"]
187 [[worker]]
188 id = "inv"
189 client_cert = "inventory.internal"
190 tasks = ["*"]
191 "#
192 ))
193 .unwrap();
194 let pay = ids.by_token("s3cret").unwrap();
195 assert_eq!(pay.id, "pay");
196 assert!(pay.may_serve("payments.charge") && pay.may_serve("audit.log"));
197 assert!(!pay.may_serve("inventory.reserve") && !pay.may_serve("audit.logs"));
198 assert!(ids.by_token("wrong").is_none());
199 assert!(
200 Identities::parse("[[worker]]\nid = \"x\"\ntasks = [\"*\"]").is_err(),
201 "needs a credential"
202 );
203 assert!(Identities::parse(
204 "[[worker]]\nid = \"x\"\ntoken_sha256 = \"abc\"\ntasks = [\"*\"]"
205 )
206 .is_err());
207 assert!(Identities::parse(
208 "[[worker]]\nid = \"x\"\nclient_cert = \"a\"\ntasks = [\"*\"]\nbogus = 1"
209 )
210 .is_err());
211 }
212}