feat: GSAP client module — shell side of GSAP protocol
Implements the shell side of GCAP-SPEC-SHELLBOUND-BROKER-0001. The broker (Capstone) issues ACs. This module consumes them. GsapClient: authorize() — request AC, validate R-20/R-22/R-23/R-24 complete() — post CR with 3x retry (R-29) ConsumedContextRegistry: Filesystem-based replay prevention (R-22) 4/4 unit tests passing: test_corpus_mismatch, test_params_modified, test_replay_rejected, test_valid_ac Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6912a46001
commit
aa5853d168
3 changed files with 385 additions and 0 deletions
|
|
@ -15,3 +15,7 @@ sha2 = "0.10"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|
|
||||||
380
org-ops-core/src/gsap_client.rs
Normal file
380
org-ops-core/src/gsap_client.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
pub expires_at: String,
|
||||||
|
pub principal: AcPrincipal,
|
||||||
|
pub accord: AcAccord,
|
||||||
|
pub operation: AcOperation,
|
||||||
|
pub identity_proof: AcIdentityProof,
|
||||||
|
pub signature: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AcPrincipal {
|
||||||
|
pub did: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub broker_session_id: Option<String>,
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AcIdentityProof {
|
||||||
|
pub idp_vendor: String,
|
||||||
|
pub token_jti: String,
|
||||||
|
pub elevation_active: Vec<String>,
|
||||||
|
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<String>,
|
||||||
|
status: Option<String>,
|
||||||
|
ac_document: Option<AuthorizationContext>,
|
||||||
|
// For 202 pending elevation:
|
||||||
|
poll_token: Option<String>,
|
||||||
|
elevation: Option<serde_json::Value>,
|
||||||
|
// For errors:
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Consumed context registry (GSAP R-22) ────────────
|
||||||
|
|
||||||
|
struct ConsumedRegistry {
|
||||||
|
path: PathBuf,
|
||||||
|
consumed: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AuthorizationContext> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ pub mod playbook_commands;
|
||||||
pub mod score_fetcher;
|
pub mod score_fetcher;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
|
pub mod gsap_client;
|
||||||
|
|
||||||
pub use auth_commands::{AuthCommands, AuthConfig};
|
pub use auth_commands::{AuthCommands, AuthConfig};
|
||||||
pub use config::OrgOpsConfig;
|
pub use config::OrgOpsConfig;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue