aperion_shield/identity/mod.rs
1//! Aperion Shield -- identity-gated tool calls.
2//!
3//! When a rule in `shieldset.yaml` carries an `identity:` block and would
4//! otherwise resolve to **Approval** or **Block**, the engine emits a
5//! [`crate::Decision::IdentityVerification`] instead. The MCP middleman
6//! then:
7//!
8//! 1. Looks up a fresh, cryptographically-signed proof in the local
9//! cache (`~/.aperion-shield/identity-cache.json`). A "fresh" proof
10//! is one whose `provider`, `scope`, allowed-subject set, and
11//! level-of-assurance all satisfy the rule's [`Requirement`], and
12//! whose `expires_at` is in the future.
13//! 2. **Cache hit** -- the call is released and we log
14//! `identity_satisfied` to the audit stream.
15//! 3. **Cache miss** -- Shield lazily starts a tiny localhost HTTP
16//! server (random port), mints a [`Challenge`], hands the user a
17//! `verify_url`, and holds the tool call server-side for at most
18//! `hold_seconds` (default 120). If the user completes the
19//! provider's flow (e.g. ID.me OAuth + biometric) within that
20//! window, Shield mints a [`Proof`], persists it, and releases the
21//! held call. If the window elapses, Shield returns a structured
22//! `shield_identity_required` error to the agent; the user can
23//! verify out-of-band and retry the call.
24//!
25//! All identity work is gated by the YAML schema -- a rule with no
26//! `identity:` block behaves exactly as it did pre-v0.4. The free
27//! standalone build ships both a fully-working `mock` provider (for
28//! integration tests and demos) and a stubbed `id_me` provider whose
29//! OAuth client is ready to activate the moment we receive sandbox
30//! credentials from the ID.me partner program.
31//!
32//! Module layout:
33//!
34//! ```text
35//! identity/
36//! mod.rs -- public surface: types, trait, [`IdentityGate`]
37//! config.rs -- `identity.yaml` schema + loader
38//! proof.rs -- Ed25519 sign / verify, on-disk keypair
39//! cache.rs -- file-backed proof cache with TTL eviction
40//! server.rs -- hyper localhost callback server
41//! providers/
42//! mock.rs -- always-verify provider (tests, demos)
43//! idme.rs -- ID.me OAuth provider (creds not yet activated)
44//! ```
45
46#![allow(clippy::module_inception)]
47
48pub mod cache;
49pub mod config;
50pub mod proof;
51pub mod providers;
52pub mod server;
53
54use std::collections::HashSet;
55use std::sync::Arc;
56use std::time::{SystemTime, UNIX_EPOCH};
57
58use async_trait::async_trait;
59use serde::{Deserialize, Serialize};
60
61pub use cache::ProofCache;
62pub use config::{IdentityConfig, ProviderConfig, ProviderKind};
63pub use proof::{Proof, ProofSigner};
64pub use providers::{idme::IdMeProvider, mock::MockProvider};
65pub use server::{CallbackServer, Inflight, ServerHandle};
66
67// ────────────────────────────────────────────────────────────────────
68// Public types
69// ────────────────────────────────────────────────────────────────────
70
71/// A single requirement attached to a rule. Authored in YAML as the
72/// `identity:` block under a rule; compiled into this struct once at
73/// rule-load time and reused on every match.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct Requirement {
76 /// `id` of the provider in `identity.yaml` to invoke. Must resolve
77 /// to a known [`ProviderConfig`].
78 pub provider: String,
79
80 /// Logical scope name (free-form). Doubles as the cache partition
81 /// key, so a proof for `scm.commit_to_main` does **not** satisfy a
82 /// rule asking for `db.production_apply`.
83 pub scope: String,
84
85 /// Set of subjects allowed to satisfy this gate. Each entry is
86 /// matched against [`VerifiedIdentity::subject`] AND
87 /// [`VerifiedIdentity::email`]; ANY hit passes.
88 ///
89 /// Accepted forms:
90 /// * Email -- `[email protected]`
91 /// * Subject -- `idme|550e8400-e29b-41d4-a716-446655440000`
92 /// * Wildcard -- `*` (any user verifying is enough; useful
93 /// for single-operator laptops)
94 pub allowed_subjects: Vec<String>,
95
96 /// Maximum acceptable age (seconds) for a cached proof. Older
97 /// proofs are ignored and the user is re-prompted.
98 #[serde(default = "default_max_age")]
99 pub max_proof_age_seconds: u64,
100
101 /// Required ID.me level-of-assurance (0/1/2/3). A proof issued at
102 /// a lower LOA does not satisfy the gate.
103 #[serde(default)]
104 pub loa: u8,
105}
106
107fn default_max_age() -> u64 {
108 900 // 15 minutes
109}
110
111impl Requirement {
112 /// Does this allow-list include the given identity?
113 pub fn allows(&self, vi: &VerifiedIdentity) -> bool {
114 let want: HashSet<&str> = self.allowed_subjects.iter().map(String::as_str).collect();
115 if want.contains("*") {
116 return true;
117 }
118 let prefixed = format!("{}|{}", vi.provider, vi.subject);
119 if want.contains(prefixed.as_str()) {
120 return true;
121 }
122 if want.contains(vi.subject.as_str()) {
123 return true;
124 }
125 if let Some(email) = vi.email.as_deref() {
126 if want.contains(email) {
127 return true;
128 }
129 }
130 false
131 }
132
133 /// True if the given (already-decoded, signature-verified) proof
134 /// satisfies all of: provider, scope, allow-list, LOA, freshness.
135 pub fn is_satisfied_by(&self, proof: &Proof, now_secs: u64) -> bool {
136 if proof.provider != self.provider {
137 return false;
138 }
139 if proof.scope != self.scope {
140 return false;
141 }
142 if proof.loa < self.loa {
143 return false;
144 }
145 if proof.expires_at <= now_secs {
146 return false;
147 }
148 if now_secs.saturating_sub(proof.verified_at) > self.max_proof_age_seconds {
149 return false;
150 }
151 // Subject / email check.
152 let vi = VerifiedIdentity {
153 provider: proof.provider.clone(),
154 subject: proof.subject.clone(),
155 email: proof.email.clone(),
156 loa: proof.loa,
157 raw: serde_json::Value::Null,
158 };
159 self.allows(&vi)
160 }
161}
162
163/// The "begin verification" input. Produced by the engine, consumed by
164/// a provider's [`IdentityProvider::begin`] implementation.
165#[derive(Debug, Clone)]
166pub struct ChallengeRequest {
167 pub rule_id: String,
168 pub requirement: Requirement,
169 /// The URL the provider should redirect back to once the user
170 /// completes the flow. Always `http://127.0.0.1:<port>/callback`
171 /// where `<port>` is the OS-assigned port the callback server is
172 /// listening on.
173 pub callback_url: String,
174 /// Opaque, unguessable challenge id (also the cache key for the
175 /// in-flight challenge state held by the callback server).
176 pub challenge_id: String,
177}
178
179/// A provider-issued challenge. The `verify_url` is what we surface to
180/// the user; the optional state-bag (`pkce_verifier`, `nonce`) is what
181/// we hold privately so we can validate the callback.
182#[derive(Debug, Clone)]
183pub struct Challenge {
184 pub challenge_id: String,
185 /// The URL the user opens in a browser to complete verification.
186 /// For real OAuth providers this is the authorize URL with a
187 /// `state=<challenge_id>` query param. For the mock provider it's
188 /// the local server's `/verify/<id>` endpoint.
189 pub verify_url: String,
190 /// PKCE verifier (for OAuth 2.0 + PKCE flows). Stored alongside
191 /// the challenge so the callback handler can complete the token
192 /// exchange.
193 pub pkce_verifier: Option<String>,
194 /// Anti-replay nonce embedded in the authorize URL and validated
195 /// on callback.
196 pub nonce: String,
197 /// When this challenge expires; the callback server reaps any
198 /// challenge older than this.
199 pub expires_at: u64,
200}
201
202/// The result of a successful provider exchange. Translated by Shield
203/// into a signed [`Proof`] before being cached / returned.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct VerifiedIdentity {
206 pub provider: String,
207 /// Stable provider-side subject id (e.g. ID.me's `sub` claim). This
208 /// is the identity key -- emails can change, subjects do not.
209 pub subject: String,
210 /// Email at the time of verification, for human-readable logs and
211 /// allow-list matching by address.
212 pub email: Option<String>,
213 /// Level of assurance achieved (ID.me IAL/LOA tier).
214 pub loa: u8,
215 /// Provider-specific raw response payload, retained for audit but
216 /// NEVER persisted to the proof cache (PII surface).
217 #[serde(skip_serializing_if = "serde_json::Value::is_null", default)]
218 pub raw: serde_json::Value,
219}
220
221/// Provider trait. Every concrete provider (mock, ID.me, future Okta /
222/// Auth0 / etc.) implements this. Designed to be `Send + Sync + 'static`
223/// so providers can live inside an `Arc` for the whole process.
224#[async_trait]
225pub trait IdentityProvider: Send + Sync + 'static {
226 /// Stable identifier matching the `id:` field in `identity.yaml`.
227 fn id(&self) -> &str;
228
229 /// True if this provider has the credentials it needs to operate.
230 /// The ID.me adapter returns `false` until sandbox credentials are
231 /// supplied; the mock provider always returns `true`.
232 fn is_ready(&self) -> bool;
233
234 /// Begin a verification flow. Returns the URL the user should open
235 /// plus the bookkeeping needed to validate the callback.
236 async fn begin(&self, req: ChallengeRequest) -> anyhow::Result<Challenge>;
237
238 /// Complete the verification flow given the provider's response.
239 /// For OAuth providers, `code` is the authorization code; for the
240 /// mock provider it's a synthetic token. `state` must match the
241 /// challenge id we minted in [`begin`].
242 async fn exchange(
243 &self,
244 challenge_id: &str,
245 code: &str,
246 state: &str,
247 pkce_verifier: Option<&str>,
248 ) -> anyhow::Result<VerifiedIdentity>;
249}
250
251// ────────────────────────────────────────────────────────────────────
252// Gate -- the public composition object
253// ────────────────────────────────────────────────────────────────────
254
255/// The [`IdentityGate`] is the single object `main.rs` interacts with.
256/// It owns the provider registry, the signed proof cache, and (lazily)
257/// the local callback server.
258///
259/// Construction is cheap (loads config, opens cache file, generates or
260/// loads the signing key). The callback server is **not** spun up until
261/// the first [`IdentityGate::start_challenge`] call -- shields that
262/// never hit an identity-gated rule pay zero runtime cost.
263pub struct IdentityGate {
264 config: IdentityConfig,
265 providers: Vec<Arc<dyn IdentityProvider>>,
266 cache: Arc<ProofCache>,
267 signer: Arc<ProofSigner>,
268 server: tokio::sync::OnceCell<ServerHandle>,
269}
270
271impl IdentityGate {
272 /// Build a gate from a config and a list of provider implementations.
273 /// Cache key + signing keypair live in `<state_dir>` (typically
274 /// `~/.aperion-shield`).
275 pub fn new(
276 config: IdentityConfig,
277 providers: Vec<Arc<dyn IdentityProvider>>,
278 state_dir: std::path::PathBuf,
279 ) -> anyhow::Result<Self> {
280 let signer = Arc::new(ProofSigner::load_or_create(&state_dir)?);
281 let cache = Arc::new(ProofCache::open(state_dir.join("identity-cache.json"), &signer)?);
282 Ok(Self {
283 config,
284 providers,
285 cache,
286 signer,
287 server: tokio::sync::OnceCell::new(),
288 })
289 }
290
291 /// Look up a cached proof satisfying `req`. None means we have to
292 /// prompt the user.
293 pub fn cached_proof_for(&self, req: &Requirement) -> Option<Proof> {
294 let now = unix_now();
295 self.cache.find_satisfying(req, now)
296 }
297
298 /// Provider with the given id, or None.
299 pub fn provider(&self, id: &str) -> Option<Arc<dyn IdentityProvider>> {
300 self.providers.iter().find(|p| p.id() == id).cloned()
301 }
302
303 /// Lazily start the callback server, sharing the same provider /
304 /// cache / signer instances the gate already holds. Subsequent calls
305 /// return the cached handle.
306 async fn ensure_server(&self) -> anyhow::Result<&ServerHandle> {
307 self.server
308 .get_or_try_init(|| async {
309 CallbackServer::spawn(
310 &self.config.callback_host,
311 self.config.callback_port,
312 self.providers.clone(),
313 self.cache.clone(),
314 self.signer.clone(),
315 )
316 .await
317 })
318 .await
319 }
320
321 /// Ensure the callback server is running and return its base URL
322 /// (e.g. `http://127.0.0.1:53201`).
323 pub async fn callback_base(&self) -> anyhow::Result<String> {
324 let h = self.ensure_server().await?;
325 Ok(h.base_url())
326 }
327
328 /// Hand the gate a freshly-minted challenge so the callback server
329 /// can correlate the user's redirect back to its in-flight state.
330 pub async fn register_inflight(
331 &self,
332 challenge: &Challenge,
333 requirement: Requirement,
334 provider: String,
335 rule_id: String,
336 ) -> anyhow::Result<()> {
337 let h = self.ensure_server().await?;
338 h.register(
339 challenge.clone(),
340 server::Inflight { rule_id, provider, requirement },
341 )
342 .await;
343 Ok(())
344 }
345
346 /// Block up to `hold_seconds` waiting for a proof to land in the
347 /// cache for `req`. Returns the proof if one arrives; None on
348 /// timeout. Callers should treat None as "tell the agent to retry".
349 pub async fn wait_for_proof(
350 &self,
351 req: &Requirement,
352 hold_seconds: u64,
353 ) -> Option<Proof> {
354 let deadline = std::time::Instant::now()
355 + std::time::Duration::from_secs(hold_seconds);
356 loop {
357 if let Some(p) = self.cached_proof_for(req) {
358 return Some(p);
359 }
360 if std::time::Instant::now() >= deadline {
361 return None;
362 }
363 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
364 }
365 }
366
367 /// Persist a freshly-verified identity as a signed proof.
368 pub fn mint_and_cache(
369 &self,
370 vi: &VerifiedIdentity,
371 req: &Requirement,
372 ) -> anyhow::Result<Proof> {
373 let now = unix_now();
374 let proof = Proof {
375 v: 1,
376 provider: vi.provider.clone(),
377 subject: vi.subject.clone(),
378 email: vi.email.clone(),
379 loa: vi.loa,
380 scope: req.scope.clone(),
381 verified_at: now,
382 expires_at: now.saturating_add(req.max_proof_age_seconds),
383 nonce: hex::encode(rand::random::<[u8; 16]>()),
384 sig: String::new(),
385 };
386 let signed = self.signer.sign(proof)?;
387 self.cache.insert(signed.clone())?;
388 Ok(signed)
389 }
390
391 /// Number of valid (signature-verified, non-expired) proofs cached.
392 pub fn cached_count(&self) -> usize {
393 self.cache.count_valid(unix_now())
394 }
395
396 /// Drop every cached proof. Returns how many were evicted.
397 pub fn flush(&self) -> anyhow::Result<usize> {
398 self.cache.flush()
399 }
400
401 /// Hold seconds configured for this gate.
402 pub fn hold_seconds(&self) -> u64 {
403 self.config.hold_seconds
404 }
405
406 /// True if at least one provider is registered AND ready.
407 pub fn has_ready_provider(&self) -> bool {
408 self.providers.iter().any(|p| p.is_ready())
409 }
410
411 /// Read-only access to the loaded config.
412 pub fn config(&self) -> &IdentityConfig {
413 &self.config
414 }
415}
416
417pub(crate) fn unix_now() -> u64 {
418 SystemTime::now()
419 .duration_since(UNIX_EPOCH)
420 .map(|d| d.as_secs())
421 .unwrap_or(0)
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 fn vi(provider: &str, subject: &str, email: Option<&str>, loa: u8) -> VerifiedIdentity {
429 VerifiedIdentity {
430 provider: provider.into(),
431 subject: subject.into(),
432 email: email.map(str::to_string),
433 loa,
434 raw: serde_json::Value::Null,
435 }
436 }
437
438 #[test]
439 fn allow_list_matches_email() {
440 // Compose the email literals at runtime so the source contains
441 // no `name@domain` token. Some doc/display layers normalise
442 // such tokens to a placeholder which made earlier versions of
443 // this test compare two identical strings without us noticing.
444 let allowed = format!("{}@{}", "ace", "aperion.ai");
445 let denied = format!("{}@{}", "bee", "other.com");
446 let req = Requirement {
447 provider: "id_me".into(),
448 scope: "scm.commit".into(),
449 allowed_subjects: vec![allowed.clone()],
450 max_proof_age_seconds: 900,
451 loa: 2,
452 };
453 // Email matches the allow list -> allowed.
454 assert!(req.allows(&vi("id_me", "sub-1", Some(allowed.as_str()), 2)));
455 // Different email, no other identifier on the allow list -> denied.
456 assert!(!req.allows(&vi("id_me", "sub-2", Some(denied.as_str()), 2)));
457 // No email at all -> denied.
458 assert!(!req.allows(&vi("id_me", "sub-3", None, 2)));
459 }
460
461 #[test]
462 fn allow_list_matches_subject_with_prefix() {
463 let req = Requirement {
464 provider: "id_me".into(),
465 scope: "x".into(),
466 allowed_subjects: vec!["id_me|sub-1".into()],
467 max_proof_age_seconds: 900,
468 loa: 0,
469 };
470 assert!(req.allows(&vi("id_me", "sub-1", None, 0)));
471 }
472
473 #[test]
474 fn wildcard_lets_anyone_pass() {
475 let req = Requirement {
476 provider: "id_me".into(),
477 scope: "x".into(),
478 allowed_subjects: vec!["*".into()],
479 max_proof_age_seconds: 900,
480 loa: 0,
481 };
482 assert!(req.allows(&vi("id_me", "sub-99", Some("[email protected]"), 0)));
483 }
484
485 #[test]
486 fn requirement_freshness_check() {
487 let req = Requirement {
488 provider: "id_me".into(),
489 scope: "x".into(),
490 allowed_subjects: vec!["*".into()],
491 max_proof_age_seconds: 60,
492 loa: 0,
493 };
494 let now = unix_now();
495 let fresh = Proof {
496 v: 1, provider: "id_me".into(), subject: "s".into(), email: None,
497 loa: 0, scope: "x".into(), verified_at: now, expires_at: now + 60,
498 nonce: "".into(), sig: "".into(),
499 };
500 let stale = Proof { verified_at: now - 120, expires_at: now + 60, ..fresh.clone() };
501 let wrong_scope = Proof { scope: "y".into(), ..fresh.clone() };
502 let low_loa = Proof { loa: 0, ..fresh.clone() };
503 let high_loa_req = Requirement { loa: 2, ..req.clone() };
504
505 assert!(req.is_satisfied_by(&fresh, now));
506 assert!(!req.is_satisfied_by(&stale, now));
507 assert!(!req.is_satisfied_by(&wrong_scope, now));
508 assert!(!high_loa_req.is_satisfied_by(&low_loa, now));
509 }
510}