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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/javascript.yml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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"
26 changes: 26 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions crates/agentpin/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down
3 changes: 3 additions & 0 deletions crates/agentpin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
61 changes: 61 additions & 0 deletions crates/agentpin/src/mutual.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
use std::time::Duration;

use base64::{engine::general_purpose::URL_SAFE_NO_PAD as BASE64URL, Engine};
use chrono::Utc;
use p256::ecdsa::{SigningKey, VerifyingKey};
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;
Expand Down Expand Up @@ -39,6 +42,19 @@ pub fn verify_response(
response: &Response,
challenge: &Challenge,
verifying_key: &VerifyingKey,
) -> Result<bool, Error> {
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<bool, Error> {
// Check nonce matches
if response.nonce != challenge.nonce {
Expand All @@ -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,
Expand Down Expand Up @@ -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);
}
}
103 changes: 103 additions & 0 deletions crates/agentpin/src/nonce.rs
Original file line number Diff line number Diff line change
@@ -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<bool, Error>;
}

/// In-memory nonce store with lazy expiry cleanup.
pub struct InMemoryNonceStore {
entries: Mutex<HashMap<String, Instant>>,
}

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<bool, Error> {
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);
}
}
Loading
Loading