use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::path::Path;
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerIdentity {
pub id: String,
#[serde(default)]
pub token_sha256: Option<String>,
#[serde(default)]
pub client_cert: Option<String>,
pub tasks: Vec<String>,
#[serde(default)]
pub runtime: Option<String>,
}
impl WorkerIdentity {
pub fn may_serve(&self, task: &str) -> bool {
self.tasks.iter().any(|p| pattern_matches(p, task))
}
}
pub fn pattern_matches(pattern: &str, task: &str) -> bool {
match pattern.strip_suffix('*') {
Some(prefix) => task.starts_with(prefix),
None => pattern == task,
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkersFile {
#[serde(default, rename = "worker")]
pub workers: Vec<WorkerIdentity>,
}
#[derive(Debug, Default)]
pub struct Identities {
workers: Vec<WorkerIdentity>,
}
pub fn sha256_hex(s: &str) -> String {
Sha256::digest(s.as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
impl Identities {
pub fn load(path: &Path) -> Result<Identities, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
Self::parse(&text).map_err(|e| format!("{}: {e}", path.display()))
}
pub fn parse(text: &str) -> Result<Identities, String> {
let file: WorkersFile = toml::from_str(text).map_err(|e| e.to_string())?;
let mut seen = std::collections::HashSet::new();
for w in &file.workers {
if w.id.trim().is_empty() {
return Err("a worker has an empty `id`".into());
}
if !seen.insert(w.id.clone()) {
return Err(format!("worker `{}` is declared twice", w.id));
}
if w.token_sha256.is_none() && w.client_cert.is_none() {
return Err(format!(
"worker `{}` needs `token_sha256` or `client_cert`",
w.id
));
}
if let Some(h) = &w.token_sha256 {
if h.len() != 64 || !h.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(format!(
"worker `{}`: `token_sha256` must be 64 hex characters (use `orbit token`)",
w.id
));
}
}
if w.tasks.is_empty() {
return Err(format!(
"worker `{}` must list at least one task pattern",
w.id
));
}
}
Ok(Identities {
workers: file.workers,
})
}
pub fn is_empty(&self) -> bool {
self.workers.is_empty()
}
pub fn len(&self) -> usize {
self.workers.len()
}
pub fn by_token(&self, token: &str) -> Option<&WorkerIdentity> {
let h = sha256_hex(token);
self.workers.iter().find(|w| {
w.token_sha256
.as_ref()
.is_some_and(|t| constant_time_eq(t.to_ascii_lowercase().as_bytes(), h.as_bytes()))
})
}
pub fn by_certificate(&self, der: &[u8]) -> Option<&WorkerIdentity> {
let names = certificate_names(der);
self.workers.iter().find(|w| {
w.client_cert
.as_ref()
.is_some_and(|c| names.iter().any(|n| n.eq_ignore_ascii_case(c)))
})
}
}
pub fn certificate_names(der: &[u8]) -> Vec<String> {
let Ok((_, cert)) = x509_parser::parse_x509_certificate(der) else {
return vec![];
};
let mut names: Vec<String> = cert
.subject()
.iter_common_name()
.filter_map(|cn| cn.as_str().ok().map(str::to_string))
.collect();
if let Ok(Some(san)) = cert.subject_alternative_name() {
for n in &san.value.general_names {
if let x509_parser::extensions::GeneralName::DNSName(d) = n {
names.push(d.to_string());
}
}
}
names
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_and_matches() {
let token_hash = sha256_hex("s3cret");
let ids = Identities::parse(&format!(
r#"
[[worker]]
id = "pay"
token_sha256 = "{token_hash}"
tasks = ["payments.*", "audit.log"]
[[worker]]
id = "inv"
client_cert = "inventory.internal"
tasks = ["*"]
"#
))
.unwrap();
let pay = ids.by_token("s3cret").unwrap();
assert_eq!(pay.id, "pay");
assert!(pay.may_serve("payments.charge") && pay.may_serve("audit.log"));
assert!(!pay.may_serve("inventory.reserve") && !pay.may_serve("audit.logs"));
assert!(ids.by_token("wrong").is_none());
assert!(
Identities::parse("[[worker]]\nid = \"x\"\ntasks = [\"*\"]").is_err(),
"needs a credential"
);
assert!(Identities::parse(
"[[worker]]\nid = \"x\"\ntoken_sha256 = \"abc\"\ntasks = [\"*\"]"
)
.is_err());
assert!(Identities::parse(
"[[worker]]\nid = \"x\"\nclient_cert = \"a\"\ntasks = [\"*\"]\nbogus = 1"
)
.is_err());
}
}