mod audit;
mod caller;
mod client;
#[cfg(feature = "host")]
mod plugins;
#[cfg(feature = "host")]
mod server;
#[cfg(feature = "host")]
mod sign;
#[cfg(feature = "host")]
mod tokens;
#[cfg(feature = "host")]
mod windows;
pub(crate) use audit::append_audit;
pub(crate) use caller::own_ancestry;
#[cfg(feature = "host")]
pub(crate) use client::plugin_approve;
pub(crate) use client::{grant, list, plugin_run_remote, request, request_token};
#[cfg(feature = "host")]
pub(crate) use server::serve;
#[cfg(feature = "host")]
use crate::project::{self, Grant};
#[cfg(feature = "host")]
use crate::ssh;
#[cfg(feature = "host")]
use crate::store::{self, Store};
#[cfg(feature = "host")]
use age::x25519;
#[cfg(feature = "host")]
use anyhow::{Result, bail};
#[cfg(feature = "host")]
use audit::{Entry, append_audit_to};
use serde::{Deserialize, Serialize};
#[cfg(feature = "host")]
use server::{Origin, clock};
#[cfg(feature = "host")]
use std::collections::HashMap;
#[cfg(feature = "host")]
use tokens::{TokenGrant, Tokens, new_token, reject_namespaces, token_value};
#[cfg(feature = "host")]
use windows::{SshWindow, SshWindowKey, Window, WindowKey};
#[cfg(feature = "host")]
const KEY_TTL_SECS: u64 = 300;
#[cfg(feature = "host")]
pub(crate) const MAX_GRANT_SECS: u64 = 86_400;
#[derive(Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Debug)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Op {
#[default]
Get,
List,
Grant,
Plugin,
}
#[derive(Serialize, Deserialize)]
pub(crate) struct Request {
#[serde(default)]
pub name: String,
pub agent: String,
#[serde(default)]
pub op: Op,
#[serde(default)]
pub token: String,
#[serde(default)]
pub ttl: u64,
#[serde(default)]
pub names: Vec<String>,
#[serde(default)]
pub action: String,
#[serde(default)]
pub params: Vec<(String, String)>,
#[serde(default)]
pub run: bool,
}
#[derive(Serialize, Deserialize)]
pub(crate) struct Response {
pub ok: bool,
pub value: Option<String>,
pub error: Option<String>,
}
#[cfg(feature = "host")]
struct Broker {
store: Store,
key: Option<Box<x25519::Identity>>,
tokens: Tokens,
key_at: u64,
grants: Vec<Grant>,
windows: HashMap<WindowKey, Window>,
approved: Option<String>,
ssh_windows: HashMap<SshWindowKey, SshWindow>,
ssh_approved: Option<String>,
}
#[cfg(feature = "host")]
impl Broker {
fn new(store: Store, tokens: Tokens) -> Self {
Broker {
store,
key: None,
key_at: 0,
tokens,
grants: Vec::new(),
windows: HashMap::new(),
approved: None,
ssh_windows: HashMap::new(),
ssh_approved: None,
}
}
fn cached_key(&mut self) -> Option<&x25519::Identity> {
if store::now().saturating_sub(self.key_at) >= KEY_TTL_SECS {
self.key = None;
}
self.key.as_deref()
}
fn prompt(&mut self, agent: &str, secret: &str) -> Result<()> {
self.ask(&format!("release the password for {secret} to {agent}"))
}
fn refused(
&self,
agent: &str,
secrets: &[&str],
error: anyhow::Error,
caller: &str,
) -> anyhow::Error {
let decision = if error.root_cause().to_string().starts_with("denied") {
"denied"
} else {
"prompt failed"
};
for secret in secrets {
let _ = self.audit_by(agent, secret, decision, caller, "");
}
error
}
fn ask(&mut self, reason: &str) -> Result<()> {
let key = self.store.unlock_with_se(reason)?;
self.grants = project::load(&self.store, &key);
self.key = Some(Box::new(key));
self.key_at = store::now();
Ok(())
}
fn granted(&self, secret: &str, cwd: Option<&std::path::Path>) -> bool {
let Some(manifest) = cwd.and_then(project::find) else {
return false;
};
let now = store::now();
self.grants.iter().any(|g| g.covers(&manifest, secret, now))
}
fn grant_manifest(
&mut self,
request: &Request,
cwd: Option<&std::path::Path>,
caller: &str,
) -> Result<bool> {
let Some(manifest) = cwd.and_then(project::find) else {
return Ok(false);
};
if !manifest.secrets.iter().any(|s| s == &request.name) {
return Ok(false);
}
let declared: Vec<&str> = manifest.secrets.iter().map(String::as_str).collect();
self.ask(&format!(
"give {} access to {} secrets declared by {} for {} minutes",
request.agent,
manifest.secrets.len(),
manifest.dir.display(),
manifest.window_secs / 60
))
.map_err(|e| self.refused(&request.agent, &declared, e, caller))?;
self.grants.push(Grant {
dir: manifest.dir.to_string_lossy().to_string(),
hash: manifest.hash,
secrets: manifest.secrets,
until: store::now() + manifest.window_secs,
});
project::save(&self.store, &self.grants)?;
Ok(true)
}
fn handle(
&mut self,
request: &Request,
caller: &str,
cwd: Option<&std::path::Path>,
origin: Origin,
) -> Result<String> {
match request.op {
Op::Get => self.handle_get(request, caller, cwd),
Op::List => self.handle_list(request, caller),
Op::Grant => self.handle_grant(request, caller),
Op::Plugin => {
let scope = match origin {
Origin::Local => "local",
Origin::Tailnet => caller,
};
self.handle_plugin(request, caller, scope)
}
}
}
fn handle_list(&mut self, request: &Request, caller: &str) -> Result<String> {
self.ask(&format!("list your secret names for {}", request.agent))
.map_err(|e| self.refused(&request.agent, &["<list>"], e, caller))?;
let key = self.key.clone().expect("just unlocked");
self.audit(&key, &request.agent, "<list>", "approved", caller)?;
let names = self.store.names(&key)?;
if let Err(e) = ssh::rebuild(&self.store, &key) {
eprintln!("{} could not refresh the SSH keys: {e:#}", clock());
}
Ok(names.join("\n"))
}
fn handle_grant(&mut self, request: &Request, caller: &str) -> Result<String> {
let asked = if request.ttl == 0 { 300 } else { request.ttl };
let ttl = asked.min(MAX_GRANT_SECS);
if ttl < asked {
eprintln!("capped {asked}s at {MAX_GRANT_SECS}s");
}
if let Some(key) = self.cached_key().cloned() {
reject_namespaces(&request.names, &self.store.names(&key)?)?;
}
let reason = format!(
"grant {} to {} for {ttl}s",
request.names.join(", "),
request.agent
);
let asked_for: Vec<&str> = request.names.iter().map(String::as_str).collect();
self.ask(&reason)
.map_err(|e| self.refused(&request.agent, &asked_for, e, caller))?;
let key = self.key.clone().expect("just unlocked");
let all = self.store.names(&key)?;
reject_namespaces(&request.names, &all)?;
let mut wanted = request.names.clone();
wanted.sort();
wanted.dedup();
let mut values = HashMap::new();
let mut stamps = HashMap::new();
for name in &wanted {
let Some((id, secret)) = self.store.find(name, &key)? else {
continue;
};
stamps.insert(name.clone(), (id.clone(), self.store.stamp(&id)?));
values.insert(name.clone(), secret.value.clone());
}
if values.is_empty() {
bail!("{} matched no secrets", request.names.join(", "));
}
let token = new_token();
let grant = store::new_id();
let granted: Vec<String> = values.keys().cloned().collect();
self.tokens.lock().expect("tokens").insert(
token.clone(),
TokenGrant {
values,
stamps,
expires: store::now() + ttl,
grant: grant.clone(),
},
);
for name in &granted {
let to = key.to_public();
append_audit_to(
&self.store,
&to,
&Entry {
agent: &request.agent,
secret: name,
decision: "granted",
caller,
grant: &grant,
..Entry::default()
},
)?;
}
Ok(format!("{token}\n{}", granted.join("\n")))
}
fn value_for_token(&self, token: &str, name: &str) -> Option<(String, String)> {
token_value(&self.tokens, &self.store, token, name)
}
fn handle_get(
&mut self,
request: &Request,
caller: &str,
cwd: Option<&std::path::Path>,
) -> Result<String> {
if !request.token.is_empty()
&& let Some((value, grant)) = self.value_for_token(&request.token, &request.name)
{
let to = self.store.recipient()?;
append_audit_to(
&self.store,
&to,
&Entry {
agent: &request.agent,
secret: &request.name,
decision: "by token",
caller,
grant: &grant,
..Entry::default()
},
)?;
return Ok(value);
}
let covered = (self.cached_key().is_some() && self.granted(&request.name, cwd))
|| (self.cached_key().is_none() && self.grant_manifest(request, cwd, caller)?);
let outcome = if covered {
"project grant"
} else {
self.prompt(&request.agent, &request.name)
.map_err(|e| self.refused(&request.agent, &[&request.name], e, caller))?;
"released"
};
let key = self.key.clone().expect("just unlocked");
let Some((_, secret)) = self.store.find(&request.name, &key)? else {
self.audit(&key, &request.agent, &request.name, "unknown", caller)?;
bail!("no secret named {}", request.name);
};
self.audit(&key, &request.agent, &request.name, outcome, caller)?;
Ok(secret.value.clone())
}
}
pub(crate) const TAILNET_PORT: u16 = 8787;
#[cfg(all(test, feature = "host"))]
mod tests {
use super::*;
#[test]
fn a_grant_is_capped_at_a_day() {
let asked: u64 = 7 * 86_400;
assert_eq!(asked.min(MAX_GRANT_SECS), MAX_GRANT_SECS);
assert_eq!(600u64.min(MAX_GRANT_SECS), 600);
}
}