diff --git a/.github/workflows/javascript.yml b/.github/workflows/javascript.yml new file mode 100644 index 0000000..c98adee --- /dev/null +++ b/.github/workflows/javascript.yml @@ -0,0 +1,23 @@ +name: JavaScript CI +on: + push: + branches: [main] + paths: ['javascript/**'] + pull_request: + paths: ['javascript/**'] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: ['18', '20'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - working-directory: javascript + run: | + npm ci + npm test diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 0000000..83812b4 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,23 @@ +name: Python CI +on: + push: + branches: [main] + paths: ['python/**'] + pull_request: + paths: ['python/**'] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.9', '3.12'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - working-directory: python + run: | + pip install -e ".[dev]" + python -m pytest tests/ -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8e0fe09 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,24 @@ +name: Release Check +on: + push: + branches: [main] + pull_request: + +jobs: + version-consistency: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check version consistency + run: | + RUST_VER=$(grep '^version' crates/agentpin/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + PY_VER=$(grep 'version' python/pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + JS_VER=$(node -e "console.log(require('./javascript/package.json').version)") + echo "Rust: $RUST_VER" + echo "Python: $PY_VER" + echo "JavaScript: $JS_VER" + if [ "$RUST_VER" != "$PY_VER" ] || [ "$RUST_VER" != "$JS_VER" ]; then + echo "::error::Version mismatch! Rust=$RUST_VER Python=$PY_VER JavaScript=$JS_VER" + exit 1 + fi + echo "All versions match: $RUST_VER" diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..d15bcc3 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,26 @@ +name: Rust CI +on: + push: + branches: [main] + paths: ['crates/**', 'Cargo.toml', 'Cargo.lock'] + pull_request: + paths: ['crates/**', 'Cargo.toml', 'Cargo.lock'] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + rust: [stable, '1.70'] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + components: clippy, rustfmt + - run: cargo fmt --check + if: matrix.rust == 'stable' + - run: cargo clippy --workspace -j2 -- -D warnings + if: matrix.rust == 'stable' + - run: cargo test --workspace -j2 + - run: cargo test --workspace -j2 --features fetch diff --git a/crates/agentpin/src/error.rs b/crates/agentpin/src/error.rs index 9734445..c02f9ce 100644 --- a/crates/agentpin/src/error.rs +++ b/crates/agentpin/src/error.rs @@ -38,6 +38,9 @@ pub enum Error { #[error("Delegation error: {0}")] Delegation(String), + #[error("Transport error: {0}")] + Transport(String), + #[error("IO error: {0}")] Io(#[from] std::io::Error), diff --git a/crates/agentpin/src/lib.rs b/crates/agentpin/src/lib.rs index cc2b9a8..ceea02e 100644 --- a/crates/agentpin/src/lib.rs +++ b/crates/agentpin/src/lib.rs @@ -9,6 +9,9 @@ pub mod credential; pub mod delegation; pub mod discovery; pub mod mutual; +pub mod nonce; pub mod pinning; pub mod revocation; +pub mod rotation; +pub mod transport; pub mod verification; diff --git a/crates/agentpin/src/mutual.rs b/crates/agentpin/src/mutual.rs index 7be4945..f951803 100644 --- a/crates/agentpin/src/mutual.rs +++ b/crates/agentpin/src/mutual.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use base64::{engine::general_purpose::URL_SAFE_NO_PAD as BASE64URL, Engine}; use chrono::Utc; use p256::ecdsa::{SigningKey, VerifyingKey}; @@ -5,6 +7,7 @@ use rand::RngCore; use crate::crypto; use crate::error::Error; +use crate::nonce::NonceStore; use crate::types::mutual::{Challenge, Response}; const NONCE_EXPIRY_SECS: i64 = 60; @@ -39,6 +42,19 @@ pub fn verify_response( response: &Response, challenge: &Challenge, verifying_key: &VerifyingKey, +) -> Result { + verify_response_with_nonce_store(response, challenge, verifying_key, None) +} + +/// Verify a challenge response with optional nonce deduplication. +/// +/// When a `nonce_store` is provided, the response nonce is checked against +/// previously seen nonces to prevent replay attacks within the validity window. +pub fn verify_response_with_nonce_store( + response: &Response, + challenge: &Challenge, + verifying_key: &VerifyingKey, + nonce_store: Option<&dyn NonceStore>, ) -> Result { // Check nonce matches if response.nonce != challenge.nonce { @@ -56,6 +72,14 @@ pub fn verify_response( } } + // Check nonce deduplication if a store is provided + if let Some(store) = nonce_store { + let fresh = store.check_and_record(&response.nonce, Duration::from_secs(60))?; + if !fresh { + return Err(Error::Jwt("Nonce has already been used".to_string())); + } + } + // Verify signature over the nonce crypto::verify_bytes( verifying_key, @@ -146,4 +170,41 @@ mod tests { Some("eyJ...test-jwt".to_string()) ); } + + #[test] + fn test_verify_with_nonce_store() { + let kp = crypto::generate_key_pair().unwrap(); + let sk = crypto::load_signing_key(&kp.private_key_pem).unwrap(); + let vk = crypto::load_verifying_key(&kp.public_key_pem).unwrap(); + + let store = crate::nonce::InMemoryNonceStore::new(); + let challenge = create_challenge(None); + let response = create_response(&challenge, &sk, "test-key"); + + // First verification should succeed. + let valid = + verify_response_with_nonce_store(&response, &challenge, &vk, Some(&store)).unwrap(); + assert!(valid); + + // Second verification with the same nonce should fail (replay). + let result = verify_response_with_nonce_store(&response, &challenge, &vk, Some(&store)); + assert!(result.is_err(), "Replayed nonce should be rejected"); + } + + #[test] + fn test_verify_without_nonce_store() { + let kp = crypto::generate_key_pair().unwrap(); + let sk = crypto::load_signing_key(&kp.private_key_pem).unwrap(); + let vk = crypto::load_verifying_key(&kp.public_key_pem).unwrap(); + + let challenge = create_challenge(None); + let response = create_response(&challenge, &sk, "test-key"); + + // Without a nonce store, same nonce can be verified multiple times. + let valid1 = verify_response(&response, &challenge, &vk).unwrap(); + assert!(valid1); + + let valid2 = verify_response(&response, &challenge, &vk).unwrap(); + assert!(valid2); + } } diff --git a/crates/agentpin/src/nonce.rs b/crates/agentpin/src/nonce.rs new file mode 100644 index 0000000..d3f1ce4 --- /dev/null +++ b/crates/agentpin/src/nonce.rs @@ -0,0 +1,103 @@ +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use crate::error::Error; + +/// Trait for nonce deduplication stores. +pub trait NonceStore: Send + Sync { + /// Check if a nonce has been seen before. If not, record it with the given TTL. + /// Returns `Ok(true)` if the nonce is fresh (not seen before). + /// Returns `Ok(false)` if the nonce has already been used (replay). + fn check_and_record(&self, nonce: &str, ttl: Duration) -> Result; +} + +/// In-memory nonce store with lazy expiry cleanup. +pub struct InMemoryNonceStore { + entries: Mutex>, +} + +impl InMemoryNonceStore { + /// Create a new empty nonce store. + pub fn new() -> Self { + Self { + entries: Mutex::new(HashMap::new()), + } + } +} + +impl Default for InMemoryNonceStore { + fn default() -> Self { + Self::new() + } +} + +impl NonceStore for InMemoryNonceStore { + fn check_and_record(&self, nonce: &str, ttl: Duration) -> Result { + let mut map = self + .entries + .lock() + .map_err(|e| Error::Jwt(format!("Nonce store lock poisoned: {}", e)))?; + + let now = Instant::now(); + + // Lazy cleanup: remove all expired entries. + map.retain(|_, expiry| *expiry > now); + + // Check if the nonce is already present (and not expired, since we just cleaned). + if map.contains_key(nonce) { + return Ok(false); + } + + // Record the nonce with its expiry. + map.insert(nonce.to_string(), now + ttl); + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fresh_nonce_accepted() { + let store = InMemoryNonceStore::new(); + let result = store + .check_and_record("nonce-1", Duration::from_secs(60)) + .unwrap(); + assert!(result, "First use of a nonce should return true"); + } + + #[test] + fn test_duplicate_nonce_rejected() { + let store = InMemoryNonceStore::new(); + let ttl = Duration::from_secs(60); + store.check_and_record("nonce-dup", ttl).unwrap(); + let result = store.check_and_record("nonce-dup", ttl).unwrap(); + assert!(!result, "Second use of the same nonce should return false"); + } + + #[test] + fn test_expired_nonce_reusable() { + let store = InMemoryNonceStore::new(); + let ttl = Duration::from_millis(1); + store.check_and_record("nonce-exp", ttl).unwrap(); + + std::thread::sleep(Duration::from_millis(10)); + + let result = store.check_and_record("nonce-exp", ttl).unwrap(); + assert!(result, "Expired nonce should be accepted again"); + } + + #[test] + fn test_concurrent_safety() { + let store = InMemoryNonceStore::new(); + let ttl = Duration::from_secs(60); + + let first = store.check_and_record("nonce-cc", ttl).unwrap(); + assert!(first); + + let second = store.check_and_record("nonce-cc", ttl).unwrap(); + assert!(!second); + } +} diff --git a/crates/agentpin/src/rotation.rs b/crates/agentpin/src/rotation.rs new file mode 100644 index 0000000..b4d6ee4 --- /dev/null +++ b/crates/agentpin/src/rotation.rs @@ -0,0 +1,146 @@ +use chrono::Utc; + +use crate::crypto::{generate_key_id, generate_key_pair, load_verifying_key, KeyPair}; +use crate::error::Error; +use crate::jwk::{verifying_key_to_jwk, Jwk}; +use crate::revocation::add_revoked_key; +use crate::types::discovery::DiscoveryDocument; +use crate::types::revocation::{RevocationDocument, RevocationReason}; + +/// A plan for key rotation, returned by `prepare_rotation()`. +pub struct RotationPlan { + pub new_key_pair: KeyPair, + pub new_kid: String, + pub new_jwk: Jwk, + pub old_kid: String, +} + +/// Prepare a key rotation: generate a new key pair, compute its kid and JWK. +/// +/// The caller should then call `apply_rotation` to add the new key to a +/// discovery document and, after an overlap window, `complete_rotation` to +/// retire the old key. +pub fn prepare_rotation(old_kid: &str) -> Result { + let new_key_pair = generate_key_pair()?; + let new_kid = generate_key_id(&new_key_pair.public_key_pem)?; + let vk = load_verifying_key(&new_key_pair.public_key_pem)?; + let new_jwk = verifying_key_to_jwk(&vk, &new_kid); + + Ok(RotationPlan { + new_key_pair, + new_kid, + new_jwk, + old_kid: old_kid.to_string(), + }) +} + +/// Apply a rotation plan to a discovery document: add the new key while +/// keeping the old key active. Updates the `updated_at` timestamp. +pub fn apply_rotation(doc: &mut DiscoveryDocument, plan: &RotationPlan) -> Result<(), Error> { + doc.public_keys.push(plan.new_jwk.clone()); + doc.updated_at = Utc::now().to_rfc3339(); + Ok(()) +} + +/// Complete a rotation: remove the old key from the discovery document and +/// record it in the revocation document. +/// +/// Call this after the overlap window has passed so that relying parties have +/// had time to pick up the new key. +pub fn complete_rotation( + doc: &mut DiscoveryDocument, + revocation_doc: &mut RevocationDocument, + old_kid: &str, + reason: RevocationReason, +) -> Result<(), Error> { + doc.public_keys.retain(|k| k.kid != old_kid); + doc.updated_at = Utc::now().to_rfc3339(); + add_revoked_key(revocation_doc, old_kid, reason); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::jwk::pem_to_jwk; + use crate::revocation::build_revocation_document; + use crate::types::discovery::{DiscoveryDocument, EntityType}; + + fn make_discovery_doc(keys: Vec) -> DiscoveryDocument { + DiscoveryDocument { + agentpin_version: "0.1".to_string(), + entity: "example.com".to_string(), + entity_type: EntityType::Maker, + public_keys: keys, + agents: vec![], + revocation_endpoint: None, + policy_url: None, + schemapin_endpoint: None, + max_delegation_depth: 2, + updated_at: "2026-01-01T00:00:00Z".to_string(), + } + } + + #[test] + fn test_prepare_rotation() { + let plan = prepare_rotation("old-kid-placeholder").unwrap(); + // kid is a SHA-256 hex digest: 64 hex chars + assert_eq!(plan.new_kid.len(), 64); + assert!(plan.new_kid.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(plan.old_kid, "old-kid-placeholder"); + assert_eq!(plan.new_jwk.kid, plan.new_kid); + assert_eq!(plan.new_jwk.kty, "EC"); + assert_eq!(plan.new_jwk.crv, "P-256"); + } + + #[test] + fn test_apply_rotation() { + let old_kp = generate_key_pair().unwrap(); + let old_kid = generate_key_id(&old_kp.public_key_pem).unwrap(); + let old_jwk = pem_to_jwk(&old_kp.public_key_pem, &old_kid).unwrap(); + + let mut doc = make_discovery_doc(vec![old_jwk]); + assert_eq!(doc.public_keys.len(), 1); + + let plan = prepare_rotation(&old_kid).unwrap(); + apply_rotation(&mut doc, &plan).unwrap(); + + assert_eq!(doc.public_keys.len(), 2); + assert!(doc.public_keys.iter().any(|k| k.kid == plan.new_kid)); + assert!(doc.public_keys.iter().any(|k| k.kid == old_kid)); + assert_ne!(doc.updated_at, "2026-01-01T00:00:00Z"); + } + + #[test] + fn test_complete_rotation() { + let old_kp = generate_key_pair().unwrap(); + let old_kid = generate_key_id(&old_kp.public_key_pem).unwrap(); + let old_jwk = pem_to_jwk(&old_kp.public_key_pem, &old_kid).unwrap(); + + let mut doc = make_discovery_doc(vec![old_jwk]); + let plan = prepare_rotation(&old_kid).unwrap(); + apply_rotation(&mut doc, &plan).unwrap(); + assert_eq!(doc.public_keys.len(), 2); + + let mut revocation_doc = build_revocation_document("example.com"); + complete_rotation( + &mut doc, + &mut revocation_doc, + &old_kid, + RevocationReason::Superseded, + ) + .unwrap(); + + // Old key removed from discovery + assert_eq!(doc.public_keys.len(), 1); + assert_eq!(doc.public_keys[0].kid, plan.new_kid); + + // Old key added to revocation + assert_eq!(revocation_doc.revoked_keys.len(), 1); + assert_eq!(revocation_doc.revoked_keys[0].kid, old_kid); + assert_eq!( + revocation_doc.revoked_keys[0].reason, + RevocationReason::Superseded + ); + } +} diff --git a/crates/agentpin/src/transport/grpc.rs b/crates/agentpin/src/transport/grpc.rs new file mode 100644 index 0000000..b8c44f9 --- /dev/null +++ b/crates/agentpin/src/transport/grpc.rs @@ -0,0 +1,59 @@ +//! gRPC metadata transport binding. +//! +//! Extracts and formats AgentPin credentials in gRPC metadata +//! via the `agentpin-credential` key. + +use crate::error::Error; + +/// The gRPC metadata key for AgentPin credentials. +pub const METADATA_KEY: &str = "agentpin-credential"; + +/// Extract the JWT from a gRPC metadata value. +/// +/// Validates that the value is non-empty and returns it as the JWT string. +pub fn extract_credential(metadata_value: &str) -> Result { + if metadata_value.is_empty() { + return Err(Error::Transport( + "Empty gRPC metadata value for agentpin-credential".into(), + )); + } + + Ok(metadata_value.to_string()) +} + +/// Format a JWT for use as a gRPC metadata value. +/// +/// Returns the JWT string directly. The caller should attach it +/// to the `agentpin-credential` metadata key. +pub fn format_metadata_value(jwt: &str) -> String { + jwt.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_valid_metadata() { + let jwt = extract_credential("eyJ.payload.sig").unwrap(); + assert_eq!(jwt, "eyJ.payload.sig"); + } + + #[test] + fn test_extract_empty_value() { + assert!(extract_credential("").is_err()); + } + + #[test] + fn test_metadata_key() { + assert_eq!(METADATA_KEY, "agentpin-credential"); + } + + #[test] + fn test_format_roundtrip() { + let jwt = "eyJ.payload.sig"; + let value = format_metadata_value(jwt); + let extracted = extract_credential(&value).unwrap(); + assert_eq!(extracted, jwt); + } +} diff --git a/crates/agentpin/src/transport/http.rs b/crates/agentpin/src/transport/http.rs new file mode 100644 index 0000000..c751293 --- /dev/null +++ b/crates/agentpin/src/transport/http.rs @@ -0,0 +1,62 @@ +//! HTTP header transport binding. +//! +//! Extracts and formats AgentPin credentials in `Authorization: AgentPin ` headers. + +use crate::error::Error; + +const PREFIX: &str = "AgentPin "; + +/// Extract the JWT from an `Authorization` header value. +/// +/// Expects the format `AgentPin `. Returns the raw JWT string. +pub fn extract_credential(header_value: &str) -> Result { + let jwt = header_value.strip_prefix(PREFIX).ok_or_else(|| { + Error::Transport("Missing 'AgentPin ' prefix in Authorization header".into()) + })?; + + if jwt.is_empty() { + return Err(Error::Transport( + "Empty credential in Authorization header".into(), + )); + } + + Ok(jwt.to_string()) +} + +/// Format a JWT for use in an `Authorization` header. +/// +/// Returns `"AgentPin "`. +pub fn format_authorization_header(jwt: &str) -> String { + format!("AgentPin {}", jwt) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_valid_header() { + let jwt = extract_credential("AgentPin eyJhbGciOiJFUzI1NiJ9.payload.sig").unwrap(); + assert_eq!(jwt, "eyJhbGciOiJFUzI1NiJ9.payload.sig"); + } + + #[test] + fn test_extract_missing_prefix() { + let err = extract_credential("Bearer eyJhbGciOiJFUzI1NiJ9.payload.sig"); + assert!(err.is_err()); + } + + #[test] + fn test_extract_empty_credential() { + let err = extract_credential("AgentPin "); + assert!(err.is_err()); + } + + #[test] + fn test_format_roundtrip() { + let jwt = "eyJhbGciOiJFUzI1NiJ9.payload.sig"; + let header = format_authorization_header(jwt); + let extracted = extract_credential(&header).unwrap(); + assert_eq!(extracted, jwt); + } +} diff --git a/crates/agentpin/src/transport/mcp.rs b/crates/agentpin/src/transport/mcp.rs new file mode 100644 index 0000000..24b2b4b --- /dev/null +++ b/crates/agentpin/src/transport/mcp.rs @@ -0,0 +1,61 @@ +//! MCP (Model Context Protocol) transport binding. +//! +//! Extracts and formats AgentPin credentials in MCP message metadata +//! via the `agentpin_credential` field. + +use crate::error::Error; + +const FIELD_NAME: &str = "agentpin_credential"; + +/// Extract the JWT from an MCP metadata JSON value. +/// +/// Expects `meta["agentpin_credential"]` to be a string containing the JWT. +pub fn extract_credential(meta: &serde_json::Value) -> Result { + let field = meta.get(FIELD_NAME).ok_or_else(|| { + Error::Transport(format!("Missing '{}' field in MCP metadata", FIELD_NAME)) + })?; + + field + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| Error::Transport(format!("'{}' field is not a string", FIELD_NAME))) +} + +/// Format a JWT as an MCP metadata JSON value. +/// +/// Returns `{"agentpin_credential": ""}`. +pub fn format_meta_field(jwt: &str) -> serde_json::Value { + serde_json::json!({ FIELD_NAME: jwt }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_valid_meta() { + let meta = serde_json::json!({ "agentpin_credential": "eyJ.payload.sig" }); + let jwt = extract_credential(&meta).unwrap(); + assert_eq!(jwt, "eyJ.payload.sig"); + } + + #[test] + fn test_extract_missing_field() { + let meta = serde_json::json!({ "other_field": "value" }); + assert!(extract_credential(&meta).is_err()); + } + + #[test] + fn test_extract_wrong_type() { + let meta = serde_json::json!({ "agentpin_credential": 42 }); + assert!(extract_credential(&meta).is_err()); + } + + #[test] + fn test_format_roundtrip() { + let jwt = "eyJ.payload.sig"; + let meta = format_meta_field(jwt); + let extracted = extract_credential(&meta).unwrap(); + assert_eq!(extracted, jwt); + } +} diff --git a/crates/agentpin/src/transport/mod.rs b/crates/agentpin/src/transport/mod.rs new file mode 100644 index 0000000..c57104b --- /dev/null +++ b/crates/agentpin/src/transport/mod.rs @@ -0,0 +1,9 @@ +//! Transport binding modules (spec Section 13). +//! +//! Framework-agnostic helpers for extracting and formatting AgentPin +//! credentials across common transport protocols. + +pub mod grpc; +pub mod http; +pub mod mcp; +pub mod websocket; diff --git a/crates/agentpin/src/transport/websocket.rs b/crates/agentpin/src/transport/websocket.rs new file mode 100644 index 0000000..96b8b90 --- /dev/null +++ b/crates/agentpin/src/transport/websocket.rs @@ -0,0 +1,77 @@ +//! WebSocket transport binding. +//! +//! Extracts and formats AgentPin credentials in JSON auth messages +//! of the form `{"type":"agentpin-auth","credential":""}`. + +use crate::error::Error; + +const AUTH_TYPE: &str = "agentpin-auth"; + +/// Extract the JWT from a WebSocket JSON auth message. +/// +/// Expects `{"type":"agentpin-auth","credential":""}`. +pub fn extract_credential(message: &str) -> Result { + let parsed: serde_json::Value = serde_json::from_str(message) + .map_err(|e| Error::Transport(format!("Invalid JSON: {}", e)))?; + + let msg_type = parsed + .get("type") + .and_then(|v| v.as_str()) + .ok_or_else(|| Error::Transport("Missing or non-string 'type' field".into()))?; + + if msg_type != AUTH_TYPE { + return Err(Error::Transport(format!( + "Expected type '{}', got '{}'", + AUTH_TYPE, msg_type + ))); + } + + parsed + .get("credential") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| Error::Transport("Missing or non-string 'credential' field".into())) +} + +/// Format a JWT as a WebSocket auth message JSON string. +/// +/// Returns `{"type":"agentpin-auth","credential":""}`. +pub fn format_auth_message(jwt: &str) -> String { + serde_json::json!({ + "type": AUTH_TYPE, + "credential": jwt, + }) + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_valid_message() { + let msg = r#"{"type":"agentpin-auth","credential":"eyJ.payload.sig"}"#; + let jwt = extract_credential(msg).unwrap(); + assert_eq!(jwt, "eyJ.payload.sig"); + } + + #[test] + fn test_extract_wrong_type() { + let msg = r#"{"type":"other-auth","credential":"eyJ.payload.sig"}"#; + assert!(extract_credential(msg).is_err()); + } + + #[test] + fn test_extract_missing_credential() { + let msg = r#"{"type":"agentpin-auth"}"#; + assert!(extract_credential(msg).is_err()); + } + + #[test] + fn test_format_roundtrip() { + let jwt = "eyJ.payload.sig"; + let msg = format_auth_message(jwt); + let extracted = extract_credential(&msg).unwrap(); + assert_eq!(extracted, jwt); + } +} diff --git a/crates/agentpin/src/types/capability.rs b/crates/agentpin/src/types/capability.rs index f4b5394..8dea371 100644 --- a/crates/agentpin/src/types/capability.rs +++ b/crates/agentpin/src/types/capability.rs @@ -78,6 +78,55 @@ impl From for Capability { } } +/// Core actions from the AgentPin capability taxonomy. +pub const CORE_ACTIONS: &[&str] = &["read", "write", "execute", "admin", "delegate"]; + +/// Check whether a string looks like a reverse-domain prefix (e.g., `com.example.scan`). +/// Requires at least two dot-separated segments, each non-empty. +fn is_reverse_domain(action: &str) -> bool { + let parts: Vec<&str> = action.split('.').collect(); + parts.len() >= 2 && parts.iter().all(|p| !p.is_empty()) +} + +/// Validate a capability string against the AgentPin taxonomy. +/// +/// Rules: +/// - Must be in `action:resource` format +/// - If action is a core action, no additional validation +/// - If action is not a core action (custom), it MUST use reverse-domain prefix (e.g., `com.example.scan:target`) +/// - `admin:*` wildcard is rejected (admin capabilities must be explicitly scoped) +/// +/// Returns Ok(()) if valid, Err with description if invalid. +pub fn validate_capability(cap: &Capability) -> Result<(), String> { + let (action, resource) = match Capability::parse(&cap.0) { + Some(parts) => parts, + None => return Err("capability must be in 'action:resource' format".to_string()), + }; + + // Reject admin:* wildcard — admin must be explicitly scoped + if action == "admin" && resource == "*" { + return Err( + "admin:* wildcard is not allowed; admin capabilities must be explicitly scoped" + .to_string(), + ); + } + + // Core actions are always valid (with any resource) + if CORE_ACTIONS.contains(&action) { + return Ok(()); + } + + // Custom actions must use reverse-domain prefix + if !is_reverse_domain(action) { + return Err(format!( + "custom action '{}' must use reverse-domain prefix (e.g., com.example.{})", + action, action + )); + } + + Ok(()) +} + /// Check that all requested capabilities are covered by declared capabilities. pub fn capabilities_subset(declared: &[Capability], requested: &[Capability]) -> bool { requested @@ -151,6 +200,48 @@ mod tests { assert_eq!(h1.len(), 64); } + #[test] + fn test_validate_core_action() { + let cap = Capability::from("read:codebase"); + assert!(validate_capability(&cap).is_ok()); + } + + #[test] + fn test_validate_wildcard() { + let cap = Capability::from("read:*"); + assert!(validate_capability(&cap).is_ok()); + } + + #[test] + fn test_validate_admin_wildcard_rejected() { + let cap = Capability::from("admin:*"); + assert!(validate_capability(&cap).is_err()); + } + + #[test] + fn test_validate_admin_scoped_ok() { + let cap = Capability::from("admin:users"); + assert!(validate_capability(&cap).is_ok()); + } + + #[test] + fn test_validate_custom_action_with_domain() { + let cap = Capability::from("com.example.scan:target"); + assert!(validate_capability(&cap).is_ok()); + } + + #[test] + fn test_validate_custom_action_without_domain() { + let cap = Capability::from("scan:target"); + assert!(validate_capability(&cap).is_err()); + } + + #[test] + fn test_validate_missing_colon() { + let cap = Capability::from("readcodebase"); + assert!(validate_capability(&cap).is_err()); + } + #[test] fn test_capabilities_hash_order_independent() { let caps1 = vec![ diff --git a/crates/agentpin/src/verification.rs b/crates/agentpin/src/verification.rs index ddd92d3..4f2584b 100644 --- a/crates/agentpin/src/verification.rs +++ b/crates/agentpin/src/verification.rs @@ -21,6 +21,8 @@ pub struct VerifierConfig { pub clock_skew_secs: i64, /// Maximum credential lifetime in seconds (default: 86400) pub max_ttl_secs: i64, + /// When true, capabilities are validated against the taxonomy (default: false) + pub strict_capabilities: bool, } impl Default for VerifierConfig { @@ -28,6 +30,7 @@ impl Default for VerifierConfig { Self { clock_skew_secs: 60, max_ttl_secs: 86400, + strict_capabilities: false, } } } diff --git a/crates/agentpin/tests/integration.rs b/crates/agentpin/tests/integration.rs new file mode 100644 index 0000000..9bf72df --- /dev/null +++ b/crates/agentpin/tests/integration.rs @@ -0,0 +1,304 @@ +//! End-to-end integration tests for AgentPin. + +use agentpin::credential::issue_credential; +use agentpin::crypto::{generate_key_id, generate_key_pair, load_signing_key, load_verifying_key}; +use agentpin::discovery::{find_agent_by_id, find_key_by_kid, validate_discovery_document}; +use agentpin::jwk::pem_to_jwk; +use agentpin::jwt::{decode_jwt_unverified, verify_jwt}; +use agentpin::mutual::{create_challenge, create_response, verify_response_with_nonce_store}; +use agentpin::nonce::InMemoryNonceStore; +use agentpin::pinning::{check_pinning, KeyPinStore, PinningResult}; +use agentpin::resolver::TrustBundleResolver; +use agentpin::revocation::{add_revoked_key, build_revocation_document, check_revocation}; +use agentpin::rotation::{apply_rotation, complete_rotation, prepare_rotation}; +use agentpin::transport; +use agentpin::types::bundle::TrustBundle; +use agentpin::types::capability::Capability; +use agentpin::types::discovery::*; +use agentpin::types::revocation::RevocationReason; +use agentpin::verification::{ + verify_credential_offline, verify_credential_with_resolver, VerifierConfig, +}; + +fn make_test_setup() -> (String, String, String, String, DiscoveryDocument) { + let kp = generate_key_pair().unwrap(); + let kid = generate_key_id(&kp.public_key_pem).unwrap(); + let jwk = pem_to_jwk(&kp.public_key_pem, &kid).unwrap(); + let doc = DiscoveryDocument { + agentpin_version: "0.1".to_string(), + entity: "example.com".to_string(), + entity_type: EntityType::Maker, + public_keys: vec![jwk], + agents: vec![AgentDeclaration { + agent_id: "urn:agentpin:example.com:test-agent".to_string(), + agent_type: None, + name: "Test Agent".to_string(), + description: None, + version: None, + capabilities: vec![Capability::from("read:*"), Capability::from("write:report")], + constraints: None, + maker_attestation: None, + credential_ttl_max: Some(3600), + status: AgentStatus::Active, + directory_listing: None, + }], + revocation_endpoint: None, + policy_url: None, + schemapin_endpoint: None, + max_delegation_depth: 2, + updated_at: "2026-01-01T00:00:00Z".to_string(), + }; + ( + kp.private_key_pem, + kp.public_key_pem, + kid, + "urn:agentpin:example.com:test-agent".to_string(), + doc, + ) +} + +#[test] +fn test_maker_deployer_flow() { + let (private_pem, public_pem, kid, agent_id, doc) = make_test_setup(); + + // Validate the discovery document + validate_discovery_document(&doc, "example.com").unwrap(); + assert!(find_key_by_kid(&doc, &kid).is_some()); + assert!(find_agent_by_id(&doc, &agent_id).is_some()); + + // Issue a credential + let sk = load_signing_key(&private_pem).unwrap(); + let jwt_str = issue_credential( + &sk, + &kid, + "example.com", + &agent_id, + Some("verifier.com"), + vec![ + Capability::from("read:data"), + Capability::from("write:report"), + ], + None, + None, + 3600, + ) + .unwrap(); + + // Decode unverified to inspect + let (header, payload, _sig) = decode_jwt_unverified(&jwt_str).unwrap(); + assert_eq!(header.alg, "ES256"); + assert_eq!(header.typ, "agentpin-credential+jwt"); + assert_eq!(header.kid, kid); + assert_eq!(payload.iss, "example.com"); + assert_eq!(payload.sub, agent_id); + + // Verify signature + let vk = load_verifying_key(&public_pem).unwrap(); + let (verified_header, verified_payload) = verify_jwt(&jwt_str, &vk).unwrap(); + assert_eq!(verified_header.kid, kid); + assert_eq!(verified_payload.iss, "example.com"); + + // Full offline verification via TrustBundleResolver + let revocation = build_revocation_document("example.com"); + let bundle = TrustBundle { + agentpin_bundle_version: "0.1".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + documents: vec![doc.clone()], + revocations: vec![revocation], + }; + let resolver = TrustBundleResolver::new(&bundle); + let mut pin_store = KeyPinStore::new(); + let config = VerifierConfig::default(); + + let result = verify_credential_with_resolver( + &jwt_str, + &resolver, + &mut pin_store, + Some("verifier.com"), + &config, + ); + assert!(result.valid, "Expected valid, got: {:?}", result); + assert_eq!(result.agent_id, Some(agent_id)); + assert_eq!(result.issuer, Some("example.com".to_string())); +} + +#[test] +fn test_revocation_flow() { + let (private_pem, _public_pem, kid, agent_id, doc) = make_test_setup(); + + let sk = load_signing_key(&private_pem).unwrap(); + let jwt_str = issue_credential( + &sk, + &kid, + "example.com", + &agent_id, + None, + vec![Capability::from("read:data")], + None, + None, + 3600, + ) + .unwrap(); + + // Parse JWT to get the jti + let (_header, payload, _sig) = decode_jwt_unverified(&jwt_str).unwrap(); + + // Clean revocation: should pass + let mut rev_doc = build_revocation_document("example.com"); + check_revocation(&rev_doc, &payload.jti, &agent_id, &kid).unwrap(); + + // Add revoked key + add_revoked_key(&mut rev_doc, &kid, RevocationReason::KeyCompromise); + + // Now check_revocation should fail + let result = check_revocation(&rev_doc, &payload.jti, &agent_id, &kid); + assert!(result.is_err(), "Expected revocation check to fail"); + + // Full offline verification should also fail + let mut pin_store = KeyPinStore::new(); + let config = VerifierConfig::default(); + let vresult = verify_credential_offline( + &jwt_str, + &doc, + Some(&rev_doc), + &mut pin_store, + None, + &config, + ); + assert!(!vresult.valid); +} + +#[test] +fn test_mutual_verification_with_nonce_store() { + let kp = generate_key_pair().unwrap(); + let sk = load_signing_key(&kp.private_key_pem).unwrap(); + let vk = load_verifying_key(&kp.public_key_pem).unwrap(); + + let store = InMemoryNonceStore::new(); + let challenge = create_challenge(None); + let response = create_response(&challenge, &sk, "test-key"); + + // First verification should succeed + let valid = verify_response_with_nonce_store(&response, &challenge, &vk, Some(&store)).unwrap(); + assert!(valid); + + // Second verification with the same nonce should fail (replay) + let result = verify_response_with_nonce_store(&response, &challenge, &vk, Some(&store)); + assert!(result.is_err(), "Replayed nonce should be rejected"); +} + +#[test] +fn test_transport_roundtrip() { + let kp = generate_key_pair().unwrap(); + let sk = load_signing_key(&kp.private_key_pem).unwrap(); + let kid = generate_key_id(&kp.public_key_pem).unwrap(); + + let jwt_str = issue_credential( + &sk, + &kid, + "example.com", + "urn:agentpin:example.com:test-agent", + None, + vec![Capability::from("read:data")], + None, + None, + 3600, + ) + .unwrap(); + + // HTTP roundtrip + let http_header = transport::http::format_authorization_header(&jwt_str); + let http_extracted = transport::http::extract_credential(&http_header).unwrap(); + assert_eq!(http_extracted, jwt_str); + + // MCP roundtrip + let mcp_meta = transport::mcp::format_meta_field(&jwt_str); + let mcp_extracted = transport::mcp::extract_credential(&mcp_meta).unwrap(); + assert_eq!(mcp_extracted, jwt_str); + + // WebSocket roundtrip + let ws_msg = transport::websocket::format_auth_message(&jwt_str); + let ws_extracted = transport::websocket::extract_credential(&ws_msg).unwrap(); + assert_eq!(ws_extracted, jwt_str); + + // gRPC roundtrip + let grpc_val = transport::grpc::format_metadata_value(&jwt_str); + let grpc_extracted = transport::grpc::extract_credential(&grpc_val).unwrap(); + assert_eq!(grpc_extracted, jwt_str); +} + +#[test] +fn test_key_rotation_lifecycle() { + let kp = generate_key_pair().unwrap(); + let old_kid = generate_key_id(&kp.public_key_pem).unwrap(); + let old_jwk = pem_to_jwk(&kp.public_key_pem, &old_kid).unwrap(); + + let mut doc = DiscoveryDocument { + agentpin_version: "0.1".to_string(), + entity: "example.com".to_string(), + entity_type: EntityType::Maker, + public_keys: vec![old_jwk], + agents: vec![], + revocation_endpoint: None, + policy_url: None, + schemapin_endpoint: None, + max_delegation_depth: 2, + updated_at: "2026-01-01T00:00:00Z".to_string(), + }; + + assert_eq!(doc.public_keys.len(), 1); + + // Prepare rotation + let plan = prepare_rotation(&old_kid).unwrap(); + assert_ne!(plan.new_kid, old_kid); + + // Apply rotation: both keys should be present + apply_rotation(&mut doc, &plan).unwrap(); + assert_eq!(doc.public_keys.len(), 2); + assert!(doc.public_keys.iter().any(|k| k.kid == old_kid)); + assert!(doc.public_keys.iter().any(|k| k.kid == plan.new_kid)); + + // Complete rotation: old key removed, added to revocation + let mut rev_doc = build_revocation_document("example.com"); + complete_rotation( + &mut doc, + &mut rev_doc, + &old_kid, + RevocationReason::Superseded, + ) + .unwrap(); + + assert_eq!(doc.public_keys.len(), 1); + assert_eq!(doc.public_keys[0].kid, plan.new_kid); + assert_eq!(rev_doc.revoked_keys.len(), 1); + assert_eq!(rev_doc.revoked_keys[0].kid, old_kid); + assert_eq!(rev_doc.revoked_keys[0].reason, RevocationReason::Superseded); +} + +#[test] +fn test_pinning_flow() { + let kp1 = generate_key_pair().unwrap(); + let kid1 = generate_key_id(&kp1.public_key_pem).unwrap(); + let jwk1 = pem_to_jwk(&kp1.public_key_pem, &kid1).unwrap(); + + let mut store = KeyPinStore::new(); + + // First verification pins the key + let result1 = check_pinning(&mut store, "example.com", &jwk1).unwrap(); + assert_eq!(result1, PinningResult::FirstUse); + + // Same key succeeds + let result2 = check_pinning(&mut store, "example.com", &jwk1).unwrap(); + assert_eq!(result2, PinningResult::Matched); + + // Different key triggers error + let kp2 = generate_key_pair().unwrap(); + let kid2 = generate_key_id(&kp2.public_key_pem).unwrap(); + let jwk2 = pem_to_jwk(&kp2.public_key_pem, &kid2).unwrap(); + + let result3 = check_pinning(&mut store, "example.com", &jwk2); + assert!( + result3.is_err(), + "Different key should trigger pinning error" + ); +} diff --git a/examples/axum_middleware.rs b/examples/axum_middleware.rs new file mode 100644 index 0000000..a1f6e44 --- /dev/null +++ b/examples/axum_middleware.rs @@ -0,0 +1,67 @@ +//! Reference: AgentPin credential extraction as an Axum extractor. +//! +//! This example shows how to integrate AgentPin verification into an Axum HTTP server. +//! It is not a published crate — copy and adapt for your own server. +//! +//! Usage: +//! cargo run --example axum_middleware --features fetch +//! +//! Dependencies needed in your Cargo.toml: +//! axum = "0.7" +//! tokio = { version = "1", features = ["full"] } +//! agentpin = { version = "0.2", features = ["fetch"] } + +use axum::{ + async_trait, + extract::FromRequestParts, + http::{request::Parts, StatusCode}, + response::IntoResponse, + routing::get, + Router, +}; + +/// Extractor that pulls an AgentPin credential from the Authorization header. +pub struct AgentPinCredential(pub String); + +#[async_trait] +impl FromRequestParts for AgentPinCredential { + type Rejection = (StatusCode, String); + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let header = parts + .headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header".to_string()))?; + + let jwt = agentpin::transport::http::extract_credential(header) + .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?; + + // In production, you would verify the JWT here: + // let resolver = agentpin::resolver::ChainResolver::new(vec![...]); + // let result = agentpin::verification::verify_credential(&jwt, &resolver, &config).await?; + + Ok(AgentPinCredential(jwt)) + } +} + +async fn protected_handler(AgentPinCredential(jwt): AgentPinCredential) -> impl IntoResponse { + format!("Authenticated with credential: {}...", &jwt[..20.min(jwt.len())]) +} + +async fn health() -> &'static str { + "ok" +} + +#[tokio::main] +async fn main() { + let app = Router::new() + .route("/protected", get(protected_handler)) + .route("/health", get(health)); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") + .await + .unwrap(); + println!("Listening on http://127.0.0.1:3000"); + axum::serve(listener, app).await.unwrap(); +} diff --git a/examples/express_middleware.js b/examples/express_middleware.js new file mode 100644 index 0000000..6d156db --- /dev/null +++ b/examples/express_middleware.js @@ -0,0 +1,52 @@ +/** + * Reference: AgentPin credential extraction as Express middleware. + * + * Usage: + * npm install express agentpin + * node examples/express_middleware.js + * + * This is example code — copy and adapt for your own server. + */ + +import express from 'express'; +import { httpExtractCredential } from 'agentpin'; + +/** + * Express middleware that extracts an AgentPin credential from the + * Authorization header and attaches it to req.agentpinCredential. + */ +function agentpinAuth(req, res, next) { + const auth = req.headers.authorization; + if (!auth) { + return res.status(401).json({ error: 'Missing Authorization header' }); + } + + try { + req.agentpinCredential = httpExtractCredential(auth); + } catch (err) { + return res.status(401).json({ error: err.message }); + } + + // In production, verify the credential here: + // import { verifyCredentialOffline } from 'agentpin'; + // const result = verifyCredentialOffline(jwt, discoveryDoc, ...); + // if (!result.valid) return res.status(403).json({ error: result.error }); + + next(); +} + +const app = express(); + +app.get('/protected', agentpinAuth, (req, res) => { + const jwt = req.agentpinCredential; + res.json({ message: `Authenticated with credential: ${jwt.slice(0, 20)}...` }); +}); + +app.get('/health', (_req, res) => { + res.json({ status: 'ok' }); +}); + +const port = process.env.PORT || 3000; +app.listen(port, () => { + console.log(`Listening on http://127.0.0.1:${port}`); +}); diff --git a/examples/fastapi_middleware.py b/examples/fastapi_middleware.py new file mode 100644 index 0000000..279e9c7 --- /dev/null +++ b/examples/fastapi_middleware.py @@ -0,0 +1,43 @@ +"""Reference: AgentPin credential extraction as a FastAPI dependency. + +Usage: + pip install fastapi uvicorn agentpin + uvicorn examples.fastapi_middleware:app --reload + +This is example code — copy and adapt for your own server. +""" + +from fastapi import Depends, FastAPI, HTTPException, Request + +from agentpin.transport import http_extract_credential + +app = FastAPI(title="AgentPin Example Server") + + +async def get_agentpin_credential(request: Request) -> str: + """FastAPI dependency that extracts and returns the AgentPin JWT.""" + auth = request.headers.get("authorization") + if not auth: + raise HTTPException(status_code=401, detail="Missing Authorization header") + try: + jwt = http_extract_credential(auth) + except Exception as e: + raise HTTPException(status_code=401, detail=str(e)) + + # In production, verify the credential here: + # from agentpin import verify_credential_offline + # result = verify_credential_offline(jwt, discovery_doc, ...) + # if not result["valid"]: + # raise HTTPException(status_code=403, detail=result["error"]) + + return jwt + + +@app.get("/protected") +async def protected_route(credential: str = Depends(get_agentpin_credential)): + return {"message": f"Authenticated with credential: {credential[:20]}..."} + + +@app.get("/health") +async def health(): + return {"status": "ok"} diff --git a/javascript/src/capability.js b/javascript/src/capability.js index 8440cde..0a81437 100644 --- a/javascript/src/capability.js +++ b/javascript/src/capability.js @@ -107,3 +107,37 @@ export function capabilitiesHash(capabilities) { const json = JSON.stringify(sorted); return sha256Hex(json); } + +/** Core actions defined by the AgentPin taxonomy. */ +export const CORE_ACTIONS = ['read', 'write', 'execute', 'admin', 'delegate']; + +/** + * Check if a string uses reverse-domain notation (contains a dot). + * @param {string} s + * @returns {boolean} + */ +function isReverseDomain(s) { + return s.includes('.'); +} + +/** + * Validate a capability against the AgentPin taxonomy. + * @param {Capability} cap + * @throws {Error} if invalid + */ +export function validateCapability(cap) { + const parsed = Capability.parse(cap.value); + if (!parsed) { + throw new Error(`Invalid capability format (missing ':'): ${cap.value}`); + } + const [action, resource] = parsed; + if (action === 'admin' && resource === '*') { + throw new Error('admin:* wildcard is not allowed; admin capabilities must be explicitly scoped'); + } + if (CORE_ACTIONS.includes(action)) { + return; + } + if (!isReverseDomain(action)) { + throw new Error(`Custom action '${action}' must use reverse-domain prefix (e.g., com.example.${action})`); + } +} diff --git a/javascript/src/index.js b/javascript/src/index.js index 74fd78a..c6326da 100644 --- a/javascript/src/index.js +++ b/javascript/src/index.js @@ -41,6 +41,8 @@ export { Capability, capabilitiesSubset, capabilitiesHash, + validateCapability, + CORE_ACTIONS, } from './capability.js'; export { @@ -88,6 +90,7 @@ export { createChallenge, createResponse, verifyResponse, + verifyResponseWithNonceStore, } from './mutual.js'; export { @@ -103,4 +106,26 @@ export { verifyCredentialWithBundle, } from './bundle.js'; +export { + httpExtractCredential, + httpFormatAuthorizationHeader, + mcpExtractCredential, + mcpFormatMetaField, + wsExtractCredential, + wsFormatAuthMessage, + GRPC_METADATA_KEY, + grpcExtractCredential, + grpcFormatMetadataValue, +} from './transport.js'; + +export { + prepareRotation, + applyRotation, + completeRotation, +} from './rotation.js'; + +export { + InMemoryNonceStore, +} from './nonce.js'; + export const version = '0.2.0'; diff --git a/javascript/src/mutual.js b/javascript/src/mutual.js index 32cc37c..70dd738 100644 --- a/javascript/src/mutual.js +++ b/javascript/src/mutual.js @@ -5,7 +5,7 @@ import { randomBytes } from 'crypto'; import { signData, verifySignature } from './crypto.js'; -const NONCE_EXPIRY_SECS = 60; +export const NONCE_EXPIRY_SECS = 60; /** * Base64url encode bytes (no padding). @@ -85,3 +85,21 @@ export function verifyResponse(response, challenge, publicKeyPem) { // Verify signature over the nonce return verifySignature(publicKeyPem, Buffer.from(challenge.nonce), response.signature); } + +/** + * Verify a challenge response with optional nonce deduplication. + * @param {object} response + * @param {object} challenge + * @param {string} publicKeyPem + * @param {import('./nonce.js').InMemoryNonceStore|null} nonceStore - Optional nonce store for replay prevention + * @returns {boolean} + * @throws {Error} if nonce has been replayed or expired + */ +export function verifyResponseWithNonceStore(response, challenge, publicKeyPem, nonceStore = null) { + if (nonceStore) { + if (!nonceStore.checkAndRecord(response.nonce, NONCE_EXPIRY_SECS * 1000)) { + throw new Error(`Nonce '${response.nonce}' has already been used (replay attack)`); + } + } + return verifyResponse(response, challenge, publicKeyPem); +} diff --git a/javascript/src/nonce.js b/javascript/src/nonce.js new file mode 100644 index 0000000..c339cdf --- /dev/null +++ b/javascript/src/nonce.js @@ -0,0 +1,30 @@ +/** + * Nonce deduplication for replay attack prevention. + */ + +/** + * In-memory nonce store that tracks seen nonces with TTL-based expiry. + */ +export class InMemoryNonceStore { + constructor() { + /** @type {Map} nonce -> expiry timestamp (ms) */ + this._entries = new Map(); + } + + /** + * Check if nonce is fresh. Returns true if fresh, false if replay. + * @param {string} nonce + * @param {number} ttlMs - TTL in milliseconds + * @returns {boolean} + */ + checkAndRecord(nonce, ttlMs) { + const now = Date.now(); + // Lazy cleanup + for (const [key, expiry] of this._entries) { + if (expiry <= now) this._entries.delete(key); + } + if (this._entries.has(nonce)) return false; + this._entries.set(nonce, now + ttlMs); + return true; + } +} diff --git a/javascript/src/rotation.js b/javascript/src/rotation.js new file mode 100644 index 0000000..6941a4a --- /dev/null +++ b/javascript/src/rotation.js @@ -0,0 +1,42 @@ +/** + * Key rotation helpers for AgentPin. + */ + +import { generateKeyPair, generateKeyId } from './crypto.js'; +import { pemToJwk } from './jwk.js'; +import { addRevokedKey } from './revocation.js'; + +/** + * Prepare a key rotation by generating a new keypair. + * @param {string} oldKid - The key ID of the key being rotated out + * @returns {{ newKeyPair: { privateKeyPem: string, publicKeyPem: string }, newKid: string, newJwk: object, oldKid: string }} + */ +export function prepareRotation(oldKid) { + const newKeyPair = generateKeyPair(); + const newKid = generateKeyId(newKeyPair.publicKeyPem); + const newJwk = pemToJwk(newKeyPair.publicKeyPem, newKid); + return { newKeyPair, newKid, newJwk, oldKid }; +} + +/** + * Apply a rotation plan by adding the new key to a discovery document. + * @param {object} doc - Discovery document + * @param {{ newJwk: object }} plan - Rotation plan from prepareRotation + */ +export function applyRotation(doc, plan) { + doc.public_keys.push(plan.newJwk); + doc.updated_at = new Date().toISOString(); +} + +/** + * Complete a rotation by removing the old key and adding it to the revocation document. + * @param {object} doc - Discovery document + * @param {object} revocationDoc - Revocation document + * @param {string} oldKid - The key ID being retired + * @param {string} reason - Revocation reason + */ +export function completeRotation(doc, revocationDoc, oldKid, reason) { + doc.public_keys = doc.public_keys.filter(k => k.kid !== oldKid); + doc.updated_at = new Date().toISOString(); + addRevokedKey(revocationDoc, oldKid, reason); +} diff --git a/javascript/src/transport.js b/javascript/src/transport.js new file mode 100644 index 0000000..2a56cba --- /dev/null +++ b/javascript/src/transport.js @@ -0,0 +1,124 @@ +/** + * Transport binding helpers for AgentPin (spec Section 13). + */ + +import { AgentPinError, ErrorCode } from './types.js'; + +// --- HTTP --- +const HTTP_PREFIX = 'AgentPin '; + +/** + * Extract a credential JWT from an HTTP Authorization header value. + * @param {string} headerValue + * @returns {string} The JWT credential + * @throws {AgentPinError} if the header is malformed + */ +export function httpExtractCredential(headerValue) { + if (!headerValue.startsWith(HTTP_PREFIX)) { + throw new AgentPinError(ErrorCode.DISCOVERY_FETCH_FAILED, "Missing 'AgentPin ' prefix in Authorization header"); + } + const jwt = headerValue.slice(HTTP_PREFIX.length); + if (!jwt) { + throw new AgentPinError(ErrorCode.DISCOVERY_FETCH_FAILED, 'Empty credential in Authorization header'); + } + return jwt; +} + +/** + * Format a JWT into an HTTP Authorization header value. + * @param {string} jwt + * @returns {string} + */ +export function httpFormatAuthorizationHeader(jwt) { + return `AgentPin ${jwt}`; +} + +// --- MCP --- +const MCP_FIELD = 'agentpin_credential'; + +/** + * Extract a credential JWT from MCP metadata. + * @param {object} meta + * @returns {string} The JWT credential + * @throws {AgentPinError} if the field is missing or not a string + */ +export function mcpExtractCredential(meta) { + if (!(MCP_FIELD in meta)) { + throw new AgentPinError(ErrorCode.DISCOVERY_FETCH_FAILED, `Missing '${MCP_FIELD}' field in MCP metadata`); + } + const value = meta[MCP_FIELD]; + if (typeof value !== 'string') { + throw new AgentPinError(ErrorCode.DISCOVERY_FETCH_FAILED, `'${MCP_FIELD}' field is not a string`); + } + return value; +} + +/** + * Format a JWT into an MCP metadata field. + * @param {string} jwt + * @returns {object} + */ +export function mcpFormatMetaField(jwt) { + return { [MCP_FIELD]: jwt }; +} + +// --- WebSocket --- +const WS_AUTH_TYPE = 'agentpin-auth'; + +/** + * Extract a credential JWT from a WebSocket auth message (JSON string). + * @param {string} message - JSON-encoded message + * @returns {string} The JWT credential + * @throws {AgentPinError} if the message is malformed + */ +export function wsExtractCredential(message) { + let parsed; + try { + parsed = JSON.parse(message); + } catch (e) { + throw new AgentPinError(ErrorCode.DISCOVERY_FETCH_FAILED, `Invalid JSON: ${e.message}`); + } + if (parsed.type !== WS_AUTH_TYPE) { + throw new AgentPinError(ErrorCode.DISCOVERY_FETCH_FAILED, `Expected type '${WS_AUTH_TYPE}', got '${parsed.type}'`); + } + if (typeof parsed.credential !== 'string') { + throw new AgentPinError(ErrorCode.DISCOVERY_FETCH_FAILED, "Missing or non-string 'credential' field"); + } + return parsed.credential; +} + +/** + * Format a JWT into a WebSocket auth message (JSON string). + * @param {string} jwt + * @returns {string} JSON-encoded message + */ +export function wsFormatAuthMessage(jwt) { + return JSON.stringify({ type: WS_AUTH_TYPE, credential: jwt }); +} + +// --- gRPC --- + +/** The gRPC metadata key for AgentPin credentials. */ +export const GRPC_METADATA_KEY = 'agentpin-credential'; + +/** + * Extract a credential JWT from a gRPC metadata value. + * @param {string} metadataValue + * @returns {string} The JWT credential + * @throws {AgentPinError} if the value is empty + */ +export function grpcExtractCredential(metadataValue) { + if (!metadataValue) { + throw new AgentPinError(ErrorCode.DISCOVERY_FETCH_FAILED, 'Empty gRPC metadata value'); + } + return metadataValue; +} + +/** + * Format a JWT into a gRPC metadata value. + * @param {string} jwt + * @returns {string} + */ +export function grpcFormatMetadataValue(jwt) { + return jwt; +} diff --git a/javascript/tests/capability.test.js b/javascript/tests/capability.test.js index 7b8fc65..28e99ad 100644 --- a/javascript/tests/capability.test.js +++ b/javascript/tests/capability.test.js @@ -4,7 +4,7 @@ import { test, describe } from 'node:test'; import assert from 'node:assert'; -import { Capability, capabilitiesSubset, capabilitiesHash } from '../src/capability.js'; +import { Capability, capabilitiesSubset, capabilitiesHash, validateCapability, CORE_ACTIONS } from '../src/capability.js'; describe('Capability.parse', () => { test('parses action and resource', () => { @@ -64,3 +64,51 @@ describe('capabilitiesHash', () => { assert.strictEqual(capabilitiesHash(caps1), capabilitiesHash(caps2)); }); }); + +describe('CORE_ACTIONS', () => { + test('contains expected actions', () => { + assert.deepStrictEqual(CORE_ACTIONS, ['read', 'write', 'execute', 'admin', 'delegate']); + }); +}); + +describe('validateCapability', () => { + test('accepts core actions', () => { + assert.doesNotThrow(() => validateCapability(new Capability('read:codebase'))); + assert.doesNotThrow(() => validateCapability(new Capability('write:report'))); + assert.doesNotThrow(() => validateCapability(new Capability('execute:task'))); + assert.doesNotThrow(() => validateCapability(new Capability('delegate:sub'))); + }); + + test('accepts admin with scoped resource', () => { + assert.doesNotThrow(() => validateCapability(new Capability('admin:users'))); + }); + + test('rejects admin:* wildcard', () => { + assert.throws( + () => validateCapability(new Capability('admin:*')), + /admin:\* wildcard is not allowed/ + ); + }); + + test('accepts reverse-domain custom action', () => { + assert.doesNotThrow(() => validateCapability(new Capability('com.example.deploy:staging'))); + }); + + test('rejects custom action without reverse-domain', () => { + assert.throws( + () => validateCapability(new Capability('deploy:staging')), + /must use reverse-domain prefix/ + ); + }); + + test('rejects missing colon', () => { + assert.throws( + () => validateCapability(new Capability('readcodebase')), + /Invalid capability format/ + ); + }); + + test('accepts read:* wildcard', () => { + assert.doesNotThrow(() => validateCapability(new Capability('read:*'))); + }); +}); diff --git a/javascript/tests/integration.test.js b/javascript/tests/integration.test.js new file mode 100644 index 0000000..c0eae4a --- /dev/null +++ b/javascript/tests/integration.test.js @@ -0,0 +1,299 @@ +/** + * End-to-end integration tests for AgentPin. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + generateKeyPair, + generateKeyId, + pemToJwk, + issueCredential, + decodeJwtUnverified, + verifyJwt, + verifyCredentialOffline, + defaultVerifierConfig, + buildDiscoveryDocument, + validateDiscoveryDocument, + findKeyByKid, + findAgentById, + buildRevocationDocument, + addRevokedKey, + checkRevocation, + KeyPinStore, + PinningResult, + checkPinning, + createChallenge, + createResponse, + verifyResponseWithNonceStore, + InMemoryNonceStore, + httpExtractCredential, + httpFormatAuthorizationHeader, + mcpExtractCredential, + mcpFormatMetaField, + wsExtractCredential, + wsFormatAuthMessage, + grpcExtractCredential, + grpcFormatMetadataValue, + prepareRotation, + applyRotation, + completeRotation, + AgentPinError, +} from '../src/index.js'; + +function makeTestSetup() { + const { privateKeyPem, publicKeyPem } = generateKeyPair(); + const kid = generateKeyId(publicKeyPem); + const jwk = pemToJwk(publicKeyPem, kid); + const agentId = 'urn:agentpin:example.com:test-agent'; + const doc = buildDiscoveryDocument( + 'example.com', + 'maker', + [jwk], + [ + { + agent_id: agentId, + name: 'Test Agent', + capabilities: ['read:*', 'write:report'], + status: 'active', + credential_ttl_max: 3600, + }, + ], + 2, + '2026-01-01T00:00:00Z' + ); + return { privateKeyPem, publicKeyPem, kid, agentId, doc }; +} + +describe('Maker-Deployer Flow', () => { + it('should issue, decode, verify, and validate a credential end-to-end', () => { + const { privateKeyPem, publicKeyPem, kid, agentId, doc } = makeTestSetup(); + + // Issue a credential + const jwtStr = issueCredential( + privateKeyPem, + kid, + 'example.com', + agentId, + 'verifier.com', + ['read:data', 'write:report'], + null, + null, + 3600 + ); + assert.ok(jwtStr); + assert.equal(jwtStr.split('.').length, 3); + + // Decode unverified to inspect + const { header, payload } = decodeJwtUnverified(jwtStr); + assert.equal(header.alg, 'ES256'); + assert.equal(header.typ, 'agentpin-credential+jwt'); + assert.equal(header.kid, kid); + assert.equal(payload.iss, 'example.com'); + assert.equal(payload.sub, agentId); + + // Verify signature + const verified = verifyJwt(jwtStr, publicKeyPem); + assert.equal(verified.header.kid, kid); + assert.equal(verified.payload.iss, 'example.com'); + + // Full offline verification + const pinStore = new KeyPinStore(); + const config = defaultVerifierConfig(); + const result = verifyCredentialOffline( + jwtStr, + doc, + null, + pinStore, + 'verifier.com', + config + ); + assert.ok(result.valid, `Expected valid, got: ${result.error_message}`); + assert.equal(result.agent_id, agentId); + assert.equal(result.issuer, 'example.com'); + }); +}); + +describe('Revocation Flow', () => { + it('should detect a revoked key during verification', () => { + const { privateKeyPem, publicKeyPem, kid, agentId, doc } = makeTestSetup(); + + const jwtStr = issueCredential( + privateKeyPem, + kid, + 'example.com', + agentId, + null, + ['read:data'], + null, + null, + 3600 + ); + + const { header, payload } = decodeJwtUnverified(jwtStr); + + // Clean revocation: should pass + const revDoc = buildRevocationDocument('example.com'); + checkRevocation(revDoc, payload.jti, agentId, kid); // no error + + // Add revoked key + addRevokedKey(revDoc, kid, 'key_compromise'); + + // Now checkRevocation should fail + assert.throws( + () => checkRevocation(revDoc, payload.jti, agentId, kid), + AgentPinError + ); + + // Full offline verification should also fail + const pinStore = new KeyPinStore(); + const config = defaultVerifierConfig(); + const vresult = verifyCredentialOffline( + jwtStr, + doc, + revDoc, + pinStore, + null, + config + ); + assert.equal(vresult.valid, false); + }); +}); + +describe('Mutual Verification with Nonce Store', () => { + it('should prevent nonce replay', () => { + const { privateKeyPem, publicKeyPem } = generateKeyPair(); + + const store = new InMemoryNonceStore(); + const challenge = createChallenge(); + const response = createResponse(challenge, privateKeyPem, 'test-key'); + + // First verification should succeed + const valid = verifyResponseWithNonceStore( + response, + challenge, + publicKeyPem, + store + ); + assert.ok(valid); + + // Second verification with same nonce should fail (replay) + assert.throws( + () => + verifyResponseWithNonceStore( + response, + challenge, + publicKeyPem, + store + ), + /already been used/ + ); + }); +}); + +describe('Transport Roundtrip', () => { + it('should format and extract credentials across all transports', () => { + const { privateKeyPem, publicKeyPem } = generateKeyPair(); + const kid = generateKeyId(publicKeyPem); + + const jwtStr = issueCredential( + privateKeyPem, + kid, + 'example.com', + 'urn:agentpin:example.com:test-agent', + null, + ['read:data'], + null, + null, + 3600 + ); + + // HTTP roundtrip + const httpHeader = httpFormatAuthorizationHeader(jwtStr); + const httpExtracted = httpExtractCredential(httpHeader); + assert.equal(httpExtracted, jwtStr); + + // MCP roundtrip + const mcpMeta = mcpFormatMetaField(jwtStr); + const mcpExtracted = mcpExtractCredential(mcpMeta); + assert.equal(mcpExtracted, jwtStr); + + // WebSocket roundtrip + const wsMsg = wsFormatAuthMessage(jwtStr); + const wsExtracted = wsExtractCredential(wsMsg); + assert.equal(wsExtracted, jwtStr); + + // gRPC roundtrip + const grpcVal = grpcFormatMetadataValue(jwtStr); + const grpcExtracted = grpcExtractCredential(grpcVal); + assert.equal(grpcExtracted, jwtStr); + }); +}); + +describe('Key Rotation Lifecycle', () => { + it('should add new key, then remove old key and record revocation', () => { + const { publicKeyPem } = generateKeyPair(); + const oldKid = generateKeyId(publicKeyPem); + const oldJwk = pemToJwk(publicKeyPem, oldKid); + + const doc = buildDiscoveryDocument( + 'example.com', + 'maker', + [oldJwk], + [], + 2, + '2026-01-01T00:00:00Z' + ); + assert.equal(doc.public_keys.length, 1); + + // Prepare rotation + const plan = prepareRotation(oldKid); + assert.notEqual(plan.newKid, oldKid); + + // Apply rotation: both keys should be present + applyRotation(doc, plan); + assert.equal(doc.public_keys.length, 2); + const kids = doc.public_keys.map((k) => k.kid); + assert.ok(kids.includes(oldKid)); + assert.ok(kids.includes(plan.newKid)); + + // Complete rotation: old key removed, added to revocation + const revDoc = buildRevocationDocument('example.com'); + completeRotation(doc, revDoc, oldKid, 'superseded'); + + assert.equal(doc.public_keys.length, 1); + assert.equal(doc.public_keys[0].kid, plan.newKid); + assert.equal(revDoc.revoked_keys.length, 1); + assert.equal(revDoc.revoked_keys[0].kid, oldKid); + assert.equal(revDoc.revoked_keys[0].reason, 'superseded'); + }); +}); + +describe('Pinning Flow', () => { + it('should pin on first use, match on second, error on different key', () => { + const { publicKeyPem: pub1 } = generateKeyPair(); + const kid1 = generateKeyId(pub1); + const jwk1 = pemToJwk(pub1, kid1); + + const store = new KeyPinStore(); + + // First verification pins the key + const result1 = checkPinning(store, 'example.com', jwk1); + assert.equal(result1, PinningResult.FIRST_USE); + + // Same key succeeds + const result2 = checkPinning(store, 'example.com', jwk1); + assert.equal(result2, PinningResult.MATCHED); + + // Different key triggers error + const { publicKeyPem: pub2 } = generateKeyPair(); + const kid2 = generateKeyId(pub2); + const jwk2 = pemToJwk(pub2, kid2); + + assert.throws( + () => checkPinning(store, 'example.com', jwk2), + AgentPinError + ); + }); +}); diff --git a/javascript/tests/nonce.test.js b/javascript/tests/nonce.test.js new file mode 100644 index 0000000..18fb8c2 --- /dev/null +++ b/javascript/tests/nonce.test.js @@ -0,0 +1,72 @@ +/** + * Tests for nonce deduplication and replay prevention. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert'; +import { InMemoryNonceStore } from '../src/nonce.js'; +import { generateKeyPair } from '../src/crypto.js'; +import { createChallenge, createResponse, verifyResponseWithNonceStore } from '../src/mutual.js'; + +describe('InMemoryNonceStore', () => { + test('accepts fresh nonce', () => { + const store = new InMemoryNonceStore(); + assert.strictEqual(store.checkAndRecord('nonce-1', 60000), true); + }); + + test('rejects duplicate nonce', () => { + const store = new InMemoryNonceStore(); + assert.strictEqual(store.checkAndRecord('nonce-1', 60000), true); + assert.strictEqual(store.checkAndRecord('nonce-1', 60000), false); + }); + + test('accepts different nonces', () => { + const store = new InMemoryNonceStore(); + assert.strictEqual(store.checkAndRecord('nonce-1', 60000), true); + assert.strictEqual(store.checkAndRecord('nonce-2', 60000), true); + }); + + test('expired nonces are cleaned up', () => { + const store = new InMemoryNonceStore(); + // Insert with 0ms TTL (already expired) + store._entries.set('old-nonce', Date.now() - 1); + // Triggering checkAndRecord should clean up the expired entry + assert.strictEqual(store.checkAndRecord('new-nonce', 60000), true); + assert.ok(!store._entries.has('old-nonce')); + }); +}); + +describe('verifyResponseWithNonceStore', () => { + test('passes with nonce store on first use', () => { + const kp = generateKeyPair(); + const store = new InMemoryNonceStore(); + const challenge = createChallenge(); + const response = createResponse(challenge, kp.privateKeyPem, 'test-key'); + const valid = verifyResponseWithNonceStore(response, challenge, kp.publicKeyPem, store); + assert.ok(valid); + }); + + test('rejects replay with nonce store', () => { + const kp = generateKeyPair(); + const store = new InMemoryNonceStore(); + const challenge = createChallenge(); + const response = createResponse(challenge, kp.privateKeyPem, 'test-key'); + + // First use succeeds + verifyResponseWithNonceStore(response, challenge, kp.publicKeyPem, store); + + // Second use (replay) throws + assert.throws( + () => verifyResponseWithNonceStore(response, challenge, kp.publicKeyPem, store), + /replay attack/ + ); + }); + + test('works without nonce store (null)', () => { + const kp = generateKeyPair(); + const challenge = createChallenge(); + const response = createResponse(challenge, kp.privateKeyPem, 'test-key'); + const valid = verifyResponseWithNonceStore(response, challenge, kp.publicKeyPem, null); + assert.ok(valid); + }); +}); diff --git a/javascript/tests/rotation.test.js b/javascript/tests/rotation.test.js new file mode 100644 index 0000000..4edc7bc --- /dev/null +++ b/javascript/tests/rotation.test.js @@ -0,0 +1,53 @@ +/** + * Tests for key rotation helpers. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert'; +import { prepareRotation, applyRotation, completeRotation } from '../src/rotation.js'; +import { buildRevocationDocument } from '../src/revocation.js'; + +describe('key rotation', () => { + test('prepareRotation generates new key and preserves oldKid', () => { + const plan = prepareRotation('old-key-1'); + assert.strictEqual(plan.oldKid, 'old-key-1'); + assert.ok(plan.newKid); + assert.ok(plan.newKeyPair.privateKeyPem); + assert.ok(plan.newKeyPair.publicKeyPem); + assert.strictEqual(plan.newJwk.kid, plan.newKid); + assert.strictEqual(plan.newJwk.kty, 'EC'); + assert.strictEqual(plan.newJwk.crv, 'P-256'); + }); + + test('applyRotation adds new key to discovery document', () => { + const doc = { + public_keys: [{ kid: 'old-key-1', kty: 'EC' }], + updated_at: '2024-01-01T00:00:00.000Z', + }; + const plan = prepareRotation('old-key-1'); + applyRotation(doc, plan); + assert.strictEqual(doc.public_keys.length, 2); + assert.strictEqual(doc.public_keys[1].kid, plan.newKid); + assert.notStrictEqual(doc.updated_at, '2024-01-01T00:00:00.000Z'); + }); + + test('completeRotation removes old key and adds revocation', () => { + const plan = prepareRotation('old-key-1'); + const doc = { + public_keys: [ + { kid: 'old-key-1', kty: 'EC' }, + plan.newJwk, + ], + updated_at: '2024-01-01T00:00:00.000Z', + }; + const revDoc = buildRevocationDocument('example.com'); + + completeRotation(doc, revDoc, 'old-key-1', 'superseded'); + + assert.strictEqual(doc.public_keys.length, 1); + assert.strictEqual(doc.public_keys[0].kid, plan.newKid); + assert.strictEqual(revDoc.revoked_keys.length, 1); + assert.strictEqual(revDoc.revoked_keys[0].kid, 'old-key-1'); + assert.strictEqual(revDoc.revoked_keys[0].reason, 'superseded'); + }); +}); diff --git a/javascript/tests/transport.test.js b/javascript/tests/transport.test.js new file mode 100644 index 0000000..f2ddba9 --- /dev/null +++ b/javascript/tests/transport.test.js @@ -0,0 +1,141 @@ +/** + * Tests for transport binding helpers. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert'; +import { + httpExtractCredential, + httpFormatAuthorizationHeader, + mcpExtractCredential, + mcpFormatMetaField, + wsExtractCredential, + wsFormatAuthMessage, + grpcExtractCredential, + grpcFormatMetadataValue, + GRPC_METADATA_KEY, +} from '../src/transport.js'; + +const TEST_JWT = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.sig'; + +describe('HTTP transport', () => { + test('extract valid credential', () => { + const jwt = httpExtractCredential('AgentPin ' + TEST_JWT); + assert.strictEqual(jwt, TEST_JWT); + }); + + test('reject missing prefix', () => { + assert.throws( + () => httpExtractCredential('Bearer ' + TEST_JWT), + /Missing 'AgentPin ' prefix/ + ); + }); + + test('reject empty credential after prefix', () => { + assert.throws( + () => httpExtractCredential('AgentPin '), + /Empty credential/ + ); + }); + + test('format roundtrip', () => { + const header = httpFormatAuthorizationHeader(TEST_JWT); + const extracted = httpExtractCredential(header); + assert.strictEqual(extracted, TEST_JWT); + }); +}); + +describe('MCP transport', () => { + test('extract valid credential', () => { + const jwt = mcpExtractCredential({ agentpin_credential: TEST_JWT }); + assert.strictEqual(jwt, TEST_JWT); + }); + + test('reject missing field', () => { + assert.throws( + () => mcpExtractCredential({}), + /Missing 'agentpin_credential'/ + ); + }); + + test('reject non-string field', () => { + assert.throws( + () => mcpExtractCredential({ agentpin_credential: 42 }), + /not a string/ + ); + }); + + test('format roundtrip', () => { + const meta = mcpFormatMetaField(TEST_JWT); + const extracted = mcpExtractCredential(meta); + assert.strictEqual(extracted, TEST_JWT); + }); +}); + +describe('WebSocket transport', () => { + test('extract valid credential', () => { + const msg = JSON.stringify({ type: 'agentpin-auth', credential: TEST_JWT }); + const jwt = wsExtractCredential(msg); + assert.strictEqual(jwt, TEST_JWT); + }); + + test('reject invalid JSON', () => { + assert.throws( + () => wsExtractCredential('not json'), + /Invalid JSON/ + ); + }); + + test('reject wrong type', () => { + const msg = JSON.stringify({ type: 'other', credential: TEST_JWT }); + assert.throws( + () => wsExtractCredential(msg), + /Expected type 'agentpin-auth'/ + ); + }); + + test('reject missing credential field', () => { + const msg = JSON.stringify({ type: 'agentpin-auth' }); + assert.throws( + () => wsExtractCredential(msg), + /Missing or non-string 'credential'/ + ); + }); + + test('format roundtrip', () => { + const msg = wsFormatAuthMessage(TEST_JWT); + const extracted = wsExtractCredential(msg); + assert.strictEqual(extracted, TEST_JWT); + }); +}); + +describe('gRPC transport', () => { + test('metadata key is correct', () => { + assert.strictEqual(GRPC_METADATA_KEY, 'agentpin-credential'); + }); + + test('extract valid credential', () => { + const jwt = grpcExtractCredential(TEST_JWT); + assert.strictEqual(jwt, TEST_JWT); + }); + + test('reject empty value', () => { + assert.throws( + () => grpcExtractCredential(''), + /Empty gRPC metadata/ + ); + }); + + test('reject null value', () => { + assert.throws( + () => grpcExtractCredential(null), + /Empty gRPC metadata/ + ); + }); + + test('format roundtrip', () => { + const val = grpcFormatMetadataValue(TEST_JWT); + const extracted = grpcExtractCredential(val); + assert.strictEqual(extracted, TEST_JWT); + }); +}); diff --git a/python/agentpin/__init__.py b/python/agentpin/__init__.py index 88bc081..4093df6 100644 --- a/python/agentpin/__init__.py +++ b/python/agentpin/__init__.py @@ -1,9 +1,11 @@ """AgentPin: Domain-anchored cryptographic identity protocol for AI agents.""" from .capability import ( + CORE_ACTIONS, Capability, capabilities_hash, capabilities_subset, + validate_capability, ) from .constraint import ( constraints_subset_of, @@ -51,6 +53,29 @@ create_challenge, create_response, verify_response, + verify_response_with_nonce_store, +) +from .nonce import ( + InMemoryNonceStore, + NonceStore, +) +from .rotation import ( + apply_rotation, + complete_rotation, + prepare_rotation, +) +from .transport import ( + AUTH_TYPE, + FIELD_NAME, + GRPC_METADATA_KEY, + grpc_extract_credential, + grpc_format_metadata_value, + http_extract_credential, + http_format_authorization_header, + mcp_extract_credential, + mcp_format_meta_field, + ws_extract_credential, + ws_format_auth_message, ) from .pinning import ( KeyPinStore, @@ -127,6 +152,8 @@ "Capability", "capabilities_subset", "capabilities_hash", + "validate_capability", + "CORE_ACTIONS", # Constraint "parse_rate_limit", "domain_pattern_matches", @@ -160,6 +187,26 @@ "create_challenge", "create_response", "verify_response", + "verify_response_with_nonce_store", + # Nonce + "NonceStore", + "InMemoryNonceStore", + # Rotation + "prepare_rotation", + "apply_rotation", + "complete_rotation", + # Transport + "http_extract_credential", + "http_format_authorization_header", + "FIELD_NAME", + "mcp_extract_credential", + "mcp_format_meta_field", + "AUTH_TYPE", + "ws_extract_credential", + "ws_format_auth_message", + "GRPC_METADATA_KEY", + "grpc_extract_credential", + "grpc_format_metadata_value", # Verification "verify_credential_offline", "verify_credential", diff --git a/python/agentpin/capability.py b/python/agentpin/capability.py index c882bd9..61b138e 100644 --- a/python/agentpin/capability.py +++ b/python/agentpin/capability.py @@ -89,3 +89,39 @@ def capabilities_hash(capabilities: List[Capability]) -> str: sorted_caps = sorted(c.value for c in capabilities) json_str = json.dumps(sorted_caps, separators=(",", ":")) return sha256_hex(json_str.encode("utf-8")) + + +CORE_ACTIONS = ["read", "write", "execute", "admin", "delegate"] + + +def _is_reverse_domain(s: str) -> bool: + """Check if string looks like a reverse domain prefix (contains a dot).""" + return "." in s + + +def validate_capability(cap: Capability) -> None: + """Validate a capability against the AgentPin taxonomy. + + Raises ValueError if invalid. + Rules: + - Must be action:resource format + - admin:* wildcard rejected + - Custom (non-core) actions must use reverse-domain prefix + """ + parsed = Capability.parse(cap.value) + if not parsed: + raise ValueError( + f"Invalid capability format (missing ':'): {cap.value}" + ) + action, resource = parsed + if action == "admin" and resource == "*": + raise ValueError( + "admin:* wildcard is not allowed; admin capabilities must be explicitly scoped" + ) + if action in CORE_ACTIONS: + return + if not _is_reverse_domain(action): + raise ValueError( + f"Custom action '{action}' must use reverse-domain prefix " + f"(e.g., com.example.{action})" + ) diff --git a/python/agentpin/mutual.py b/python/agentpin/mutual.py index 143c467..7921067 100644 --- a/python/agentpin/mutual.py +++ b/python/agentpin/mutual.py @@ -64,3 +64,13 @@ def verify_response(response: dict, challenge: dict, public_key_pem: str) -> boo # Verify signature over the nonce return verify_signature(public_key_pem, challenge["nonce"].encode("utf-8"), response["signature"]) + + +def verify_response_with_nonce_store(response, challenge, public_key_pem, nonce_store=None): + """Verify with optional nonce dedup. Raises ValueError on nonce reuse.""" + if nonce_store is not None: + if not nonce_store.check_and_record(response["nonce"], NONCE_EXPIRY_SECS): + raise ValueError( + f"Nonce '{response['nonce']}' has already been used (replay attack)" + ) + return verify_response(response, challenge, public_key_pem) diff --git a/python/agentpin/nonce.py b/python/agentpin/nonce.py new file mode 100644 index 0000000..b2b73ae --- /dev/null +++ b/python/agentpin/nonce.py @@ -0,0 +1,32 @@ +"""Nonce deduplication for replay attack prevention.""" + +import threading +import time + + +class NonceStore: + """Abstract base for nonce deduplication.""" + + def check_and_record(self, nonce: str, ttl_seconds: float) -> bool: + """Check if nonce is fresh. Returns True if fresh, False if replay.""" + raise NotImplementedError + + +class InMemoryNonceStore(NonceStore): + """In-memory nonce store with lazy expiry cleanup.""" + + def __init__(self): + self._entries = {} # nonce -> expiry_time + self._lock = threading.Lock() + + def check_and_record(self, nonce: str, ttl_seconds: float) -> bool: + with self._lock: + now = time.monotonic() + # Lazy cleanup + self._entries = {k: v for k, v in self._entries.items() if v > now} + # Check + if nonce in self._entries: + return False + # Record + self._entries[nonce] = now + ttl_seconds + return True diff --git a/python/agentpin/rotation.py b/python/agentpin/rotation.py new file mode 100644 index 0000000..bed39cb --- /dev/null +++ b/python/agentpin/rotation.py @@ -0,0 +1,40 @@ +"""Key rotation helpers for AgentPin.""" + +from datetime import datetime, timezone + +from .crypto import generate_key_id, generate_key_pair +from .jwk import pem_to_jwk +from .revocation import add_revoked_key + + +def prepare_rotation(old_kid: str) -> dict: + """Prepare a key rotation: generate new keypair, compute kid and JWK. + + Returns dict with keys: new_key_pair, new_kid, new_jwk, old_kid + """ + private_pem, public_pem = generate_key_pair() + new_kid = generate_key_id(public_pem) + new_jwk = pem_to_jwk(public_pem, new_kid) + return { + "new_key_pair": (private_pem, public_pem), + "new_kid": new_kid, + "new_jwk": new_jwk, + "old_kid": old_kid, + } + + +def apply_rotation(doc: dict, plan: dict) -> None: + """Apply rotation plan: add new key to discovery document.""" + doc["public_keys"].append(plan["new_jwk"]) + doc["updated_at"] = datetime.now(timezone.utc).isoformat() + + +def complete_rotation( + doc: dict, revocation_doc: dict, old_kid: str, reason: str +) -> None: + """Complete rotation: remove old key from discovery, add to revocation.""" + doc["public_keys"] = [ + k for k in doc["public_keys"] if k.get("kid") != old_kid + ] + doc["updated_at"] = datetime.now(timezone.utc).isoformat() + add_revoked_key(revocation_doc, old_kid, reason) diff --git a/python/agentpin/transport.py b/python/agentpin/transport.py new file mode 100644 index 0000000..bc6c3ae --- /dev/null +++ b/python/agentpin/transport.py @@ -0,0 +1,107 @@ +"""Transport binding helpers for AgentPin (spec Section 13).""" + +import json + +from .types import AgentPinError, ErrorCode + +# --- HTTP --- + + +def http_extract_credential(header_value: str) -> str: + """Extract JWT from 'Authorization: AgentPin ' header value.""" + prefix = "AgentPin " + if not header_value.startswith(prefix): + raise AgentPinError( + ErrorCode.DISCOVERY_FETCH_FAILED, + "Missing 'AgentPin ' prefix in Authorization header", + ) + jwt = header_value[len(prefix):] + if not jwt: + raise AgentPinError( + ErrorCode.DISCOVERY_FETCH_FAILED, + "Empty credential in Authorization header", + ) + return jwt + + +def http_format_authorization_header(jwt: str) -> str: + """Format JWT for Authorization header: 'AgentPin '.""" + return f"AgentPin {jwt}" + + +# --- MCP --- + +FIELD_NAME = "agentpin_credential" + + +def mcp_extract_credential(meta: dict) -> str: + """Extract JWT from MCP metadata dict's 'agentpin_credential' field.""" + if FIELD_NAME not in meta: + raise AgentPinError( + ErrorCode.DISCOVERY_FETCH_FAILED, + f"Missing '{FIELD_NAME}' field in MCP metadata", + ) + value = meta[FIELD_NAME] + if not isinstance(value, str): + raise AgentPinError( + ErrorCode.DISCOVERY_FETCH_FAILED, + f"'{FIELD_NAME}' field is not a string", + ) + return value + + +def mcp_format_meta_field(jwt: str) -> dict: + """Format JWT as MCP metadata dict.""" + return {FIELD_NAME: jwt} + + +# --- WebSocket --- + +AUTH_TYPE = "agentpin-auth" + + +def ws_extract_credential(message: str) -> str: + """Extract JWT from WebSocket JSON auth message.""" + try: + parsed = json.loads(message) + except json.JSONDecodeError as e: + raise AgentPinError( + ErrorCode.DISCOVERY_FETCH_FAILED, f"Invalid JSON: {e}" + ) + msg_type = parsed.get("type") + if msg_type != AUTH_TYPE: + raise AgentPinError( + ErrorCode.DISCOVERY_FETCH_FAILED, + f"Expected type '{AUTH_TYPE}', got '{msg_type}'", + ) + credential = parsed.get("credential") + if not isinstance(credential, str): + raise AgentPinError( + ErrorCode.DISCOVERY_FETCH_FAILED, + "Missing or non-string 'credential' field", + ) + return credential + + +def ws_format_auth_message(jwt: str) -> str: + """Format JWT as WebSocket auth message JSON string.""" + return json.dumps({"type": AUTH_TYPE, "credential": jwt}) + + +# --- gRPC --- + +GRPC_METADATA_KEY = "agentpin-credential" + + +def grpc_extract_credential(metadata_value: str) -> str: + """Extract JWT from gRPC metadata value.""" + if not metadata_value: + raise AgentPinError( + ErrorCode.DISCOVERY_FETCH_FAILED, "Empty gRPC metadata value" + ) + return metadata_value + + +def grpc_format_metadata_value(jwt: str) -> str: + """Format JWT for gRPC metadata (identity function, documents key name).""" + return jwt diff --git a/python/tests/test_capability.py b/python/tests/test_capability.py index b1dd6a2..bfe80ac 100644 --- a/python/tests/test_capability.py +++ b/python/tests/test_capability.py @@ -1,6 +1,14 @@ """Tests for capability parsing and matching.""" -from agentpin.capability import Capability, capabilities_hash, capabilities_subset +import pytest + +from agentpin.capability import ( + CORE_ACTIONS, + Capability, + capabilities_hash, + capabilities_subset, + validate_capability, +) class TestCapabilityParse: @@ -52,3 +60,34 @@ def test_order_independent(self): caps1 = [Capability("read:codebase"), Capability("write:report")] caps2 = [Capability("write:report"), Capability("read:codebase")] assert capabilities_hash(caps1) == capabilities_hash(caps2) + + +class TestValidateCapability: + def test_validate_core_action(self): + validate_capability(Capability("read:codebase")) + validate_capability(Capability("write:report")) + validate_capability(Capability("execute:task")) + + def test_validate_wildcard(self): + validate_capability(Capability("read:*")) + validate_capability(Capability("write:*")) + + def test_validate_admin_wildcard_rejected(self): + with pytest.raises(ValueError, match="admin:\\* wildcard is not allowed"): + validate_capability(Capability("admin:*")) + + def test_validate_admin_scoped_ok(self): + validate_capability(Capability("admin:users")) + validate_capability(Capability("admin:config")) + + def test_validate_custom_action_with_domain(self): + validate_capability(Capability("com.example.audit:logs")) + validate_capability(Capability("org.acme.deploy:staging")) + + def test_validate_custom_action_without_domain(self): + with pytest.raises(ValueError, match="reverse-domain prefix"): + validate_capability(Capability("audit:logs")) + + def test_validate_missing_colon(self): + with pytest.raises(ValueError, match="missing ':'"): + validate_capability(Capability("readcodebase")) diff --git a/python/tests/test_integration.py b/python/tests/test_integration.py new file mode 100644 index 0000000..ead0786 --- /dev/null +++ b/python/tests/test_integration.py @@ -0,0 +1,267 @@ +"""End-to-end integration tests for AgentPin.""" + +import json + +import pytest + +from agentpin import ( + KeyPinStore, + PinningResult, + VerifierConfig, + build_discovery_document, + build_revocation_document, + add_revoked_key, + check_pinning, + check_revocation, + create_challenge, + create_response, + generate_key_id, + generate_key_pair, + issue_credential, + pem_to_jwk, + decode_jwt_unverified, + verify_jwt, + verify_credential_offline, + verify_response_with_nonce_store, + http_extract_credential, + http_format_authorization_header, + mcp_extract_credential, + mcp_format_meta_field, + ws_extract_credential, + ws_format_auth_message, + grpc_extract_credential, + grpc_format_metadata_value, + apply_rotation, + complete_rotation, + prepare_rotation, + AgentPinError, +) +from agentpin.nonce import InMemoryNonceStore + + +def make_test_setup(): + """Create a keypair, kid, JWK, and discovery document for testing.""" + private_pem, public_pem = generate_key_pair() + kid = generate_key_id(public_pem) + jwk = pem_to_jwk(public_pem, kid) + agent_id = "urn:agentpin:example.com:test-agent" + doc = build_discovery_document( + entity="example.com", + entity_type="maker", + public_keys=[jwk], + agents=[ + { + "agent_id": agent_id, + "name": "Test Agent", + "capabilities": ["read:*", "write:report"], + "status": "active", + "credential_ttl_max": 3600, + } + ], + max_delegation_depth=2, + updated_at="2026-01-01T00:00:00Z", + ) + return private_pem, public_pem, kid, agent_id, doc + + +class TestMakerDeployerFlow: + def test_full_credential_lifecycle(self): + private_pem, public_pem, kid, agent_id, doc = make_test_setup() + + # Issue a credential + jwt_str = issue_credential( + private_key_pem=private_pem, + kid=kid, + issuer="example.com", + agent_id=agent_id, + audience="verifier.com", + capabilities=["read:data", "write:report"], + constraints=None, + delegation_chain=None, + ttl_secs=3600, + ) + assert jwt_str + assert jwt_str.count(".") == 2 + + # Decode unverified to inspect + header, payload, _sig = decode_jwt_unverified(jwt_str) + assert header["alg"] == "ES256" + assert header["typ"] == "agentpin-credential+jwt" + assert header["kid"] == kid + assert payload["iss"] == "example.com" + assert payload["sub"] == agent_id + + # Verify signature + verified_header, verified_payload = verify_jwt(jwt_str, public_pem) + assert verified_header["kid"] == kid + assert verified_payload["iss"] == "example.com" + + # Full offline verification + pin_store = KeyPinStore() + config = VerifierConfig() + result = verify_credential_offline( + jwt_str, doc, None, pin_store, "verifier.com", config + ) + assert result.valid, f"Expected valid, got: {result.error_message}" + assert result.agent_id == agent_id + assert result.issuer == "example.com" + + +class TestRevocationFlow: + def test_revoked_key_detected(self): + private_pem, _public_pem, kid, agent_id, doc = make_test_setup() + + jwt_str = issue_credential( + private_key_pem=private_pem, + kid=kid, + issuer="example.com", + agent_id=agent_id, + audience=None, + capabilities=["read:data"], + constraints=None, + delegation_chain=None, + ttl_secs=3600, + ) + + header, payload, _sig = decode_jwt_unverified(jwt_str) + + # Clean revocation: should pass + rev_doc = build_revocation_document("example.com") + check_revocation(rev_doc, payload["jti"], agent_id, kid) # no error + + # Add revoked key + add_revoked_key(rev_doc, kid, "key_compromise") + + # Now check_revocation should fail + with pytest.raises(AgentPinError): + check_revocation(rev_doc, payload["jti"], agent_id, kid) + + # Full offline verification should also fail + pin_store = KeyPinStore() + config = VerifierConfig() + vresult = verify_credential_offline( + jwt_str, doc, rev_doc, pin_store, None, config + ) + assert not vresult.valid + + +class TestMutualVerificationWithNonceStore: + def test_nonce_replay_prevention(self): + private_pem, public_pem = generate_key_pair() + + store = InMemoryNonceStore() + challenge = create_challenge() + response = create_response(challenge, private_pem, "test-key") + + # First verification should succeed + valid = verify_response_with_nonce_store( + response, challenge, public_pem, store + ) + assert valid + + # Second verification with same nonce should fail (replay) + with pytest.raises(ValueError, match="already been used"): + verify_response_with_nonce_store( + response, challenge, public_pem, store + ) + + +class TestTransportRoundtrip: + def test_all_transports(self): + private_pem, _public_pem = generate_key_pair() + kid = generate_key_id(_public_pem) + + jwt_str = issue_credential( + private_key_pem=private_pem, + kid=kid, + issuer="example.com", + agent_id="urn:agentpin:example.com:test-agent", + audience=None, + capabilities=["read:data"], + constraints=None, + delegation_chain=None, + ttl_secs=3600, + ) + + # HTTP roundtrip + http_header = http_format_authorization_header(jwt_str) + http_extracted = http_extract_credential(http_header) + assert http_extracted == jwt_str + + # MCP roundtrip + mcp_meta = mcp_format_meta_field(jwt_str) + mcp_extracted = mcp_extract_credential(mcp_meta) + assert mcp_extracted == jwt_str + + # WebSocket roundtrip + ws_msg = ws_format_auth_message(jwt_str) + ws_extracted = ws_extract_credential(ws_msg) + assert ws_extracted == jwt_str + + # gRPC roundtrip + grpc_val = grpc_format_metadata_value(jwt_str) + grpc_extracted = grpc_extract_credential(grpc_val) + assert grpc_extracted == jwt_str + + +class TestKeyRotationLifecycle: + def test_rotation_add_and_remove(self): + private_pem, public_pem = generate_key_pair() + old_kid = generate_key_id(public_pem) + old_jwk = pem_to_jwk(public_pem, old_kid) + + doc = build_discovery_document( + entity="example.com", + entity_type="maker", + public_keys=[old_jwk], + agents=[], + max_delegation_depth=2, + updated_at="2026-01-01T00:00:00Z", + ) + assert len(doc["public_keys"]) == 1 + + # Prepare rotation + plan = prepare_rotation(old_kid) + assert plan["new_kid"] != old_kid + + # Apply rotation: both keys should be present + apply_rotation(doc, plan) + assert len(doc["public_keys"]) == 2 + kids = [k["kid"] for k in doc["public_keys"]] + assert old_kid in kids + assert plan["new_kid"] in kids + + # Complete rotation: old key removed, added to revocation + rev_doc = build_revocation_document("example.com") + complete_rotation(doc, rev_doc, old_kid, "superseded") + + assert len(doc["public_keys"]) == 1 + assert doc["public_keys"][0]["kid"] == plan["new_kid"] + assert len(rev_doc["revoked_keys"]) == 1 + assert rev_doc["revoked_keys"][0]["kid"] == old_kid + assert rev_doc["revoked_keys"][0]["reason"] == "superseded" + + +class TestPinningFlow: + def test_tofu_pinning(self): + _priv1, pub1 = generate_key_pair() + kid1 = generate_key_id(pub1) + jwk1 = pem_to_jwk(pub1, kid1) + + store = KeyPinStore() + + # First verification pins the key + result1 = check_pinning(store, "example.com", jwk1) + assert result1 == PinningResult.FIRST_USE + + # Same key succeeds + result2 = check_pinning(store, "example.com", jwk1) + assert result2 == PinningResult.MATCHED + + # Different key triggers error + _priv2, pub2 = generate_key_pair() + kid2 = generate_key_id(pub2) + jwk2 = pem_to_jwk(pub2, kid2) + + with pytest.raises(AgentPinError): + check_pinning(store, "example.com", jwk2) diff --git a/python/tests/test_nonce.py b/python/tests/test_nonce.py new file mode 100644 index 0000000..2b055fe --- /dev/null +++ b/python/tests/test_nonce.py @@ -0,0 +1,45 @@ +"""Tests for nonce deduplication.""" + +import threading +import time + +from agentpin.nonce import InMemoryNonceStore + + +class TestInMemoryNonceStore: + def test_fresh_nonce(self): + store = InMemoryNonceStore() + assert store.check_and_record("nonce-1", 60.0) is True + + def test_duplicate_nonce(self): + store = InMemoryNonceStore() + assert store.check_and_record("nonce-1", 60.0) is True + assert store.check_and_record("nonce-1", 60.0) is False + + def test_expired_nonce(self): + store = InMemoryNonceStore() + # Record with a very short TTL + assert store.check_and_record("nonce-1", 0.05) is True + time.sleep(0.1) + # Should be fresh again after expiry + assert store.check_and_record("nonce-1", 60.0) is True + + def test_concurrent_safety(self): + store = InMemoryNonceStore() + results = [] + barrier = threading.Barrier(10) + + def try_record(): + barrier.wait() + result = store.check_and_record("shared-nonce", 60.0) + results.append(result) + + threads = [threading.Thread(target=try_record) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + # Exactly one thread should succeed + assert results.count(True) == 1 + assert results.count(False) == 9 diff --git a/python/tests/test_rotation.py b/python/tests/test_rotation.py new file mode 100644 index 0000000..fa221b0 --- /dev/null +++ b/python/tests/test_rotation.py @@ -0,0 +1,57 @@ +"""Tests for key rotation helpers.""" + +from agentpin.crypto import generate_key_id, generate_key_pair +from agentpin.jwk import pem_to_jwk +from agentpin.revocation import build_revocation_document +from agentpin.rotation import apply_rotation, complete_rotation, prepare_rotation + + +class TestRotation: + def test_prepare_rotation(self): + _, pub = generate_key_pair() + old_kid = generate_key_id(pub) + plan = prepare_rotation(old_kid) + + assert plan["old_kid"] == old_kid + assert plan["new_kid"] != old_kid + assert plan["new_jwk"]["kid"] == plan["new_kid"] + assert len(plan["new_key_pair"]) == 2 # (private, public) + + def test_apply_rotation(self): + # Build a minimal discovery doc + _, pub = generate_key_pair() + old_kid = generate_key_id(pub) + old_jwk = pem_to_jwk(pub, old_kid) + doc = {"public_keys": [old_jwk], "updated_at": "old"} + + plan = prepare_rotation(old_kid) + apply_rotation(doc, plan) + + assert len(doc["public_keys"]) == 2 + kids = [k["kid"] for k in doc["public_keys"]] + assert old_kid in kids + assert plan["new_kid"] in kids + assert doc["updated_at"] != "old" + + def test_complete_rotation(self): + # Build docs + _, pub = generate_key_pair() + old_kid = generate_key_id(pub) + old_jwk = pem_to_jwk(pub, old_kid) + + plan = prepare_rotation(old_kid) + doc = {"public_keys": [old_jwk, plan["new_jwk"]], "updated_at": "old"} + rev_doc = build_revocation_document("example.com") + + complete_rotation(doc, rev_doc, old_kid, "key_compromise") + + # Old key removed from discovery + kids = [k["kid"] for k in doc["public_keys"]] + assert old_kid not in kids + assert plan["new_kid"] in kids + assert len(doc["public_keys"]) == 1 + + # Old key added to revocation + assert len(rev_doc["revoked_keys"]) == 1 + assert rev_doc["revoked_keys"][0]["kid"] == old_kid + assert rev_doc["revoked_keys"][0]["reason"] == "key_compromise" diff --git a/python/tests/test_transport.py b/python/tests/test_transport.py new file mode 100644 index 0000000..c68270d --- /dev/null +++ b/python/tests/test_transport.py @@ -0,0 +1,100 @@ +"""Tests for transport binding helpers.""" + +import json + +import pytest + +from agentpin.transport import ( + AUTH_TYPE, + FIELD_NAME, + GRPC_METADATA_KEY, + grpc_extract_credential, + grpc_format_metadata_value, + http_extract_credential, + http_format_authorization_header, + mcp_extract_credential, + mcp_format_meta_field, + ws_extract_credential, + ws_format_auth_message, +) +from agentpin.types import AgentPinError + + +class TestHttpTransport: + def test_extract_valid(self): + assert http_extract_credential("AgentPin eyJ.test.jwt") == "eyJ.test.jwt" + + def test_extract_missing_prefix(self): + with pytest.raises(AgentPinError, match="Missing 'AgentPin ' prefix"): + http_extract_credential("Bearer eyJ.test.jwt") + + def test_extract_empty_credential(self): + with pytest.raises(AgentPinError, match="Empty credential"): + http_extract_credential("AgentPin ") + + def test_format_roundtrip(self): + jwt = "eyJ.test.jwt" + header = http_format_authorization_header(jwt) + assert header == "AgentPin eyJ.test.jwt" + assert http_extract_credential(header) == jwt + + +class TestMcpTransport: + def test_extract_valid(self): + meta = {FIELD_NAME: "eyJ.test.jwt"} + assert mcp_extract_credential(meta) == "eyJ.test.jwt" + + def test_extract_missing_field(self): + with pytest.raises(AgentPinError, match="Missing"): + mcp_extract_credential({}) + + def test_extract_non_string(self): + with pytest.raises(AgentPinError, match="not a string"): + mcp_extract_credential({FIELD_NAME: 42}) + + def test_format_roundtrip(self): + jwt = "eyJ.test.jwt" + meta = mcp_format_meta_field(jwt) + assert mcp_extract_credential(meta) == jwt + + +class TestWsTransport: + def test_extract_valid(self): + msg = json.dumps({"type": AUTH_TYPE, "credential": "eyJ.test.jwt"}) + assert ws_extract_credential(msg) == "eyJ.test.jwt" + + def test_extract_invalid_json(self): + with pytest.raises(AgentPinError, match="Invalid JSON"): + ws_extract_credential("not json") + + def test_extract_wrong_type(self): + msg = json.dumps({"type": "other", "credential": "eyJ.test.jwt"}) + with pytest.raises(AgentPinError, match="Expected type"): + ws_extract_credential(msg) + + def test_extract_missing_credential(self): + msg = json.dumps({"type": AUTH_TYPE}) + with pytest.raises(AgentPinError, match="Missing or non-string"): + ws_extract_credential(msg) + + def test_format_roundtrip(self): + jwt = "eyJ.test.jwt" + msg = ws_format_auth_message(jwt) + assert ws_extract_credential(msg) == jwt + + +class TestGrpcTransport: + def test_extract_valid(self): + assert grpc_extract_credential("eyJ.test.jwt") == "eyJ.test.jwt" + + def test_extract_empty(self): + with pytest.raises(AgentPinError, match="Empty gRPC metadata"): + grpc_extract_credential("") + + def test_format_roundtrip(self): + jwt = "eyJ.test.jwt" + value = grpc_format_metadata_value(jwt) + assert grpc_extract_credential(value) == jwt + + def test_metadata_key_name(self): + assert GRPC_METADATA_KEY == "agentpin-credential"