Skip to content

Commit 164e770

Browse files
authored
Merge pull request #1 from ThirdKeyAI/dev
Add transport bindings, capability validation, key rotation, nonce dedup, CI/CD
2 parents 5e199b0 + eeab269 commit 164e770

42 files changed

Lines changed: 2909 additions & 3 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/javascript.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: JavaScript CI
2+
on:
3+
push:
4+
branches: [main]
5+
paths: ['javascript/**']
6+
pull_request:
7+
paths: ['javascript/**']
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
node-version: ['18', '20']
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: ${{ matrix.node-version }}
20+
- working-directory: javascript
21+
run: |
22+
npm ci
23+
npm test

.github/workflows/python.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: Python CI
2+
on:
3+
push:
4+
branches: [main]
5+
paths: ['python/**']
6+
pull_request:
7+
paths: ['python/**']
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
python-version: ['3.9', '3.12']
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-python@v5
18+
with:
19+
python-version: ${{ matrix.python-version }}
20+
- working-directory: python
21+
run: |
22+
pip install -e ".[dev]"
23+
python -m pytest tests/ -v

.github/workflows/release.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: Release Check
2+
on:
3+
push:
4+
branches: [main]
5+
pull_request:
6+
7+
jobs:
8+
version-consistency:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
- name: Check version consistency
13+
run: |
14+
RUST_VER=$(grep '^version' crates/agentpin/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
15+
PY_VER=$(grep 'version' python/pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
16+
JS_VER=$(node -e "console.log(require('./javascript/package.json').version)")
17+
echo "Rust: $RUST_VER"
18+
echo "Python: $PY_VER"
19+
echo "JavaScript: $JS_VER"
20+
if [ "$RUST_VER" != "$PY_VER" ] || [ "$RUST_VER" != "$JS_VER" ]; then
21+
echo "::error::Version mismatch! Rust=$RUST_VER Python=$PY_VER JavaScript=$JS_VER"
22+
exit 1
23+
fi
24+
echo "All versions match: $RUST_VER"

.github/workflows/rust.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Rust CI
2+
on:
3+
push:
4+
branches: [main]
5+
paths: ['crates/**', 'Cargo.toml', 'Cargo.lock']
6+
pull_request:
7+
paths: ['crates/**', 'Cargo.toml', 'Cargo.lock']
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
rust: [stable, '1.70']
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: dtolnay/rust-toolchain@master
18+
with:
19+
toolchain: ${{ matrix.rust }}
20+
components: clippy, rustfmt
21+
- run: cargo fmt --check
22+
if: matrix.rust == 'stable'
23+
- run: cargo clippy --workspace -j2 -- -D warnings
24+
if: matrix.rust == 'stable'
25+
- run: cargo test --workspace -j2
26+
- run: cargo test --workspace -j2 --features fetch

crates/agentpin/src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ pub enum Error {
3838
#[error("Delegation error: {0}")]
3939
Delegation(String),
4040

41+
#[error("Transport error: {0}")]
42+
Transport(String),
43+
4144
#[error("IO error: {0}")]
4245
Io(#[from] std::io::Error),
4346

crates/agentpin/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ pub mod credential;
99
pub mod delegation;
1010
pub mod discovery;
1111
pub mod mutual;
12+
pub mod nonce;
1213
pub mod pinning;
1314
pub mod revocation;
15+
pub mod rotation;
16+
pub mod transport;
1417
pub mod verification;

crates/agentpin/src/mutual.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
use std::time::Duration;
2+
13
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as BASE64URL, Engine};
24
use chrono::Utc;
35
use p256::ecdsa::{SigningKey, VerifyingKey};
46
use rand::RngCore;
57

68
use crate::crypto;
79
use crate::error::Error;
10+
use crate::nonce::NonceStore;
811
use crate::types::mutual::{Challenge, Response};
912

1013
const NONCE_EXPIRY_SECS: i64 = 60;
@@ -39,6 +42,19 @@ pub fn verify_response(
3942
response: &Response,
4043
challenge: &Challenge,
4144
verifying_key: &VerifyingKey,
45+
) -> Result<bool, Error> {
46+
verify_response_with_nonce_store(response, challenge, verifying_key, None)
47+
}
48+
49+
/// Verify a challenge response with optional nonce deduplication.
50+
///
51+
/// When a `nonce_store` is provided, the response nonce is checked against
52+
/// previously seen nonces to prevent replay attacks within the validity window.
53+
pub fn verify_response_with_nonce_store(
54+
response: &Response,
55+
challenge: &Challenge,
56+
verifying_key: &VerifyingKey,
57+
nonce_store: Option<&dyn NonceStore>,
4258
) -> Result<bool, Error> {
4359
// Check nonce matches
4460
if response.nonce != challenge.nonce {
@@ -56,6 +72,14 @@ pub fn verify_response(
5672
}
5773
}
5874

75+
// Check nonce deduplication if a store is provided
76+
if let Some(store) = nonce_store {
77+
let fresh = store.check_and_record(&response.nonce, Duration::from_secs(60))?;
78+
if !fresh {
79+
return Err(Error::Jwt("Nonce has already been used".to_string()));
80+
}
81+
}
82+
5983
// Verify signature over the nonce
6084
crypto::verify_bytes(
6185
verifying_key,
@@ -146,4 +170,41 @@ mod tests {
146170
Some("eyJ...test-jwt".to_string())
147171
);
148172
}
173+
174+
#[test]
175+
fn test_verify_with_nonce_store() {
176+
let kp = crypto::generate_key_pair().unwrap();
177+
let sk = crypto::load_signing_key(&kp.private_key_pem).unwrap();
178+
let vk = crypto::load_verifying_key(&kp.public_key_pem).unwrap();
179+
180+
let store = crate::nonce::InMemoryNonceStore::new();
181+
let challenge = create_challenge(None);
182+
let response = create_response(&challenge, &sk, "test-key");
183+
184+
// First verification should succeed.
185+
let valid =
186+
verify_response_with_nonce_store(&response, &challenge, &vk, Some(&store)).unwrap();
187+
assert!(valid);
188+
189+
// Second verification with the same nonce should fail (replay).
190+
let result = verify_response_with_nonce_store(&response, &challenge, &vk, Some(&store));
191+
assert!(result.is_err(), "Replayed nonce should be rejected");
192+
}
193+
194+
#[test]
195+
fn test_verify_without_nonce_store() {
196+
let kp = crypto::generate_key_pair().unwrap();
197+
let sk = crypto::load_signing_key(&kp.private_key_pem).unwrap();
198+
let vk = crypto::load_verifying_key(&kp.public_key_pem).unwrap();
199+
200+
let challenge = create_challenge(None);
201+
let response = create_response(&challenge, &sk, "test-key");
202+
203+
// Without a nonce store, same nonce can be verified multiple times.
204+
let valid1 = verify_response(&response, &challenge, &vk).unwrap();
205+
assert!(valid1);
206+
207+
let valid2 = verify_response(&response, &challenge, &vk).unwrap();
208+
assert!(valid2);
209+
}
149210
}

crates/agentpin/src/nonce.rs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
use std::collections::HashMap;
2+
use std::sync::Mutex;
3+
use std::time::{Duration, Instant};
4+
5+
use crate::error::Error;
6+
7+
/// Trait for nonce deduplication stores.
8+
pub trait NonceStore: Send + Sync {
9+
/// Check if a nonce has been seen before. If not, record it with the given TTL.
10+
/// Returns `Ok(true)` if the nonce is fresh (not seen before).
11+
/// Returns `Ok(false)` if the nonce has already been used (replay).
12+
fn check_and_record(&self, nonce: &str, ttl: Duration) -> Result<bool, Error>;
13+
}
14+
15+
/// In-memory nonce store with lazy expiry cleanup.
16+
pub struct InMemoryNonceStore {
17+
entries: Mutex<HashMap<String, Instant>>,
18+
}
19+
20+
impl InMemoryNonceStore {
21+
/// Create a new empty nonce store.
22+
pub fn new() -> Self {
23+
Self {
24+
entries: Mutex::new(HashMap::new()),
25+
}
26+
}
27+
}
28+
29+
impl Default for InMemoryNonceStore {
30+
fn default() -> Self {
31+
Self::new()
32+
}
33+
}
34+
35+
impl NonceStore for InMemoryNonceStore {
36+
fn check_and_record(&self, nonce: &str, ttl: Duration) -> Result<bool, Error> {
37+
let mut map = self
38+
.entries
39+
.lock()
40+
.map_err(|e| Error::Jwt(format!("Nonce store lock poisoned: {}", e)))?;
41+
42+
let now = Instant::now();
43+
44+
// Lazy cleanup: remove all expired entries.
45+
map.retain(|_, expiry| *expiry > now);
46+
47+
// Check if the nonce is already present (and not expired, since we just cleaned).
48+
if map.contains_key(nonce) {
49+
return Ok(false);
50+
}
51+
52+
// Record the nonce with its expiry.
53+
map.insert(nonce.to_string(), now + ttl);
54+
Ok(true)
55+
}
56+
}
57+
58+
#[cfg(test)]
59+
mod tests {
60+
use super::*;
61+
62+
#[test]
63+
fn test_fresh_nonce_accepted() {
64+
let store = InMemoryNonceStore::new();
65+
let result = store
66+
.check_and_record("nonce-1", Duration::from_secs(60))
67+
.unwrap();
68+
assert!(result, "First use of a nonce should return true");
69+
}
70+
71+
#[test]
72+
fn test_duplicate_nonce_rejected() {
73+
let store = InMemoryNonceStore::new();
74+
let ttl = Duration::from_secs(60);
75+
store.check_and_record("nonce-dup", ttl).unwrap();
76+
let result = store.check_and_record("nonce-dup", ttl).unwrap();
77+
assert!(!result, "Second use of the same nonce should return false");
78+
}
79+
80+
#[test]
81+
fn test_expired_nonce_reusable() {
82+
let store = InMemoryNonceStore::new();
83+
let ttl = Duration::from_millis(1);
84+
store.check_and_record("nonce-exp", ttl).unwrap();
85+
86+
std::thread::sleep(Duration::from_millis(10));
87+
88+
let result = store.check_and_record("nonce-exp", ttl).unwrap();
89+
assert!(result, "Expired nonce should be accepted again");
90+
}
91+
92+
#[test]
93+
fn test_concurrent_safety() {
94+
let store = InMemoryNonceStore::new();
95+
let ttl = Duration::from_secs(60);
96+
97+
let first = store.check_and_record("nonce-cc", ttl).unwrap();
98+
assert!(first);
99+
100+
let second = store.check_and_record("nonce-cc", ttl).unwrap();
101+
assert!(!second);
102+
}
103+
}

0 commit comments

Comments
 (0)