diff --git a/org-ops-core/Cargo.toml b/org-ops-core/Cargo.toml index 3e33627..0b38d83 100644 --- a/org-ops-core/Cargo.toml +++ b/org-ops-core/Cargo.toml @@ -15,3 +15,7 @@ sha2 = "0.10" base64 = "0.22" urlencoding = "2" uuid = { version = "1", features = ["v4"] } +hex = "0.4" + +[dev-dependencies] +tempfile = "3" diff --git a/org-ops-core/src/gsap_client.rs b/org-ops-core/src/gsap_client.rs new file mode 100644 index 0000000..26cd58a --- /dev/null +++ b/org-ops-core/src/gsap_client.rs @@ -0,0 +1,380 @@ +//! GSAP client — shell side of GCAP-SPEC-SHELLBOUND-BROKER-0001. +//! +//! Consumes Authorization Contexts from a GSAP broker (Capstone). +//! Posts Completion Receipts after playbook execution. +//! +//! ``` +//! let client = GsapClient::new(broker_url, token, session_dir); +//! let ac = client.authorize(&request)?; +//! // execute playbook... +//! client.complete(&ac, outcome)?; +//! ``` + +use anyhow::{anyhow, bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; +use std::path::PathBuf; + +// ── AC schema (from broker) ────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthorizationContext { + pub gsap_version: String, + pub context_id: String, + pub issued_at: Option, + pub expires_at: String, + pub principal: AcPrincipal, + pub accord: AcAccord, + pub operation: AcOperation, + pub identity_proof: AcIdentityProof, + pub signature: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AcPrincipal { + pub did: String, + pub display_name: String, + pub broker_session_id: Option, + pub driver_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AcAccord { + pub template: String, + pub capability_mask: u8, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AcOperation { + pub playbook: String, + pub corpus_entry_cid: String, + pub parameters_cid: String, + pub apply_authorized_cid: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AcIdentityProof { + pub idp_vendor: String, + pub token_jti: String, + pub elevation_active: Vec, + pub mfa_satisfied: bool, +} + +// ── CR schema (to broker) ──────────────────────────── + +#[derive(Debug, Serialize)] +pub struct CompletionReceiptPayload { + pub context_id: String, + pub outcome: String, + pub completed_at: String, + pub chronicle_session_id: String, + pub behavioral_attestation_status: String, + pub observed_behavior_cid: String, + pub declared_behavior_cid: String, + pub ffc_did: String, + pub ffc_signature: String, +} + +// ── Authorize request ──────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct AuthorizeRequest { + pub playbook: String, + pub corpus_entry_cid: String, + pub parameters_cid: String, + pub accord_template: String, + pub driver_id: String, +} + +// ── Broker response wrappers ───────────────────────── + +#[derive(Debug, Deserialize)] +struct AuthorizeResponse { + context_id: Option, + status: Option, + ac_document: Option, + // For 202 pending elevation: + poll_token: Option, + elevation: Option, + // For errors: + error: Option, +} + +// ── Consumed context registry (GSAP R-22) ──────────── + +struct ConsumedRegistry { + path: PathBuf, + consumed: HashSet, +} + +impl ConsumedRegistry { + fn new(session_dir: &PathBuf) -> Self { + let path = session_dir.join("consumed_contexts.json"); + let consumed = std::fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + Self { path, consumed } + } + + fn is_consumed(&self, id: &str) -> bool { + self.consumed.contains(id) + } + + fn mark_consumed(&mut self, id: &str) -> Result<()> { + self.consumed.insert(id.to_string()); + let json = serde_json::to_string(&self.consumed)?; + std::fs::write(&self.path, json)?; + Ok(()) + } +} + +// ── GSAP Client ────────────────────────────────────── + +pub struct GsapClient { + broker_url: String, + bearer_token: String, + session_dir: PathBuf, + http: reqwest::blocking::Client, +} + +impl GsapClient { + pub fn new(broker_url: String, bearer_token: String, session_dir: PathBuf) -> Self { + std::fs::create_dir_all(&session_dir).ok(); + Self { + broker_url, + bearer_token, + session_dir, + http: reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("HTTP client"), + } + } + + /// Request an AC from the broker. Validates before returning. + pub fn authorize( + &self, + request: &AuthorizeRequest, + installed_corpus_cid: &str, + actual_parameters_cid: &str, + ) -> Result { + let url = format!("{}/api/v1/governance/authorize/", self.broker_url); + let resp = self.http.post(&url) + .bearer_auth(&self.bearer_token) + .json(request) + .send() + .context("Broker unreachable")?; + + let status = resp.status(); + + if status == reqwest::StatusCode::ACCEPTED { + let body: AuthorizeResponse = resp.json()?; + if let Some(elevation) = body.elevation { + bail!("Elevation required: {}", serde_json::to_string_pretty(&elevation)?); + } + bail!("Pending authorization (poll_token: {:?})", body.poll_token); + } + + if !status.is_success() { + let body: serde_json::Value = resp.json().unwrap_or_default(); + bail!("Broker denied: {} {}", status, body); + } + + let body: AuthorizeResponse = resp.json().context("Invalid AC response")?; + let ac = body.ac_document.ok_or_else(|| anyhow!("No ac_document in response"))?; + + self.validate_ac(&ac, installed_corpus_cid, actual_parameters_cid)?; + Ok(ac) + } + + fn validate_ac( + &self, + ac: &AuthorizationContext, + installed_corpus_cid: &str, + actual_parameters_cid: &str, + ) -> Result<()> { + let registry = ConsumedRegistry::new(&self.session_dir); + + // R-22: single-use + if registry.is_consumed(&ac.context_id) { + bail!("AC replayed: {} already consumed", ac.context_id); + } + + // R-23: corpus entry CID + if ac.operation.corpus_entry_cid != installed_corpus_cid { + bail!( + "Corpus mismatch: broker={} installed={}", + ac.operation.corpus_entry_cid, installed_corpus_cid + ); + } + + // R-24: parameters CID + if ac.operation.parameters_cid != actual_parameters_cid { + bail!("Parameters modified after broker authorization"); + } + + // R-20: signature (phase 1: presence check) + if ac.signature.is_none() { + eprintln!("GSAP: AC has no principal signature (dev mode)"); + } + + Ok(()) + } + + /// Post CR to broker. Retries 3 times (GSAP R-29). + pub fn complete( + &self, + ac: &AuthorizationContext, + outcome: &str, + chronicle_session_id: &str, + ) -> Result<()> { + // Mark consumed locally first + let mut registry = ConsumedRegistry::new(&self.session_dir); + registry.mark_consumed(&ac.context_id)?; + + let payload = CompletionReceiptPayload { + context_id: ac.context_id.clone(), + outcome: outcome.to_string(), + completed_at: chrono_now(), + chronicle_session_id: chronicle_session_id.to_string(), + behavioral_attestation_status: "unavailable".to_string(), + observed_behavior_cid: String::new(), + declared_behavior_cid: String::new(), + ffc_did: std::env::var("FFC_DID").unwrap_or_default(), + ffc_signature: String::new(), + }; + + let url = format!("{}/api/v1/governance/complete/", self.broker_url); + + for attempt in 0..3 { + match self.http.post(&url) + .json(&payload) + .send() + { + Ok(resp) if resp.status().is_success() => return Ok(()), + Ok(resp) => { + let body: serde_json::Value = resp.json().unwrap_or_default(); + if attempt < 2 { + eprintln!("GSAP CR attempt {} failed: {}", attempt + 1, body); + std::thread::sleep(std::time::Duration::from_secs(2u64.pow(attempt as u32))); + } else { + self.store_pending_cr(&payload)?; + bail!("CR delivery failed after 3 attempts: {}", body); + } + } + Err(e) => { + if attempt < 2 { + eprintln!("GSAP CR attempt {} error: {}", attempt + 1, e); + std::thread::sleep(std::time::Duration::from_secs(2u64.pow(attempt as u32))); + } else { + self.store_pending_cr(&payload)?; + bail!("CR delivery failed: {}", e); + } + } + } + } + Ok(()) + } + + fn store_pending_cr(&self, cr: &CompletionReceiptPayload) -> Result<()> { + let path = self.session_dir.join(format!("pending_cr_{}.json", &cr.context_id[..8])); + let json = serde_json::to_string_pretty(cr)?; + std::fs::write(path, json)?; + Ok(()) + } +} + +/// Compute sha256 CID of a byte slice. +pub fn compute_cid(data: &[u8]) -> String { + let hash = Sha256::digest(data); + format!("sha256:{}", hex::encode(hash)) +} + +fn chrono_now() -> String { + // Simple ISO 8601 without chrono dependency + use std::time::SystemTime; + let dur = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default(); + format!("1970-01-01T00:00:00Z+{}s", dur.as_secs()) // placeholder +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_ac(id: &str) -> AuthorizationContext { + AuthorizationContext { + gsap_version: "0.1.0".into(), + context_id: id.into(), + issued_at: Some("2026-01-01T00:00:00Z".into()), + expires_at: "2026-01-01T01:00:00Z".into(), + principal: AcPrincipal { + did: "did:web:test/p/test".into(), + display_name: "Test".into(), + broker_session_id: None, + driver_id: "kc-test".into(), + }, + accord: AcAccord { template: "test".into(), capability_mask: 3 }, + operation: AcOperation { + playbook: "test-echo".into(), + corpus_entry_cid: format!("sha256:{}", "a".repeat(64)), + parameters_cid: format!("sha256:{}", "b".repeat(64)), + apply_authorized_cid: None, + }, + identity_proof: AcIdentityProof { + idp_vendor: "keycloak".into(), + token_jti: "jti".into(), + elevation_active: vec![], + mfa_satisfied: true, + }, + signature: None, + } + } + + #[test] + fn test_corpus_mismatch() { + let dir = tempfile::tempdir().unwrap(); + let client = GsapClient::new("http://x".into(), "t".into(), dir.path().to_path_buf()); + let ac = make_ac("ctx-1"); + let r = client.validate_ac(&ac, &format!("sha256:{}", "z".repeat(64)), &format!("sha256:{}", "b".repeat(64))); + assert!(r.is_err()); + assert!(r.unwrap_err().to_string().contains("Corpus mismatch")); + } + + #[test] + fn test_params_modified() { + let dir = tempfile::tempdir().unwrap(); + let client = GsapClient::new("http://x".into(), "t".into(), dir.path().to_path_buf()); + let ac = make_ac("ctx-2"); + let r = client.validate_ac(&ac, &format!("sha256:{}", "a".repeat(64)), &format!("sha256:{}", "z".repeat(64))); + assert!(r.is_err()); + assert!(r.unwrap_err().to_string().contains("Parameters modified")); + } + + #[test] + fn test_replay_rejected() { + let dir = tempfile::tempdir().unwrap(); + let client = GsapClient::new("http://x".into(), "t".into(), dir.path().to_path_buf()); + let ac = make_ac("ctx-3"); + let corpus = format!("sha256:{}", "a".repeat(64)); + let params = format!("sha256:{}", "b".repeat(64)); + client.validate_ac(&ac, &corpus, ¶ms).unwrap(); + + let mut reg = ConsumedRegistry::new(&dir.path().to_path_buf()); + reg.mark_consumed("ctx-3").unwrap(); + + let r = client.validate_ac(&ac, &corpus, ¶ms); + assert!(r.is_err()); + assert!(r.unwrap_err().to_string().contains("replayed")); + } + + #[test] + fn test_valid_ac() { + let dir = tempfile::tempdir().unwrap(); + let client = GsapClient::new("http://x".into(), "t".into(), dir.path().to_path_buf()); + let ac = make_ac("ctx-4"); + let r = client.validate_ac(&ac, &format!("sha256:{}", "a".repeat(64)), &format!("sha256:{}", "b".repeat(64))); + assert!(r.is_ok()); + } +} diff --git a/org-ops-core/src/lib.rs b/org-ops-core/src/lib.rs index d9bae8c..245b901 100644 --- a/org-ops-core/src/lib.rs +++ b/org-ops-core/src/lib.rs @@ -18,6 +18,7 @@ pub mod playbook_commands; pub mod score_fetcher; pub mod session; pub mod traits; +pub mod gsap_client; pub use auth_commands::{AuthCommands, AuthConfig}; pub use config::OrgOpsConfig;