Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,8 @@ test-unit:
./scripts/test-ensure-local-relay-key.sh
if command -v cargo-nextest &>/dev/null; then
cargo nextest run -p buzz-core -p buzz-auth --lib
cargo nextest run -p buzz-audit --lib
cargo nextest run -p git-sign-nostr
# buzz-auth NIP-FI verifier doctests. The sealed-authority
# `compile_fail` doctests prove the default-feature public API alone
# cannot forge the issuer→JWKS authority; nextest does not run
Expand Down
6 changes: 6 additions & 0 deletions crates/buzz-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,3 +383,9 @@ Test strategy is **real subprocess, no mocks**:
- **Fake LLM** — `tests/fake_llm.rs` and the helpers in `tests/regressions.rs` spin up a real `tokio::net::TcpListener` on port 0, parse `Content-Length`, and return scripted JSON. No HTTP mocking library.
- **Fake MCP server** — `tests/bin/fake_mcp.rs` is a separate binary controlled by env vars: `FAKE_MCP_HANG_INIT`, `FAKE_MCP_TOOL_DELAY`, `FAKE_MCP_SPAWN_GRANDCHILD`, etc. Each fault path is a real process being abused.
- **Regression tests are the changelog.** Each `#[test]` in `regressions.rs` is named for the bug it locks down: `assistant_text_preserved_across_prompts`, `cancel_leaves_history_valid_for_next_prompt`, `mcp_init_timeout_kills_child`, `oversize_line_kills_connection`. Read them in order to learn the protocol's failure modes.

OAuth cache filenames now use a versioned JSON encoding of the discovery URL,
client ID, and scopes array. Existing caches from the delimiter-joined format
are left in place but are not loaded: their tokens cannot be unambiguously
associated with a configuration. Sign in once after upgrading; headless agents
need a populated new cache or their configured static bearer token.
30 changes: 19 additions & 11 deletions crates/buzz-agent/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
//! and refresh logic; the [`Llm`] just asks for a bearer per request.
//!
//! The PKCE engine implements RFC 6749 + RFC 7636 with on-disk token
//! caching keyed by `sha256(discovery_url|client_id|scopes)`. It's the
//! same shape goose uses for Databricks, but we own the wire format and
//! cache directory so the two are independently upgradable.
//! caching keyed by a versioned JSON tuple of discovery URL, client ID, and
//! the scopes array. Buzz owns the wire format and cache directory, so it
//! can upgrade them independently of other OAuth clients.
//!
//! First-use (cache empty) requires a browser: the engine opens
//! `authorization_endpoint` in `webbrowser`, listens on `127.0.0.1:0`,
Expand Down Expand Up @@ -1377,21 +1377,29 @@ fn default_oauth_cache_root() -> Result<PathBuf, AgentError> {
}

fn cache_path_for(cfg: &PkceOAuthConfig) -> Result<PathBuf, AgentError> {
let mut h = sha2::Sha256::new();
h.update(cfg.discovery_url.as_bytes());
h.update(b"|");
h.update(cfg.client_id.as_bytes());
h.update(b"|");
h.update(cfg.scopes.join(",").as_bytes());
let hash = hex::encode(h.finalize());
// JSON preserves field boundaries and scope element boundaries, including
// delimiters inside values. Do not fall back to the old joined cache key:
// a token there could belong to a different OAuth configuration.
let identity = serde_json::to_vec(&(
"buzz:oauth-cache:v2",
&cfg.discovery_url,
&cfg.client_id,
&cfg.scopes,
))
.map_err(|e| AgentError::Llm(format!("oauth cache identity: {e}")))?;
let hash = hex::encode(sha2::Sha256::digest(identity));

let dir = match &cfg.cache_dir_override {
Some(p) => p.join(&cfg.cache_namespace),
None => default_oauth_cache_root()?.join(&cfg.cache_namespace),
};
Ok(dir.join(format!("{hash}.json")))
Ok(dir.join(format!("v2-{hash}.json")))
}

#[cfg(test)]
#[path = "auth_cache_identity_tests.rs"]
mod cache_identity_tests;

/// Append `ext` as an extra extension onto `base` (e.g. `<hash>.json` →
/// `<hash>.json.lock`). Keeps the lock and cooldown sidecars in the same
/// per-key directory as the cache, so they inherit its `$HOME` override and
Expand Down
104 changes: 104 additions & 0 deletions crates/buzz-agent/src/auth_cache_identity_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
use super::*;

fn config(dir: &Path) -> PkceOAuthConfig {
PkceOAuthConfig {
discovery_url: "https://example.com/.well-known".into(),
client_id: "client".into(),
scopes: vec!["read".into(), "write".into()],
cache_namespace: "test".into(),
cache_dir_override: Some(dir.to_owned()),
}
}

fn legacy_identity(cfg: &PkceOAuthConfig) -> String {
format!(
"{}|{}|{}",
cfg.discovery_url,
cfg.client_id,
cfg.scopes.join(",")
)
}

#[test]
fn cache_keys_preserve_field_and_scope_boundaries() {
let dir = tempfile::tempdir().unwrap();
let first = config(dir.path());
let mut scope_alias = first.clone();
scope_alias.scopes = vec!["read,write".into()];
let mut url_alias = first.clone();
url_alias.discovery_url.push_str("|client");
url_alias.client_id = "other".into();
let mut client_alias = first.clone();
client_alias.client_id = "client|other".into();
let mut empty_array = first.clone();
empty_array.scopes.clear();
let mut empty_element = empty_array.clone();
empty_element.scopes.push(String::new());
let mut client_scope = first.clone();
client_scope.client_id.push_str("|read");
client_scope.scopes = vec!["write".into()];
let mut scope_client = first.clone();
scope_client.scopes = vec!["read|write".into()];

for (a, b) in [
(first, scope_alias),
(url_alias, client_alias),
(empty_array, empty_element),
(client_scope, scope_client),
] {
assert_eq!(legacy_identity(&a), legacy_identity(&b));
assert_ne!(cache_path_for(&a).unwrap(), cache_path_for(&b).unwrap());
}
}

#[tokio::test]
async fn colliding_configs_do_not_share_tokens_or_coordination_files() {
let dir = tempfile::tempdir().unwrap();
let cfg = config(dir.path());
let source = PkceOAuthTokenSource::new(cfg.clone()).unwrap();
source
.save(
&mut *source.state.lock().await,
CachedToken {
access_token: "only-for-two-scopes".into(),
refresh_token: Some("only-refresh-two-scopes".into()),
expires_at: None,
},
)
.unwrap();
let same = PkceOAuthTokenSource::new(cfg.clone()).unwrap();
assert_eq!(
same.state.lock().await.as_ref().unwrap().access_token,
"only-for-two-scopes"
);
let mut other = cfg.clone();
other.scopes = vec!["read,write".into()];
assert_eq!(legacy_identity(&cfg), legacy_identity(&other));
let alias = PkceOAuthTokenSource::new(other).unwrap();
assert!(alias.state.lock().await.is_none());
for suffix in ["lock", "cooldown", "attempt"] {
assert_ne!(
append_ext(&source.cache_path, suffix),
append_ext(&alias.cache_path, suffix)
);
}
}

#[tokio::test]
async fn legacy_cache_is_not_imported() {
let dir = tempfile::tempdir().unwrap();
let cfg = config(dir.path());
let namespace = dir.path().join(&cfg.cache_namespace);
fs::create_dir_all(&namespace).unwrap();
let hash = hex::encode(sha2::Sha256::digest(legacy_identity(&cfg)));
let old = namespace.join(format!("{hash}.json"));
fs::write(
&old,
br#"{"access_token":"ambiguous","refresh_token":"ambiguous","expires_at":null}"#,
)
.unwrap();
let source = PkceOAuthTokenSource::new(cfg).unwrap();
assert!(source.state.lock().await.is_none());
assert!(!source.cache_path.exists());
assert!(old.exists());
}
17 changes: 9 additions & 8 deletions crates/buzz-agent/tests/databricks_auth_coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -543,16 +543,17 @@ fn future_secs() -> u64 {

fn cache_file_path(cfg: &PkceOAuthConfig, cache_dir: &std::path::Path) -> std::path::PathBuf {
use sha2::Digest;
let mut h = sha2::Sha256::new();
h.update(cfg.discovery_url.as_bytes());
h.update(b"|");
h.update(cfg.client_id.as_bytes());
h.update(b"|");
h.update(cfg.scopes.join(",").as_bytes());
let hash = hex::encode(h.finalize());
let identity = serde_json::to_vec(&(
"buzz:oauth-cache:v2",
&cfg.discovery_url,
&cfg.client_id,
&cfg.scopes,
))
.unwrap();
let hash = hex::encode(sha2::Sha256::digest(identity));
cache_dir
.join(&cfg.cache_namespace)
.join(format!("{hash}.json"))
.join(format!("v2-{hash}.json"))
}

/// The cross-process attempt sidecar path for a config, matching the
Expand Down
36 changes: 19 additions & 17 deletions crates/buzz-agent/tests/databricks_oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,19 +81,20 @@ async fn spawn_oidc() -> (String, Arc<AtomicU64>) {
(base, counter)
}

/// Cache key construction matches the auth module: sha256(discovery|client|scopes).
/// Seed the version 2 cache used by the auth module.
fn cache_path_for(cache_dir: &std::path::Path, cfg: &PkceOAuthConfig) -> std::path::PathBuf {
use sha2::Digest;
let mut h = sha2::Sha256::new();
h.update(cfg.discovery_url.as_bytes());
h.update(b"|");
h.update(cfg.client_id.as_bytes());
h.update(b"|");
h.update(cfg.scopes.join(",").as_bytes());
let hash = hex::encode(h.finalize());
let identity = serde_json::to_vec(&(
"buzz:oauth-cache:v2",
&cfg.discovery_url,
&cfg.client_id,
&cfg.scopes,
))
.unwrap();
let hash = hex::encode(sha2::Sha256::digest(identity));
cache_dir
.join(&cfg.cache_namespace)
.join(format!("{hash}.json"))
.join(format!("v2-{hash}.json"))
}

/// Write a token file the engine should pick up on construction.
Expand Down Expand Up @@ -1088,18 +1089,19 @@ fn databricks_oauth_cache_path(home: &std::path::Path, host: &str) -> std::path:
"{}/oidc/.well-known/oauth-authorization-server",
host.trim_end_matches('/')
);
let mut hasher = Sha256::new();
hasher.update(discovery_url.as_bytes());
hasher.update(b"|");
hasher.update(b"databricks-cli");
hasher.update(b"|");
hasher.update(b"all-apis,offline_access");
let hash = hex::encode(hasher.finalize());
let identity = serde_json::to_vec(&(
"buzz:oauth-cache:v2",
&discovery_url,
"databricks-cli",
["all-apis", "offline_access"],
))
.unwrap();
let hash = hex::encode(Sha256::digest(identity));
home.join(".config")
.join("buzz-agent")
.join("oauth")
.join("databricks")
.join(format!("{hash}.json"))
.join(format!("v2-{hash}.json"))
}

fn write_cached_oauth_token(home: &std::path::Path, host: &str, access_token: &str) {
Expand Down
31 changes: 31 additions & 0 deletions crates/buzz-audit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Audit hash encodings

New entries use `hash_version = 2`. The SHA-256 input starts with
`buzz:audit:v2\0`, then fields in this order: community UUID bytes, signed
64-bit big-endian sequence, storage-precision RFC3339 timestamp, action,
optional actor bytes, optional object ID, canonical JSON detail, optional
previous hash. Field tags are the single bytes 1 through 8. Each required
field carries its tag, unsigned 64-bit big-endian byte length, and bytes.
Optional fields carry their tag and a zero/one presence byte; present values
then carry their length and bytes. Public keys and previous hashes must be
32 bytes when present.

Apply migration 0045 (or the desired schema) and upgrade verifiers before
deploying the new writer. Older verifiers cannot validate version 2 rows.
The column defaults to 1 so existing rows and old writers retain their actual
encoding during a rolling deployment. New writers explicitly select 2 and
link to the previous row's unchanged hash. Do not backfill the version or
recompute historical hashes.

Legacy verification accepts absent object IDs, canonical hyphenated UUIDs,
and 64-character lowercase hex identifiers. It rejects other identifiers and
invalid cryptographic field widths because the old concatenation cannot
reliably separate those values. Historical event and media writers used these
accepted forms. A legacy entry with a nonconforming identifier needs operator
inspection; changing its identifier or version is not a repair of its proof.
Legacy genesis entries must have no previous hash, and subsequent entries
must have one.

These checks prevent alternate interpretations accepted by this verifier.
They cannot prove that a legacy row was never altered before this upgrade.
Preserve any independently trusted chain checkpoints when rolling forward.
4 changes: 3 additions & 1 deletion crates/buzz-audit/src/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@ use crate::action::AuditAction;
/// community*. The chain is independent per tenant.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditEntry {
/// Encoding used to compute `hash`; legacy rows use 1, new writes use 2.
pub hash_version: i16,
/// Server-resolved community this entry belongs to. Leads the primary key.
pub community_id: Uuid,
/// Sequence number, monotonic within `community_id` (starts at 1).
pub seq: i64,
/// SHA-256 of this entry's fields including `community_id` and `prev_hash`.
pub hash: Vec<u8>,
/// SHA-256 of the previous entry in *this community's* chain, or `None` for
/// the community's first entry (hashed as [`crate::hash::GENESIS_HASH`]).
/// the community's first entry. Version 2 explicitly encodes its absence.
pub prev_hash: Option<Vec<u8>>,
/// Action that was performed.
pub action: AuditAction,
Expand Down
16 changes: 16 additions & 0 deletions crates/buzz-audit/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ pub enum AuditError {
#[error("unknown audit action in database")]
UnknownAction,

/// The entry uses an encoding this verifier does not support.
#[error("unsupported audit hash version {version}")]
UnsupportedHashVersion {
/// Version stored with the entry.
version: i16,
},

/// A field violates the encoding's structural requirements.
#[error("invalid audit entry at seq {seq}: {field}")]
InvalidField {
/// Per-community sequence number.
seq: i64,
/// Static field description, never the untrusted field value.
field: &'static str,
},

/// A JSON serialization error occurred (e.g. while canonicalising `detail`).
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
Expand Down
Loading
Loading