Repository navigation
feat(hush): add a broker daemon that alone holds the BWS token #75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 6 commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
0bfe9cc
feat(hush): add broker daemon and metadata catalog
AojdevStudio 3c38647
fix(hush): harden broker token, socket, bws, and fallback paths
AojdevStudio 9b09338
fix(hush): read token metadata from the open file so the check builds…
AojdevStudio e651b46
fix(hush): harden trusted path, dev flag, bws timeout, socket dir
AojdevStudio c107963
test(hush): pin broker test temp dirs to 0700 so a loose umask does n…
AojdevStudio c1cc927
fix(hush): let debug same-user daemons trust root-owned ancestors of bws
AojdevStudio 36dfe94
fix(hush): apply review round fixes to the broker core
AojdevStudio f9b79c5
ci(gitguardian): ignore the ADR CLI example placeholder finding
AojdevStudio 41efb02
fix(hush): bound the socket liveness probe and wake the test listener
AojdevStudio 5dca726
fix(hush): keep saturated macOS listeners alive during the liveness p…
AojdevStudio a1e8b0b
fix(hush): make the saturation test deterministic and umask-proof
AojdevStudio beb0504
fix(hush): reach queue saturation deterministically in the probe test
AojdevStudio e5dd00e
docs(gitguardian): use the documented ggshield config filename
AojdevStudio ba3057d
chore(hush): trigger a fresh GitGuardian scan after the incident disp…
AojdevStudio 1559939
fix(hush): a failed poll in the liveness probe leaves the socket in p…
AojdevStudio File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| //! Daemon-owned audit log. Names only, never values or tokens. | ||
|
|
||
| use super::peercred::PeerCredentials; | ||
| use anyhow::{Context, Result}; | ||
| use std::fs::OpenOptions; | ||
| use std::io::Write; | ||
| use std::path::{Path, PathBuf}; | ||
| use std::sync::Mutex; | ||
| use std::time::SystemTime; | ||
|
|
||
| /// The daemon records every catalog request with kernel peer identity. | ||
| pub(crate) trait AuditLog: Send + Sync { | ||
| fn record_catalog(&self, peer: PeerCredentials, project: Option<&str>) -> Result<()>; | ||
| } | ||
|
|
||
| /// Append-only file log. The daemon owns this file, not the client audit log. | ||
| pub(crate) struct FileAuditLog { | ||
| file: Mutex<std::fs::File>, | ||
| } | ||
|
|
||
| impl FileAuditLog { | ||
| pub(crate) fn open(path: &Path) -> Result<Self> { | ||
| if let Some(parent) = path.parent() { | ||
| std::fs::create_dir_all(parent)?; | ||
| } | ||
| let file = OpenOptions::new() | ||
| .create(true) | ||
| .append(true) | ||
| .open(path) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| .with_context(|| format!("failed to open daemon audit log at {}", path.display()))?; | ||
| Ok(Self { | ||
| file: Mutex::new(file), | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| impl AuditLog for FileAuditLog { | ||
| fn record_catalog(&self, peer: PeerCredentials, project: Option<&str>) -> Result<()> { | ||
| let line = format!( | ||
| "{} catalog uid={} gid={} pid={} project={}\n", | ||
| humantime::format_rfc3339_seconds(SystemTime::now()), | ||
| peer.uid, | ||
| peer.gid, | ||
| peer.pid, | ||
| project.unwrap_or("-"), | ||
|
AojdevStudio marked this conversation as resolved.
Outdated
|
||
| ); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| let mut file = self | ||
| .file | ||
| .lock() | ||
| .map_err(|_| anyhow::anyhow!("audit log lock poisoned"))?; | ||
| file.write_all(line.as_bytes())?; | ||
| Ok(()) | ||
| } | ||
| } | ||
|
|
||
| /// Default daemon audit path. The `daemon` subcommand can override it. | ||
| pub(crate) fn default_audit_path() -> PathBuf { | ||
| if let Some(path) = std::env::var_os("HUSH_AUDIT_LOG") { | ||
| return PathBuf::from(path); | ||
| } | ||
| #[cfg(target_os = "macos")] | ||
| { | ||
| PathBuf::from("/var/db/hush/audit.log") | ||
| } | ||
| #[cfg(not(target_os = "macos"))] | ||
| { | ||
| PathBuf::from("/var/log/hush/audit.log") | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,269 @@ | ||
| //! Vault access. The `bws` wire structs stay private to this module. | ||
|
|
||
| use super::material::SecretMaterial; | ||
| use super::meta::{SecretMeta, SecretRef}; | ||
| use anyhow::{anyhow, Context, Result}; | ||
| use secrecy::{ExposeSecret, SecretString}; | ||
| use serde::Deserialize; | ||
| #[cfg(test)] | ||
| use std::collections::HashMap; | ||
| use std::io::Read; | ||
| use std::path::PathBuf; | ||
| use std::process::{Child, Command, Stdio}; | ||
| use std::time::{Duration, Instant}; | ||
|
|
||
| /// Hard cap on bws stdout. `bws secret list` returns every value at once. | ||
| pub(crate) const MAX_BWS_OUTPUT: usize = 16 * 1024 * 1024; | ||
|
|
||
| /// Hard deadline for one bws call. | ||
| pub(crate) const BWS_TIMEOUT: Duration = Duration::from_secs(60); | ||
|
|
||
| /// The daemon's only path to stored values. `catalog` never returns values. | ||
| #[allow(dead_code)] | ||
| pub(crate) trait VaultBackend: Send + Sync { | ||
| fn catalog(&self) -> Result<Vec<SecretMeta>>; | ||
| fn fetch(&self, secret: &SecretRef) -> Result<SecretMaterial>; | ||
| } | ||
|
|
||
| #[derive(Debug, Deserialize)] | ||
| #[serde(rename_all = "camelCase")] | ||
| struct BwsProject { | ||
| id: String, | ||
| name: String, | ||
| } | ||
|
|
||
| #[derive(Debug, Deserialize)] | ||
| #[serde(rename_all = "camelCase")] | ||
| struct BwsSecret { | ||
| id: String, | ||
| key: String, | ||
| value: String, | ||
| #[serde(default)] | ||
| note: String, | ||
| project_id: Option<String>, | ||
| } | ||
|
|
||
| fn drop_value(secret: BwsSecret, projects: &[BwsProject]) -> SecretMeta { | ||
| let BwsSecret { | ||
| id, | ||
| key, | ||
| note, | ||
| project_id, | ||
| value, | ||
| } = secret; | ||
| drop(value); | ||
| SecretMeta { | ||
| id, | ||
| key, | ||
| project: project_id.as_ref().and_then(|wanted| { | ||
| projects | ||
| .iter() | ||
| .find(|p| &p.id == wanted) | ||
| .map(|p| p.name.clone()) | ||
| }), | ||
| project_id, | ||
| note, | ||
| } | ||
| } | ||
|
|
||
| /// Real backend. Shells out to `bws` by absolute path with a cleared environment. | ||
| pub(crate) struct BwsBackend { | ||
| bws_path: PathBuf, | ||
| token: SecretString, | ||
| } | ||
|
|
||
| impl BwsBackend { | ||
| pub(crate) fn new(bws_path: PathBuf, token: SecretString) -> Self { | ||
| Self { bws_path, token } | ||
| } | ||
|
|
||
| fn run_bws(&self, args: &[&str]) -> Result<String> { | ||
| self.run_bws_capped(args, MAX_BWS_OUTPUT) | ||
| } | ||
|
|
||
| /// Run bws with a byte cap on stdout. Stderr is discarded. | ||
| pub(crate) fn run_bws_capped(&self, args: &[&str], cap: usize) -> Result<String> { | ||
| self.run_bws_with_deadline(args, cap, BWS_TIMEOUT) | ||
| } | ||
|
|
||
| /// Run bws with a byte cap on stdout and a hard deadline. Stderr is discarded. | ||
| pub(crate) fn run_bws_with_deadline( | ||
| &self, | ||
| args: &[&str], | ||
| cap: usize, | ||
| timeout: Duration, | ||
| ) -> Result<String> { | ||
| let deadline = Instant::now() | ||
| .checked_add(timeout) | ||
| .context("failed to run bws")?; | ||
| let child = Command::new(&self.bws_path) | ||
| .env_clear() | ||
| .env("PATH", "/usr/local/bin:/usr/bin:/bin") | ||
| .env("BWS_ACCESS_TOKEN", self.token.expose_secret()) | ||
| .args(args) | ||
| .args(["-o", "json"]) | ||
| .stdin(Stdio::null()) | ||
| .stdout(Stdio::piped()) | ||
| .stderr(Stdio::null()) | ||
| .spawn() | ||
| .context("failed to run bws")?; | ||
| let mut guard = ChildGuard::new(child); | ||
| let stdout = guard.take_stdout().context("failed to run bws")?; | ||
| let (mut bytes, overflow) = read_capped_until(stdout, cap, deadline)?; | ||
| if overflow { | ||
| return Err(anyhow!("bws output exceeded the size limit")); | ||
| } | ||
| let status = guard.wait_until(deadline)?; | ||
| if !status.success() { | ||
| let name = args.iter().take(2).copied().collect::<Vec<_>>().join(" "); | ||
| return Err(anyhow!("bws {name} failed ({status})")); | ||
| } | ||
| Ok(String::from_utf8(std::mem::take(&mut bytes))?) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
| } | ||
|
|
||
| /// Read at most `cap` bytes. The flag is true when more data remains. | ||
| fn read_capped(reader: &mut impl Read, cap: usize) -> Result<(Vec<u8>, bool)> { | ||
| let mut out = Vec::new(); | ||
| let mut chunk = [0u8; 8192]; | ||
| loop { | ||
| let read = reader.read(&mut chunk)?; | ||
| if read == 0 { | ||
| return Ok((out, false)); | ||
| } | ||
| if out.len() + read > cap { | ||
| let keep = cap.saturating_sub(out.len()); | ||
| out.extend_from_slice(&chunk[..keep]); | ||
| return Ok((out, true)); | ||
| } | ||
| out.extend_from_slice(&chunk[..read]); | ||
| } | ||
| } | ||
|
|
||
| /// Read at most `cap` bytes before the deadline. Stdout waits on a helper thread. | ||
| fn read_capped_until( | ||
| mut stdout: std::process::ChildStdout, | ||
| cap: usize, | ||
| deadline: Instant, | ||
| ) -> Result<(Vec<u8>, bool)> { | ||
| let (sender, receiver) = std::sync::mpsc::channel(); | ||
| let _reader = std::thread::spawn(move || { | ||
| let _ = sender.send(read_capped(&mut stdout, cap)); | ||
| }); | ||
| let remaining = deadline.saturating_duration_since(Instant::now()); | ||
| match receiver.recv_timeout(remaining) { | ||
| Ok(result) => result.context("failed to run bws"), | ||
| Err(_) => Err(anyhow!("bws timed out")), | ||
| } | ||
| } | ||
|
|
||
| /// Kills and reaps the child on drop unless it already exited through `wait_until`. | ||
| struct ChildGuard { | ||
| child: Option<Child>, | ||
| } | ||
|
|
||
| impl ChildGuard { | ||
| /// Wrap a spawned child. | ||
| fn new(child: Child) -> Self { | ||
| Self { child: Some(child) } | ||
| } | ||
|
|
||
| /// Take the piped stdout handle. | ||
| fn take_stdout(&mut self) -> Option<std::process::ChildStdout> { | ||
| self.child.as_mut()?.stdout.take() | ||
| } | ||
|
|
||
| /// Wait for exit before the deadline. | ||
| fn wait_until(&mut self, deadline: Instant) -> Result<std::process::ExitStatus> { | ||
| loop { | ||
| let child = match self.child.as_mut() { | ||
| Some(child) => child, | ||
| None => return Err(anyhow!("failed to run bws")), | ||
| }; | ||
| if let Some(status) = child.try_wait().context("failed to run bws")? { | ||
| self.child = None; | ||
| return Ok(status); | ||
| } | ||
| if Instant::now() >= deadline { | ||
| return Err(anyhow!("bws timed out")); | ||
| } | ||
| std::thread::sleep( | ||
| Duration::from_millis(20).min(deadline.saturating_duration_since(Instant::now())), | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| impl Drop for ChildGuard { | ||
| fn drop(&mut self) { | ||
| if let Some(mut child) = self.child.take() { | ||
| let _ = child.kill(); | ||
| let _ = child.wait(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| impl VaultBackend for BwsBackend { | ||
| fn catalog(&self) -> Result<Vec<SecretMeta>> { | ||
| let projects: Vec<BwsProject> = serde_json::from_str(&self.run_bws(&["project", "list"])?)?; | ||
| let secrets: Vec<BwsSecret> = serde_json::from_str(&self.run_bws(&["secret", "list"])?)?; | ||
| Ok(secrets | ||
| .into_iter() | ||
| .map(|secret| drop_value(secret, &projects)) | ||
| .collect()) | ||
| } | ||
|
|
||
| fn fetch(&self, secret: &SecretRef) -> Result<SecretMaterial> { | ||
| let SecretRef::Id(id) = secret; | ||
| let wire: BwsSecret = serde_json::from_str(&self.run_bws(&["secret", "get", id])?)?; | ||
| Ok(SecretMaterial::new(wire.value.into_bytes())) | ||
| } | ||
| } | ||
|
|
||
| /// In-memory backend for tests. Values live only in this struct and leave | ||
| /// exclusively through [`VaultBackend::fetch`]. | ||
| #[cfg(test)] | ||
| pub(crate) struct FakeBackend { | ||
| secrets: HashMap<String, (SecretMeta, String)>, | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| impl FakeBackend { | ||
| pub(crate) fn new(secrets: HashMap<String, (SecretMeta, String)>) -> Self { | ||
| Self { secrets } | ||
| } | ||
| } | ||
|
|
||
| /// Catalog always fails. Tests use it to prove connected errors propagate. | ||
| #[cfg(test)] | ||
| pub(crate) struct FailingBackend; | ||
|
|
||
| #[cfg(test)] | ||
| impl VaultBackend for FailingBackend { | ||
| fn catalog(&self) -> Result<Vec<SecretMeta>> { | ||
| Err(anyhow!("backend exploded")) | ||
| } | ||
|
|
||
| fn fetch(&self, _secret: &SecretRef) -> Result<SecretMaterial> { | ||
| Err(anyhow!("backend exploded")) | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| impl VaultBackend for FakeBackend { | ||
| fn catalog(&self) -> Result<Vec<SecretMeta>> { | ||
| let mut metas: Vec<SecretMeta> = self | ||
| .secrets | ||
| .values() | ||
| .map(|(meta, _)| meta.clone()) | ||
| .collect(); | ||
| metas.sort_by(|left, right| left.key.cmp(&right.key)); | ||
| Ok(metas) | ||
| } | ||
|
|
||
| fn fetch(&self, secret: &SecretRef) -> Result<SecretMaterial> { | ||
| let SecretRef::Id(id) = secret; | ||
| let (_, value) = self.secrets.get(id).context("unknown secret id")?; | ||
| Ok(SecretMaterial::new(value.as_bytes().to_vec())) | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.