From e4b05a63df23b5c86ade4a4e9e7e5208352fd1a1 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 18 Jul 2026 09:41:22 +0100 Subject: [PATCH 01/12] =?UTF-8?q?mcp:=20fleet=20treasury=20coordinator=20?= =?UTF-8?q?=E2=80=94=20one=20cap=20across=20N=20gateway=20processes,=20sig?= =?UTF-8?q?ned=20{fleet,count,cumulative}=20checkpoints,=20verify-spend=20?= =?UTF-8?q?cross-check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- Cargo.lock | 1 + crates/auths-mcp-core/src/lib.rs | 5 + crates/auths-mcp-core/src/treasury.rs | 443 +++++++++++++++++++++++ crates/auths-mcp-gateway/Cargo.toml | 3 + crates/auths-mcp-gateway/src/main.rs | 152 +++++++- crates/auths-mcp-gateway/src/proxy.rs | 75 +++- crates/auths-mcp-gateway/src/treasury.rs | 364 +++++++++++++++++++ 7 files changed, 1028 insertions(+), 15 deletions(-) create mode 100644 crates/auths-mcp-core/src/treasury.rs create mode 100644 crates/auths-mcp-gateway/src/treasury.rs diff --git a/Cargo.lock b/Cargo.lock index 4f4f4cf7..8e5f8de2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -733,6 +733,7 @@ name = "auths-mcp-gateway" version = "0.1.5" dependencies = [ "anyhow", + "auths-crypto", "auths-mcp-core", "auths-sdk", "chrono", diff --git a/crates/auths-mcp-core/src/lib.rs b/crates/auths-mcp-core/src/lib.rs index f3def954..fc46c42a 100644 --- a/crates/auths-mcp-core/src/lib.rs +++ b/crates/auths-mcp-core/src/lib.rs @@ -40,6 +40,7 @@ pub mod paymode; pub mod rail; pub mod receipt; pub mod session; +pub mod treasury; pub use attestation::{AttestationError, Attested, RailAttestation}; pub use audit::{ @@ -59,6 +60,10 @@ pub use paymode::{ pub use rail::{ExtractedCost, RailError, extract as extract_rail_cost, extract_stripe}; pub use receipt::{Receipt, ReceiptError}; pub use session::{Budget, BudgetParseError}; +pub use treasury::{ + FleetLedger, FleetReserveOutcome, SignedTreasuryCheckpoint, TreasuryCheckpoint, TreasuryError, + TreasuryReply, TreasuryRequest, verify_checkpoint_trail, +}; /// A capability string a downstream tool maps to (e.g. `fs.read`, `fs.write`, /// `github.comment`). The gate enforces that the capability a `tools/call` diff --git a/crates/auths-mcp-core/src/treasury.rs b/crates/auths-mcp-core/src/treasury.rs new file mode 100644 index 00000000..99c7f465 --- /dev/null +++ b/crates/auths-mcp-core/src/treasury.rs @@ -0,0 +1,443 @@ +//! The fleet treasury — ONE spending cap across N gateway processes. +//! +//! N delegations for throughput means N split budgets unless something outside every +//! gateway process holds the fleet-wide counter. This module is that counter's domain +//! logic: a monotonic [`FleetLedger`] the coordinator serves, the line-protocol wire +//! types the gateways speak to it, and the signed [`TreasuryCheckpoint`] trail that +//! anchors `{fleet, count, cumulative}` outside any single operator's process so +//! `verify-spend` can cross-check the fleet's re-derived totals against it. +//! +//! Enforcement stance: a reservation COMMITS on grant — the fleet counter rises the +//! moment capacity is granted, so a crashed gateway can never have spent capacity the +//! coordinator did not count. Gateways that cannot reach the coordinator fall back to +//! their local (smaller) budget — fail-closed to the tighter cap, never open. +//! +//! This module is pure domain state + wire shapes: no sockets, no clocks, no files. +//! The gateway binary owns the TCP loop, persistence, and checkpoint cadence. + +use crate::Cents; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Errors from fleet-ledger restoration and checkpoint verification. +#[derive(Debug, Error)] +pub enum TreasuryError { + /// A checkpoint line was not valid JSON or missed required fields. + #[error("malformed checkpoint line {line}: {reason}")] + Malformed { line: usize, reason: String }, + /// A checkpoint's signature did not verify under its embedded public key. + #[error("checkpoint line {line}: signature invalid")] + BadSignature { line: usize }, + /// A checkpoint's signer differed from the expected (or first-seen) public key. + #[error("checkpoint line {line}: signer changed mid-trail")] + SignerChanged { line: usize }, + /// A later checkpoint reported a lower count or cumulative than an earlier one. + #[error("checkpoint line {line}: {what} regressed ({later} < {earlier})")] + Rollback { + line: usize, + what: &'static str, + earlier: u64, + later: u64, + }, + /// The trail was empty — nothing to cross-check against. + #[error("checkpoint trail is empty")] + Empty, + /// The final cumulative did not equal the total the caller re-derived from logs. + #[error("cumulative mismatch: checkpoints say {checkpointed} cents, logs re-derive {rederived}")] + CumulativeMismatch { checkpointed: u64, rederived: u64 }, +} + +/// The one fleet-wide counter: a cap, the monotonic committed total, and the grant count. +/// +/// Args: +/// * `cap_cents`: the fleet cap — the total the whole fleet may spend. +/// +/// Usage: +/// ```ignore +/// let mut ledger = FleetLedger::new(Cents::new(500)); +/// match ledger.reserve(Cents::new(120)) { +/// FleetReserveOutcome::Granted { headroom_cents } => { /* forward the call */ } +/// FleetReserveOutcome::Refused { .. } => { /* usage-cap-exceeded */ } +/// } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FleetLedger { + cap_cents: Cents, + settled_cents: Cents, + count: u64, +} + +/// The outcome of one fleet reservation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FleetReserveOutcome { + /// Capacity granted and committed; `headroom_cents` remains for the fleet. + Granted { headroom_cents: Cents }, + /// The reservation would cross the fleet cap — refused before any rail is touched. + Refused { + cap_cents: Cents, + would_be_cents: Cents, + }, +} + +impl FleetLedger { + /// Start a fresh ledger at zero spend under `cap_cents`. + /// + /// Args: + /// * `cap_cents`: the fleet-wide cap. + /// + /// Usage: + /// ```ignore + /// let ledger = FleetLedger::new(Cents::new(500)); + /// ``` + pub fn new(cap_cents: Cents) -> Self { + FleetLedger { + cap_cents, + settled_cents: Cents::ZERO, + count: 0, + } + } + + /// Restore a persisted ledger — the coordinator's restart path. The committed + /// total only ever rises, so a restart resumes from the durable high-water. + /// + /// Args: + /// * `cap_cents`: the fleet cap in force. + /// * `settled_cents`: the persisted committed total. + /// * `count`: the persisted grant count. + /// + /// Usage: + /// ```ignore + /// let ledger = FleetLedger::restore(cap, persisted.settled_cents(), persisted.count()); + /// ``` + pub fn restore(cap_cents: Cents, settled_cents: Cents, count: u64) -> Self { + FleetLedger { + cap_cents, + settled_cents, + count, + } + } + + /// Reserve `cents` against the fleet cap; a grant COMMITS immediately. + /// + /// Args: + /// * `cents`: the ceiling this call pre-authorizes. + /// + /// Usage: + /// ```ignore + /// let outcome = ledger.reserve(Cents::new(3)); + /// ``` + pub fn reserve(&mut self, cents: Cents) -> FleetReserveOutcome { + let would_be = self.settled_cents.saturating_add(cents); + if would_be > self.cap_cents { + return FleetReserveOutcome::Refused { + cap_cents: self.cap_cents, + would_be_cents: would_be, + }; + } + self.settled_cents = would_be; + self.count += 1; + FleetReserveOutcome::Granted { + headroom_cents: Cents::new(self.cap_cents.get() - self.settled_cents.get()), + } + } + + /// The committed fleet total. + pub fn settled_cents(&self) -> Cents { + self.settled_cents + } + + /// The number of grants committed. + pub fn count(&self) -> u64 { + self.count + } + + /// The cap in force. + pub fn cap_cents(&self) -> Cents { + self.cap_cents + } +} + +/// One request line a gateway sends the coordinator (JSON, newline-delimited). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "kebab-case")] +pub enum TreasuryRequest { + /// Reserve `cents` of fleet capacity for one call by `delegation`. + Reserve { + fleet: String, + delegation: String, + cents: Cents, + }, + /// Read the fleet counter without reserving. + Status { fleet: String }, +} + +/// One reply line the coordinator sends back. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "outcome", rename_all = "kebab-case")] +pub enum TreasuryReply { + /// Capacity granted and committed. + Granted { headroom_cents: Cents }, + /// The reservation would cross the fleet cap. + Refused { + cap_cents: Cents, + would_be_cents: Cents, + }, + /// The current counter. + Status { + fleet: String, + count: u64, + settled_cents: Cents, + cap_cents: Cents, + }, + /// The request could not be served (wrong fleet, malformed line). + Error { reason: String }, +} + +/// The payload a coordinator signs on a cadence: the fleet's running totals at `at`. +/// +/// Signed over its canonical JSON (json-canon), so any holder can re-derive the exact +/// bytes from the fields alone. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TreasuryCheckpoint { + /// The fleet this trail counts for (the delegator root AID by default). + pub fleet: String, + /// Grants committed so far. + pub count: u64, + /// The committed fleet total in cents. + pub cumulative_cents: Cents, + /// When the coordinator took this snapshot. + pub at: DateTime, +} + +impl TreasuryCheckpoint { + /// The exact bytes the coordinator signs — canonical JSON of the payload. + /// + /// Usage: + /// ```ignore + /// let sig = RingCryptoProvider::p256_sign(seed.as_bytes(), &checkpoint.signing_bytes()?)?; + /// ``` + pub fn signing_bytes(&self) -> Result, serde_json::Error> { + let value = serde_json::to_value(self)?; + json_canon::to_string(&value).map(String::into_bytes) + } +} + +/// One line of the checkpoint trail: the payload plus its P-256 signature, both keys hex. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignedTreasuryCheckpoint { + /// The signed payload. + #[serde(flatten)] + pub checkpoint: TreasuryCheckpoint, + /// The coordinator's compressed P-256 public key, hex. + pub public_key_hex: String, + /// The P-256 signature over [`TreasuryCheckpoint::signing_bytes`], hex. + pub signature_hex: String, +} + +/// Decode a hex string into bytes. +/// +/// Args: +/// * `hex`: an even-length lowercase/uppercase hex string. +/// +/// Usage: +/// ```ignore +/// let bytes = decode_hex(&signed.signature_hex)?; +/// ``` +pub fn decode_hex(hex: &str) -> Result, String> { + if !hex.len().is_multiple_of(2) { + return Err("odd-length hex".to_string()); + } + (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| e.to_string())) + .collect() +} + +/// Encode bytes as lowercase hex. +/// +/// Args: +/// * `bytes`: the raw bytes. +/// +/// Usage: +/// ```ignore +/// let hex = encode_hex(&public_key); +/// ``` +pub fn encode_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Verify a checkpoint trail: every signature valid, one stable signer, counts and +/// cumulative monotonic. Returns the FINAL checkpoint for cross-checking. +/// +/// Args: +/// * `lines`: the trail, one signed-checkpoint JSON object per line. +/// * `expect_pubkey_hex`: pin the signer; `None` accepts the first line's key. +/// * `verify`: the signature verifier `(pubkey, message, signature) -> bool`. +/// +/// Usage: +/// ```ignore +/// let last = verify_checkpoint_trail(&lines, None, |pk, m, s| { +/// RingCryptoProvider::p256_verify(pk, m, s).is_ok() +/// })?; +/// ``` +pub fn verify_checkpoint_trail( + lines: &[String], + expect_pubkey_hex: Option<&str>, + verify: impl Fn(&[u8], &[u8], &[u8]) -> bool, +) -> Result { + let mut pinned: Option = expect_pubkey_hex.map(str::to_string); + let mut last: Option = None; + for (idx, raw) in lines.iter().enumerate() { + let line = idx + 1; + if raw.trim().is_empty() { + continue; + } + let signed: SignedTreasuryCheckpoint = + serde_json::from_str(raw).map_err(|e| TreasuryError::Malformed { + line, + reason: e.to_string(), + })?; + match &pinned { + Some(p) if *p != signed.public_key_hex => { + return Err(TreasuryError::SignerChanged { line }); + } + Some(_) => {} + None => pinned = Some(signed.public_key_hex.clone()), + } + let pubkey = + decode_hex(&signed.public_key_hex).map_err(|reason| TreasuryError::Malformed { + line, + reason: format!("public_key_hex: {reason}"), + })?; + let sig = decode_hex(&signed.signature_hex).map_err(|reason| TreasuryError::Malformed { + line, + reason: format!("signature_hex: {reason}"), + })?; + let message = + signed + .checkpoint + .signing_bytes() + .map_err(|e| TreasuryError::Malformed { + line, + reason: format!("canonicalize: {e}"), + })?; + if !verify(&pubkey, &message, &sig) { + return Err(TreasuryError::BadSignature { line }); + } + if let Some(prev) = &last { + if signed.checkpoint.count < prev.count { + return Err(TreasuryError::Rollback { + line, + what: "count", + earlier: prev.count, + later: signed.checkpoint.count, + }); + } + if signed.checkpoint.cumulative_cents < prev.cumulative_cents { + return Err(TreasuryError::Rollback { + line, + what: "cumulative_cents", + earlier: prev.cumulative_cents.get(), + later: signed.checkpoint.cumulative_cents.get(), + }); + } + } + last = Some(signed.checkpoint); + } + last.ok_or(TreasuryError::Empty) +} + +#[cfg(test)] +mod tests { + use super::*; + use auths_crypto::ring_provider::RingCryptoProvider; + use chrono::TimeZone; + + fn signed( + seed: &auths_crypto::SecureSeed, + pubkey: &[u8], + fleet: &str, + count: u64, + cumulative: u64, + ) -> String { + let checkpoint = TreasuryCheckpoint { + fleet: fleet.to_string(), + count, + cumulative_cents: Cents::new(cumulative), + at: Utc.timestamp_opt(1_700_000_000 + count as i64, 0).unwrap(), + }; + let sig = + RingCryptoProvider::p256_sign(seed.as_bytes(), &checkpoint.signing_bytes().unwrap()) + .unwrap(); + serde_json::to_string(&SignedTreasuryCheckpoint { + checkpoint, + public_key_hex: encode_hex(pubkey), + signature_hex: encode_hex(&sig), + }) + .unwrap() + } + + #[test] + fn reserve_commits_on_grant_and_refuses_past_cap() { + let mut ledger = FleetLedger::new(Cents::new(10)); + assert!(matches!( + ledger.reserve(Cents::new(6)), + FleetReserveOutcome::Granted { headroom_cents } if headroom_cents == Cents::new(4) + )); + assert!(matches!( + ledger.reserve(Cents::new(5)), + FleetReserveOutcome::Refused { cap_cents, would_be_cents } + if cap_cents == Cents::new(10) && would_be_cents == Cents::new(11) + )); + assert_eq!(ledger.settled_cents(), Cents::new(6)); + assert_eq!(ledger.count(), 1); + } + + #[test] + fn restore_resumes_the_high_water() { + let mut ledger = FleetLedger::restore(Cents::new(10), Cents::new(9), 3); + assert!(matches!( + ledger.reserve(Cents::new(2)), + FleetReserveOutcome::Refused { .. } + )); + } + + #[test] + fn checkpoint_trail_verifies_and_catches_tamper() { + let (seed, pubkey) = RingCryptoProvider::p256_generate().unwrap(); + let lines = vec![ + signed(&seed, &pubkey, "did:keri:Eroot", 1, 3), + signed(&seed, &pubkey, "did:keri:Eroot", 2, 6), + ]; + let last = verify_checkpoint_trail(&lines, None, |pk, m, s| { + RingCryptoProvider::p256_verify(pk, m, s).is_ok() + }) + .unwrap(); + assert_eq!(last.count, 2); + assert_eq!(last.cumulative_cents, Cents::new(6)); + + let tampered = vec![lines[0].replace("\"cumulative_cents\":3", "\"cumulative_cents\":1")]; + assert!(matches!( + verify_checkpoint_trail(&tampered, None, |pk, m, s| { + RingCryptoProvider::p256_verify(pk, m, s).is_ok() + }), + Err(TreasuryError::BadSignature { .. }) + )); + } + + #[test] + fn checkpoint_trail_refuses_rollback() { + let (seed, pubkey) = RingCryptoProvider::p256_generate().unwrap(); + let lines = vec![ + signed(&seed, &pubkey, "f", 2, 6), + signed(&seed, &pubkey, "f", 1, 3), + ]; + assert!(matches!( + verify_checkpoint_trail(&lines, None, |pk, m, s| { + RingCryptoProvider::p256_verify(pk, m, s).is_ok() + }), + Err(TreasuryError::Rollback { .. }) + )); + } +} diff --git a/crates/auths-mcp-gateway/Cargo.toml b/crates/auths-mcp-gateway/Cargo.toml index 9af2cc86..52e36045 100644 --- a/crates/auths-mcp-gateway/Cargo.toml +++ b/crates/auths-mcp-gateway/Cargo.toml @@ -21,6 +21,9 @@ auths-mcp-core = { path = "../auths-mcp-core", version = "0.1.5" } # native per-call gate can authenticate each signed call (no shelling on the # enforcement path — D2). auths-sdk = { workspace = true, features = ["backend-git"] } +# The treasury coordinator signs its {fleet, count, cumulative} checkpoints with a +# P-256 key and verify-spend re-verifies them — same primitives as the rest of the stack. +auths-crypto = { workspace = true } # Real MCP transport (PRD §5 Build item 2 / D1): the official Rust MCP SDK. The # `wrap` proxy speaks MCP JSON-RPC up to the agent (server) and down to the wrapped diff --git a/crates/auths-mcp-gateway/src/main.rs b/crates/auths-mcp-gateway/src/main.rs index ce0047f5..c57c492c 100644 --- a/crates/auths-mcp-gateway/src/main.rs +++ b/crates/auths-mcp-gateway/src/main.rs @@ -35,6 +35,7 @@ mod proxy; mod replay; mod spend_log; mod transcript; +mod treasury; #[derive(Parser)] #[command( @@ -64,6 +65,48 @@ enum Command { /// trust in the operator that produced the log. Exits non-zero on any non-`consistent` /// verdict. Needs the issuer's registry to resolve the agent + delegator KELs. VerifySpend(VerifySpendArgs), + + /// The fleet treasury coordinator: ONE spending cap across N gateway processes. + /// + /// Gateways point `TREASURY_URL=tcp://host:port` at it; each metered call + /// reserves fleet capacity here BEFORE the local budget, so the tighter cap + /// always governs. Signs periodic `{fleet, count, cumulative}` checkpoints + /// that `verify-spend --treasury-checkpoints` cross-checks offline. + #[command(subcommand)] + Treasury(TreasuryCommand), +} + +#[derive(Subcommand)] +enum TreasuryCommand { + /// Serve the fleet counter until killed. + Serve(TreasuryServeArgs), +} + +#[derive(Parser)] +struct TreasuryServeArgs { + /// `host:port` to listen on, e.g. `127.0.0.1:7801`. + #[arg(long = "listen", value_name = "ADDR")] + listen: String, + + /// The fleet identifier — by convention the delegator root `did:keri:…`. + #[arg(long = "fleet", value_name = "ID")] + fleet: String, + + /// The fleet-wide cap, e.g. `--cap '$5'`. + #[arg(long = "cap", value_name = "BUDGET")] + cap: String, + + /// Where the durable ledger and the signed checkpoint trail persist. + #[arg(long = "state-dir", value_name = "DIR")] + state_dir: std::path::PathBuf, + + /// Hex seed for the P-256 checkpoint-signing key; generated fresh when omitted. + #[arg(long = "signing-seed", value_name = "HEX", env = "TREASURY_SIGNING_SEED")] + signing_seed: Option, + + /// Seconds between checkpoint signatures (written only when the counter moved). + #[arg(long = "checkpoint-secs", value_name = "SECS", default_value = "5")] + checkpoint_secs: u64, } #[derive(Parser)] @@ -165,6 +208,21 @@ struct VerifySpendArgs { /// The delegator/root `did:keri:…` the grant is anchored to (the pinned trust root). #[arg(long = "root", value_name = "DID")] root: String, + + /// A treasury checkpoint trail (`checkpoints.jsonl`) to cross-check: every + /// signature must verify, the trail must be monotonic, and with + /// `--expect-cumulative` the final total must equal the re-derived sum. + #[arg(long = "treasury-checkpoints", value_name = "FILE")] + treasury_checkpoints: Option, + + /// Pin the coordinator's checkpoint-signing public key (compressed P-256, hex). + #[arg(long = "treasury-pubkey", value_name = "HEX")] + treasury_pubkey: Option, + + /// Assert the final checkpointed cumulative equals this many cents (the caller's + /// re-derived fleet-wide sum across every delegation log). + #[arg(long = "expect-cumulative", value_name = "CENTS")] + expect_cumulative: Option, } fn main() -> ExitCode { @@ -180,6 +238,35 @@ fn main() -> ExitCode { Command::Wrap(args) => runtime.block_on(run_wrap(args)), Command::Replay(args) => runtime.block_on(run_replay(args)), Command::VerifySpend(args) => runtime.block_on(run_verify_spend(args)), + Command::Treasury(TreasuryCommand::Serve(args)) => { + runtime.block_on(run_treasury_serve(args)) + } + } +} + +/// The fleet treasury coordinator process: parse the cap, then serve until killed. +async fn run_treasury_serve(args: TreasuryServeArgs) -> ExitCode { + let cap_cents = match auths_mcp_core::Budget::parse(&args.cap) { + Ok(budget) => budget.cap_cents(), + Err(e) => { + eprintln!("auths-mcp-gateway: invalid --cap `{}`: {e}", args.cap); + return ExitCode::FAILURE; + } + }; + let cfg = treasury::ServeConfig { + listen: args.listen, + fleet: args.fleet, + cap_cents, + state_dir: args.state_dir, + signing_seed_hex: args.signing_seed, + checkpoint_secs: args.checkpoint_secs, + }; + match treasury::serve(cfg).await { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("auths-mcp-gateway: treasury serve exited: {e}"); + ExitCode::FAILURE + } } } @@ -280,9 +367,66 @@ async fn run_verify_spend(args: VerifySpendArgs) -> ExitCode { .audit_spend_log(&records, chrono::Utc::now().timestamp(), &counter, None) .await; println!("verify-spend: {} — {verdict}", verdict.code()); - if verdict.is_consistent() { - ExitCode::SUCCESS - } else { - ExitCode::FAILURE + if !verdict.is_consistent() { + return ExitCode::FAILURE; + } + if let Some(path) = &args.treasury_checkpoints + && !cross_check_treasury( + path, + args.treasury_pubkey.as_deref(), + args.expect_cumulative, + ) + { + return ExitCode::FAILURE; + } + ExitCode::SUCCESS +} + +/// Cross-check a treasury checkpoint trail: signatures, monotonicity, and (when +/// asserted) the final cumulative against the caller's re-derived fleet total. +fn cross_check_treasury( + path: &std::path::Path, + expect_pubkey: Option<&str>, + expect_cumulative: Option, +) -> bool { + use auths_crypto::ring_provider::RingCryptoProvider; + let raw = match std::fs::read_to_string(path) { + Ok(raw) => raw, + Err(e) => { + eprintln!( + "auths-mcp-gateway: cannot read treasury checkpoints `{}`: {e}", + path.display() + ); + return false; + } + }; + let lines: Vec = raw.lines().map(str::to_string).collect(); + let last = match auths_mcp_core::verify_checkpoint_trail(&lines, expect_pubkey, |pk, m, s| { + RingCryptoProvider::p256_verify(pk, m, s).is_ok() + }) { + Ok(last) => last, + Err(e) => { + println!("treasury-checkpoints: invalid — {e}"); + return false; + } + }; + if let Some(expected) = expect_cumulative + && last.cumulative_cents.get() != expected + { + println!( + "treasury-checkpoints: invalid — {}", + auths_mcp_core::TreasuryError::CumulativeMismatch { + checkpointed: last.cumulative_cents.get(), + rederived: expected, + } + ); + return false; } + println!( + "treasury-checkpoints: valid — fleet {} at count {}, cumulative {} cents", + last.fleet, + last.count, + last.cumulative_cents.get() + ); + true } diff --git a/crates/auths-mcp-gateway/src/proxy.rs b/crates/auths-mcp-gateway/src/proxy.rs index 631938c8..bb7736c1 100644 --- a/crates/auths-mcp-gateway/src/proxy.rs +++ b/crates/auths-mcp-gateway/src/proxy.rs @@ -25,8 +25,8 @@ use std::sync::Arc; use auths_mcp_core::budget::CrossRailBudget; use auths_mcp_core::{ Actual, AtomicUsdc, Budget, Capability, Cents, Decision, Meter, NonZeroCents, PaymentMode, - Receipt, Settlement, SpendLogRecord, TEST_MODE_ENV, ToolCall, Verdict, env_opts_into_test, - require_budget, + Receipt, Settlement, SpendLogRecord, TEST_MODE_ENV, ToolCall, TreasuryReply, Verdict, + env_opts_into_test, require_budget, }; use auths_sdk::storage::{GitRegistryBackend, RegistryConfig}; use chrono::Utc; @@ -277,9 +277,53 @@ struct GatewayProxy { /// the sequential single-agent flow; concurrent persists from multiple agents on one gateway /// would need this serialized across sign+append (a follow-on). prev_binding: Arc>, + /// The fleet treasury coordinator, when `TREASURY_URL` names one: every metered + /// call reserves fleet capacity there BEFORE the local budget, so ONE cap binds + /// N gateway processes. `None` (unset or unreachable) leaves the local — smaller + /// — budget as the only cap: fail-closed to the tighter bound, never open. + treasury: Option>, } impl GatewayProxy { + /// Pre-authorize a metered call's ceiling against the fleet treasury when one is + /// configured (`TREASURY_URL`). `Some(decision)` is the fleet's refusal — the + /// call is `usage-cap-exceeded` before any local reserve or rail touch. `None` + /// means no coordinator, a fleet grant, or an unreachable coordinator — in every + /// one of those the local (smaller) budget still judges next, so degradation is + /// fail-closed to the tighter cap, never open. + async fn fleet_refusal( + &self, + rail: &str, + ceiling: NonZeroCents, + ) -> Result, McpError> { + let Some(treasury) = &self.treasury else { + return Ok(None); + }; + let Some(TreasuryReply::Refused { + cap_cents, + would_be_cents, + }) = treasury.reserve(&self.chain.agent_did, ceiling.get()).await + else { + return Ok(None); + }; + let cumulative = { + let budget = self.budget.lock().await; + budget.settled_cents().map_err(|e| { + McpError::internal_error(format!("read the cross-rail counter: {e}"), None) + })? + }; + Ok(Some(Decision { + verdict: Verdict::UsageCapExceeded { + cap_cents, + would_be_cents, + }, + cumulative_cents: cumulative, + reserved_cents: Cents::ZERO, + hold: None, + rail: Some(rail.to_string()), + })) + } + /// Derive the metered cost + rail of a brokered call for the cross-rail budget. /// /// The live per-call *cost extraction* from a rail's charge response is the @@ -455,15 +499,22 @@ impl ServerHandler for GatewayProxy { .map_err(|e| McpError::internal_error(format!("per-call gate: {e}"), None))? } CallCost::Metered { rail, ceiling, .. } => { - let meter = Meter::Metered { - rail: rail.clone(), - ceiling: *ceiling, - }; - let mut budget = self.budget.lock().await; - self.gate - .judge(&meter, &proof_bytes, now, &mut budget) - .await - .map_err(|e| McpError::internal_error(format!("per-call gate: {e}"), None))? + match self.fleet_refusal(rail, *ceiling).await? { + Some(fleet_refused) => fleet_refused, + None => { + let meter = Meter::Metered { + rail: rail.clone(), + ceiling: *ceiling, + }; + let mut budget = self.budget.lock().await; + self.gate + .judge(&meter, &proof_bytes, now, &mut budget) + .await + .map_err(|e| { + McpError::internal_error(format!("per-call gate: {e}"), None) + })? + } + } } }; // Forward to the downstream ONLY on a forwarding verdict — a refused call never touches @@ -832,6 +883,7 @@ pub async fn serve(cfg: WrapConfig) -> anyhow::Result<()> { Err(_) => auths_mcp_core::SPEND_LOG_GENESIS.to_string(), }; + let treasury = crate::treasury::TreasuryClient::from_env(&chain.root_did).map(Arc::new); let proxy = GatewayProxy { downstream, budget: Arc::new(Mutex::new(budget)), @@ -840,6 +892,7 @@ pub async fn serve(cfg: WrapConfig) -> anyhow::Result<()> { next_call: Arc::new(std::sync::atomic::AtomicUsize::new(0)), rail: cfg.rail, prev_binding: Arc::new(Mutex::new(prev_binding)), + treasury, }; // 2. Serve MCP UP to the agent over stdio, brokering each tools/call. diff --git a/crates/auths-mcp-gateway/src/treasury.rs b/crates/auths-mcp-gateway/src/treasury.rs new file mode 100644 index 00000000..85ef8a9e --- /dev/null +++ b/crates/auths-mcp-gateway/src/treasury.rs @@ -0,0 +1,364 @@ +//! The treasury coordinator wire — the gateway side of `auths_mcp_core::treasury`. +//! +//! One process (`auths-mcp-gateway treasury serve`) holds the fleet's +//! [`FleetLedger`] and answers newline-delimited JSON reserve requests over TCP. +//! Every wrapped gateway in the fleet points `TREASURY_URL=tcp://host:port` at it and +//! pre-authorizes each metered call there BEFORE its own local budget: the fleet cap +//! and the local cap both bind, so the tighter one always governs. A gateway that +//! cannot reach the coordinator proceeds under its local (smaller) budget alone — +//! fail-closed to the tighter cap, never open. +//! +//! The coordinator persists the ledger atomically per grant (restart resumes the +//! high-water, mirroring the per-delegation counter's monotonicity) and appends a +//! P-256-signed `{fleet, count, cumulative}` checkpoint to `checkpoints.jsonl` on a +//! cadence — the running total anchored outside every gateway process, which +//! `verify-spend --treasury-checkpoints` cross-checks offline. + +use anyhow::{Context, bail}; +use auths_crypto::ring_provider::RingCryptoProvider; +use auths_mcp_core::treasury::{ + FleetLedger, SignedTreasuryCheckpoint, TreasuryCheckpoint, encode_hex, +}; +use auths_mcp_core::{Cents, TreasuryReply, TreasuryRequest}; +use chrono::Utc; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::Mutex; + +/// How long a gateway waits on the coordinator before falling back to its local cap. +const RESERVE_TIMEOUT: Duration = Duration::from_millis(400); + +/// The gateway-held client for one fleet's coordinator. +/// +/// Args: +/// * `url`: `tcp://host:port` (the `TREASURY_URL` env value). +/// * `fleet`: the fleet identifier — the delegator root AID unless `TREASURY_FLEET` overrides. +/// +/// Usage: +/// ```ignore +/// if let Some(treasury) = TreasuryClient::from_env(&chain.root_did) { +/// let reply = treasury.reserve(&chain.agent_did, ceiling).await; +/// } +/// ``` +pub struct TreasuryClient { + addr: String, + fleet: String, + unreachable_reported: AtomicBool, +} + +impl TreasuryClient { + /// Build the client from `TREASURY_URL` (+ optional `TREASURY_FLEET`); `None` + /// when no coordinator is configured. + /// + /// Args: + /// * `default_fleet`: the fleet id used when `TREASURY_FLEET` is unset. + /// + /// Usage: + /// ```ignore + /// let treasury = TreasuryClient::from_env(&chain.root_did); + /// ``` + pub fn from_env(default_fleet: &str) -> Option { + let url = std::env::var("TREASURY_URL").ok()?; + let addr = url.strip_prefix("tcp://")?.to_string(); + let fleet = + std::env::var("TREASURY_FLEET").unwrap_or_else(|_| default_fleet.to_string()); + Some(TreasuryClient { + addr, + fleet, + unreachable_reported: AtomicBool::new(false), + }) + } + + /// Reserve `cents` of fleet capacity for one call; `None` means the coordinator + /// was unreachable and the caller must enforce its local budget alone. + /// + /// Args: + /// * `delegation`: the calling agent's delegated AID. + /// * `cents`: the ceiling this call pre-authorizes. + /// + /// Usage: + /// ```ignore + /// match treasury.reserve(agent_did, ceiling).await { + /// Some(TreasuryReply::Refused { .. }) => { /* usage-cap-exceeded */ } + /// _ => { /* local budget governs from here */ } + /// } + /// ``` + pub async fn reserve(&self, delegation: &str, cents: Cents) -> Option { + let request = TreasuryRequest::Reserve { + fleet: self.fleet.clone(), + delegation: delegation.to_string(), + cents, + }; + match tokio::time::timeout(RESERVE_TIMEOUT, self.exchange(&request)).await { + Ok(Ok(reply)) => Some(reply), + Ok(Err(_)) | Err(_) => { + if !self.unreachable_reported.swap(true, Ordering::Relaxed) { + eprintln!( + "auths-mcp-gateway: treasury {} unreachable — enforcing the local \ + budget alone (the tighter cap still binds; fail-closed, never open)", + self.addr + ); + } + None + } + } + } + + async fn exchange(&self, request: &TreasuryRequest) -> anyhow::Result { + let mut stream = TcpStream::connect(&self.addr).await?; + let mut line = serde_json::to_string(request)?; + line.push('\n'); + stream.write_all(line.as_bytes()).await?; + let mut reply = String::new(); + BufReader::new(&mut stream).read_line(&mut reply).await?; + Ok(serde_json::from_str(&reply)?) + } +} + +/// The coordinator's serve configuration. +pub struct ServeConfig { + /// `host:port` to listen on. + pub listen: String, + /// The fleet this coordinator counts for. + pub fleet: String, + /// The fleet-wide cap. + pub cap_cents: Cents, + /// Where the ledger and checkpoint trail persist. + pub state_dir: PathBuf, + /// Signing seed (hex) for checkpoints; a fresh keypair is generated when absent. + pub signing_seed_hex: Option, + /// Seconds between checkpoint signatures (only written when the counter moved). + pub checkpoint_secs: u64, +} + +struct ServeState { + ledger: FleetLedger, + last_checkpointed_count: u64, +} + +/// Run the coordinator until the process is killed. +/// +/// Args: +/// * `cfg`: the serve configuration. +/// +/// Usage: +/// ```ignore +/// treasury::serve(cfg).await?; +/// ``` +pub async fn serve(cfg: ServeConfig) -> anyhow::Result<()> { + std::fs::create_dir_all(&cfg.state_dir) + .with_context(|| format!("create state dir {}", cfg.state_dir.display()))?; + let ledger_path = cfg.state_dir.join("fleet-ledger.json"); + let ledger = load_ledger(&ledger_path, cfg.cap_cents)?; + + let (seed, public_key) = match &cfg.signing_seed_hex { + Some(hex) => { + let seed = auths_crypto::decode_seed_hex(hex).context("parse --signing-seed")?; + let public_key = RingCryptoProvider::p256_public_key_from_seed(seed.as_bytes()) + .context("derive the checkpoint public key")?; + (seed, public_key) + } + None => RingCryptoProvider::p256_generate().context("generate the checkpoint key")?, + }; + + let listener = TcpListener::bind(&cfg.listen) + .await + .with_context(|| format!("bind {}", cfg.listen))?; + println!( + "treasury: fleet={} cap=${:.2} listen={} checkpoint-pubkey={} state={}", + cfg.fleet, + cfg.cap_cents.get() as f64 / 100.0, + cfg.listen, + encode_hex(&public_key), + cfg.state_dir.display(), + ); + + let state = Arc::new(Mutex::new(ServeState { + last_checkpointed_count: ledger.count(), + ledger, + })); + + let checkpoint_state = Arc::clone(&state); + let checkpoint_path = cfg.state_dir.join("checkpoints.jsonl"); + let fleet = cfg.fleet.clone(); + let cadence = Duration::from_secs(cfg.checkpoint_secs.max(1)); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(cadence); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + ticker.tick().await; + let snapshot = { + let mut guard = checkpoint_state.lock().await; + if guard.ledger.count() == guard.last_checkpointed_count { + continue; + } + guard.last_checkpointed_count = guard.ledger.count(); + (guard.ledger.count(), guard.ledger.settled_cents()) + }; + if let Err(e) = append_checkpoint( + &checkpoint_path, + &fleet, + snapshot, + &seed, + &public_key, + ) { + eprintln!("treasury: checkpoint append failed: {e}"); + } + } + }); + + loop { + let (stream, _) = listener.accept().await?; + let conn_state = Arc::clone(&state); + let conn_fleet = cfg.fleet.clone(); + let conn_ledger_path = ledger_path.clone(); + tokio::spawn(async move { + if let Err(e) = + handle_connection(stream, conn_state, &conn_fleet, &conn_ledger_path).await + { + eprintln!("treasury: connection error: {e}"); + } + }); + } +} + +async fn handle_connection( + stream: TcpStream, + state: Arc>, + fleet: &str, + ledger_path: &Path, +) -> anyhow::Result<()> { + let (read_half, mut write_half) = stream.into_split(); + let mut lines = BufReader::new(read_half).lines(); + while let Some(line) = lines.next_line().await? { + if line.trim().is_empty() { + continue; + } + let reply = match serde_json::from_str::(&line) { + Ok(request) => answer(&state, fleet, ledger_path, request).await, + Err(e) => TreasuryReply::Error { + reason: format!("malformed request: {e}"), + }, + }; + let mut out = serde_json::to_string(&reply)?; + out.push('\n'); + write_half.write_all(out.as_bytes()).await?; + } + Ok(()) +} + +async fn answer( + state: &Arc>, + fleet: &str, + ledger_path: &Path, + request: TreasuryRequest, +) -> TreasuryReply { + match request { + TreasuryRequest::Reserve { + fleet: requested, .. + } if requested != fleet => TreasuryReply::Error { + reason: format!("unknown fleet `{requested}` (serving `{fleet}`)"), + }, + TreasuryRequest::Reserve { cents, .. } => { + let mut guard = state.lock().await; + let outcome = guard.ledger.reserve(cents); + let snapshot = guard.ledger.clone(); + drop(guard); + if let Err(e) = persist_ledger(ledger_path, &snapshot) { + eprintln!("treasury: ledger persist failed: {e}"); + } + match outcome { + auths_mcp_core::FleetReserveOutcome::Granted { headroom_cents } => { + TreasuryReply::Granted { headroom_cents } + } + auths_mcp_core::FleetReserveOutcome::Refused { + cap_cents, + would_be_cents, + } => TreasuryReply::Refused { + cap_cents, + would_be_cents, + }, + } + } + TreasuryRequest::Status { fleet: requested } if requested != fleet => { + TreasuryReply::Error { + reason: format!("unknown fleet `{requested}` (serving `{fleet}`)"), + } + } + TreasuryRequest::Status { .. } => { + let guard = state.lock().await; + TreasuryReply::Status { + fleet: fleet.to_string(), + count: guard.ledger.count(), + settled_cents: guard.ledger.settled_cents(), + cap_cents: guard.ledger.cap_cents(), + } + } + } +} + +fn load_ledger(path: &Path, cap_cents: Cents) -> anyhow::Result { + if !path.exists() { + return Ok(FleetLedger::new(cap_cents)); + } + let raw = std::fs::read_to_string(path) + .with_context(|| format!("read persisted ledger {}", path.display()))?; + let persisted: FleetLedger = serde_json::from_str(&raw) + .with_context(|| format!("parse persisted ledger {}", path.display()))?; + if persisted.settled_cents() > cap_cents { + bail!( + "persisted fleet spend ({} cents) already exceeds the requested cap ({} cents) — \ + refusing to serve an over-spent fleet", + persisted.settled_cents().get(), + cap_cents.get(), + ); + } + Ok(FleetLedger::restore( + cap_cents, + persisted.settled_cents(), + persisted.count(), + )) +} + +fn persist_ledger(path: &Path, ledger: &FleetLedger) -> anyhow::Result<()> { + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, serde_json::to_vec(ledger)?)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + +fn append_checkpoint( + path: &Path, + fleet: &str, + (count, cumulative_cents): (u64, Cents), + seed: &auths_crypto::SecureSeed, + public_key: &[u8], +) -> anyhow::Result<()> { + let checkpoint = TreasuryCheckpoint { + fleet: fleet.to_string(), + count, + cumulative_cents, + at: Utc::now(), + }; + let message = checkpoint.signing_bytes()?; + let signature = RingCryptoProvider::p256_sign(seed.as_bytes(), &message) + .map_err(|e| anyhow::anyhow!("sign checkpoint: {e}"))?; + let signed = SignedTreasuryCheckpoint { + checkpoint, + public_key_hex: encode_hex(public_key), + signature_hex: encode_hex(&signature), + }; + let mut line = serde_json::to_string(&signed)?; + line.push('\n'); + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + file.write_all(line.as_bytes())?; + Ok(()) +} From f22254b49b8da6806d7fa1e02e5df80e48511909 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 18 Jul 2026 10:18:45 +0100 Subject: [PATCH 02/12] =?UTF-8?q?mcp:=20fleet=20gateways=20=E2=80=94=20joi?= =?UTF-8?q?n=20one=20shared=20root=20(AUTHS=5FMCP=5FAGENT=5FLABEL),=20per-?= =?UTF-8?q?delegation=20work=20namespaces,=20in-process=20session=20signin?= =?UTF-8?q?g=20for=20per-call=20throughput?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- Cargo.lock | 1 + crates/auths-mcp-gateway/Cargo.toml | 2 + crates/auths-mcp-gateway/src/chain.rs | 109 +++++-- crates/auths-mcp-gateway/src/inproc_sign.rs | 315 ++++++++++++++++++++ crates/auths-mcp-gateway/src/main.rs | 1 + 5 files changed, 399 insertions(+), 29 deletions(-) create mode 100644 crates/auths-mcp-gateway/src/inproc_sign.rs diff --git a/Cargo.lock b/Cargo.lock index 8e5f8de2..0530146a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -741,6 +741,7 @@ dependencies = [ "rmcp", "serde", "serde_json", + "sha1", "tempfile", "tokio", ] diff --git a/crates/auths-mcp-gateway/Cargo.toml b/crates/auths-mcp-gateway/Cargo.toml index 52e36045..bca72949 100644 --- a/crates/auths-mcp-gateway/Cargo.toml +++ b/crates/auths-mcp-gateway/Cargo.toml @@ -36,6 +36,8 @@ rmcp = { version = "0.9", features = [ ] } clap = { version = "4", features = ["derive", "env"] } +# In-process per-call signing builds git objects directly; repos are SHA-1. +sha1 = "0.10" tokio = { workspace = true } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/auths-mcp-gateway/src/chain.rs b/crates/auths-mcp-gateway/src/chain.rs index 6cece2ee..557ea3d0 100644 --- a/crates/auths-mcp-gateway/src/chain.rs +++ b/crates/auths-mcp-gateway/src/chain.rs @@ -37,6 +37,10 @@ pub struct Chain { agent_alias: String, /// Whether the agent's delegation has been revoked this session. revoked: bool, + /// In-process per-call signing: the first call of each kind runs the full + /// subprocess ceremony and is harvested as a template; later calls sign the + /// same commit shape in memory (the fleet-throughput path). + inproc: crate::inproc_sign::InprocState, } /// The registry the verifier replays both KELs from (org icp + scope seal + agent @@ -102,46 +106,74 @@ impl Chain { /// chain construction inherits them. pub fn build(lab: &Path, scope: &[String]) -> anyhow::Result { let auths_bin = locate_auths()?; + // A fleet gateway names its own delegation label so N wraps can join ONE + // shared root registry (one label per gateway); the default single-wrap + // sandbox keeps the historical `agent` label and layout. + let agent_alias = + std::env::var("AUTHS_MCP_AGENT_LABEL").unwrap_or_else(|_| "agent".to_string()); let org_repo = lab.join("registry"); - let agent_repo = lab.join("registry-agent"); - let work_root = lab.join("work"); + let agent_repo = if agent_alias == "agent" { + lab.join("registry-agent") + } else { + lab.join(format!("registry-agent-{agent_alias}")) + }; + // Per-delegation work namespace: N fleet gateways share one lab, and each + // signs into its own subtree so per-call repos never collide across processes. + let work_root = lab.join("work").join(&agent_alias); std::fs::create_dir_all(&org_repo)?; std::fs::create_dir_all(&work_root)?; - // 1. The org root identity. - let meta = lab.join("org-meta.json"); - std::fs::write( - &meta, - br#"{"name":"Parent Root","purpose":"bounded-agent MCP gateway"}"#, - )?; - must( - Command::new(&auths_bin) - .args([ - "--repo", - &org_repo.to_string_lossy(), - "--json", - "id", - "create", - ]) - .args(["--metadata-file", &meta.to_string_lossy()]) - .args(["--local-key-alias", "root"]), - "id create (org root)", - )?; - let show = must( - Command::new(&auths_bin).args([ + // 1. The org root identity — reused when the registry already holds one, so + // a fleet of gateways delegates under a single shared root. + let existing_root = Command::new(&auths_bin) + .args([ "--repo", &org_repo.to_string_lossy(), "--json", "id", "show", - ]), - "id show (org root)", - )?; - let root_did = first_did(&show.stdout) - .ok_or_else(|| anyhow::anyhow!("could not read the org root did:keri from id show"))?; + ]) + .output() + .ok() + .filter(|out| out.status.success()) + .and_then(|out| first_did(&out.stdout)); + let root_did = if let Some(did) = existing_root { + did + } else { + let meta = lab.join("org-meta.json"); + std::fs::write( + &meta, + br#"{"name":"Parent Root","purpose":"bounded-agent MCP gateway"}"#, + )?; + must( + Command::new(&auths_bin) + .args([ + "--repo", + &org_repo.to_string_lossy(), + "--json", + "id", + "create", + ]) + .args(["--metadata-file", &meta.to_string_lossy()]) + .args(["--local-key-alias", "root"]), + "id create (org root)", + )?; + let show = must( + Command::new(&auths_bin).args([ + "--repo", + &org_repo.to_string_lossy(), + "--json", + "id", + "show", + ]), + "id show (org root)", + )?; + first_did(&show.stdout).ok_or_else(|| { + anyhow::anyhow!("could not read the org root did:keri from id show") + })? + }; // 2. The delegated scoped agent. - let agent_alias = "agent".to_string(); let mut add = Command::new(&auths_bin); add.args([ "--repo", @@ -166,6 +198,7 @@ impl Chain { // signer resolves to the agent (tree surgery only — no key moved). materialize_agent_machine(&org_repo, &agent_repo, &root_did)?; + let inproc = crate::inproc_sign::InprocState::new(&agent_alias); Ok(Self { auths_bin, org_repo, @@ -175,6 +208,7 @@ impl Chain { agent_did, agent_alias, revoked: false, + inproc, }) } @@ -214,6 +248,12 @@ impl Chain { capability: &str, prev_binding: &str, ) -> anyhow::Result<(Vec, String)> { + if let Some(signed) = self + .inproc + .try_sign_call(canonical, capability, prev_binding) + { + return Ok(signed); + } let work = self.work_root.join(format!("call-{idx}")); std::fs::create_dir_all(&work)?; std::fs::write(work.join("call.json"), canonical)?; @@ -281,6 +321,7 @@ impl Chain { .current_dir(&work), "git cat-file commit", )?; + self.inproc.learn_call(capability, &raw.stdout); Ok((raw.stdout, sha)) } @@ -304,6 +345,15 @@ impl Chain { rail_ref: &str, cumulative_cents: Cents, ) -> anyhow::Result<(Vec, String)> { + if let Some(signed) = self.inproc.try_sign_settlement( + call_binding, + rail, + actual.get().get(), + rail_ref, + cumulative_cents.get(), + ) { + return Ok(signed); + } let work = self.work_root.join(format!("settle-{idx}")); std::fs::create_dir_all(&work)?; // A minimal payload so the commit has a tree; the cost lives in the SIGNED trailers below. @@ -374,6 +424,7 @@ impl Chain { .current_dir(&work), "git cat-file commit", )?; + self.inproc.learn_settlement(&raw.stdout); Ok((raw.stdout, sha)) } } diff --git a/crates/auths-mcp-gateway/src/inproc_sign.rs b/crates/auths-mcp-gateway/src/inproc_sign.rs new file mode 100644 index 00000000..74aec87d --- /dev/null +++ b/crates/auths-mcp-gateway/src/inproc_sign.rs @@ -0,0 +1,315 @@ +//! In-process per-call signing — the fleet-throughput path. +//! +//! The subprocess ceremony (`git init` + `git commit` + `auths sign HEAD`, which +//! re-opens the Argon2id-protected keychain on every invocation) costs seconds per +//! brokered call. A fleet cannot meter at that latency. This module signs the SAME +//! commit object in-process: the agent seed is decrypted ONCE per session from the +//! headless file keychain, and every subsequent call builds the git commit bytes +//! directly (blob → tree → commit) and signs them with the identical SSHSIG the CLI +//! produces (`create_sshsig`, namespace `git`). +//! +//! Fidelity over cleverness: the FIRST call of each kind still runs the full +//! subprocess ceremony, and its raw commit is harvested into a template — the +//! session-static trailers (`Auths-Id`, `Auths-Device`, `Auths-Anchor-Seq`, +//! `Auths-Scope`) and the git identity line are reused VERBATIM; only the dynamic +//! trailer values (`Auths-Prev`, the `Auths-Settle-*` set) and the tree/timestamps +//! change per call. The offline `verify-spend` audit re-verifies every record +//! through the same verifier either way — a byte-level divergence fails closed as +//! `tampered-proof`, never silently. +//! +//! Availability: only when `AUTHS_PASSPHRASE` is set (the headless posture, where +//! the keychain can be opened without prompting). Anything else — no passphrase, +//! keychain errors, an unparsed first commit — quietly leaves the subprocess path +//! in charge. + +use anyhow::{Context, bail}; +use auths_crypto::SecureSeed; +use auths_mcp_core::treasury::encode_hex; +use auths_sdk::keychain::{KeyAlias, get_platform_keychain}; +use sha1::{Digest, Sha1}; +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +/// The dynamic trailer keys of a CALL commit. +const CALL_SLOTS: &[&str] = &["Auths-Prev"]; +/// The dynamic trailer keys of a SETTLEMENT commit. +const SETTLE_SLOTS: &[&str] = &[ + "Auths-Settle-Call", + "Auths-Settle-Rail", + "Auths-Settle-Cents", + "Auths-Settle-Ref", + "Auths-Settle-Cumulative", +]; + +/// One message line: literal text, or a dynamic trailer slot to fill per call. +#[derive(Debug, Clone)] +enum Piece { + Lit(String), + Slot { prefix: String, key: &'static str }, +} + +/// A harvested commit shape: the git identity line plus the message with its +/// dynamic trailer values holed out. +#[derive(Debug, Clone)] +struct CommitTemplate { + identity_line: String, + pieces: Vec, +} + +impl CommitTemplate { + /// Harvest a template from a subprocess-signed raw commit. + fn harvest(raw: &[u8], slots: &[&'static str]) -> Option { + let text = std::str::from_utf8(raw).ok()?; + let (head, message) = text.split_once("\n\n")?; + let author = head + .lines() + .find_map(|l| l.strip_prefix("author "))? + .to_string(); + // "NAME TS TZ" → keep "NAME ". + let identity_line = author.rsplitn(3, ' ').nth(2)?.to_string(); + let mut pieces = Vec::new(); + for line in message.lines() { + let slot = slots + .iter() + .find(|k| line.strip_prefix(**k).is_some_and(|rest| rest.starts_with(':'))); + match slot { + Some(key) => { + let after = &line[key.len() + 1..]; + let ws = after.len() - after.trim_start().len(); + pieces.push(Piece::Slot { + prefix: line[..key.len() + 1 + ws].to_string(), + key, + }); + } + None => pieces.push(Piece::Lit(line.to_string())), + } + } + Some(CommitTemplate { + identity_line, + pieces, + }) + } + + /// Render the message with this call's dynamic trailer values. + fn render(&self, values: &HashMap<&str, String>) -> Option { + let mut out = String::new(); + for piece in &self.pieces { + match piece { + Piece::Lit(line) => out.push_str(line), + Piece::Slot { prefix, key } => { + out.push_str(prefix); + out.push_str(values.get(key)?); + } + } + out.push('\n'); + } + Some(out) + } +} + +/// The session key: the agent seed decrypted once from the headless file keychain. +struct SessionKey { + seed: SecureSeed, + curve: auths_crypto::CurveType, +} + +impl SessionKey { + fn try_new(alias: &str) -> anyhow::Result { + let Ok(passphrase) = std::env::var("AUTHS_PASSPHRASE") else { + bail!("AUTHS_PASSPHRASE unset — in-process signing needs the headless passphrase"); + }; + let keychain = get_platform_keychain() + .map_err(|e| anyhow::anyhow!("open the keychain for session signing: {e}"))?; + let (_did, _role, encrypted) = keychain + .load_key(&KeyAlias::new_unchecked(alias)) + .map_err(|e| anyhow::anyhow!("load key `{alias}` for session signing: {e}"))?; + let decrypted = auths_sdk::crypto::decrypt_keypair(&encrypted, &passphrase) + .map_err(|e| anyhow::anyhow!("decrypt key `{alias}` for session signing: {e}"))?; + let parsed = auths_crypto::parse_key_material(&decrypted) + .map_err(|e| anyhow::anyhow!("parse key material for `{alias}`: {e}"))?; + let curve = parsed.seed.curve(); + Ok(SessionKey { + seed: parsed.seed.to_secure_seed(), + curve, + }) + } +} + +/// Per-session in-process signing state, held by the chain. +pub struct InprocState { + alias: String, + key: OnceLock>, + call_templates: Mutex>, + settle_template: Mutex>, +} + +impl InprocState { + /// Fresh state for one wrapped session (no keychain touch until first use). + pub fn new(alias: &str) -> InprocState { + InprocState { + alias: alias.to_string(), + key: OnceLock::new(), + call_templates: Mutex::new(HashMap::new()), + settle_template: Mutex::new(None), + } + } + + fn session_key(&self) -> Option<&SessionKey> { + self.key + .get_or_init(|| match SessionKey::try_new(&self.alias) { + Ok(key) => Some(key), + Err(e) => { + eprintln!( + "auths-mcp-gateway: in-process signing unavailable ({e}) — \ + staying on the per-call subprocess signer" + ); + None + } + }) + .as_ref() + } + + /// Learn the CALL commit shape for `capability` from a subprocess-signed commit. + pub fn learn_call(&self, capability: &str, raw: &[u8]) { + #[allow(clippy::expect_used)] // INVARIANT: poisoned mutex = another thread panicked + let mut templates = self.call_templates.lock().expect("call templates lock"); + if !templates.contains_key(capability) + && let Some(t) = CommitTemplate::harvest(raw, CALL_SLOTS) + { + templates.insert(capability.to_string(), t); + } + } + + /// Learn the SETTLEMENT commit shape from a subprocess-signed commit. + pub fn learn_settlement(&self, raw: &[u8]) { + #[allow(clippy::expect_used)] // INVARIANT: poisoned mutex = another thread panicked + let mut template = self.settle_template.lock().expect("settle template lock"); + if template.is_none() { + *template = CommitTemplate::harvest(raw, SETTLE_SLOTS); + } + } + + /// Sign a CALL commit in-process; `None` = not ready (caller uses the subprocess path). + pub fn try_sign_call( + &self, + canonical: &[u8], + capability: &str, + prev_binding: &str, + ) -> Option<(Vec, String)> { + let template = { + #[allow(clippy::expect_used)] // INVARIANT: poisoned mutex = another thread panicked + let templates = self.call_templates.lock().expect("call templates lock"); + templates.get(capability).cloned() + }?; + let key = self.session_key()?; + let values = HashMap::from([("Auths-Prev", prev_binding.to_string())]); + let message = template.render(&values)?; + sign_commit_object(key, &template.identity_line, "call.json", canonical, &message) + .map_err(|e| eprintln!("auths-mcp-gateway: in-process call sign failed ({e})")) + .ok() + } + + /// Sign a SETTLEMENT commit in-process; `None` = not ready. + #[allow(clippy::too_many_arguments)] + pub fn try_sign_settlement( + &self, + call_binding: &str, + rail: &str, + actual_cents: u64, + rail_ref: &str, + cumulative_cents: u64, + ) -> Option<(Vec, String)> { + let template = { + #[allow(clippy::expect_used)] // INVARIANT: poisoned mutex = another thread panicked + let template = self.settle_template.lock().expect("settle template lock"); + template.clone() + }?; + let key = self.session_key()?; + let values = HashMap::from([ + ("Auths-Settle-Call", call_binding.to_string()), + ("Auths-Settle-Rail", rail.to_string()), + ("Auths-Settle-Cents", actual_cents.to_string()), + ("Auths-Settle-Ref", rail_ref.to_string()), + ("Auths-Settle-Cumulative", cumulative_cents.to_string()), + ]); + let message = template.render(&values)?; + sign_commit_object(key, &template.identity_line, "settle.json", b"{}", &message) + .map_err(|e| eprintln!("auths-mcp-gateway: in-process settle sign failed ({e})")) + .ok() + } +} + +fn git_object_sha(kind: &str, content: &[u8]) -> [u8; 20] { + let mut hasher = Sha1::new(); + hasher.update(format!("{kind} {}\0", content.len()).as_bytes()); + hasher.update(content); + hasher.finalize().into() +} + +fn tree_for_single_file(name: &str, content: &[u8]) -> [u8; 20] { + let blob = git_object_sha("blob", content); + let mut entry = Vec::with_capacity(name.len() + 28); + entry.extend_from_slice(format!("100644 {name}\0").as_bytes()); + entry.extend_from_slice(&blob); + git_object_sha("tree", &entry) +} + +/// Build + SSHSIG-sign one git commit object exactly as `git commit` with +/// `gpg.format=ssh` does: the signature covers the object bytes WITHOUT the +/// `gpgsig` header; the header carries the PEM with space-continuation lines. +fn sign_commit_object( + key: &SessionKey, + identity_line: &str, + filename: &str, + file_content: &[u8], + message: &str, +) -> anyhow::Result<(Vec, String)> { + let tree_hex = encode_hex(&tree_for_single_file(filename, file_content)); + #[allow(clippy::disallowed_methods)] // CLI process boundary: wall-clock commit stamps + let ts = chrono::Utc::now().timestamp(); + let unsigned_head = format!( + "tree {tree_hex}\nauthor {identity_line} {ts} +0000\ncommitter {identity_line} {ts} +0000\n" + ); + let payload = format!("{unsigned_head}\n{message}"); + let pem = auths_sdk::crypto::create_sshsig(&key.seed, payload.as_bytes(), "git", key.curve) + .context("create the SSHSIG over the commit payload")?; + let continuation = pem.trim_end().lines().collect::>().join("\n "); + let raw = format!("{unsigned_head}gpgsig {continuation}\n\n{message}"); + let sha = encode_hex(&git_object_sha("commit", raw.as_bytes())); + Ok((raw.into_bytes(), sha)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn git_object_hashes_match_git() { + // `echo -n 'hello' | git hash-object --stdin` → b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0 + assert_eq!( + encode_hex(&git_object_sha("blob", b"hello")), + "b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0" + ); + } + + #[test] + fn harvest_and_render_swap_only_dynamic_values() { + let raw = b"tree 0000000000000000000000000000000000000000\n\ +author fleet 1700000000 +0000\n\ +committer fleet 1700000000 +0000\n\ +\n\ +tools/call\n\ +\n\ +Auths-Prev: OLDPREV\n\ +Auths-Id: did:keri:Eroot\n\ +Auths-Scope: paid:call\n"; + let t = CommitTemplate::harvest(raw, CALL_SLOTS).unwrap(); + assert_eq!(t.identity_line, "fleet "); + let rendered = t + .render(&HashMap::from([("Auths-Prev", "NEWPREV".to_string())])) + .unwrap(); + assert!(rendered.contains("Auths-Prev: NEWPREV")); + assert!(rendered.contains("Auths-Id: did:keri:Eroot")); + assert!(!rendered.contains("OLDPREV")); + } +} diff --git a/crates/auths-mcp-gateway/src/main.rs b/crates/auths-mcp-gateway/src/main.rs index c57c492c..8768242b 100644 --- a/crates/auths-mcp-gateway/src/main.rs +++ b/crates/auths-mcp-gateway/src/main.rs @@ -31,6 +31,7 @@ use std::process::ExitCode; use clap::{Parser, Subcommand}; mod chain; +mod inproc_sign; mod proxy; mod replay; mod spend_log; From 4a995c0d3eed8e18e996b1e2212f59035c63693f Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 18 Jul 2026 10:31:18 +0100 Subject: [PATCH 03/12] =?UTF-8?q?mcp:=20payment=20channels=20=E2=80=94=20o?= =?UTF-8?q?pen=20records=20the=20metered=20reservation,=20close=20emits=20?= =?UTF-8?q?the=20netted=20log=5Fhash-cited=20settlement;=20rail=20legs=20e?= =?UTF-8?q?nv-gated,=20custody-never?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-mcp-core/src/channel.rs | 162 ++++++++++++++++++++++++ crates/auths-mcp-core/src/lib.rs | 4 + crates/auths-mcp-gateway/src/channel.rs | 158 +++++++++++++++++++++++ crates/auths-mcp-gateway/src/main.rs | 68 ++++++++++ 4 files changed, 392 insertions(+) create mode 100644 crates/auths-mcp-core/src/channel.rs create mode 100644 crates/auths-mcp-gateway/src/channel.rs diff --git a/crates/auths-mcp-core/src/channel.rs b/crates/auths-mcp-core/src/channel.rs new file mode 100644 index 00000000..b92469a0 --- /dev/null +++ b/crates/auths-mcp-core/src/channel.rs @@ -0,0 +1,162 @@ +//! Payment channels — reserve → stream → settle, with the spend log as the state. +//! +//! The spend log's agent-signed running cumulative already IS a payment-channel +//! state update, and `verify-spend` already IS the closing proof. This module adds +//! only the ends: the OPEN record (a capacity reservation the gateway meters +//! against with zero rail touches per call) and the CLOSE record (the netted +//! settlement evidence a receipts worker re-derives, citing the exact `log_hash` +//! it was computed from). +//! +//! Custody stance (decided): the market is never a treasurer. Rail-touching legs +//! (a Stripe Connect direct-charge capture, an on-chain channel contract) live +//! OUTSIDE these records — this module produces the evidence both sides settle +//! on, never a held balance. + +use crate::Cents; +use crate::treasury::encode_hex; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// A channel's lifecycle state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ChannelState { + /// Reserved capacity the gateway meters against. + Open, + /// Closed with a netted settlement record emitted. + Settled, +} + +/// The OPEN side: a funded (or explicitly unfunded) capacity reservation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelRecord { + /// Deterministic channel id (hash of seller, rail, capacity, opened_at). + pub channel_id: String, + /// The seller this channel streams to. + pub seller: String, + /// The rail the channel settles on at close. + pub rail: String, + /// The reserved capacity the gateway meters against. + pub capacity_cents: Cents, + /// Where the hold lives — a rail reference, or a stated `unfunded:` posture. + pub escrow_ref: String, + /// When the channel opened. + pub opened_at: DateTime, + /// Lifecycle state. + pub state: ChannelState, +} + +impl ChannelRecord { + /// Open a channel: derive the deterministic id and record the reservation. + /// + /// Args: + /// * `seller`: the seller identity the channel streams to. + /// * `rail`: the settling rail (`x402` / `stripe`). + /// * `capacity_cents`: the reserved capacity. + /// * `escrow_ref`: the rail hold reference, or the stated unfunded posture. + /// * `opened_at`: injected clock. + /// + /// Usage: + /// ```ignore + /// let channel = ChannelRecord::open("did:keri:Eseller", "x402", Cents::new(5000), + /// "unfunded:x402:credentials-absent", now); + /// ``` + pub fn open( + seller: &str, + rail: &str, + capacity_cents: Cents, + escrow_ref: &str, + opened_at: DateTime, + ) -> ChannelRecord { + let mut hasher = Sha256::new(); + hasher.update(seller.as_bytes()); + hasher.update(rail.as_bytes()); + hasher.update(capacity_cents.get().to_be_bytes()); + hasher.update(opened_at.timestamp_micros().to_be_bytes()); + let digest = hasher.finalize(); + ChannelRecord { + channel_id: encode_hex(&digest[..16]), + seller: seller.to_string(), + rail: rail.to_string(), + capacity_cents, + escrow_ref: escrow_ref.to_string(), + opened_at, + state: ChannelState::Open, + } + } +} + +/// The CLOSE side: the netted settlement evidence, citing its exact log. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelSettlement { + /// The channel this settles. + pub channel_id: String, + /// The settling rail. + pub rail: String, + /// The netted total: `min(re-derived cumulative, capacity)` — one rail action. + pub gross_cents: Cents, + /// SHA-256 of the exact spend-log bytes the total was re-derived from. + pub log_hash: String, + /// Calls in the log at settlement. + pub calls: u64, + /// When the settlement evidence was emitted. + pub settled_at: DateTime, +} + +/// The netted close amount: the channel settles `min(cumulative, capacity)` in ONE +/// rail action — streamed spend beyond the reserved capacity never settles. +/// +/// Args: +/// * `cumulative_cents`: the log's re-derived running total. +/// * `capacity_cents`: the channel's reserved capacity. +/// +/// Usage: +/// ```ignore +/// let net = netted_settle_cents(Cents::new(137), Cents::new(100)); +/// assert_eq!(net, Cents::new(100)); +/// ``` +pub fn netted_settle_cents(cumulative_cents: Cents, capacity_cents: Cents) -> Cents { + cumulative_cents.min(capacity_cents) +} + +/// SHA-256 of the spend-log bytes, hex — the citation every fee row carries. +/// +/// Args: +/// * `log_bytes`: the raw spend-log file contents. +/// +/// Usage: +/// ```ignore +/// let hash = spend_log_hash(&std::fs::read(&log_path)?); +/// ``` +pub fn spend_log_hash(log_bytes: &[u8]) -> String { + encode_hex(&Sha256::digest(log_bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn open_derives_a_stable_id_and_starts_open() { + let at = Utc.timestamp_opt(1_700_000_000, 0).unwrap(); + let a = ChannelRecord::open("did:keri:Es", "x402", Cents::new(5000), "ref", at); + let b = ChannelRecord::open("did:keri:Es", "x402", Cents::new(5000), "ref", at); + assert_eq!(a.channel_id, b.channel_id); + assert_eq!(a.state, ChannelState::Open); + assert_eq!(a.channel_id.len(), 32); + } + + #[test] + fn netted_settle_is_bounded_by_capacity() { + assert_eq!( + netted_settle_cents(Cents::new(137), Cents::new(100)), + Cents::new(100) + ); + assert_eq!( + netted_settle_cents(Cents::new(40), Cents::new(100)), + Cents::new(40) + ); + } +} diff --git a/crates/auths-mcp-core/src/lib.rs b/crates/auths-mcp-core/src/lib.rs index fc46c42a..7bc92908 100644 --- a/crates/auths-mcp-core/src/lib.rs +++ b/crates/auths-mcp-core/src/lib.rs @@ -34,6 +34,7 @@ pub mod attestation; pub mod audit; pub mod budget; +pub mod channel; pub mod gate; pub mod money; pub mod paymode; @@ -59,6 +60,9 @@ pub use paymode::{ }; pub use rail::{ExtractedCost, RailError, extract as extract_rail_cost, extract_stripe}; pub use receipt::{Receipt, ReceiptError}; +pub use channel::{ + ChannelRecord, ChannelSettlement, ChannelState, netted_settle_cents, spend_log_hash, +}; pub use session::{Budget, BudgetParseError}; pub use treasury::{ FleetLedger, FleetReserveOutcome, SignedTreasuryCheckpoint, TreasuryCheckpoint, TreasuryError, diff --git a/crates/auths-mcp-gateway/src/channel.rs b/crates/auths-mcp-gateway/src/channel.rs new file mode 100644 index 00000000..00e02862 --- /dev/null +++ b/crates/auths-mcp-gateway/src/channel.rs @@ -0,0 +1,158 @@ +//! The `channel` subcommand — open a metered reservation, close with one netted settle. +//! +//! `channel open` records the funded reservation the gateway meters against with +//! ZERO rail touches per call; `channel close` re-derives the streamed total from +//! the signed spend log and emits the netted settlement record (citing the exact +//! `log_hash`) that the receipts worker re-derives. +//! +//! Rail legs are env-gated and never faked (custody-never): absent credentials +//! record an explicitly `unfunded:` reservation with a stated reason; present +//! credentials still route the actual money movement through the non-custodial +//! leg that owns it (Stripe Connect direct charges on the seller's account, or +//! the x402 channel contract) keyed to the settlement evidence emitted here. + +use anyhow::Context; +use auths_mcp_core::channel::{ChannelRecord, ChannelSettlement, ChannelState}; +use auths_mcp_core::{Cents, netted_settle_cents, read_spend_log, spend_log_hash}; +use chrono::Utc; +use std::path::{Path, PathBuf}; + +/// Where channel records live under the live dir. +fn channels_dir(live_dir: &Path) -> PathBuf { + live_dir.join("channels") +} + +/// The stated escrow posture for a rail, judged from the environment. +/// +/// Args: +/// * `rail`: `x402` or `stripe`. +/// +/// Usage: +/// ```ignore +/// let (escrow_ref, notice) = escrow_posture("x402"); +/// ``` +fn escrow_posture(rail: &str) -> (String, String) { + let creds_present = match rail { + "stripe" => std::env::var("STRIPE_SECRET_KEY").is_ok(), + _ => { + std::env::var("X402_WALLET_PRIVATE_KEY").is_ok() + && std::env::var("X402_FACILITATOR_URL").is_ok() + } + }; + if !creds_present { + return ( + format!("unfunded:{rail}:credentials-absent"), + format!( + "channel open: {rail} rail not configured (credentials absent) — recording an \ + UNFUNDED reservation; the gateway still meters against its capacity \ + (seller-bounded credit posture, settle at close)" + ), + ); + } + ( + format!("unfunded:{rail}:non-custodial-leg"), + format!( + "channel open: {rail} credentials detected — the funded hold itself lives in the \ + non-custodial leg (Stripe Connect direct charge / x402 channel contract), keyed to \ + this reservation; the gateway records the capacity and meters against it" + ), + ) +} + +/// `channel open`: record the reservation the gateway meters against. +/// +/// Args: +/// * `seller`: the seller identity the channel streams to. +/// * `capacity`: budget syntax, e.g. `$50`. +/// * `rail`: the settling rail. +/// * `live_dir`: the gateway live dir the channel record persists under. +/// +/// Usage: +/// ```ignore +/// channel::open("did:keri:Eseller", "$50", "x402", &live_dir)?; +/// ``` +pub fn open(seller: &str, capacity: &str, rail: &str, live_dir: &Path) -> anyhow::Result<()> { + let capacity_cents = auths_mcp_core::Budget::parse(capacity) + .map_err(|e| anyhow::anyhow!("invalid --capacity `{capacity}`: {e}"))? + .cap_cents(); + let (escrow_ref, notice) = escrow_posture(rail); + let record = ChannelRecord::open(seller, rail, capacity_cents, &escrow_ref, Utc::now()); + let dir = channels_dir(live_dir); + std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; + let path = dir.join(format!("{}.json", record.channel_id)); + std::fs::write(&path, serde_json::to_vec_pretty(&record)?) + .with_context(|| format!("write {}", path.display()))?; + println!("{notice}"); + println!( + "channel open: id={} seller={} rail={} capacity={} cents escrow={} → {}", + record.channel_id, + record.seller, + record.rail, + record.capacity_cents.get(), + record.escrow_ref, + path.display(), + ); + Ok(()) +} + +/// `channel close`: re-derive the streamed total from the signed log, emit the +/// netted settlement record, and mark the channel settled. +/// +/// Args: +/// * `channel_id`: the channel to close. +/// * `log`: the spend log whose signed cumulative is the closing state. +/// * `live_dir`: the gateway live dir holding the channel record. +/// +/// Usage: +/// ```ignore +/// channel::close(&id, &log_path, &live_dir)?; +/// ``` +pub fn close(channel_id: &str, log: &Path, live_dir: &Path) -> anyhow::Result<()> { + let dir = channels_dir(live_dir); + let path = dir.join(format!("{channel_id}.json")); + let mut record: ChannelRecord = serde_json::from_str( + &std::fs::read_to_string(&path) + .with_context(|| format!("no channel record at {}", path.display()))?, + )?; + if record.state == ChannelState::Settled { + anyhow::bail!("channel {channel_id} is already settled"); + } + let log_bytes = + std::fs::read(log).with_context(|| format!("read spend log {}", log.display()))?; + let records = read_spend_log(log).with_context(|| "parse the spend log")?; + let cumulative = records + .last() + .map(|r| r.receipt.cumulative_cents) + .unwrap_or(Cents::ZERO); + let gross = netted_settle_cents(cumulative, record.capacity_cents); + let settlement = ChannelSettlement { + channel_id: record.channel_id.clone(), + rail: record.rail.clone(), + gross_cents: gross, + log_hash: spend_log_hash(&log_bytes), + calls: records.len() as u64, + settled_at: Utc::now(), + }; + let settlement_path = dir.join(format!("{channel_id}.settlement.json")); + std::fs::write(&settlement_path, serde_json::to_vec_pretty(&settlement)?) + .with_context(|| format!("write {}", settlement_path.display()))?; + record.state = ChannelState::Settled; + std::fs::write(&path, serde_json::to_vec_pretty(&record)?)?; + println!( + "channel close: id={channel_id} netted={} cents over {} call(s) (cumulative {} bounded \ + by capacity {}) log_hash={} → {}", + gross.get(), + settlement.calls, + cumulative.get(), + record.capacity_cents.get(), + settlement.log_hash, + settlement_path.display(), + ); + println!( + "channel close: ONE rail action settles this net on {} through the non-custodial leg \ + (Connect direct charge / channel contract), citing log_hash above — the receipts \ + worker re-derives the same number from the signed log", + record.rail, + ); + Ok(()) +} diff --git a/crates/auths-mcp-gateway/src/main.rs b/crates/auths-mcp-gateway/src/main.rs index 8768242b..8d9560f5 100644 --- a/crates/auths-mcp-gateway/src/main.rs +++ b/crates/auths-mcp-gateway/src/main.rs @@ -31,6 +31,7 @@ use std::process::ExitCode; use clap::{Parser, Subcommand}; mod chain; +mod channel; mod inproc_sign; mod proxy; mod replay; @@ -75,6 +76,55 @@ enum Command { /// that `verify-spend --treasury-checkpoints` cross-checks offline. #[command(subcommand)] Treasury(TreasuryCommand), + + /// Payment channels: open a metered capacity reservation, stream rail-free + /// per-call spend against it (the signed spend log IS the channel state), and + /// close with ONE netted rail action plus a settlement record citing the + /// exact log_hash it was re-derived from. Rail legs are env-gated, never faked. + #[command(subcommand)] + Channel(ChannelCommand), +} + +#[derive(Subcommand)] +enum ChannelCommand { + /// Record a funded (or stated-unfunded) capacity reservation. + Open(ChannelOpenArgs), + /// Re-derive the streamed total from the signed log and emit the netted settlement. + Close(ChannelCloseArgs), +} + +#[derive(Parser)] +struct ChannelOpenArgs { + /// The seller identity the channel streams to. + #[arg(long = "seller", value_name = "DID")] + seller: String, + + /// The reserved channel capacity, e.g. `--capacity '$50'`. + #[arg(long = "capacity", value_name = "BUDGET")] + capacity: String, + + /// The settling rail: `x402` or `stripe`. + #[arg(long = "rail", value_name = "RAIL")] + rail: String, + + /// The gateway live dir the channel record persists under. + #[arg(long = "live-dir", value_name = "DIR", env = "AUTHS_MCP_LIVE_DIR")] + live_dir: std::path::PathBuf, +} + +#[derive(Parser)] +struct ChannelCloseArgs { + /// The channel id to close (from `channel open`). + #[arg(long = "channel", value_name = "ID")] + channel: String, + + /// The spend log whose agent-signed cumulative is the closing state. + #[arg(long = "log", value_name = "FILE")] + log: std::path::PathBuf, + + /// The gateway live dir holding the channel record. + #[arg(long = "live-dir", value_name = "DIR", env = "AUTHS_MCP_LIVE_DIR")] + live_dir: std::path::PathBuf, } #[derive(Subcommand)] @@ -242,6 +292,24 @@ fn main() -> ExitCode { Command::Treasury(TreasuryCommand::Serve(args)) => { runtime.block_on(run_treasury_serve(args)) } + Command::Channel(cmd) => run_channel(cmd), + } +} + +/// The channel CLI: pure record I/O — no async, no rail touch. +fn run_channel(cmd: ChannelCommand) -> ExitCode { + let result = match cmd { + ChannelCommand::Open(args) => { + channel::open(&args.seller, &args.capacity, &args.rail, &args.live_dir) + } + ChannelCommand::Close(args) => channel::close(&args.channel, &args.log, &args.live_dir), + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("auths-mcp-gateway: channel: {e}"); + ExitCode::FAILURE + } } } From 86cf30e2b08da6849a1393b5a7de69a9d2022c59 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 18 Jul 2026 10:39:33 +0100 Subject: [PATCH 04/12] mcp: chain subprocesses default their own git identity so clean machines build the signing chain Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-mcp-gateway/src/chain.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/auths-mcp-gateway/src/chain.rs b/crates/auths-mcp-gateway/src/chain.rs index 557ea3d0..e9c8c88e 100644 --- a/crates/auths-mcp-gateway/src/chain.rs +++ b/crates/auths-mcp-gateway/src/chain.rs @@ -60,7 +60,28 @@ fn to_auths_capability(cap: &str) -> String { cap.replace('.', ":") } +/// The identity the gateway commits under when the machine has none: a clean HOME +/// (no gitconfig, no GIT_* env) must never abort a chain build on git's +/// auto-detect. Caller-provided env always wins — these apply only when absent. +fn default_git_identity() -> [(&'static str, &'static str); 4] { + [ + ("GIT_AUTHOR_NAME", "auths-mcp-gateway"), + ("GIT_AUTHOR_EMAIL", "gateway@auths.local"), + ("GIT_COMMITTER_NAME", "auths-mcp-gateway"), + ("GIT_COMMITTER_EMAIL", "gateway@auths.local"), + ] +} + fn run(cmd: &mut Command) -> anyhow::Result { + for (key, value) in default_git_identity() { + if std::env::var_os(key).is_none() + && !cmd + .get_envs() + .any(|(k, _)| k == std::ffi::OsStr::new(key)) + { + cmd.env(key, value); + } + } let out = cmd .output() .map_err(|e| anyhow::anyhow!("spawn {:?}: {e}", cmd.get_program()))?; From dff1f705a805a110e246c4afc696c44c6cbebd02 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 18 Jul 2026 10:45:34 +0100 Subject: [PATCH 05/12] mcp: metered-amount-required refusal embeds a runnable amount_atomic example Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-mcp-gateway/src/proxy.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/auths-mcp-gateway/src/proxy.rs b/crates/auths-mcp-gateway/src/proxy.rs index bb7736c1..301fcd04 100644 --- a/crates/auths-mcp-gateway/src/proxy.rs +++ b/crates/auths-mcp-gateway/src/proxy.rs @@ -685,13 +685,24 @@ impl ServerHandler for GatewayProxy { downstream NOT touched; proof={proof_short}", verdict.code(), ); - Err(McpError::invalid_request( - format!( + let refusal = match &verdict { + // The refusal teaches the fix: a buyer can self-correct from the + // error alone, without docs (the example is a runnable tools/call). + Verdict::MeteredAmountRequired { rail } => format!( + "metered-amount-required: this downstream settles on `{rail}`, so every \ + call must declare what it intends to spend before the rail is touched. \ + Re-send with the amount in your arguments, e.g. \ + {{\"method\":\"tools/call\",\"params\":{{\"name\":\"{tool}\",\ + \"arguments\":{{\"amount_atomic\":30000}}}}}} \ + (USDC 6-decimals: 30000 = $0.03) — or declare a raw cent ceiling with \ + \"_auths_reserve_ceiling_cents\"." + ), + _ => format!( "{}: the per-call gate refused this call before the downstream was touched", verdict.code() ), - None, - )) + }; + Err(McpError::invalid_request(refusal, None)) } } } From 3dbd92b5666790b4a1b82efc02b897470e487c48 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 18 Jul 2026 10:47:45 +0100 Subject: [PATCH 06/12] =?UTF-8?q?sdk:=20authenticatePresentation=20is=20an?= =?UTF-8?q?=20async=20napi=20export=20=E2=80=94=20verification=20runs=20of?= =?UTF-8?q?f=20the=20Node=20event=20loop=20via=20spawn=5Fblocking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- packages/auths-node/Cargo.toml | 2 +- packages/auths-node/index.d.ts | 4 ++-- packages/auths-node/src/rp.rs | 12 +++++++++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/auths-node/Cargo.toml b/packages/auths-node/Cargo.toml index 32ed2ebe..37d76664 100644 --- a/packages/auths-node/Cargo.toml +++ b/packages/auths-node/Cargo.toml @@ -33,7 +33,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" base64 = "0.22" rand = "0.8" -tokio = { version = "1", features = ["sync", "net"] } +tokio = { version = "1", features = ["sync", "net", "rt"] } reqwest = { version = "0.13.2", default-features = false, features = ["rustls", "json"] } hex = "0.4" chrono = "0.4" diff --git a/packages/auths-node/index.d.ts b/packages/auths-node/index.d.ts index 6f4a7d4c..71c6ec19 100644 --- a/packages/auths-node/index.d.ts +++ b/packages/auths-node/index.d.ts @@ -30,11 +30,11 @@ export declare function addWitness(urlStr: string, aid: string, repoPath: string * * Usage: * ```ignore - * const report = authenticatePresentation(header, evidenceJson, 'market.auths.dev', peek.nonce); + * const report = await authenticatePresentation(header, evidenceJson, 'market.auths.dev', peek.nonce); * if (report.authorized) { /* report.subject, report.subjectRoot, report.caps *\/ } * ``` */ -export declare function authenticatePresentation(authorizationHeader: string, evidenceJson: string, expectedAudience: string, expectedNonce: string, nowIso?: string | undefined | null): NapiAgentAuthReport +export declare function authenticatePresentation(authorizationHeader: string, evidenceJson: string, expectedAudience: string, expectedNonce: string, nowIso?: string | undefined | null): Promise export declare function compilePolicy(policyJson: string): string diff --git a/packages/auths-node/src/rp.rs b/packages/auths-node/src/rp.rs index 35a99d7f..7dbdb58f 100644 --- a/packages/auths-node/src/rp.rs +++ b/packages/auths-node/src/rp.rs @@ -143,11 +143,11 @@ pub fn presentation_nonce(authorization_header: String) -> napi::Result Date: Sat, 18 Jul 2026 10:57:48 +0100 Subject: [PATCH 07/12] mcp: spend logs rotate by period per delegation; export-spend-bundle emits the one-shot verifier-ready bundle Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-mcp-core/src/audit.rs | 94 +++++++++++++++++--- crates/auths-mcp-core/src/lib.rs | 3 +- crates/auths-mcp-gateway/src/chain.rs | 2 +- crates/auths-mcp-gateway/src/main.rs | 102 ++++++++++++++++++++++ crates/auths-mcp-gateway/src/proxy.rs | 2 +- crates/auths-mcp-gateway/src/spend_log.rs | 8 +- 6 files changed, 194 insertions(+), 17 deletions(-) diff --git a/crates/auths-mcp-core/src/audit.rs b/crates/auths-mcp-core/src/audit.rs index 51bfbc18..54e31852 100644 --- a/crates/auths-mcp-core/src/audit.rs +++ b/crates/auths-mcp-core/src/audit.rs @@ -252,18 +252,92 @@ pub fn spend_log_path(repo: &Path, delegation: &str) -> PathBuf { .join(format!("{}.jsonl", safe_key(delegation))) } +/// The delegation's ROTATED spend-log directory: `spend-log//` holding +/// one period-named file per UTC month. Rotation bounds any single file without +/// weakening tamper evidence — the `Auths-Prev` chain runs across files, so a +/// missing middle period still breaks re-derivation. +/// +/// Args: +/// * `repo`: the verifier registry root. +/// * `delegation`: the agent delegation the log belongs to. +/// +/// Usage: +/// ```ignore +/// let dir = spend_log_dir(repo, "did:keri:Eagent…"); +/// ``` +pub fn spend_log_dir(repo: &Path, delegation: &str) -> PathBuf { + repo.join("spend-log").join(safe_key(delegation)) +} + +/// The period file a record stamped `at` lands in (UTC month, lexicographically +/// ordered so a sorted directory walk replays in append order). +/// +/// Args: +/// * `repo`: the verifier registry root. +/// * `delegation`: the agent delegation. +/// * `at`: the record's own timestamp (injected clock). +/// +/// Usage: +/// ```ignore +/// let file = spend_log_period_path(repo, delegation, now); +/// ``` +pub fn spend_log_period_path( + repo: &Path, + delegation: &str, + at: chrono::DateTime, +) -> PathBuf { + spend_log_dir(repo, delegation).join(format!("{}.jsonl", at.format("%Y-%m"))) +} + +/// Resolve where a delegation's log actually lives: the rotated directory when it +/// exists, else the legacy single file. +/// +/// Args: +/// * `repo`: the verifier registry root. +/// * `delegation`: the agent delegation. +/// +/// Usage: +/// ```ignore +/// let records = read_spend_log(&resolve_spend_log(repo, delegation))?; +/// ``` +pub fn resolve_spend_log(repo: &Path, delegation: &str) -> PathBuf { + let dir = spend_log_dir(repo, delegation); + if dir.is_dir() { + dir + } else { + spend_log_path(repo, delegation) + } +} + /// Read every [`SpendLogRecord`] from a delegation's spend log, in order (for the offline -/// auditor). A blank trailing line is ignored; a non-blank line that fails to parse is -/// `InvalidData` — a corrupted or edited log fails closed rather than silently dropping a call. +/// auditor). `path` may be a single JSONL file or a ROTATED directory of period files — +/// a directory is walked in sorted (chronological) order and its files concatenate into +/// one record stream, which the `Auths-Prev` chain then proves complete across the +/// rotation boundary. A blank trailing line is ignored; a non-blank line that fails to +/// parse is `InvalidData` — a corrupted or edited log fails closed rather than silently +/// dropping a call. pub fn read_spend_log(path: &Path) -> std::io::Result> { - let raw = std::fs::read_to_string(path)?; - raw.lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| { - serde_json::from_str::(l) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) - }) - .collect() + let files: Vec = if path.is_dir() { + let mut period_files: Vec = std::fs::read_dir(path)? + .filter_map(|entry| entry.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|ext| ext == "jsonl")) + .collect(); + period_files.sort(); + period_files + } else { + vec![path.to_path_buf()] + }; + let mut records = Vec::new(); + for file in files { + let raw = std::fs::read_to_string(&file)?; + for line in raw.lines().filter(|l| !l.trim().is_empty()) { + records.push( + serde_json::from_str::(line) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?, + ); + } + } + Ok(records) } /// A filesystem-safe single component from a delegation id: strip the `did:keri:` scheme and map diff --git a/crates/auths-mcp-core/src/lib.rs b/crates/auths-mcp-core/src/lib.rs index 7bc92908..9dc5d71d 100644 --- a/crates/auths-mcp-core/src/lib.rs +++ b/crates/auths-mcp-core/src/lib.rs @@ -46,7 +46,8 @@ pub mod treasury; pub use attestation::{AttestationError, Attested, RailAttestation}; pub use audit::{ AuditVerdict, ConsistentProof, SPEND_LOG_GENESIS, Settlement, SpendLogRecord, audit_spend_log, - call_commit_binding, read_spend_log, spend_log_path, + call_commit_binding, read_spend_log, resolve_spend_log, spend_log_dir, + spend_log_path, spend_log_period_path, }; pub use budget::{ BudgetError, CounterKey, CounterRef, CrossRailBudget, Hold, ReserveOutcome, ReservedHolds, diff --git a/crates/auths-mcp-gateway/src/chain.rs b/crates/auths-mcp-gateway/src/chain.rs index e9c8c88e..2fa81920 100644 --- a/crates/auths-mcp-gateway/src/chain.rs +++ b/crates/auths-mcp-gateway/src/chain.rs @@ -63,7 +63,7 @@ fn to_auths_capability(cap: &str) -> String { /// The identity the gateway commits under when the machine has none: a clean HOME /// (no gitconfig, no GIT_* env) must never abort a chain build on git's /// auto-detect. Caller-provided env always wins — these apply only when absent. -fn default_git_identity() -> [(&'static str, &'static str); 4] { +pub(crate) fn default_git_identity() -> [(&'static str, &'static str); 4] { [ ("GIT_AUTHOR_NAME", "auths-mcp-gateway"), ("GIT_AUTHOR_EMAIL", "gateway@auths.local"), diff --git a/crates/auths-mcp-gateway/src/main.rs b/crates/auths-mcp-gateway/src/main.rs index 8d9560f5..d86224d8 100644 --- a/crates/auths-mcp-gateway/src/main.rs +++ b/crates/auths-mcp-gateway/src/main.rs @@ -83,6 +83,35 @@ enum Command { /// exact log_hash it was re-derived from. Rail legs are env-gated, never faked. #[command(subcommand)] Channel(ChannelCommand), + + /// One-shot verifier-ready export: flatten the delegation's (rotated) spend log + /// into `spend.jsonl`, write `audit.json` naming `registry_git_url`, `agent`, + /// and `root`, and leave the registry working files committed — everything a + /// stranger needs to run `verify-spend` with no trust in this operator. + ExportSpendBundle(ExportSpendBundleArgs), +} + +#[derive(Parser)] +struct ExportSpendBundleArgs { + /// The gateway live dir (holds `registry/` with the spend log + counter). + #[arg(long = "live-dir", value_name = "DIR", env = "AUTHS_MCP_LIVE_DIR")] + live_dir: std::path::PathBuf, + + /// The agent delegation whose log is exported. + #[arg(long = "agent", value_name = "DID")] + agent: String, + + /// The delegator/root the grant is anchored to. + #[arg(long = "root", value_name = "DID")] + root: String, + + /// The URL a verifier fetches the registry from (recorded in audit.json). + #[arg(long = "registry-url", value_name = "URL")] + registry_url: String, + + /// Where `spend.jsonl` + `audit.json` are written. + #[arg(long = "out", value_name = "DIR")] + out: std::path::PathBuf, } #[derive(Subcommand)] @@ -293,6 +322,79 @@ fn main() -> ExitCode { runtime.block_on(run_treasury_serve(args)) } Command::Channel(cmd) => run_channel(cmd), + Command::ExportSpendBundle(args) => run_export_spend_bundle(args), + } +} + +/// Flatten the (possibly rotated) spend log, emit the audit manifest, and commit +/// the registry working state so a fetched copy re-derives the same counter. +fn run_export_spend_bundle(args: ExportSpendBundleArgs) -> ExitCode { + let registry = args.live_dir.join("registry"); + let source = auths_mcp_core::resolve_spend_log(®istry, &args.agent); + let records = match auths_mcp_core::read_spend_log(&source) { + Ok(records) => records, + Err(e) => { + eprintln!( + "auths-mcp-gateway: cannot read the spend log at `{}`: {e}", + source.display() + ); + return ExitCode::FAILURE; + } + }; + let result = (|| -> anyhow::Result<()> { + std::fs::create_dir_all(&args.out)?; + let mut flat = String::new(); + for record in &records { + flat.push_str(&serde_json::to_string(record)?); + flat.push('\n'); + } + std::fs::write(args.out.join("spend.jsonl"), flat)?; + let manifest = serde_json::json!({ + "registry_git_url": args.registry_url, + "agent": args.agent, + "root": args.root, + }); + std::fs::write( + args.out.join("audit.json"), + serde_json::to_vec_pretty(&manifest)?, + )?; + let add = std::process::Command::new("git") + .args(["-C", ®istry.to_string_lossy(), "add", "-A"]) + .envs(chain::default_git_identity()) + .output()?; + if !add.status.success() { + anyhow::bail!( + "git add in the registry failed: {}", + String::from_utf8_lossy(&add.stderr) + ); + } + // A clean tree is fine — the working files may already be committed. + let _ = std::process::Command::new("git") + .args([ + "-C", + ®istry.to_string_lossy(), + "commit", + "--quiet", + "-m", + "spend bundle export", + ]) + .envs(chain::default_git_identity()) + .output()?; + Ok(()) + })(); + match result { + Ok(()) => { + println!( + "export-spend-bundle: {} record(s) → {} (spend.jsonl + audit.json; registry committed)", + records.len(), + args.out.display(), + ); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("auths-mcp-gateway: export-spend-bundle: {e}"); + ExitCode::FAILURE + } } } diff --git a/crates/auths-mcp-gateway/src/proxy.rs b/crates/auths-mcp-gateway/src/proxy.rs index 301fcd04..63ae25ec 100644 --- a/crates/auths-mcp-gateway/src/proxy.rs +++ b/crates/auths-mcp-gateway/src/proxy.rs @@ -870,7 +870,7 @@ pub async fn serve(cfg: WrapConfig) -> anyhow::Result<()> { rem = cap_cents.get() % 100, ); - let spend_log = auths_mcp_core::spend_log_path(chain.org_repo(), &chain.agent_did); + let spend_log = auths_mcp_core::resolve_spend_log(chain.org_repo(), &chain.agent_did); eprintln!( "auths-mcp-gateway: live-wire signing ON — agent={} root={}; every brokered call is signed. \ Re-verify the spend log offline (trusting neither this gateway nor its operator) with:", diff --git a/crates/auths-mcp-gateway/src/spend_log.rs b/crates/auths-mcp-gateway/src/spend_log.rs index f5bfc349..32be1d18 100644 --- a/crates/auths-mcp-gateway/src/spend_log.rs +++ b/crates/auths-mcp-gateway/src/spend_log.rs @@ -25,14 +25,14 @@ //! `auths_mcp_core` so the gateway (writer) and the `auths-cli` auditor (reader) share ONE //! definition; this module is only the gateway-side append. -use auths_mcp_core::{SpendLogRecord, spend_log_path}; +use auths_mcp_core::{SpendLogRecord, spend_log_period_path}; use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::Path; /// Append one record as a single JSONL line. Append-only: prior records are never rewritten. pub fn append(repo: &Path, delegation: &str, record: &SpendLogRecord) -> anyhow::Result<()> { - let path = spend_log_path(repo, delegation); + let path = spend_log_period_path(repo, delegation, record.receipt.at); if let Some(dir) = path.parent() { fs::create_dir_all(dir)?; } @@ -96,8 +96,8 @@ mod tests { append(repo, dlg, &record(100)).unwrap(); append(repo, dlg, &record(250)).unwrap(); - let path = spend_log_path(repo, dlg); - let back = read_spend_log(&path).unwrap(); + let dir = auths_mcp_core::spend_log_dir(repo, dlg); + let back = read_spend_log(&dir).unwrap(); assert_eq!( back.len(), 2, From 570869489bf4be75853a753e42acbddc6dde4c40 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 18 Jul 2026 11:06:39 +0100 Subject: [PATCH 08/12] =?UTF-8?q?mcp:=20resumable=20spend-log=20audit=20?= =?UTF-8?q?=E2=80=94=20verify-spend=20resumes=20after=20a=20proven=20prefi?= =?UTF-8?q?x=20and=20emits=20its=20checkpoint=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-mcp-core/src/audit.rs | 85 ++++++++++++++++++++++++---- crates/auths-mcp-core/src/gate.rs | 23 ++++++++ crates/auths-mcp-core/src/lib.rs | 3 +- crates/auths-mcp-gateway/src/main.rs | 51 ++++++++++++++++- 4 files changed, 146 insertions(+), 16 deletions(-) diff --git a/crates/auths-mcp-core/src/audit.rs b/crates/auths-mcp-core/src/audit.rs index 54e31852..b23306c0 100644 --- a/crates/auths-mcp-core/src/audit.rs +++ b/crates/auths-mcp-core/src/audit.rs @@ -382,12 +382,70 @@ pub async fn audit_spend_log( now: i64, counter: &CounterRef, facilitator_pubkey: Option<&[u8]>, +) -> AuditVerdict { + audit_spend_log_resumed( + records, + agent_kel, + delegator_kel, + pinned_roots, + now, + counter, + facilitator_pubkey, + &AuditResume::genesis(), + ) + .await +} + +/// Where a resumed audit picks up: the state a PRIOR full verification of the log's +/// prefix established. Sound because the `Auths-Prev` chain forces the suffix's +/// first record to link to `prior_binding`, and every suffix signature is still +/// re-verified — only work already proven by the caller's own earlier audit is +/// skipped, never trust in the operator. +#[derive(Debug, Clone)] +pub struct AuditResume { + /// Records already verified (the suffix's index offset in the full log). + pub prior_records: usize, + /// The verified prefix's final commit binding (`Auths-Prev` for the next record). + pub prior_binding: String, + /// The settled total the verified prefix re-derived. + pub prior_settled_cents: Cents, +} + +impl AuditResume { + /// A from-the-top audit: genesis binding, zero prior spend. + /// + /// Usage: + /// ```ignore + /// let verdict = audit_spend_log_resumed(records, …, &AuditResume::genesis()).await; + /// ``` + pub fn genesis() -> AuditResume { + AuditResume { + prior_records: 0, + prior_binding: SPEND_LOG_GENESIS.to_string(), + prior_settled_cents: Cents::ZERO, + } + } +} + +/// [`audit_spend_log`], resuming after an already-verified prefix: `records` is the +/// SUFFIX, and `resume` carries the prefix's proven end state. The final verdict +/// reports whole-log totals (prefix + suffix). +#[allow(clippy::too_many_arguments)] +pub async fn audit_spend_log_resumed( + records: &[SpendLogRecord], + agent_kel: &[Event], + delegator_kel: &[Event], + pinned_roots: &[String], + now: i64, + counter: &CounterRef, + facilitator_pubkey: Option<&[u8]>, + resume: &AuditResume, ) -> AuditVerdict { let provider = auths_crypto::default_provider(); - let mut settled = Cents::ZERO; - // The binding each record's `Auths-Prev` must match — the prior record's commit hash, or the - // genesis sentinel for the first record. - let mut expected_prev = SPEND_LOG_GENESIS.to_string(); + let mut settled = resume.prior_settled_cents; + // The binding each record's `Auths-Prev` must match — the prior record's commit hash, the + // resumed prefix's final binding, or the genesis sentinel for a from-the-top audit. + let mut expected_prev = resume.prior_binding.clone(); for (i, rec) in records.iter().enumerate() { // Re-verify the SIGNED proof bytes — the gate's own authenticity check, re-run offline. let commit_verdict = auths_verifier::verify_commit_against_kel_scoped( @@ -567,15 +625,15 @@ pub async fn audit_spend_log( } } // The operator's claimed cross-rail total is the last record's cumulative — an UNTRUSTED hint we - // compare against the cost we re-derived from the rail responses. - let claimed = records - .last() - .map(|r| r.receipt.cumulative_cents) - .unwrap_or(Cents::ZERO); - if settled != claimed { + // compare against the cost we re-derived from the rail responses. An EMPTY resumed suffix has + // no new claim to compare (the prefix's claim was checked when it was verified); the durable + // counter below still cross-checks the carried total. + if let Some(last) = records.last() + && settled != last.receipt.cumulative_cents + { return AuditVerdict::BudgetMismatch { recomputed_cents: settled, - claimed_cents: claimed, + claimed_cents: last.receipt.cumulative_cents, }; } // Cross-check the re-derived total against the DURABLE verifier-held counter the wire advanced — @@ -599,7 +657,10 @@ pub async fn audit_spend_log( claimed_cents: durable.get(), }; } - AuditVerdict::Consistent(ConsistentProof::new(records.len(), settled)) + AuditVerdict::Consistent(ConsistentProof::new( + resume.prior_records + records.len(), + settled, + )) } /// The first record in a spend log has no predecessor; its signed `Auths-Prev` trailer carries this diff --git a/crates/auths-mcp-core/src/gate.rs b/crates/auths-mcp-core/src/gate.rs index 7cc3a963..ffd12444 100644 --- a/crates/auths-mcp-core/src/gate.rs +++ b/crates/auths-mcp-core/src/gate.rs @@ -272,6 +272,29 @@ impl PerCallGate { .await } + /// [`Self::audit_spend_log`], resuming after an already-verified prefix — + /// `records` is the suffix and `resume` the prefix's proven end state. + pub async fn audit_spend_log_resumed( + &self, + records: &[crate::audit::SpendLogRecord], + now: i64, + counter: &crate::budget::CounterRef, + facilitator_pubkey: Option<&[u8]>, + resume: &crate::audit::AuditResume, + ) -> crate::audit::AuditVerdict { + crate::audit::audit_spend_log_resumed( + records, + &self.agent_kel, + &self.delegator_kel, + std::slice::from_ref(&self.delegator_did), + now, + counter, + facilitator_pubkey, + resume, + ) + .await + } + /// Judge one `tools/call`, given the bytes of the agent's signed proof, the /// [`Meter`] it carries (non-metered, or metered on a rail with a non-zero ceiling), /// and the cross-rail budget it PRE-AUTHORIZES against. diff --git a/crates/auths-mcp-core/src/lib.rs b/crates/auths-mcp-core/src/lib.rs index 9dc5d71d..cb11b5f9 100644 --- a/crates/auths-mcp-core/src/lib.rs +++ b/crates/auths-mcp-core/src/lib.rs @@ -45,7 +45,8 @@ pub mod treasury; pub use attestation::{AttestationError, Attested, RailAttestation}; pub use audit::{ - AuditVerdict, ConsistentProof, SPEND_LOG_GENESIS, Settlement, SpendLogRecord, audit_spend_log, + AuditResume, AuditVerdict, ConsistentProof, SPEND_LOG_GENESIS, Settlement, SpendLogRecord, + audit_spend_log, audit_spend_log_resumed, call_commit_binding, read_spend_log, resolve_spend_log, spend_log_dir, spend_log_path, spend_log_period_path, }; diff --git a/crates/auths-mcp-gateway/src/main.rs b/crates/auths-mcp-gateway/src/main.rs index d86224d8..cc593296 100644 --- a/crates/auths-mcp-gateway/src/main.rs +++ b/crates/auths-mcp-gateway/src/main.rs @@ -303,6 +303,19 @@ struct VerifySpendArgs { /// re-derived fleet-wide sum across every delegation log). #[arg(long = "expect-cumulative", value_name = "CENTS")] expect_cumulative: Option, + + /// Resume after an already-verified prefix of this many RECORDS (from a prior + /// run's `checkpoint:` line). Requires `--resume-binding` and `--resume-cents`. + #[arg(long = "resume-index", value_name = "N", requires_all = ["resume_binding", "resume_cents"])] + resume_index: Option, + + /// The verified prefix's final commit binding (`checkpoint:` line `binding=`). + #[arg(long = "resume-binding", value_name = "HASH")] + resume_binding: Option, + + /// The verified prefix's re-derived settled cents (`checkpoint:` line `settled_cents=`). + #[arg(long = "resume-cents", value_name = "CENTS")] + resume_cents: Option, } fn main() -> ExitCode { @@ -534,13 +547,45 @@ async fn run_verify_spend(args: VerifySpendArgs) -> ExitCode { }; // The facilitator key that would verify a captured rail attestation is supplied out-of-band once // the wire captures one (a follow-on); without it the offline audit still re-derives the spend. - let verdict = gate - .audit_spend_log(&records, chrono::Utc::now().timestamp(), &counter, None) - .await; + let now_ts = chrono::Utc::now().timestamp(); + let verdict = match args.resume_index { + Some(index) => { + if index > records.len() { + eprintln!( + "auths-mcp-gateway: --resume-index {index} exceeds the log ({} records)", + records.len() + ); + return ExitCode::FAILURE; + } + #[allow(clippy::expect_used)] // INVARIANT: clap requires_all guarantees both flags + let resume = auths_mcp_core::AuditResume { + prior_records: index, + prior_binding: args.resume_binding.clone().expect("required by clap"), + prior_settled_cents: auths_mcp_core::Cents::new( + args.resume_cents.expect("required by clap"), + ), + }; + gate.audit_spend_log_resumed(&records[index..], now_ts, &counter, None, &resume) + .await + } + None => gate.audit_spend_log(&records, now_ts, &counter, None).await, + }; println!("verify-spend: {} — {verdict}", verdict.code()); if !verdict.is_consistent() { return ExitCode::FAILURE; } + // The resumable end state a checkpointing caller stores for its next run. + if let Some(last) = records.last() { + println!( + "checkpoint: records={} settled_cents={} binding={}", + records.len(), + match &verdict { + auths_mcp_core::AuditVerdict::Consistent(proof) => proof.settled_cents().get(), + _ => 0, + }, + auths_mcp_core::call_commit_binding(&last.call_commit), + ); + } if let Some(path) = &args.treasury_checkpoints && !cross_check_treasury( path, From 747f9cd677b85d472e2df73d394e32886ad0559a Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 18 Jul 2026 13:14:02 +0100 Subject: [PATCH 09/12] fix: formatting Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-mcp-core/src/lib.rs | 11 +++++------ crates/auths-mcp-core/src/treasury.rs | 19 ++++++++++--------- crates/auths-mcp-gateway/src/chain.rs | 4 +--- crates/auths-mcp-gateway/src/inproc_sign.rs | 19 +++++++++++++------ crates/auths-mcp-gateway/src/main.rs | 6 +++++- crates/auths-mcp-gateway/src/treasury.rs | 13 ++++--------- packages/auths-node/src/rp.rs | 9 +++++---- 7 files changed, 43 insertions(+), 38 deletions(-) diff --git a/crates/auths-mcp-core/src/lib.rs b/crates/auths-mcp-core/src/lib.rs index cb11b5f9..cb2e45db 100644 --- a/crates/auths-mcp-core/src/lib.rs +++ b/crates/auths-mcp-core/src/lib.rs @@ -46,14 +46,16 @@ pub mod treasury; pub use attestation::{AttestationError, Attested, RailAttestation}; pub use audit::{ AuditResume, AuditVerdict, ConsistentProof, SPEND_LOG_GENESIS, Settlement, SpendLogRecord, - audit_spend_log, audit_spend_log_resumed, - call_commit_binding, read_spend_log, resolve_spend_log, spend_log_dir, - spend_log_path, spend_log_period_path, + audit_spend_log, audit_spend_log_resumed, call_commit_binding, read_spend_log, + resolve_spend_log, spend_log_dir, spend_log_path, spend_log_period_path, }; pub use budget::{ BudgetError, CounterKey, CounterRef, CrossRailBudget, Hold, ReserveOutcome, ReservedHolds, SettleOutcome, SettledCounter, }; +pub use channel::{ + ChannelRecord, ChannelSettlement, ChannelState, netted_settle_cents, spend_log_hash, +}; pub use gate::{Decision, GateError, Meter, PerCallGate, ToolCall, Verdict}; pub use money::{Actual, AtomicUsdc, Ceiling, Cents, NonZeroCents}; pub use paymode::{ @@ -62,9 +64,6 @@ pub use paymode::{ }; pub use rail::{ExtractedCost, RailError, extract as extract_rail_cost, extract_stripe}; pub use receipt::{Receipt, ReceiptError}; -pub use channel::{ - ChannelRecord, ChannelSettlement, ChannelState, netted_settle_cents, spend_log_hash, -}; pub use session::{Budget, BudgetParseError}; pub use treasury::{ FleetLedger, FleetReserveOutcome, SignedTreasuryCheckpoint, TreasuryCheckpoint, TreasuryError, diff --git a/crates/auths-mcp-core/src/treasury.rs b/crates/auths-mcp-core/src/treasury.rs index 99c7f465..45fdcbaa 100644 --- a/crates/auths-mcp-core/src/treasury.rs +++ b/crates/auths-mcp-core/src/treasury.rs @@ -44,7 +44,9 @@ pub enum TreasuryError { #[error("checkpoint trail is empty")] Empty, /// The final cumulative did not equal the total the caller re-derived from logs. - #[error("cumulative mismatch: checkpoints say {checkpointed} cents, logs re-derive {rederived}")] + #[error( + "cumulative mismatch: checkpoints say {checkpointed} cents, logs re-derive {rederived}" + )] CumulativeMismatch { checkpointed: u64, rederived: u64 }, } @@ -314,14 +316,13 @@ pub fn verify_checkpoint_trail( line, reason: format!("signature_hex: {reason}"), })?; - let message = - signed - .checkpoint - .signing_bytes() - .map_err(|e| TreasuryError::Malformed { - line, - reason: format!("canonicalize: {e}"), - })?; + let message = signed + .checkpoint + .signing_bytes() + .map_err(|e| TreasuryError::Malformed { + line, + reason: format!("canonicalize: {e}"), + })?; if !verify(&pubkey, &message, &sig) { return Err(TreasuryError::BadSignature { line }); } diff --git a/crates/auths-mcp-gateway/src/chain.rs b/crates/auths-mcp-gateway/src/chain.rs index 2fa81920..bdbc882b 100644 --- a/crates/auths-mcp-gateway/src/chain.rs +++ b/crates/auths-mcp-gateway/src/chain.rs @@ -75,9 +75,7 @@ pub(crate) fn default_git_identity() -> [(&'static str, &'static str); 4] { fn run(cmd: &mut Command) -> anyhow::Result { for (key, value) in default_git_identity() { if std::env::var_os(key).is_none() - && !cmd - .get_envs() - .any(|(k, _)| k == std::ffi::OsStr::new(key)) + && !cmd.get_envs().any(|(k, _)| k == std::ffi::OsStr::new(key)) { cmd.env(key, value); } diff --git a/crates/auths-mcp-gateway/src/inproc_sign.rs b/crates/auths-mcp-gateway/src/inproc_sign.rs index 74aec87d..592a3999 100644 --- a/crates/auths-mcp-gateway/src/inproc_sign.rs +++ b/crates/auths-mcp-gateway/src/inproc_sign.rs @@ -69,9 +69,10 @@ impl CommitTemplate { let identity_line = author.rsplitn(3, ' ').nth(2)?.to_string(); let mut pieces = Vec::new(); for line in message.lines() { - let slot = slots - .iter() - .find(|k| line.strip_prefix(**k).is_some_and(|rest| rest.starts_with(':'))); + let slot = slots.iter().find(|k| { + line.strip_prefix(**k) + .is_some_and(|rest| rest.starts_with(':')) + }); match slot { Some(key) => { let after = &line[key.len() + 1..]; @@ -204,9 +205,15 @@ impl InprocState { let key = self.session_key()?; let values = HashMap::from([("Auths-Prev", prev_binding.to_string())]); let message = template.render(&values)?; - sign_commit_object(key, &template.identity_line, "call.json", canonical, &message) - .map_err(|e| eprintln!("auths-mcp-gateway: in-process call sign failed ({e})")) - .ok() + sign_commit_object( + key, + &template.identity_line, + "call.json", + canonical, + &message, + ) + .map_err(|e| eprintln!("auths-mcp-gateway: in-process call sign failed ({e})")) + .ok() } /// Sign a SETTLEMENT commit in-process; `None` = not ready. diff --git a/crates/auths-mcp-gateway/src/main.rs b/crates/auths-mcp-gateway/src/main.rs index cc593296..e20d1f0b 100644 --- a/crates/auths-mcp-gateway/src/main.rs +++ b/crates/auths-mcp-gateway/src/main.rs @@ -181,7 +181,11 @@ struct TreasuryServeArgs { state_dir: std::path::PathBuf, /// Hex seed for the P-256 checkpoint-signing key; generated fresh when omitted. - #[arg(long = "signing-seed", value_name = "HEX", env = "TREASURY_SIGNING_SEED")] + #[arg( + long = "signing-seed", + value_name = "HEX", + env = "TREASURY_SIGNING_SEED" + )] signing_seed: Option, /// Seconds between checkpoint signatures (written only when the counter moved). diff --git a/crates/auths-mcp-gateway/src/treasury.rs b/crates/auths-mcp-gateway/src/treasury.rs index 85ef8a9e..a2d8f6ab 100644 --- a/crates/auths-mcp-gateway/src/treasury.rs +++ b/crates/auths-mcp-gateway/src/treasury.rs @@ -64,8 +64,7 @@ impl TreasuryClient { pub fn from_env(default_fleet: &str) -> Option { let url = std::env::var("TREASURY_URL").ok()?; let addr = url.strip_prefix("tcp://")?.to_string(); - let fleet = - std::env::var("TREASURY_FLEET").unwrap_or_else(|_| default_fleet.to_string()); + let fleet = std::env::var("TREASURY_FLEET").unwrap_or_else(|_| default_fleet.to_string()); Some(TreasuryClient { addr, fleet, @@ -199,13 +198,9 @@ pub async fn serve(cfg: ServeConfig) -> anyhow::Result<()> { guard.last_checkpointed_count = guard.ledger.count(); (guard.ledger.count(), guard.ledger.settled_cents()) }; - if let Err(e) = append_checkpoint( - &checkpoint_path, - &fleet, - snapshot, - &seed, - &public_key, - ) { + if let Err(e) = + append_checkpoint(&checkpoint_path, &fleet, snapshot, &seed, &public_key) + { eprintln!("treasury: checkpoint append failed: {e}"); } } diff --git a/packages/auths-node/src/rp.rs b/packages/auths-node/src/rp.rs index 7dbdb58f..500fadee 100644 --- a/packages/auths-node/src/rp.rs +++ b/packages/auths-node/src/rp.rs @@ -214,10 +214,11 @@ pub async fn authenticate_presentation( // KEL replay + signature verification is CPU-bound: run it off the Node event // loop so a burst of agent logins cannot stall unrelated requests. let request_string = request.to_string(); - let verdict_json = - tokio::task::spawn_blocking(move || auths_verifier::verify_presentation_json(&request_string)) - .await - .map_err(|e| napi::Error::from_reason(format!("verification task failed: {e}")))?; + let verdict_json = tokio::task::spawn_blocking(move || { + auths_verifier::verify_presentation_json(&request_string) + }) + .await + .map_err(|e| napi::Error::from_reason(format!("verification task failed: {e}")))?; let verdict: serde_json::Value = serde_json::from_str(&verdict_json) .unwrap_or_else(|_| serde_json::json!({ "kind": "malformedRequest" })); let kind = verdict["kind"].as_str().unwrap_or("malformedRequest"); From 188713cfc0721668da07ca4939c6f41ef89c8396 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 18 Jul 2026 13:27:47 +0100 Subject: [PATCH 10/12] =?UTF-8?q?crypto:=20typed=20sync=20verify=20+=20gen?= =?UTF-8?q?erate=20dispatchers;=20treasury=20signs=20and=20verifies=20thro?= =?UTF-8?q?ugh=20them=20=E2=80=94=20no=20curve-specific=20names=20outside?= =?UTF-8?q?=20auths-crypto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-crypto/src/aws_lc_provider.rs | 9 ++++ crates/auths-crypto/src/cnsa_provider.rs | 32 +++++++++++++ crates/auths-crypto/src/key_ops.rs | 53 ++++++++++++++++++++++ crates/auths-crypto/src/lib.rs | 5 +- crates/auths-crypto/src/ring_provider.rs | 9 ++++ crates/auths-mcp-gateway/src/main.rs | 6 ++- crates/auths-mcp-gateway/src/treasury.rs | 22 +++++---- 7 files changed, 125 insertions(+), 11 deletions(-) diff --git a/crates/auths-crypto/src/aws_lc_provider.rs b/crates/auths-crypto/src/aws_lc_provider.rs index 653158c9..18041639 100644 --- a/crates/auths-crypto/src/aws_lc_provider.rs +++ b/crates/auths-crypto/src/aws_lc_provider.rs @@ -51,6 +51,15 @@ impl AwsLcProvider { Ok((SecureSeed::new(seed), pubkey_bytes)) } + /// Generate an Ed25519 keypair. Returns `(seed, public_key)`. + pub fn ed25519_generate() -> Result<(SecureSeed, [u8; 32]), CryptoError> { + use p256::elliptic_curve::rand_core::{OsRng, RngCore}; + let mut seed = [0u8; 32]; + OsRng.fill_bytes(&mut seed); + let public_key = Self::ed25519_public_key(&seed)?; + Ok((SecureSeed::new(seed), public_key)) + } + /// Sign with P-256 via aws-lc-rs (FIPS-validated). Returns 64-byte r||s. pub fn p256_sign(seed: &[u8; 32], message: &[u8]) -> Result, CryptoError> { use aws_lc_rs::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair}; diff --git a/crates/auths-crypto/src/cnsa_provider.rs b/crates/auths-crypto/src/cnsa_provider.rs index ce339ad8..cbb43b2a 100644 --- a/crates/auths-crypto/src/cnsa_provider.rs +++ b/crates/auths-crypto/src/cnsa_provider.rs @@ -96,6 +96,38 @@ impl CnsaProvider { crate::ring_provider::RingCryptoProvider::ed25519_sign(seed, message) } + /// Inherent sync shim for `key_ops::generate`. See [`Self::p256_sign`]. + pub fn p256_generate() -> Result<(SecureSeed, Vec), CryptoError> { + Err(CryptoError::OperationFailed( + "P-256 keygen is rejected under --features cnsa; use P-384".into(), + )) + } + + /// Inherent sync shim for `key_ops::verify`. See [`Self::p256_sign`]. + pub fn p256_verify( + _pubkey: &[u8], + _message: &[u8], + _signature: &[u8], + ) -> Result<(), CryptoError> { + Err(CryptoError::OperationFailed( + "P-256 verify is rejected under --features cnsa; use P-384".into(), + )) + } + + /// Inherent sync Ed25519 verify (Ed25519 stays available under CNSA). + pub fn ed25519_verify( + pubkey: &[u8], + message: &[u8], + signature: &[u8], + ) -> Result<(), CryptoError> { + crate::ring_provider::RingCryptoProvider::ed25519_verify(pubkey, message, signature) + } + + /// Inherent sync Ed25519 keypair generation (delegates to the ring path). + pub fn ed25519_generate() -> Result<(SecureSeed, [u8; 32]), CryptoError> { + crate::ring_provider::RingCryptoProvider::ed25519_generate() + } + /// Inherent sync Ed25519 public key derivation. pub fn ed25519_public_key(seed: &[u8; 32]) -> Result<[u8; 32], CryptoError> { crate::ring_provider::RingCryptoProvider::ed25519_public_key(seed) diff --git a/crates/auths-crypto/src/key_ops.rs b/crates/auths-crypto/src/key_ops.rs index a9127553..03e1ce7e 100644 --- a/crates/auths-crypto/src/key_ops.rs +++ b/crates/auths-crypto/src/key_ops.rs @@ -185,6 +185,59 @@ pub fn public_key(seed: &TypedSeed) -> Result, CryptoError> { } } +/// Verify a signature under the named curve — the sync twin of [`sign`], for +/// callers outside async provider contexts. Same dispatcher shape: one `match`, +/// each arm a cfg-swappable provider inherent method. +/// +/// Args: +/// * `curve`: the signature's curve, carried explicitly (never inferred from length). +/// * `public_key`: 32 raw bytes (Ed25519) or SEC1 bytes (P-256). +/// * `message`: the signed bytes. +/// * `signature`: the raw signature. +/// +/// Usage: +/// ```ignore +/// verify(CurveType::P256, &pubkey, msg, &sig)?; +/// ``` +#[cfg(all(feature = "native", not(target_arch = "wasm32")))] +pub fn verify( + curve: CurveType, + public_key: &[u8], + message: &[u8], + signature: &[u8], +) -> Result<(), CryptoError> { + match curve { + CurveType::Ed25519 => SyncProvider::ed25519_verify(public_key, message, signature), + CurveType::P256 => SyncProvider::p256_verify(public_key, message, signature), + } +} + +/// Generate a fresh keypair on the named curve. +/// +/// Returns the curve-tagged seed plus the public key ([`public_key`]'s +/// encoding). Same dispatcher shape as [`sign`]. +/// +/// Args: +/// * `curve`: the curve to generate on. +/// +/// Usage: +/// ```ignore +/// let (seed, public_key) = generate(CurveType::P256)?; +/// ``` +#[cfg(all(feature = "native", not(target_arch = "wasm32")))] +pub fn generate(curve: CurveType) -> Result<(TypedSeed, Vec), CryptoError> { + match curve { + CurveType::Ed25519 => { + let (seed, public_key) = SyncProvider::ed25519_generate()?; + Ok((TypedSeed::Ed25519(*seed.as_bytes()), public_key.to_vec())) + } + CurveType::P256 => { + let (seed, public_key) = SyncProvider::p256_generate()?; + Ok((TypedSeed::P256(*seed.as_bytes()), public_key)) + } + } +} + /// Parsed signing key with curve carried explicitly — the authoritative owner /// of a private-key + curve pair across sign / verify / PKCS8 export / CESR /// encoding flows. diff --git a/crates/auths-crypto/src/lib.rs b/crates/auths-crypto/src/lib.rs index 9b5a9aea..d32a0f54 100644 --- a/crates/auths-crypto/src/lib.rs +++ b/crates/auths-crypto/src/lib.rs @@ -46,7 +46,10 @@ pub use hash256::Hash256; pub use key_material::{build_ed25519_pkcs8_v2, parse_ed25519_key_material, parse_ed25519_seed}; pub use key_ops::{ParsedKey, TypedSeed, TypedSignerKey, normalize_verkey, parse_key_material}; #[cfg(all(feature = "native", not(target_arch = "wasm32")))] -pub use key_ops::{public_key as typed_public_key, sign as typed_sign}; +pub use key_ops::{ + generate as typed_generate, public_key as typed_public_key, sign as typed_sign, + verify as typed_verify, +}; pub use pkcs8::Pkcs8Der; #[cfg(all(feature = "native", not(target_arch = "wasm32")))] pub use provider::default_provider; diff --git a/crates/auths-crypto/src/ring_provider.rs b/crates/auths-crypto/src/ring_provider.rs index 37e36acf..a458895f 100644 --- a/crates/auths-crypto/src/ring_provider.rs +++ b/crates/auths-crypto/src/ring_provider.rs @@ -105,6 +105,15 @@ impl RingCryptoProvider { } /// Derive compressed public key from P-256 seed. + /// Generate an Ed25519 keypair. Returns `(seed, public_key)`. + pub fn ed25519_generate() -> Result<(SecureSeed, [u8; 32]), CryptoError> { + use p256::elliptic_curve::rand_core::{OsRng, RngCore}; + let mut seed = [0u8; 32]; + OsRng.fill_bytes(&mut seed); + let public_key = Self::ed25519_public_key(&seed)?; + Ok((SecureSeed::new(seed), public_key)) + } + pub fn p256_public_key_from_seed(seed: &[u8; 32]) -> Result, CryptoError> { use p256::ecdsa::SigningKey; diff --git a/crates/auths-mcp-gateway/src/main.rs b/crates/auths-mcp-gateway/src/main.rs index e20d1f0b..360c7ce0 100644 --- a/crates/auths-mcp-gateway/src/main.rs +++ b/crates/auths-mcp-gateway/src/main.rs @@ -609,7 +609,6 @@ fn cross_check_treasury( expect_pubkey: Option<&str>, expect_cumulative: Option, ) -> bool { - use auths_crypto::ring_provider::RingCryptoProvider; let raw = match std::fs::read_to_string(path) { Ok(raw) => raw, Err(e) => { @@ -621,8 +620,11 @@ fn cross_check_treasury( } }; let lines: Vec = raw.lines().map(str::to_string).collect(); + // The coordinator signs on the project-default curve; absent an explicit + // curve field on the trail, P-256 is the wire default (see the curve-tagging + // rules) — dispatched through the typed API, never by key length. let last = match auths_mcp_core::verify_checkpoint_trail(&lines, expect_pubkey, |pk, m, s| { - RingCryptoProvider::p256_verify(pk, m, s).is_ok() + auths_crypto::typed_verify(auths_crypto::CurveType::P256, pk, m, s).is_ok() }) { Ok(last) => last, Err(e) => { diff --git a/crates/auths-mcp-gateway/src/treasury.rs b/crates/auths-mcp-gateway/src/treasury.rs index a2d8f6ab..4c6e528b 100644 --- a/crates/auths-mcp-gateway/src/treasury.rs +++ b/crates/auths-mcp-gateway/src/treasury.rs @@ -15,7 +15,7 @@ //! `verify-spend --treasury-checkpoints` cross-checks offline. use anyhow::{Context, bail}; -use auths_crypto::ring_provider::RingCryptoProvider; +use auths_crypto::{CurveType, TypedSeed}; use auths_mcp_core::treasury::{ FleetLedger, SignedTreasuryCheckpoint, TreasuryCheckpoint, encode_hex, }; @@ -154,15 +154,21 @@ pub async fn serve(cfg: ServeConfig) -> anyhow::Result<()> { let ledger_path = cfg.state_dir.join("fleet-ledger.json"); let ledger = load_ledger(&ledger_path, cfg.cap_cents)?; - let (seed, public_key) = match &cfg.signing_seed_hex { + // Checkpoints sign on the project-default curve; the trail's verifier + // dispatches on the same explicit CurveType, never on key length. + let signing_key = match &cfg.signing_seed_hex { Some(hex) => { let seed = auths_crypto::decode_seed_hex(hex).context("parse --signing-seed")?; - let public_key = RingCryptoProvider::p256_public_key_from_seed(seed.as_bytes()) - .context("derive the checkpoint public key")?; - (seed, public_key) + TypedSeed::from_curve(CurveType::P256, *seed.as_bytes()) + } + None => { + auths_crypto::typed_generate(CurveType::P256) + .context("generate the checkpoint key")? + .0 } - None => RingCryptoProvider::p256_generate().context("generate the checkpoint key")?, }; + let public_key = + auths_crypto::typed_public_key(&signing_key).context("derive the checkpoint public key")?; let listener = TcpListener::bind(&cfg.listen) .await @@ -330,7 +336,7 @@ fn append_checkpoint( path: &Path, fleet: &str, (count, cumulative_cents): (u64, Cents), - seed: &auths_crypto::SecureSeed, + signing_key: &TypedSeed, public_key: &[u8], ) -> anyhow::Result<()> { let checkpoint = TreasuryCheckpoint { @@ -340,7 +346,7 @@ fn append_checkpoint( at: Utc::now(), }; let message = checkpoint.signing_bytes()?; - let signature = RingCryptoProvider::p256_sign(seed.as_bytes(), &message) + let signature = auths_crypto::typed_sign(signing_key, &message) .map_err(|e| anyhow::anyhow!("sign checkpoint: {e}"))?; let signed = SignedTreasuryCheckpoint { checkpoint, From 23818c1089a60ae22dfb162df9f8acf7f5d006ff Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 18 Jul 2026 13:38:15 +0100 Subject: [PATCH 11/12] fix: checkpoint task passes the typed signing key Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-mcp-gateway/src/treasury.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/auths-mcp-gateway/src/treasury.rs b/crates/auths-mcp-gateway/src/treasury.rs index 4c6e528b..54652813 100644 --- a/crates/auths-mcp-gateway/src/treasury.rs +++ b/crates/auths-mcp-gateway/src/treasury.rs @@ -205,7 +205,7 @@ pub async fn serve(cfg: ServeConfig) -> anyhow::Result<()> { (guard.ledger.count(), guard.ledger.settled_cents()) }; if let Err(e) = - append_checkpoint(&checkpoint_path, &fleet, snapshot, &seed, &public_key) + append_checkpoint(&checkpoint_path, &fleet, snapshot, &signing_key, &public_key) { eprintln!("treasury: checkpoint append failed: {e}"); } From 9a84724ccd241a3ce82e1a3bfbeb7c99058ea4a4 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 18 Jul 2026 13:40:19 +0100 Subject: [PATCH 12/12] fix: formatting Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-mcp-gateway/src/treasury.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/auths-mcp-gateway/src/treasury.rs b/crates/auths-mcp-gateway/src/treasury.rs index 54652813..3f9425ee 100644 --- a/crates/auths-mcp-gateway/src/treasury.rs +++ b/crates/auths-mcp-gateway/src/treasury.rs @@ -204,9 +204,13 @@ pub async fn serve(cfg: ServeConfig) -> anyhow::Result<()> { guard.last_checkpointed_count = guard.ledger.count(); (guard.ledger.count(), guard.ledger.settled_cents()) }; - if let Err(e) = - append_checkpoint(&checkpoint_path, &fleet, snapshot, &signing_key, &public_key) - { + if let Err(e) = append_checkpoint( + &checkpoint_path, + &fleet, + snapshot, + &signing_key, + &public_key, + ) { eprintln!("treasury: checkpoint append failed: {e}"); } }