diff --git a/.ci/readability-baseline.env b/.ci/readability-baseline.env index 9de17adc4..004f300fc 100644 --- a/.ci/readability-baseline.env +++ b/.ci/readability-baseline.env @@ -1,11 +1,11 @@ # Generated by scripts/readability-ratchet.sh -PROD_RS_TOTAL=523 -PROD_FILES_GT300=209 -PROD_FILES_GT500=84 +PROD_RS_TOTAL=524 +PROD_FILES_GT300=211 +PROD_FILES_GT500=87 PROD_FILES_GT1000=22 PROD_MAX_FILE_LINES=2829 PROD_MAX_FILE_PATH=crates/temper-server/src/storage/mod.rs -ALLOW_CLIPPY_COUNT=35 +ALLOW_CLIPPY_COUNT=36 ALLOW_DEAD_CODE_COUNT=14 PROD_PRINTLN_COUNT=247 PROD_UNWRAP_CI_OK_COUNT=132 diff --git a/AGENTS.md b/AGENTS.md index aa335a4a0..801b61d3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -193,3 +193,8 @@ Before deploying any spec change: 3. Entity actors hot-deploy without dropping existing state 4. OData endpoints respond correctly for all entity types 5. Telemetry emits WideEvents for all transitions + +## Definition of done — review bar + +- **Three independent fresh-context reviews before anything is "done".** Nothing counts as fully implemented until all three have reviewed it, each with NO prior context on the work: **Codex** (`codex exec --model gpt-5.6-sol -c model_reasoning_effort="high" --sandbox read-only`), **a second independent Codex Sol session**, and **an independent Fable** (a fresh Claude subagent, `model: fable`). Run **Greptile** on every PR too (`@greptile review` as a PR comment). Ask each for severity, `file:line`, and a concrete failure scenario, then fix everything they find — including findings that criticise your own fixes — and re-verify. They catch different classes: one finds bypass surface, one finds fail-open behaviour, one finds whether it actually runs. Two agreeing does not excuse skipping the third. +- **Test the production shape, not a convenient one.** A gate proven against a permissive or mock engine, and an end-to-end that only exercises the happy verb, are not proof — verify against the real policy/config set and probe the generic paths too (PATCH/PUT/DELETE, not only the named action). diff --git a/CLAUDE.md b/CLAUDE.md index 4dd12b3b2..354dc44fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,3 +193,8 @@ Before deploying any spec change: 3. Entity actors hot-deploy without dropping existing state 4. OData endpoints respond correctly for all entity types 5. Telemetry emits WideEvents for all transitions + +## Definition of done — review bar + +- **Three independent fresh-context reviews before anything is "done".** Nothing counts as fully implemented until all three have reviewed it, each with NO prior context on the work: **Codex** (`codex exec --model gpt-5.6-sol -c model_reasoning_effort="high" --sandbox read-only`), **a second independent Codex Sol session**, and **an independent Fable** (a fresh Claude subagent, `model: fable`). Run **Greptile** on every PR too (`@greptile review` as a PR comment). Ask each for severity, `file:line`, and a concrete failure scenario, then fix everything they find — including findings that criticise your own fixes — and re-verify. They catch different classes: one finds bypass surface, one finds fail-open behaviour, one finds whether it actually runs. Two agreeing does not excuse skipping the third. +- **Test the production shape, not a convenient one.** A gate proven against a permissive or mock engine, and an end-to-end that only exercises the happy verb, are not proof — verify against the real policy/config set and probe the generic paths too (PATCH/PUT/DELETE, not only the named action). diff --git a/Cargo.lock b/Cargo.lock index 5b9d7ed73..cccd97709 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,6 +436,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -1467,6 +1473,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1825,6 +1843,20 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ecommerce-reference" version = "0.1.0" @@ -1889,6 +1921,26 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embedded-io" version = "0.4.0" @@ -2049,6 +2101,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -2330,6 +2392,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2439,6 +2502,17 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.3.27" @@ -4237,6 +4311,18 @@ dependencies = [ "indexmap 2.13.0", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "parking" version = "2.2.1" @@ -4629,6 +4715,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-utils" version = "0.10.0" @@ -5207,6 +5302,16 @@ dependencies = [ "webpki-roots 1.0.6", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -5542,6 +5647,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -6593,6 +6712,7 @@ dependencies = [ "chrono", "hyper 1.8.1", "opentelemetry", + "p256", "reqwest", "serde", "serde_json", @@ -6684,6 +6804,7 @@ dependencies = [ "lru", "opentelemetry", "opentelemetry_sdk", + "p256", "pprof", "reqwest", "serde", diff --git a/crates/temper-authz/src/context.rs b/crates/temper-authz/src/context.rs index 36eb26f5d..1b9266461 100644 --- a/crates/temper-authz/src/context.rs +++ b/crates/temper-authz/src/context.rs @@ -200,6 +200,71 @@ impl SecurityContext { } } + /// Construct security context from a JWT whose signature was verified + /// against a registered trusted issuer. + /// + /// Like [`from_resolved_identity`](Self::from_resolved_identity), identity + /// comes from a platform-verified source, never self-declared headers, and + /// `agentTypeVerified` is set. Unlike it, this path carries `acting_for` + /// (the owning human behind an agent) and `role`, both taken from verified + /// token claims. See RFC-0002. + #[allow(clippy::too_many_arguments)] + pub fn from_verified_jwt( + principal_id: &str, + kind: PrincipalKind, + agent_type: Option<&str>, + acting_for: Option<&str>, + role: Option<&str>, + session_id: Option<&str>, + ) -> Self { + let mut attributes = HashMap::new(); + attributes.insert( + "agentTypeVerified".to_string(), + serde_json::Value::Bool(true), + ); + + let mut context_attrs = HashMap::new(); + context_attrs.insert( + "agentId".to_string(), + serde_json::Value::String(principal_id.to_string()), + ); + if let Some(at) = agent_type { + context_attrs.insert( + "agentType".to_string(), + serde_json::Value::String(at.to_string()), + ); + } + context_attrs.insert( + "agentTypeVerified".to_string(), + serde_json::Value::Bool(true), + ); + if let Some(af) = acting_for { + context_attrs.insert( + "actingFor".to_string(), + serde_json::Value::String(af.to_string()), + ); + } + if let Some(sid) = session_id { + context_attrs.insert( + "sessionId".to_string(), + serde_json::Value::String(sid.to_string()), + ); + } + + SecurityContext { + principal: Principal { + id: principal_id.to_string(), + kind, + role: role.map(|r| r.to_string()), + acting_for: acting_for.map(|a| a.to_string()), + agent_type: agent_type.map(|a| a.to_string()), + attributes, + }, + context_attrs, + correlation_id: uuid::Uuid::now_v7().to_string(), + } + } + /// Enrich security context with agent identity from self-declared headers. /// /// **Deprecated**: Use `from_resolved_identity()` for credential-based identity. diff --git a/crates/temper-authz/src/engine/mod.rs b/crates/temper-authz/src/engine/mod.rs index f2caf62cb..fbf59e0b8 100644 --- a/crates/temper-authz/src/engine/mod.rs +++ b/crates/temper-authz/src/engine/mod.rs @@ -133,8 +133,13 @@ impl AuthzEngine { /// so that Cedar evaluates to Allow for every principal kind (System or /// otherwise). Used in tests and permissive dev environments. pub fn permissive() -> Self { - let policy_set = + let mut policy_set = PolicySet::from_str("permit(principal, action, resource);").unwrap_or_default(); + // Even a permit-all fallback (e.g. the ARN-230 fail-open path) must keep + // the system-platform forbids — the god-mode identity entities + // (TrustedIssuer / PrincipalGeneration) stay System/Admin-only, so a + // fail-open tenant can never become an authz-takeover (ARN-255). + merge_system_platform_policy(&mut policy_set); Self { tenant_policies: RwLock::new(BTreeMap::new()), fallback_policy_set: RwLock::new(CompiledPolicies::new(policy_set)), @@ -680,6 +685,22 @@ impl AuthzEngine { const SYSTEM_PLATFORM_POLICY: &str = r#" @id("system-platform:broad-permit") permit(principal is System, action, resource); + +@id("system-platform:identity-entities-permit-trusted-issuer") +permit(principal, action, resource is TrustedIssuer) +when { principal is System || principal is Admin || (principal has agent_type && principal.agent_type == "operator") }; + +@id("system-platform:identity-entities-permit-principal-generation") +permit(principal, action, resource is PrincipalGeneration) +when { principal is System || principal is Admin || (principal has agent_type && principal.agent_type == "operator") }; + +@id("system-platform:protect-trusted-issuer") +forbid(principal, action, resource is TrustedIssuer) +unless { principal is System || principal is Admin || (principal has agent_type && principal.agent_type == "operator") }; + +@id("system-platform:protect-principal-generation") +forbid(principal, action, resource is PrincipalGeneration) +unless { principal is System || principal is Admin || (principal has agent_type && principal.agent_type == "operator") }; "#; /// PolicyId prefix used for the built-in system-platform policies diff --git a/crates/temper-authz/src/engine/tests.rs b/crates/temper-authz/src/engine/tests.rs index 5d167451e..9015f8f4a 100644 --- a/crates/temper-authz/src/engine/tests.rs +++ b/crates/temper-authz/src/engine/tests.rs @@ -760,3 +760,164 @@ fn candidate_filter_preserves_named_forbid_policy_ids() { "candidate filtering must preserve named policy diagnostics, got: {policy_ids:?}" ); } + +// --- Platform gate on the god-mode identity entities (ARN-255) --------------- + +fn agent_context(id: &str) -> SecurityContext { + SecurityContext::from_headers(&[ + ("X-Temper-Principal-Id".to_string(), id.to_string()), + ("X-Temper-Principal-Kind".to_string(), "agent".to_string()), + ]) +} + +/// RegisterIssuer / BumpGeneration must be System/Admin-only even when the +/// tenant base is permit-all (the ARN-230 fail-open scenario) — the +/// system-platform forbid overrides it. This is the fix for the "an agent +/// registers its own signing key → mints owner tokens" takeover. + +/// The platform's own operator credential (the shared API key, resolved through +/// the bootstrapped operator AgentType) must still manage these entities under a +/// PRODUCTION-shaped policy set — an app bundle that carries no permit for them +/// at all. Locking it out breaks token issue/refresh, grant revocation and +/// sign-out-everywhere, since the authorization server calls with exactly that +/// credential. This is not a weakening: a holder of the shared key can already +/// self-declare admin at the ingress. +#[test] +fn operator_credential_can_manage_identity_entities_under_app_policies() { + // An app bundle like katagami's: permits for its own entities, nothing for + // TrustedIssuer or PrincipalGeneration. + let engine = AuthzEngine::new("permit(principal, action, resource is DesignLanguage);") + .expect("policy parses"); + let attrs = HashMap::new(); + + let operator = SecurityContext::from_headers(&[ + ("X-Temper-Principal-Id".to_string(), "operator".to_string()), + ("X-Temper-Principal-Kind".to_string(), "agent".to_string()), + ("X-Temper-Agent-Type".to_string(), "operator".to_string()), + ]); + + for entity in ["TrustedIssuer", "PrincipalGeneration"] { + for action in ["read", "update", "RegisterIssuer", "BumpGeneration"] { + assert!( + engine + .authorize(&operator, action, entity, &attrs) + .is_allowed(), + "the operator credential must be able to {action} {entity}" + ); + } + } + + // A contributor agent is still shut out under the same policy set. + let contributor = SecurityContext::from_headers(&[ + ( + "X-Temper-Principal-Id".to_string(), + "kc_attacker".to_string(), + ), + ("X-Temper-Principal-Kind".to_string(), "agent".to_string()), + ("X-Temper-Agent-Type".to_string(), "contributor".to_string()), + ]); + for entity in ["TrustedIssuer", "PrincipalGeneration"] { + for action in ["read", "update", "RegisterIssuer", "BumpGeneration"] { + assert!( + !engine + .authorize(&contributor, action, entity, &attrs) + .is_allowed(), + "a contributor must not {action} {entity}" + ); + } + } +} + +#[test] +fn issuer_registry_is_admin_system_only_even_on_permit_all() { + let engine = AuthzEngine::permissive(); // permit(principal, action, resource) + system-platform + let attrs = HashMap::new(); + + // Allowed: System (platform) and Admin (operator key / the AS). + assert!( + engine + .authorize( + &SecurityContext::system(), + "RegisterIssuer", + "TrustedIssuer", + &attrs + ) + .is_allowed() + ); + assert!( + engine + .authorize(&admin_context(), "RegisterIssuer", "TrustedIssuer", &attrs) + .is_allowed() + ); + + // Forbidden: a verified agent or human — the takeover path. + for action in ["RegisterIssuer", "RotateIssuerKeys", "RevokeIssuer"] { + assert!( + !engine + .authorize( + &agent_context("kc_attacker"), + action, + "TrustedIssuer", + &attrs + ) + .is_allowed(), + "agent must be forbidden from {action} on TrustedIssuer" + ); + assert!( + !engine + .authorize( + &customer_context("human-x"), + action, + "TrustedIssuer", + &attrs + ) + .is_allowed(), + "customer must be forbidden from {action} on TrustedIssuer" + ); + } + + // BumpGeneration (per-user sign-out DoS) is likewise gated. + assert!( + engine + .authorize( + &admin_context(), + "BumpGeneration", + "PrincipalGeneration", + &attrs + ) + .is_allowed(), + "the AS (Admin) must be able to BumpGeneration" + ); + assert!( + !engine + .authorize( + &agent_context("kc_attacker"), + "BumpGeneration", + "PrincipalGeneration", + &attrs + ) + .is_allowed(), + "an agent must not be able to sign out arbitrary users" + ); + + // Generic OData CRUD must not walk around the named actions: PATCH is + // authorized as "update", so a gate listing only named actions would let an + // agent rewrite TrustedIssuer.jwks_json with its own key and mint owner + // tokens. The forbid is resource-wide for exactly that reason. + for entity in ["TrustedIssuer", "PrincipalGeneration"] { + for action in ["create", "read", "update", "delete"] { + assert!( + !engine + .authorize(&agent_context("kc_attacker"), action, entity, &attrs) + .is_allowed(), + "agent must not reach {entity} via generic {action}" + ); + assert!( + engine + .authorize(&admin_context(), action, entity, &attrs) + .is_allowed(), + "Admin must still {action} {entity}" + ); + } + } +} diff --git a/crates/temper-cli/src/serve/bootstrap.rs b/crates/temper-cli/src/serve/bootstrap.rs index 2e206e453..137d0e6a2 100644 --- a/crates/temper-cli/src/serve/bootstrap.rs +++ b/crates/temper-cli/src/serve/bootstrap.rs @@ -439,6 +439,13 @@ pub(super) async fn bootstrap_tenants(state: &PlatformState, apps: &[(String, St temper_platform::persist_system_verification(&turso, &sys_hashes, &sys_cache).await; } + // Every tenant that receives the agent specs also needs the trusted issuer + // registered, or issuer-signed JWTs 401 there: bearer resolution looks the + // issuer up in the REQUEST's tenant, not in `default`. + let mut agent_spec_tenants: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + agent_spec_tenants.insert("default".to_string()); + let default_cache = load_verified_cache(state, "default").await; let default_hashes = temper_platform::bootstrap_agent_specs(state, "default", false, &default_cache); @@ -453,6 +460,7 @@ pub(super) async fn bootstrap_tenants(state: &PlatformState, apps: &[(String, St } for (tenant, _dir) in apps { + agent_spec_tenants.insert(tenant.clone()); let cache = load_verified_cache(state, tenant).await; // App tenants already have user specs loaded in Phase 2; merge the // built-in agent OS entities so we do not replace their entity-set map. @@ -472,6 +480,7 @@ pub(super) async fn bootstrap_tenants(state: &PlatformState, apps: &[(String, St .and_then(|stack| stack.turso.clone()) { for tenant in provider.connected_tenants().await { + agent_spec_tenants.insert(tenant.clone()); let cache = load_verified_cache(state, &tenant).await; let hashes = temper_platform::bootstrap_agent_specs(state, &tenant, true, &cache); if let Some(turso) = state.server.turso_store_for_tenant(&tenant).await { @@ -486,6 +495,14 @@ pub(super) async fn bootstrap_tenants(state: &PlatformState, apps: &[(String, St if let Some(ref api_key) = state.api_token { temper_platform::bootstrap_operator_credential(state, api_key, "default").await; } + + // Register a trusted JWT issuer from env config, if provided (ARN-255). + // This is how a deployment activates the platform-issued-token path without + // an authenticated API call. Registered for every tenant that carries the + // agent specs, since the issuer is resolved in the request's own tenant. + for tenant in &agent_spec_tenants { + temper_platform::bootstrap_trusted_issuer_from_env(state, tenant).await; + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/crates/temper-platform/Cargo.toml b/crates/temper-platform/Cargo.toml index f29554c8e..1b9924433 100644 --- a/crates/temper-platform/Cargo.toml +++ b/crates/temper-platform/Cargo.toml @@ -40,3 +40,6 @@ tower = { workspace = true } hyper = { workspace = true } wiremock = { workspace = true } tempfile = "3.27.0" +# ES256 token minting for the TrustedIssuer resolver integration test. +p256 = { version = "0.13", features = ["ecdsa"] } +base64 = "0.22" diff --git a/crates/temper-platform/src/bootstrap.rs b/crates/temper-platform/src/bootstrap.rs index 8cbbdd176..fce0a4a50 100644 --- a/crates/temper-platform/src/bootstrap.rs +++ b/crates/temper-platform/src/bootstrap.rs @@ -62,6 +62,8 @@ const TOOL_CALL_IOA: &str = include_str!("specs/tool_call.ioa.toml"); const SCHEDULE_IOA: &str = include_str!("specs/schedule.ioa.toml"); const POLICY_IOA: &str = include_str!("specs/policy.ioa.toml"); const AGENT_CREDENTIAL_IOA: &str = include_str!("specs/agent_credential.ioa.toml"); +const TRUSTED_ISSUER_IOA: &str = include_str!("specs/trusted_issuer.ioa.toml"); +const PRINCIPAL_GENERATION_IOA: &str = include_str!("specs/principal_generation.ioa.toml"); const AGENT_CSDL: &str = include_str!("specs/agent_model.csdl.xml"); /// Agent entity specs as (entity_type, ioa_source) pairs. @@ -74,6 +76,8 @@ const AGENT_SPECS: &[(&str, &str)] = &[ ("Schedule", SCHEDULE_IOA), ("Policy", POLICY_IOA), ("AgentCredential", AGENT_CREDENTIAL_IOA), + ("TrustedIssuer", TRUSTED_ISSUER_IOA), + ("PrincipalGeneration", PRINCIPAL_GENERATION_IOA), ]; /// Verify, parse, and register a set of IOA specs under a tenant. @@ -464,6 +468,68 @@ pub async fn bootstrap_operator_credential(state: &PlatformState, api_key: &str, ); } +/// Register a trusted JWT issuer from environment configuration at startup. +/// +/// Mirrors [`bootstrap_operator_credential`]: a deployment activates the +/// platform-issued-token path (ARN-255) by setting three env vars — the issuer +/// URL, its inline JWKS, and the expected audience — instead of making an +/// authenticated API call. Registration goes through the normal dispatch path +/// under a service context, so it is not gated on a tenant Cedar policy. +/// +/// No-op when the env vars are unset. Idempotent: re-registering an existing +/// issuer is a self-loop on `Active`. +pub async fn bootstrap_trusted_issuer_from_env(state: &PlatformState, tenant: &str) { + let (issuer, jwks_json, audience) = match ( + std::env::var("TEMPER_TRUSTED_ISSUER_URL") + .ok() + .filter(|s| !s.is_empty()), + std::env::var("TEMPER_TRUSTED_ISSUER_JWKS") + .ok() + .filter(|s| !s.is_empty()), + std::env::var("TEMPER_TRUSTED_ISSUER_AUD") + .ok() + .filter(|s| !s.is_empty()), + ) { + (Some(u), Some(j), Some(a)) => (u, j, a), + _ => return, + }; + + let tenant_id = temper_runtime::tenant::TenantId::new(tenant); + // Registering a trusted issuer is System-only (system-platform Cedar policy); + // the platform seeding itself acts as System. + let agent_ctx = temper_server::request_context::AgentContext::system(); + let algorithms = + std::env::var("TEMPER_TRUSTED_ISSUER_ALGS").unwrap_or_else(|_| "ES256".to_string()); + + let result = state + .server + .dispatch_tenant_action( + &tenant_id, + "TrustedIssuer", + &issuer, + "RegisterIssuer", + serde_json::json!({ + "issuer": issuer, + "jwks_json": jwks_json, + "audience": audience, + "algorithms": algorithms, + "description": "Registered from environment at startup", + "created_by": "bootstrap", + }), + &agent_ctx, + ) + .await; + + match result { + Ok(_) => tracing::info!( + "Trusted issuer '{issuer}' registered for tenant '{tenant}' from environment" + ), + Err(e) => tracing::error!( + "Failed to register trusted issuer '{issuer}' for tenant '{tenant}': {e}" + ), + } +} + #[cfg(test)] mod tests { use super::*; @@ -774,6 +840,24 @@ initial = "Created" #[test] fn test_agent_specs_count() { - assert_eq!(AGENT_SPECS.len(), 8); + assert_eq!(AGENT_SPECS.len(), 10); + } + + #[test] + fn test_trusted_issuer_spec_is_registered() { + assert!( + AGENT_SPECS + .iter() + .any(|(name, source)| *name == "TrustedIssuer" && !source.is_empty()) + ); + } + + #[test] + fn test_principal_generation_spec_is_registered() { + assert!( + AGENT_SPECS + .iter() + .any(|(name, source)| *name == "PrincipalGeneration" && !source.is_empty()) + ); } } diff --git a/crates/temper-platform/src/lib.rs b/crates/temper-platform/src/lib.rs index 756496fc8..be3839aa8 100644 --- a/crates/temper-platform/src/lib.rs +++ b/crates/temper-platform/src/lib.rs @@ -26,7 +26,7 @@ pub mod tenant_api; // Re-export primary types at crate root. pub use bootstrap::{ bootstrap_agent_specs, bootstrap_operator_credential, bootstrap_system_tenant, - persist_agent_verification, persist_system_verification, + bootstrap_trusted_issuer_from_env, persist_agent_verification, persist_system_verification, }; pub use os_apps::{AppBundle, AppEntry, AppManifest, InstallResult, install_os_app, list_os_apps}; pub use protocol::{PlatformEvent, VerifyStepStatus}; diff --git a/crates/temper-platform/src/specs/agent_model.csdl.xml b/crates/temper-platform/src/specs/agent_model.csdl.xml index a4a18ba20..d27527196 100644 --- a/crates/temper-platform/src/specs/agent_model.csdl.xml +++ b/crates/temper-platform/src/specs/agent_model.csdl.xml @@ -141,6 +141,25 @@ + + + + + + + + + + + + + + + + + + + @@ -166,6 +185,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -191,6 +247,8 @@ + + diff --git a/crates/temper-platform/src/specs/principal_generation.ioa.toml b/crates/temper-platform/src/specs/principal_generation.ioa.toml new file mode 100644 index 000000000..97fe0a017 --- /dev/null +++ b/crates/temper-platform/src/specs/principal_generation.ioa.toml @@ -0,0 +1,43 @@ +# PrincipalGeneration Entity — I/O Automaton Specification +# +# The kernel-side source of truth for "sign out everywhere" (RFC-0002, ARN-255, +# option A). One entity per principal subject (entity id = the human's `sub`), +# holding a monotonically increasing `generation` counter. +# +# Flow: +# - The authorization server reads this counter when it mints a token and +# stamps the value into the token's `auth_generation` claim. +# - The kernel resolver rejects any token whose `auth_generation` is older +# than the current counter. +# - "Sign out everywhere" dispatches `BumpGeneration`, which advances the +# counter and thereby invalidates every token issued before that moment +# (within the resolver's short cache window). +# +# The generation lives here, in the kernel, NOT on an app entity like Member — +# so the kernel can enforce it generically for any app. Bumping it is a +# governed action, so revocation is a first-class platform capability. + +[automaton] +name = "PrincipalGeneration" +states = ["Active"] +initial = "Active" + +# --- State Variables --- + +# Monotonic token generation. Every `BumpGeneration` advances it by one. +[[state]] +name = "generation" +type = "counter" +initial = "0" + +# --- Actions --- + +[[action]] +name = "BumpGeneration" +kind = "input" +from = ["Active"] +effect = [ + { type = "increment", var = "generation" }, + { type = "emit", event = "GenerationBumped" } +] +hint = "Sign out everywhere: invalidate every token issued for this principal before now by advancing its generation." diff --git a/crates/temper-platform/src/specs/trusted_issuer.ioa.toml b/crates/temper-platform/src/specs/trusted_issuer.ioa.toml new file mode 100644 index 000000000..9f489f636 --- /dev/null +++ b/crates/temper-platform/src/specs/trusted_issuer.ioa.toml @@ -0,0 +1,105 @@ +# TrustedIssuer Entity — I/O Automaton Specification +# +# A per-tenant allowlist entry for a JWT issuer the kernel will trust. The +# bearer resolver verifies platform-issued ES256 tokens against the JWKS +# registered here (entity id = the issuer URL, i.e. the token's `iss` claim). +# Keys are stored inline so token verification makes no outbound HTTP call and +# stays deterministic under DST; rotation is a governed action, not an +# automatic JWKS refetch. +# +# See RFC-0002 (First-Class Authorization) and ADR-0033 (Platform-Assigned +# Agent Identity). + +[automaton] +name = "TrustedIssuer" +states = ["Active", "Suspended", "Revoked"] +initial = "Active" + +# --- State Variables --- + +[[state]] +name = "issuer" +type = "string" +initial = "" + +# Inline JWKS document (JSON) holding the issuer's public keys. +[[state]] +name = "jwks_json" +type = "string" +initial = "" + +# Expected `aud` claim for tokens from this issuer. +[[state]] +name = "audience" +type = "string" +initial = "" + +# Accepted signing algorithms (informational; the verifier enforces ES256). +[[state]] +name = "algorithms" +type = "string" +initial = "ES256" + +[[state]] +name = "description" +type = "string" +initial = "" + +[[state]] +name = "created_by" +type = "string" +initial = "" + +# --- Actions --- + +[[action]] +name = "RegisterIssuer" +kind = "input" +from = ["Active"] +params = ["issuer", "jwks_json", "audience", "algorithms", "description", "created_by"] +hint = "Register a trusted JWT issuer for this tenant. Entity id must be the issuer URL." + +[[action]] +name = "RotateIssuerKeys" +kind = "input" +from = ["Active"] +params = ["jwks_json"] +hint = "Replace the issuer's inline JWKS with a rotated key set." + +[[action]] +name = "SuspendIssuer" +kind = "input" +from = ["Active"] +to = "Suspended" +hint = "Temporarily stop trusting tokens from this issuer without discarding its registration." + +[[action]] +name = "ResumeIssuer" +kind = "input" +from = ["Suspended"] +to = "Active" +hint = "Resume trusting tokens from a suspended issuer." + +[[action]] +name = "RevokeIssuer" +kind = "input" +from = ["Active", "Suspended"] +to = "Revoked" +hint = "Permanently stop trusting this issuer." + +# --- Safety Invariants --- + +[[invariant]] +name = "ActiveRequiresIssuer" +when = ["Active"] +assert = "issuer != ''" + +[[invariant]] +name = "ActiveRequiresJwks" +when = ["Active"] +assert = "jwks_json != ''" + +[[invariant]] +name = "ActiveRequiresAudience" +when = ["Active"] +assert = "audience != ''" diff --git a/crates/temper-platform/tests/trusted_issuer_resolve.rs b/crates/temper-platform/tests/trusted_issuer_resolve.rs new file mode 100644 index 000000000..d73377b28 --- /dev/null +++ b/crates/temper-platform/tests/trusted_issuer_resolve.rs @@ -0,0 +1,414 @@ +//! Integration test for ARN-255 step 1: the kernel resolver verifies ES256 +//! JWTs against a registered `TrustedIssuer` and rejects bad ones. +//! +//! Exercises the real path end to end: a real `PlatformState` with the agent +//! specs bootstrapped, a real `TrustedIssuer` entity seeded through the normal +//! dispatch path with a real P-256 JWKS, real ES256 tokens minted here, and +//! the real `IdentityResolver::resolve` mapping verified claims to a principal. + +use std::collections::BTreeMap; + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use p256::ecdsa::signature::Signer; +use p256::ecdsa::{Signature, SigningKey}; + +use temper_platform::{PlatformState, bootstrap_agent_specs, bootstrap_system_tenant}; +use temper_runtime::tenant::TenantId; +use temper_server::identity::IdentityResolver; +use temper_server::request_context::AgentContext; + +const ISSUER: &str = "https://issuer.e2e.local"; +const AUD: &str = "temper-e2e"; + +fn b64(v: &serde_json::Value) -> String { + URL_SAFE_NO_PAD.encode(serde_json::to_vec(v).unwrap()) +} + +/// Mint a signed ES256 token from header + claims JSON. +fn mint(sk: &SigningKey, header: serde_json::Value, claims: serde_json::Value) -> String { + let signing_input = format!("{}.{}", b64(&header), b64(&claims)); + let sig: Signature = sk.sign(signing_input.as_bytes()); + format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(sig.to_bytes())) +} + +/// Build a single-key JWKS JSON document for a signing key. +fn jwks_json(sk: &SigningKey, kid: &str) -> String { + let vk = sk.verifying_key(); + let pt = vk.to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(pt.x().unwrap()); + let y = URL_SAFE_NO_PAD.encode(pt.y().unwrap()); + serde_json::json!({ + "keys": [{ "kty": "EC", "crv": "P-256", "kid": kid, "x": x, "y": y }] + }) + .to_string() +} + +fn header() -> serde_json::Value { + serde_json::json!({ "alg": "ES256", "kid": "k1", "typ": "JWT" }) +} + +/// A valid contributor token: far-future exp so it is valid regardless of the +/// wall clock, nbf in the distant past. +fn contributor_claims() -> serde_json::Value { + serde_json::json!({ + "iss": ISSUER, + "sub": "human-e2e", + "aud": AUD, + "client_id": "kc_agent_e2e", + "agent_type": "contributor", + "grant_id": "grant-e2e", + "auth_generation": 3, + "nbf": 0, + "exp": 4_102_444_800i64, // year 2100 + }) +} + +async fn state_with_issuer(sk: &SigningKey) -> PlatformState { + let state = PlatformState::new(None); + let cache = BTreeMap::new(); + bootstrap_system_tenant(&state, &cache); + bootstrap_agent_specs(&state, "default", false, &cache); + + let tenant = TenantId::new("default"); + let ctx = AgentContext::system(); + state + .server + .dispatch_tenant_action( + &tenant, + "TrustedIssuer", + ISSUER, + "RegisterIssuer", + serde_json::json!({ + "issuer": ISSUER, + "jwks_json": jwks_json(sk, "k1"), + "audience": AUD, + "algorithms": "ES256", + "description": "e2e issuer", + "created_by": "e2e", + }), + &ctx, + ) + .await + .expect("register issuer"); + state +} + +#[tokio::test] +async fn valid_token_resolves_to_verified_agent_acting_for_human() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + let token = mint(&sk, header(), contributor_claims()); + + let resolver = IdentityResolver::new(); + let id = resolver + .resolve(&state.server, &TenantId::new("default"), &token) + .await + .expect("valid token should resolve"); + + assert!(id.verified); + assert!(id.from_jwt); + assert_eq!(id.agent_instance_id, "kc_agent_e2e"); + assert_eq!(id.agent_type_name, "contributor"); + assert_eq!(id.acting_for.as_deref(), Some("human-e2e")); + assert_eq!(id.auth_generation, Some(3)); +} + +#[tokio::test] +async fn token_signed_by_unknown_key_is_rejected() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + + // Sign with a different key than the registered JWKS. + let rogue = SigningKey::from_slice(&[9u8; 32]).unwrap(); + let token = mint(&rogue, header(), contributor_claims()); + + let resolver = IdentityResolver::new(); + let id = resolver + .resolve(&state.server, &TenantId::new("default"), &token) + .await; + assert!(id.is_none(), "rogue-key token must not resolve"); +} + +#[tokio::test] +async fn expired_token_is_rejected() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + + let mut claims = contributor_claims(); + claims["exp"] = serde_json::json!(100); // 1970 — long expired vs wall clock + let token = mint(&sk, header(), claims); + + let resolver = IdentityResolver::new(); + let id = resolver + .resolve(&state.server, &TenantId::new("default"), &token) + .await; + assert!(id.is_none(), "expired token must not resolve"); +} + +#[tokio::test] +async fn unregistered_issuer_is_rejected() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + + let mut claims = contributor_claims(); + claims["iss"] = serde_json::json!("https://not-registered.example"); + let token = mint(&sk, header(), claims); + + let resolver = IdentityResolver::new(); + let id = resolver + .resolve(&state.server, &TenantId::new("default"), &token) + .await; + assert!( + id.is_none(), + "token from an unregistered issuer must not resolve" + ); +} + +#[tokio::test] +async fn tampered_payload_is_rejected() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + + let token = mint(&sk, header(), contributor_claims()); + // Swap the payload for one escalating agent_type, keep the original signature. + let mut parts: Vec<&str> = token.split('.').collect(); + let forged = b64(&serde_json::json!({ + "iss": ISSUER, "aud": AUD, "client_id": "kc_agent_e2e", + "agent_type": "owner", "exp": 4_102_444_800i64, + })); + parts[1] = &forged; + let tampered = parts.join("."); + + let resolver = IdentityResolver::new(); + let id = resolver + .resolve(&state.server, &TenantId::new("default"), &tampered) + .await; + assert!(id.is_none(), "tampered token must not resolve"); +} + +#[tokio::test] +async fn suspended_issuer_stops_resolving() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + let token = mint(&sk, header(), contributor_claims()); + let tenant = TenantId::new("default"); + + // Valid before suspension. + let resolver = IdentityResolver::new(); + assert!( + resolver + .resolve(&state.server, &tenant, &token) + .await + .is_some(), + "token should resolve while issuer is Active" + ); + + // Suspend the issuer. + let ctx = AgentContext::system(); + state + .server + .dispatch_tenant_action( + &tenant, + "TrustedIssuer", + ISSUER, + "SuspendIssuer", + serde_json::json!({}), + &ctx, + ) + .await + .expect("suspend issuer"); + + // A fresh resolver (empty cache) must now reject the same token. + let fresh = IdentityResolver::new(); + assert!( + fresh + .resolve(&state.server, &tenant, &token) + .await + .is_none(), + "token must not resolve once its issuer is Suspended" + ); +} + +#[tokio::test] +async fn human_token_resolves_to_customer_with_role() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + + // A human token: `sub` + `role`, no `agent_type`/`client_id`. + let claims = serde_json::json!({ + "iss": ISSUER, "aud": AUD, "sub": "human-owner", + "role": "owner", "auth_generation": 0, + "nbf": 0, "exp": 4_102_444_800i64, + }); + let token = mint(&sk, header(), claims); + + let resolver = IdentityResolver::new(); + let id = resolver + .resolve(&state.server, &TenantId::new("default"), &token) + .await + .expect("human token should resolve"); + + assert!(id.from_jwt); + assert!(id.is_human); + assert_eq!(id.agent_instance_id, "human-owner"); + assert_eq!(id.role.as_deref(), Some("owner")); + assert!(id.acting_for.is_none()); +} + +#[tokio::test] +async fn bumping_generation_invalidates_older_tokens() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + let tenant = TenantId::new("default"); + + // Token minted at generation 0 (contributor_claims stamps auth_generation=3; + // build one at gen 0 for clarity). + let gen0 = { + let mut c = contributor_claims(); + c["auth_generation"] = serde_json::json!(0); + mint(&sk, header(), c) + }; + + // Valid before any bump. + let resolver = IdentityResolver::new(); + assert!( + resolver + .resolve(&state.server, &tenant, &gen0) + .await + .is_some(), + "gen-0 token valid before sign-out-everywhere" + ); + + // Sign out everywhere: bump the human's generation to 1. The generation is + // keyed on the human `sub`, which contributor_claims sets to "human-e2e". + let ctx = AgentContext::system(); + state + .server + .dispatch_tenant_action( + &tenant, + "PrincipalGeneration", + "human-e2e", + "BumpGeneration", + serde_json::json!({}), + &ctx, + ) + .await + .expect("bump generation"); + + // A fresh resolver must now reject the gen-0 token (0 < current 1)... + let fresh = IdentityResolver::new(); + assert!( + fresh.resolve(&state.server, &tenant, &gen0).await.is_none(), + "gen-0 token must be rejected after the generation is bumped" + ); + + // ...but a token minted at the new generation (1) still resolves. + let gen1 = { + let mut c = contributor_claims(); + c["auth_generation"] = serde_json::json!(1); + mint(&sk, header(), c) + }; + assert!( + fresh.resolve(&state.server, &tenant, &gen1).await.is_some(), + "a token minted at the current generation must still resolve" + ); +} + +#[tokio::test] +async fn revoking_a_grant_stops_the_agents_token_at_the_kernel() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + let tenant = TenantId::new("default"); + let token = mint(&sk, header(), contributor_claims()); // grant_id = "grant-e2e" + + // Valid while the grant is live. + let resolver = IdentityResolver::new(); + assert!( + resolver + .resolve(&state.server, &tenant, &token) + .await + .is_some(), + "agent token resolves while its grant is live" + ); + + // Revoke the grant: bump the counter keyed by grant_id. + let ctx = AgentContext::system(); + state + .server + .dispatch_tenant_action( + &tenant, + "PrincipalGeneration", + "grant-e2e", + "BumpGeneration", + serde_json::json!({}), + &ctx, + ) + .await + .expect("revoke grant"); + + // A fresh resolver must now reject it — no waiting for token expiry. + let fresh = IdentityResolver::new(); + assert!( + fresh + .resolve(&state.server, &tenant, &token) + .await + .is_none(), + "a revoked grant must stop resolving at the kernel immediately" + ); +} + +#[tokio::test] +async fn a_rejected_token_never_materialises_a_trusted_issuer() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + let tenant = TenantId::new("default"); + + // `iss` is attacker-chosen and read before any signature check, and entity + // reads spawn on demand — so this used to persist an Active TrustedIssuer + // row with empty fields, unauthenticated, for any string the caller chose. + let ghost = "https://ghost.attacker.example"; + let mut claims = contributor_claims(); + claims["iss"] = serde_json::json!(ghost); + let token = mint(&sk, header(), claims); + + let resolver = IdentityResolver::new(); + assert!( + resolver + .resolve(&state.server, &tenant, &token) + .await + .is_none(), + "a token from an unregistered issuer must not resolve" + ); + assert!( + !state.server.entity_exists(&tenant, "TrustedIssuer", ghost), + "rejecting the token must not leave a TrustedIssuer row behind" + ); +} + +#[tokio::test] +async fn resolving_does_not_materialise_generation_rows() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + let tenant = TenantId::new("default"); + let token = mint(&sk, header(), contributor_claims()); + + let resolver = IdentityResolver::new(); + assert!( + resolver + .resolve(&state.server, &tenant, &token) + .await + .is_some() + ); + + // Never-revoked principals read as generation 0 without persisting a + // counter row per subject and per grant. + for key in ["human-e2e", "grant-e2e"] { + assert!( + !state + .server + .entity_exists(&tenant, "PrincipalGeneration", key), + "resolving must not create a PrincipalGeneration row for {key}" + ); + } +} diff --git a/crates/temper-server/Cargo.toml b/crates/temper-server/Cargo.toml index 6ce2378df..ba8caf720 100644 --- a/crates/temper-server/Cargo.toml +++ b/crates/temper-server/Cargo.toml @@ -51,6 +51,11 @@ cedar-policy = { workspace = true } async-trait = { workspace = true } tokio-tungstenite = { version = "0.27", features = ["native-tls"] } ed25519-dalek = "2.1" +# ES256 (ECDSA P-256) verification for platform-issued JWTs (ARN-255). +# RustCrypto (pure Rust, no ring) to match existing sha2/aes-gcm/dalek usage; +# time validation is done against sim_now(), never the wall clock, so no +# JWT library's internal clock is involved. +p256 = { version = "0.13", features = ["ecdsa"] } futures-util = "0.3" sha1 = "0.10" base64 = "0.22" diff --git a/crates/temper-server/src/identity/jwt.rs b/crates/temper-server/src/identity/jwt.rs new file mode 100644 index 000000000..72fa4197a --- /dev/null +++ b/crates/temper-server/src/identity/jwt.rs @@ -0,0 +1,508 @@ +//! ES256 (ECDSA P-256) JWT verification for platform-issued access tokens. +//! +//! The platform authorization server (and, during the ARN-255 rollout, +//! katagami.ai's authorization server as the first allowlisted issuer) mints +//! short-lived ES256 JWTs. This module verifies one against a registered +//! issuer's JWKS and returns the validated claims. See RFC-0002. +//! +//! Design constraints: +//! - **ES256 only.** The header `alg` must be exactly `ES256`; every other +//! value — including `none`, `HS256`, and `RS256` — is rejected before any +//! key is consulted. The verification path only ever performs P-256 ECDSA, +//! so an algorithm-substitution ("alg confusion") attack cannot select a +//! different primitive. +//! - **Time is caller-supplied.** `exp`/`nbf` are validated against a +//! `now_unix` the caller derives from `sim_now()`, never a wall clock, so +//! verification is deterministic under DST. No JWT library's internal clock +//! is involved. +//! - **The signature is the gate.** The unverified `iss` claim is only used by +//! the caller to select which registered issuer's keys to check against; a +//! forged token fails the signature check because it lacks the issuer's +//! private key. + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use p256::ecdsa::signature::Verifier; +use p256::ecdsa::{Signature, VerifyingKey}; +use serde::Deserialize; + +/// The only JWS algorithm this verifier accepts. +const REQUIRED_ALG: &str = "ES256"; + +/// Reasons a token is rejected. Kept coarse on purpose: callers log the +/// variant internally but must not leak which step failed back to the client. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum JwtError { + /// Not three non-empty base64url segments, or a segment failed to decode. + Malformed, + /// Header `alg` was not exactly `ES256` (covers `none`, HS*, RS*, PS*, …). + UnsupportedAlg, + /// No JWK in the issuer's set matched the token's `kid`. + UnknownKid, + /// A matched JWK could not be turned into a P-256 verifying key. + BadKey, + /// The signature did not verify against the selected key. + BadSignature, + /// `exp` is missing, or `now > exp + leeway`. + Expired, + /// `nbf` is present and `now < nbf - leeway`. + NotYetValid, + /// `iss` did not equal the expected issuer. + WrongIssuer, + /// `aud` did not contain the expected audience. + WrongAudience, +} + +/// A single JSON Web Key (the subset this verifier needs). +#[derive(Debug, Clone, Deserialize)] +pub struct Jwk { + /// Key type; must be `EC`. + pub kty: String, + /// Curve; must be `P-256`. + #[serde(default)] + pub crv: String, + /// Key ID, matched against the token header's `kid`. + #[serde(default)] + pub kid: Option, + /// Base64url X coordinate (32 bytes once decoded). + #[serde(default)] + pub x: String, + /// Base64url Y coordinate (32 bytes once decoded). + #[serde(default)] + pub y: String, +} + +/// A JWKS document — a set of JWKs. +#[derive(Debug, Clone, Deserialize)] +pub struct Jwks { + pub keys: Vec, +} + +/// JWT header (the fields this verifier reads). +#[derive(Debug, Deserialize)] +struct Header { + alg: String, + #[serde(default)] + kid: Option, +} + +/// `aud` may be a single string or an array of strings per RFC 7519. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +enum Audience { + One(String), + Many(Vec), +} + +impl Claims { + /// The `exp` claim (seconds since epoch). Used by the resolver to cap how + /// long a verified token may be cached — never past its own expiry. + pub fn expiry(&self) -> i64 { + self.exp + } +} + +impl Audience { + fn contains(&self, expected: &str) -> bool { + match self { + Audience::One(a) => a == expected, + Audience::Many(list) => list.iter().any(|a| a == expected), + } + } +} + +/// The validated claim set returned on success. +/// +/// Fields beyond the registered/standard set are ignored. `sub` is the owning +/// human; `client_id` is the acting agent; `agent_type` drives Cedar; the +/// remaining optional fields are carried for downstream use (grant liveness, +/// sign-out-everywhere) without being validated here. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct Claims { + pub iss: String, + #[serde(default)] + pub sub: Option, + aud: Audience, + exp: i64, + #[serde(default)] + nbf: Option, + #[serde(default)] + pub client_id: Option, + #[serde(default)] + pub agent_type: Option, + /// The owning human's role (owner/curator/contributor), set by the AS from + /// the Member record. Carried onto the principal for Cedar evaluation. + #[serde(default)] + pub role: Option, + #[serde(default)] + pub grant_id: Option, + #[serde(default)] + pub auth_generation: Option, + #[serde(default)] + pub scope: Option, +} + +/// Decode the claim segment WITHOUT verifying the signature. +/// +/// The only legitimate use is reading `iss` to select which registered issuer +/// to verify against; the returned claims are untrusted until [`verify`] has +/// run against that issuer's keys. +pub fn decode_claims_unverified(token: &str) -> Result { + let mut parts = token.split('.'); + let (_h, payload, _s) = match (parts.next(), parts.next(), parts.next(), parts.next()) { + (Some(h), Some(p), Some(s), None) if !h.is_empty() && !p.is_empty() && !s.is_empty() => { + (h, p, s) + } + _ => return Err(JwtError::Malformed), + }; + let payload_bytes = URL_SAFE_NO_PAD + .decode(payload) + .map_err(|_| JwtError::Malformed)?; + serde_json::from_slice(&payload_bytes).map_err(|_| JwtError::Malformed) +} + +/// Verify an ES256 JWT against a registered issuer's key set and validate its +/// standard claims. +/// +/// - `now_unix` MUST come from `sim_now().timestamp()`. +/// - `leeway_secs` absorbs small clock skew between issuer and kernel in +/// production; under DST it is deterministic like everything else. +/// +/// On success the signature verified, `alg == ES256`, `iss`/`aud` matched, and +/// the token is within its `nbf`/`exp` window. +pub fn verify( + token: &str, + jwks: &Jwks, + expected_iss: &str, + expected_aud: &str, + now_unix: i64, + leeway_secs: i64, +) -> Result { + // 1. Split into exactly three non-empty segments. + let mut parts = token.split('.'); + let (header_b64, payload_b64, sig_b64) = + match (parts.next(), parts.next(), parts.next(), parts.next()) { + (Some(h), Some(p), Some(s), None) + if !h.is_empty() && !p.is_empty() && !s.is_empty() => + { + (h, p, s) + } + _ => return Err(JwtError::Malformed), + }; + + // 2. Header: require alg == ES256 before touching any key material. + let header_bytes = URL_SAFE_NO_PAD + .decode(header_b64) + .map_err(|_| JwtError::Malformed)?; + let header: Header = serde_json::from_slice(&header_bytes).map_err(|_| JwtError::Malformed)?; + if header.alg != REQUIRED_ALG { + return Err(JwtError::UnsupportedAlg); + } + + // 3. Select the key by kid. If the token names a kid, it must match; if it + // omits one, a single-key set is unambiguous, otherwise reject. + let jwk = select_key(jwks, header.kid.as_deref())?; + let verifying_key = verifying_key_from_jwk(jwk)?; + + // 4. Verify the signature over "header.payload" (raw r||s, 64 bytes). + let sig_bytes = URL_SAFE_NO_PAD + .decode(sig_b64) + .map_err(|_| JwtError::Malformed)?; + let signature = Signature::from_slice(&sig_bytes).map_err(|_| JwtError::BadSignature)?; + let signing_input = format!("{header_b64}.{payload_b64}"); + verifying_key + .verify(signing_input.as_bytes(), &signature) + .map_err(|_| JwtError::BadSignature)?; + + // 5. Signature is valid — now parse and validate claims. + let payload_bytes = URL_SAFE_NO_PAD + .decode(payload_b64) + .map_err(|_| JwtError::Malformed)?; + let claims: Claims = serde_json::from_slice(&payload_bytes).map_err(|_| JwtError::Malformed)?; + + if claims.iss != expected_iss { + return Err(JwtError::WrongIssuer); + } + if !claims.aud.contains(expected_aud) { + return Err(JwtError::WrongAudience); + } + if now_unix > claims.exp + leeway_secs { + return Err(JwtError::Expired); + } + if let Some(nbf) = claims.nbf + && now_unix < nbf - leeway_secs + { + return Err(JwtError::NotYetValid); + } + + Ok(claims) +} + +/// Pick the JWK to verify against, honoring the token's `kid`. +fn select_key<'a>(jwks: &'a Jwks, kid: Option<&str>) -> Result<&'a Jwk, JwtError> { + match kid { + Some(k) => jwks + .keys + .iter() + .find(|j| j.kid.as_deref() == Some(k)) + .ok_or(JwtError::UnknownKid), + // No kid in the header: only unambiguous if the set has exactly one key. + None => match jwks.keys.as_slice() { + [single] => Ok(single), + _ => Err(JwtError::UnknownKid), + }, + } +} + +/// Build a P-256 verifying key from a JWK's affine coordinates. +fn verifying_key_from_jwk(jwk: &Jwk) -> Result { + if jwk.kty != "EC" || jwk.crv != "P-256" { + return Err(JwtError::BadKey); + } + let x = URL_SAFE_NO_PAD + .decode(&jwk.x) + .map_err(|_| JwtError::BadKey)?; + let y = URL_SAFE_NO_PAD + .decode(&jwk.y) + .map_err(|_| JwtError::BadKey)?; + if x.len() != 32 || y.len() != 32 { + return Err(JwtError::BadKey); + } + let point = p256::EncodedPoint::from_affine_coordinates( + p256::FieldBytes::from_slice(&x), + p256::FieldBytes::from_slice(&y), + false, + ); + let key = VerifyingKey::from_encoded_point(&point).map_err(|_| JwtError::BadKey)?; + Ok(key) +} + +#[cfg(test)] +mod tests { + use super::*; + use p256::ecdsa::signature::Signer; + use p256::ecdsa::{Signature as EcdsaSig, SigningKey}; + + // A fixed, deterministic test keypair (DST-safe — no randomness at test time). + fn test_key() -> SigningKey { + // 32-byte scalar; fixed so tests are reproducible. + let bytes = [7u8; 32]; + SigningKey::from_slice(&bytes).expect("valid scalar") + } + + fn jwks_for(sk: &SigningKey, kid: &str) -> Jwks { + let vk = sk.verifying_key(); + let point = vk.to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); + Jwks { + keys: vec![Jwk { + kty: "EC".into(), + crv: "P-256".into(), + kid: Some(kid.into()), + x, + y, + }], + } + } + + fn b64(v: &serde_json::Value) -> String { + URL_SAFE_NO_PAD.encode(serde_json::to_vec(v).unwrap()) + } + + /// Mint a signed ES256 token from header+claims JSON. + fn mint(sk: &SigningKey, header: serde_json::Value, claims: serde_json::Value) -> String { + let signing_input = format!("{}.{}", b64(&header), b64(&claims)); + let sig: EcdsaSig = sk.sign(signing_input.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(sig.to_bytes()); + format!("{signing_input}.{sig_b64}") + } + + fn valid_header() -> serde_json::Value { + serde_json::json!({ "alg": "ES256", "kid": "k1", "typ": "JWT" }) + } + + fn valid_claims() -> serde_json::Value { + serde_json::json!({ + "iss": "https://katagami.ai", + "sub": "human-sub-123", + "aud": "temper", + "exp": 2000, + "nbf": 1000, + "client_id": "agent-xyz", + "agent_type": "contributor", + }) + } + + fn verify_valid(token: &str, jwks: &Jwks) -> Result { + verify(token, jwks, "https://katagami.ai", "temper", 1500, 60) + } + + #[test] + fn valid_token_verifies_and_maps_claims() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let token = mint(&sk, valid_header(), valid_claims()); + let claims = verify_valid(&token, &jwks).expect("should verify"); + assert_eq!(claims.iss, "https://katagami.ai"); + assert_eq!(claims.sub.as_deref(), Some("human-sub-123")); + assert_eq!(claims.client_id.as_deref(), Some("agent-xyz")); + assert_eq!(claims.agent_type.as_deref(), Some("contributor")); + } + + #[test] + fn alg_none_is_rejected() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + // alg=none, empty signature — the classic bypass. + let header = serde_json::json!({ "alg": "none", "kid": "k1" }); + let signing_input = format!("{}.{}", b64(&header), b64(&valid_claims())); + let token = format!("{signing_input}."); + // Empty third segment → Malformed before alg is even read. + assert_eq!(verify_valid(&token, &jwks), Err(JwtError::Malformed)); + } + + #[test] + fn alg_none_with_nonempty_sig_is_unsupported() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let header = serde_json::json!({ "alg": "none", "kid": "k1" }); + let token = mint(&sk, header, valid_claims()); + assert_eq!(verify_valid(&token, &jwks), Err(JwtError::UnsupportedAlg)); + } + + #[test] + fn alg_confusion_hs256_is_rejected() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let header = serde_json::json!({ "alg": "HS256", "kid": "k1" }); + let token = mint(&sk, header, valid_claims()); + assert_eq!(verify_valid(&token, &jwks), Err(JwtError::UnsupportedAlg)); + } + + #[test] + fn wrong_key_is_rejected() { + let sk = test_key(); + // Verify against a different key than the one that signed. + let other = SigningKey::from_slice(&[9u8; 32]).unwrap(); + let jwks = jwks_for(&other, "k1"); + let token = mint(&sk, valid_header(), valid_claims()); + assert_eq!(verify_valid(&token, &jwks), Err(JwtError::BadSignature)); + } + + #[test] + fn tampered_payload_is_rejected() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let token = mint(&sk, valid_header(), valid_claims()); + // Swap the payload for one granting a different agent_type, keep the sig. + let mut parts: Vec<&str> = token.split('.').collect(); + let forged = b64(&serde_json::json!({ + "iss": "https://katagami.ai", "aud": "temper", "exp": 2000, + "agent_type": "owner", + })); + parts[1] = &forged; + let token = parts.join("."); + assert_eq!(verify_valid(&token, &jwks), Err(JwtError::BadSignature)); + } + + #[test] + fn expired_token_is_rejected() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let token = mint(&sk, valid_header(), valid_claims()); + // now = 3000, exp = 2000, leeway 60 → expired. + let r = verify(&token, &jwks, "https://katagami.ai", "temper", 3000, 60); + assert_eq!(r, Err(JwtError::Expired)); + } + + #[test] + fn not_yet_valid_token_is_rejected() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let token = mint(&sk, valid_header(), valid_claims()); + // now = 500, nbf = 1000, leeway 60 → not yet valid. + let r = verify(&token, &jwks, "https://katagami.ai", "temper", 500, 60); + assert_eq!(r, Err(JwtError::NotYetValid)); + } + + #[test] + fn leeway_admits_small_skew() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let token = mint(&sk, valid_header(), valid_claims()); + // now = 2030, exp = 2000, leeway 60 → still valid within skew. + assert!(verify(&token, &jwks, "https://katagami.ai", "temper", 2030, 60).is_ok()); + } + + #[test] + fn wrong_issuer_is_rejected() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let token = mint(&sk, valid_header(), valid_claims()); + let r = verify(&token, &jwks, "https://evil.example", "temper", 1500, 60); + assert_eq!(r, Err(JwtError::WrongIssuer)); + } + + #[test] + fn wrong_audience_is_rejected() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let token = mint(&sk, valid_header(), valid_claims()); + let r = verify(&token, &jwks, "https://katagami.ai", "other-app", 1500, 60); + assert_eq!(r, Err(JwtError::WrongAudience)); + } + + #[test] + fn audience_array_is_honored() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let mut claims = valid_claims(); + claims["aud"] = serde_json::json!(["other", "temper"]); + let token = mint(&sk, valid_header(), claims); + assert!(verify_valid(&token, &jwks).is_ok()); + } + + #[test] + fn unknown_kid_is_rejected() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let header = serde_json::json!({ "alg": "ES256", "kid": "does-not-exist" }); + let token = mint(&sk, header, valid_claims()); + assert_eq!(verify_valid(&token, &jwks), Err(JwtError::UnknownKid)); + } + + #[test] + fn missing_kid_with_multiple_keys_is_rejected() { + let sk = test_key(); + let mut jwks = jwks_for(&sk, "k1"); + // Add a second key so a kid-less header is ambiguous. + let mut second = jwks.keys[0].clone(); + second.kid = Some("k2".into()); + jwks.keys.push(second); + let header = serde_json::json!({ "alg": "ES256" }); + let token = mint(&sk, header, valid_claims()); + assert_eq!(verify_valid(&token, &jwks), Err(JwtError::UnknownKid)); + } + + #[test] + fn malformed_tokens_are_rejected() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + for bad in ["", "a.b", "a.b.c.d", "..", "a..c", ".b.c"] { + assert_eq!( + verify_valid(bad, &jwks), + Err(JwtError::Malformed), + "token {bad:?} should be malformed" + ); + } + } + + #[test] + fn decode_unverified_reads_iss_without_a_key() { + let sk = test_key(); + let token = mint(&sk, valid_header(), valid_claims()); + let claims = decode_claims_unverified(&token).expect("decodes"); + assert_eq!(claims.iss, "https://katagami.ai"); + } +} diff --git a/crates/temper-server/src/identity/mod.rs b/crates/temper-server/src/identity/mod.rs index 57216adeb..374ce0c48 100644 --- a/crates/temper-server/src/identity/mod.rs +++ b/crates/temper-server/src/identity/mod.rs @@ -5,6 +5,7 @@ //! See ADR-0033: Platform-Assigned Agent Identity. pub mod endpoint; +pub mod jwt; mod resolver; pub use resolver::{IdentityResolver, ResolvedIdentity, hash_token}; diff --git a/crates/temper-server/src/identity/resolver.rs b/crates/temper-server/src/identity/resolver.rs index 42e79fb36..1fc1d3821 100644 --- a/crates/temper-server/src/identity/resolver.rs +++ b/crates/temper-server/src/identity/resolver.rs @@ -9,28 +9,88 @@ use std::sync::{Arc, RwLock}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use temper_authz::{PrincipalKind, SecurityContext}; use temper_runtime::scheduler::sim_now; use temper_runtime::tenant::TenantId; +use crate::identity::jwt; use crate::state::ServerState; /// Cache entry TTL in seconds. const CACHE_TTL_SECS: i64 = 60; +/// Clock-skew tolerance (seconds) for JWT `exp`/`nbf` validation. +const JWT_LEEWAY_SECS: i64 = 60; + /// A platform-resolved agent identity. /// /// All fields are derived from the credential registry — never from /// self-declared headers or client-reported values. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResolvedIdentity { - /// Platform-assigned unique agent instance ID (UUIDv7). + /// Platform-assigned unique agent instance ID (UUIDv7) for the credential + /// path; the token's `client_id` (acting agent) for the JWT path. pub agent_instance_id: String, - /// The AgentType entity ID this credential is linked to. + /// The AgentType entity ID this credential is linked to. Empty for the JWT + /// path, which takes its type name straight from a verified claim. pub agent_type_id: String, /// The AgentType's human-readable name (e.g., "claude-code"). pub agent_type_name: String, - /// Whether this identity was verified through the credential registry. + /// Whether this identity was verified (registry or trusted-issuer JWT). pub verified: bool, + /// JWT path only: the owning human (the agent's `acting_for` / token `sub`). + #[serde(default)] + pub acting_for: Option, + /// JWT path only: the sign-out-everywhere generation carried by the token, + /// for the follow-up revocation check. + #[serde(default)] + pub auth_generation: Option, + /// True when this identity came from a verified JWT rather than an + /// `AgentCredential`. Selects the security-context constructor downstream. + #[serde(default)] + pub from_jwt: bool, + /// JWT path only: true when the token represents a human (Customer) rather + /// than an agent — i.e. it carries a `sub` but no `agent_type`. + #[serde(default)] + pub is_human: bool, + /// JWT path only: the principal's role (owner/curator/contributor) from a + /// verified token claim, for Cedar evaluation. + #[serde(default)] + pub role: Option, +} + +impl ResolvedIdentity { + /// The one authoritative conversion from a resolved identity to the Cedar + /// principal, used by EVERY external entry point (bound actions, reads, + /// create, update, delete, streams, rate limiting). + /// + /// Keeping this in one place matters: constructing it ad hoc let the CRUD + /// paths fall back to the agent-only constructor, which turned a verified + /// human into an Agent with an empty type and silently dropped `role` and + /// `acting_for` — so a read and a bound action evaluated different + /// principals for the same token. + pub fn to_security_context(&self, session_id: Option<&str>) -> SecurityContext { + if !self.from_jwt { + return SecurityContext::from_resolved_identity( + &self.agent_instance_id, + &self.agent_type_name, + session_id, + ); + } + let (kind, agent_type) = if self.is_human { + (PrincipalKind::Customer, None) + } else { + (PrincipalKind::Agent, Some(self.agent_type_name.as_str())) + }; + SecurityContext::from_verified_jwt( + &self.agent_instance_id, + kind, + agent_type, + self.acting_for.as_deref(), + self.role.as_deref(), + session_id, + ) + } } /// Cached resolution result with expiry. @@ -63,15 +123,11 @@ impl IdentityResolver { } } - /// Resolve a bearer token to a verified agent identity. + /// Resolve a bearer token to a verified identity. /// - /// 1. Hash the token (SHA-256) - /// 2. Check cache (hit → return immediately) - /// 3. Look up `AgentCredential` entity by using key_hash as entity ID - /// 4. Verify credential is `Active` - /// 5. Look up linked `AgentType` entity - /// 6. Verify AgentType is `Active` - /// 7. Cache and return `ResolvedIdentity` + /// JWT-shaped tokens (`header.payload.signature`) are verified against a + /// registered [`TrustedIssuer`]; opaque tokens are looked up in the + /// `AgentCredential` registry. Both paths share the in-memory cache. pub async fn resolve( &self, state: &ServerState, @@ -86,15 +142,38 @@ impl IdentityResolver { return Some(cached); } - // Look up AgentCredential entity. We use the key_hash as entity ID - // for O(1) lookup — the Issue action must use the key_hash as the - // entity ID when creating credentials. + if looks_like_jwt(bearer_token) { + // JWT path: verify against the token's registered issuer. Cache no + // longer than the token's own expiry so an expired token is never + // served from cache. + let (identity, exp_unix) = self.resolve_jwt(state, tenant, bearer_token).await?; + let cap = sim_now() + chrono::Duration::seconds(CACHE_TTL_SECS); + let token_exp = chrono::DateTime::from_timestamp(exp_unix, 0).unwrap_or(cap); + self.put_cached_until(cache_key, identity.clone(), cap.min(token_exp)); + Some(identity) + } else { + let identity = self.resolve_credential(state, tenant, &key_hash).await?; + self.put_cached(cache_key, identity.clone()); + Some(identity) + } + } + + /// Opaque-token path: look up the `AgentCredential` registry. + /// + /// key_hash is the entity ID (the `Issue` action uses it as such), giving + /// an O(1) lookup. Verifies the credential and its linked `AgentType` are + /// both `Active`. + async fn resolve_credential( + &self, + state: &ServerState, + tenant: &TenantId, + key_hash: &str, + ) -> Option { let cred_response = state - .get_tenant_entity_state(tenant, "AgentCredential", &key_hash) + .get_tenant_entity_state(tenant, "AgentCredential", key_hash) .await .ok()?; - // Verify credential is Active. if cred_response.state.status != "Active" { return None; } @@ -107,13 +186,11 @@ impl IdentityResolver { return None; } - // Look up linked AgentType entity. let type_response = state .get_tenant_entity_state(tenant, "AgentType", agent_type_id) .await .ok()?; - // Verify AgentType is Active. if type_response.state.status != "Active" { return None; } @@ -126,17 +203,178 @@ impl IdentityResolver { .unwrap_or("") .to_string(); - let identity = ResolvedIdentity { + Some(ResolvedIdentity { agent_instance_id: agent_instance_id.to_string(), agent_type_id: agent_type_id.to_string(), agent_type_name, verified: true, + acting_for: None, + auth_generation: None, + from_jwt: false, + is_human: false, + role: None, + }) + } + + /// JWT path: verify an ES256 token against its registered `TrustedIssuer`. + /// + /// Returns the resolved identity and the token's `exp` (used to cap cache + /// TTL). The unverified `iss` claim only selects which issuer's keys to + /// check against; the signature is the gate. + async fn resolve_jwt( + &self, + state: &ServerState, + tenant: &TenantId, + token: &str, + ) -> Option<(ResolvedIdentity, i64)> { + // Read `iss` from the unverified payload to pick the issuer entity. + let unverified = jwt::decode_claims_unverified(token).ok()?; + let issuer_id = unverified.iss; + + // `iss` here is attacker-chosen and unverified, and entity reads spawn + // the entity on demand — so reading it directly would let anyone + // presenting a junk token persist an Active TrustedIssuer row with empty + // fields. Check existence first and never materialise one from a token. + if !state.entity_exists(tenant, "TrustedIssuer", &issuer_id) { + return None; + } + + let issuer_response = state + .get_tenant_entity_state(tenant, "TrustedIssuer", &issuer_id) + .await + .ok()?; + if issuer_response.state.status != "Active" { + return None; + } + + let fields = &issuer_response.state.fields; + let jwks_json = fields.get("jwks_json")?.as_str()?; + let audience = fields.get("audience")?.as_str()?; + let jwks: jwt::Jwks = serde_json::from_str(jwks_json).ok()?; + + let now_unix = sim_now().timestamp(); + let claims = jwt::verify( + token, + &jwks, + &issuer_id, + audience, + now_unix, + JWT_LEEWAY_SECS, + ) + .ok()?; + + // Sign-out-everywhere: reject a token whose generation is older than the + // principal's current generation (RFC-0002, ARN-255 option A). Keyed on + // the human `sub`, so signing out a human also invalidates the tokens of + // agents acting for them. A token that omits the claim is treated as + // generation 0 — revocation must NOT be skippable by an issuer that fails + // to stamp it, so a single BumpGeneration still invalidates it. + if let Some(sub) = claims.sub.as_deref() { + let token_gen = claims.auth_generation.unwrap_or(0); + // `?` denies when the read failed — see current_generation. + if token_gen < self.current_generation(state, tenant, sub).await? { + return None; + } + } + + // Grant liveness: a revoked agent grant must stop working at the kernel, + // not just at the MCP front door — otherwise a revoked agent keeps full + // OData access until its token expires. The same monotonic counter is + // keyed by `grant_id`; revoking a grant bumps it, and ANY bump (> 0) + // means revoked, since a grant is never re-issued under the same id. + if let Some(grant_id) = claims.grant_id.as_deref().filter(|g| !g.is_empty()) + && self.current_generation(state, tenant, grant_id).await? > 0 + { + return None; + } + + // Map verified claims → identity. A token with an `agent_type` is an + // agent acting for the human `sub`; a token with only a `sub` is the + // human themselves (a Customer principal). + let identity = match claims.agent_type.as_deref().filter(|s| !s.is_empty()) { + Some(agent_type) => { + let client_id = claims.client_id.clone().unwrap_or_default(); + if client_id.is_empty() { + return None; + } + ResolvedIdentity { + agent_instance_id: client_id, + agent_type_id: String::new(), + agent_type_name: agent_type.to_string(), + verified: true, + acting_for: claims.sub.clone(), + auth_generation: claims.auth_generation, + from_jwt: true, + is_human: false, + role: claims.role.clone(), + } + } + None => { + let sub = claims.sub.clone().unwrap_or_default(); + if sub.is_empty() { + return None; + } + ResolvedIdentity { + agent_instance_id: sub, + agent_type_id: String::new(), + agent_type_name: String::new(), + verified: true, + acting_for: None, + auth_generation: claims.auth_generation, + from_jwt: true, + is_human: true, + role: claims.role.clone(), + } + } }; + Some((identity, claims.expiry())) + } - // Cache the result. - self.put_cached(cache_key, identity.clone()); + /// Read a principal's current sign-out-everywhere generation. + /// + /// `None` means the read failed and the caller must deny. A principal that + /// has never been revoked is not an error: the entity materialises on read + /// at generation 0, which is the correct baseline. Conflating the two would + /// make a revoked token acceptable during an actor timeout or a missing + /// transition table — a revocation check has to fail closed. + /// + /// The counter lives in the kernel's `PrincipalGeneration` entity, so this + /// check is generic across apps. + async fn current_generation( + &self, + state: &ServerState, + tenant: &TenantId, + key: &str, + ) -> Option { + // Absent means never revoked. Check first rather than reading, because + // an entity read spawns the entity: otherwise every token would + // materialise a counter row keyed by its own subject and grant. + if !state.entity_exists(tenant, "PrincipalGeneration", key) { + return Some(0); + } - Some(identity) + match state + .get_tenant_entity_state(tenant, "PrincipalGeneration", key) + .await + { + Ok(resp) => Some( + resp.state + .counters + .get("generation") + .map(|c| *c as i64) + .unwrap_or(0), + ), + Err(e) => { + tracing::warn!( + tenant = %tenant, + key, + error = %e, + "PrincipalGeneration read failed; denying the token rather than \ + treating it as never-revoked" + ); + None + } + } } /// Invalidate all cached entries (e.g., after credential rotation/revocation). @@ -178,6 +416,16 @@ impl IdentityResolver { fn put_cached(&self, cache_key: String, identity: ResolvedIdentity) { let expires_at = sim_now() + chrono::Duration::seconds(CACHE_TTL_SECS); + self.put_cached_until(cache_key, identity, expires_at); + } + + /// Cache with an explicit expiry (JWT path caps this at the token's `exp`). + fn put_cached_until( + &self, + cache_key: String, + identity: ResolvedIdentity, + expires_at: chrono::DateTime, + ) { let mut cache = self.cache.write().unwrap(); // ci-ok: infallible lock // Evict expired entries opportunistically (bounded work: max 32 per insert). @@ -206,6 +454,19 @@ fn cache_key(tenant: &TenantId, key_hash: &str) -> String { format!("{}:{key_hash}", tenant.as_str()) } +/// Heuristic: does this bearer token have JWS compact form +/// (`header.payload.signature`, three non-empty dot-separated segments)? +/// +/// Opaque `AgentCredential` tokens are single-segment, so this cleanly +/// separates the two paths without decoding anything. +fn looks_like_jwt(token: &str) -> bool { + let mut parts = token.split('.'); + matches!( + (parts.next(), parts.next(), parts.next(), parts.next()), + (Some(h), Some(p), Some(s), None) if !h.is_empty() && !p.is_empty() && !s.is_empty() + ) +} + /// Hash a bearer token with SHA-256 for credential lookup. pub fn hash_token(token: &str) -> String { let mut hasher = Sha256::new(); @@ -232,4 +493,19 @@ mod tests { let h2 = hash_token("token-b"); assert_ne!(h1, h2); } + + #[test] + fn jwt_shape_detection_routes_correctly() { + // JWS compact form → JWT path. + assert!(looks_like_jwt("eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJ4In0.c2ln")); + // Opaque credential tokens → registry path. + assert!(!looks_like_jwt("kc_3f2a9b8c7d6e5f4a")); + assert!(!looks_like_jwt("")); + // Wrong segment counts or empty segments are not JWTs. + assert!(!looks_like_jwt("a.b")); + assert!(!looks_like_jwt("a.b.c.d")); + assert!(!looks_like_jwt("a..c")); + assert!(!looks_like_jwt(".b.c")); + assert!(!looks_like_jwt("a.b.")); + } } diff --git a/crates/temper-server/src/odata/authz.rs b/crates/temper-server/src/odata/authz.rs index c77018745..7dbcfb058 100644 --- a/crates/temper-server/src/odata/authz.rs +++ b/crates/temper-server/src/odata/authz.rs @@ -26,11 +26,7 @@ pub(super) fn request_security_context( resolved_identity: Option<&ResolvedIdentity>, ) -> SecurityContext { if let Some(identity) = resolved_identity { - SecurityContext::from_resolved_identity( - &identity.agent_instance_id, - &identity.agent_type_name, - agent_ctx.session_id.as_deref(), - ) + identity.to_security_context(agent_ctx.session_id.as_deref()) } else { security_context_from_headers(headers, None, agent_ctx.session_id.as_deref(), None) } diff --git a/crates/temper-server/src/odata/bindings.rs b/crates/temper-server/src/odata/bindings.rs index ffe35b806..864a47bf5 100644 --- a/crates/temper-server/src/odata/bindings.rs +++ b/crates/temper-server/src/odata/bindings.rs @@ -8,8 +8,6 @@ use temper_runtime::scheduler::sim_now; use temper_runtime::tenant::TenantId; use tracing_opentelemetry::OpenTelemetrySpanExt; -use temper_authz::SecurityContext; - use super::account_verification::enforce_commons_account_verified_for_action; use super::common::run_write_prechecks; use super::rate_limit::{enforce_commons_write_rate_limit, owner_id_from_action}; @@ -83,11 +81,9 @@ pub(super) async fn dispatch_bound_action( "agent.type", identity.agent_type_name.clone(), )); - SecurityContext::from_resolved_identity( - &identity.agent_instance_id, - &identity.agent_type_name, - agent_ctx.session_id.as_deref(), - ) + // One authoritative conversion, shared with the read/create/update/ + // delete paths — see ResolvedIdentity::to_security_context. + identity.to_security_context(agent_ctx.session_id.as_deref()) } else { // No credential resolved — operator/admin access via global API key. // Build SecurityContext from X-Temper-Principal-Kind header (admin/system) diff --git a/scripts/e2e-trusted-issuer.sh b/scripts/e2e-trusted-issuer.sh new file mode 100755 index 000000000..0b945dd81 --- /dev/null +++ b/scripts/e2e-trusted-issuer.sh @@ -0,0 +1,129 @@ +#!/bin/bash +# Live end-to-end check for platform-issued token verification (ARN-255). +# +# Boots a real temper server, activates a trusted issuer through the same +# environment configuration a deployment uses, mints real ES256 tokens with the +# matching private key, and drives the real HTTP surface to prove: +# 1. a valid token authenticates (not 401) +# 2. a token signed by an unknown key is rejected (401) +# 3. an expired token is rejected (401) +# 4. a token from an unregistered issuer is rejected (401) +# 5. a garbage/tampered token is rejected (401) +# 6. the operator key still works — the change is additive (200) +# 7. a verified agent token CANNOT register an issuer (403) +# (the takeover path: register your own key, mint owner tokens) +# 8. a verified agent token CANNOT bump a generation (403) +# (per-user sign-out denial of service) +# +# Requires: cargo, python3 with 'cryptography', curl. Usage: +# scripts/e2e-trusted-issuer.sh [port] +set -uo pipefail + +PORT="${1:-3477}" +BASE="http://localhost:${PORT}" +TENANT="default" +API_KEY="local-e2e-operator-key" +ISSUER="https://e2e.issuer.local" +AUD="temper-e2e" +WORK="$(mktemp -d)" +SERVER_PID="" +cleanup() { [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null; rm -rf "$WORK"; } +trap cleanup EXIT + +say() { printf '\n\033[1m== %s\033[0m\n' "$*"; } + +say "Minting a P-256 key, its JWKS, and four test tokens" +python3 - "$WORK" "$ISSUER" "$AUD" <<'PY' +import base64, json, sys, time +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature +from cryptography.hazmat.primitives import hashes + +work, issuer, aud = sys.argv[1], sys.argv[2], sys.argv[3] +b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode() + +def mint(key, claims, kid="e2e-k1"): + head = {"alg": "ES256", "kid": kid, "typ": "JWT"} + si = f'{b64(json.dumps(head).encode())}.{b64(json.dumps(claims).encode())}' + r, s = decode_dss_signature(key.sign(si.encode(), ec.ECDSA(hashes.SHA256()))) + return f'{si}.{b64(r.to_bytes(32,"big") + s.to_bytes(32,"big"))}' + +key = ec.generate_private_key(ec.SECP256R1()) +pn = key.public_key().public_numbers() +open(f"{work}/jwks.json","w").write(json.dumps({"keys":[{ + "kty":"EC","crv":"P-256","kid":"e2e-k1", + "x": b64(pn.x.to_bytes(32,"big")), "y": b64(pn.y.to_bytes(32,"big"))}]})) + +now = int(time.time()) +base = {"iss": issuer, "aud": aud, "sub": "human-e2e", "client_id": "kc_agent_e2e", + "agent_type": "contributor", "grant_id": "grant-e2e", "nbf": now - 300} +open(f"{work}/valid.txt","w").write(mint(key, {**base, "exp": now + 900})) +open(f"{work}/expired.txt","w").write(mint(key, {**base, "exp": now - 600})) +open(f"{work}/bad_iss.txt","w").write(mint(key, {**base, "iss": "https://unregistered.example", "exp": now + 900})) +open(f"{work}/rogue.txt","w").write(mint(ec.generate_private_key(ec.SECP256R1()), {**base, "exp": now + 900})) +print(" 4 tokens + JWKS ready") +PY +[ -f "$WORK/valid.txt" ] || { echo "token minting failed"; exit 1; } + +say "Starting a real temper server on :$PORT with the issuer activated by env" +TEMPER_API_KEY="$API_KEY" \ +TEMPER_TRUSTED_ISSUER_URL="$ISSUER" \ +TEMPER_TRUSTED_ISSUER_JWKS="$(cat "$WORK/jwks.json")" \ +TEMPER_TRUSTED_ISSUER_AUD="$AUD" \ + cargo run -q -p temper-cli --bin temper -- serve --port "$PORT" --no-observe \ + >"$WORK/server.log" 2>&1 & +SERVER_PID=$! +for _ in $(seq 1 150); do + curl -sf "$BASE/healthz" >/dev/null 2>&1 && break + sleep 2 + kill -0 "$SERVER_PID" 2>/dev/null || { echo "server died:"; tail -30 "$WORK/server.log"; exit 1; } +done +curl -sf "$BASE/healthz" >/dev/null || { echo "never healthy:"; tail -30 "$WORK/server.log"; exit 1; } +echo " healthy" +grep -q "Trusted issuer '$ISSUER' registered" "$WORK/server.log" \ + && echo " issuer registered from environment at boot" \ + || { echo " ISSUER NOT REGISTERED — see log"; tail -20 "$WORK/server.log"; } + +code() { # code [method] [path] [body] + local tok="$1" method="${2:-GET}" path="${3:-/tdata/TrustedIssuers}" body="${4:-}" + if [ -n "$body" ]; then + curl -s -o /dev/null -w '%{http_code}' -X "$method" "$BASE$path" \ + -H "Authorization: Bearer $tok" -H "X-Tenant-Id: $TENANT" \ + -H "Content-Type: application/json" -d "$body" + else + curl -s -o /dev/null -w '%{http_code}' -X "$method" "$BASE$path" \ + -H "Authorization: Bearer $tok" -H "X-Tenant-Id: $TENANT" + fi +} + +PASS=0; FAIL=0 +check() { # check + local name="$1" got="$2"; shift 2 + for want in "$@"; do + if [ "$got" = "$want" ]; then printf ' \033[32mPASS\033[0m %s (HTTP %s)\n' "$name" "$got"; PASS=$((PASS+1)); return; fi + done + printf ' \033[31mFAIL\033[0m %s (got HTTP %s, wanted %s)\n' "$name" "$got" "$*"; FAIL=$((FAIL+1)) +} + +VALID=$(cat "$WORK/valid.txt") +ISS_ENC="https%3A%2F%2Fe2e.issuer.local" + +say "Token verification" +check "valid token authenticates" "$(code "$VALID")" 200 403 404 +check "rogue-key token rejected" "$(code "$(cat "$WORK/rogue.txt")")" 401 +check "expired token rejected" "$(code "$(cat "$WORK/expired.txt")")" 401 +check "unregistered issuer rejected" "$(code "$(cat "$WORK/bad_iss.txt")")" 401 +check "garbage token rejected" "$(code 'not.a.jwt')" 401 +check "operator key still works (additive)" "$(code "$API_KEY")" 200 + +say "Privilege boundary on the identity entities" +REG_BODY='{"issuer":"https://attacker.example","jwks_json":"{\"keys\":[]}","audience":"x","algorithms":"ES256","description":"takeover attempt","created_by":"attacker"}' +check "agent token CANNOT register an issuer" \ + "$(code "$VALID" POST "/tdata/TrustedIssuers('https%3A%2F%2Fattacker.example')/Temper.RegisterIssuer" "$REG_BODY")" 403 +check "agent token CANNOT rotate issuer keys" \ + "$(code "$VALID" POST "/tdata/TrustedIssuers('$ISS_ENC')/Temper.RotateIssuerKeys" '{"jwks_json":"{\"keys\":[]}"}')" 403 +check "agent token CANNOT bump a generation" \ + "$(code "$VALID" POST "/tdata/PrincipalGenerations('human-e2e')/Temper.BumpGeneration" '{}')" 403 + +say "Result: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ]