diff --git a/CHANGELOG.md b/CHANGELOG.md index 82feedb..cd94eae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to Agents.KT are documented here. The format follows [Keep a ## [Unreleased] +### Added — `agents-kt-identity`: AGNTCY Identity badge verify (#4521, PRD §12.6) — AGNTCY interop + +`IdentityVerifier.verify(compactJws, jwks)` validates an [AGNTCY Identity](https://docs.agntcy.org/identity/) +agent **badge** — a W3C Verifiable Credential secured with JOSE/JWS — against an issuer's JWKS +(`/.well-known/jwks.json`), returning a `VerifiedBadge` (issuer / subject / `credentialSubject`) or throwing +`BadgeVerificationException`. The **trust** pillar of the AGNTCY epic (#4517), beside the OASF discovery +record (§12.6) and A2A invocation (§12.5): in a trust-gated network you accept work only from agents whose +badge a known issuer signed. **Verify-only** — issuance (keys/signing/vaults) is deferred to the self-hosted +stack. **Fail-closed and not hand-rolled**: verification delegates to the vetted `nimbus-jose-jwt` processor +(rejects `alg: none`, `HS*` algorithm-confusion, expired/not-yet-valid, tampered, wrong/unknown key — each a +negative test). `IdentityResolver` fetches the JWKS (bounded timeouts + size cap). Ships as a new feature +module `agents-kt-identity` (package `agents_engine.agntcy.identity`) so the JOSE dependency stays out of +core — same pattern as `agents-kt-rag`. 8 tests. Remaining #4517 subtasks: DIR client (#4520), OASF +import/validate (#4519). + ### Added — OASF 1.0.0 record export: `toOasfRecord()` (#4518, PRD §12.6) — AGNTCY interop `agent.toOasfRecord(version, authors, locators, …)` emits an [OASF](https://github.com/agntcy/oasf) 1.0.0 diff --git a/README.md b/README.md index 273d2af..f9b1ee4 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,7 @@ These APIs work in `main`, are unit-tested, and are exercised by integration tes - **Web-grounded search tool (`perplexitySearch`)** — `tools { +perplexitySearchTool(perplexityKey) }` lets an agent reasoning on its *own* model (Claude/OpenAI/Ollama/…) fetch live, cited facts from Perplexity's Sonar API. The tool is `untrustedOutput = true`, so results are auto-wrapped in the `{"trusted":false}` envelope and the model is warned to treat them as data, not instructions (#642) — web search is the canonical prompt-injection vector. The result renders the answer plus a numbered source list parsed from `search_results[]` (citations land in both the model context and the JSONL audit row). Controls via `perplexitySearchOptions { mode = SearchMode.ACADEMIC; recency = SearchRecency.WEEK; allowDomains("arxiv.org"); contextSize = SearchContextSize.HIGH; structuredOutput(MyType::class) }` map to `search_mode` / `search_recency_filter` / `search_domain_filter` / `web_search_options` / `response_format` json_schema (#3674). Key from `.secrets/perplexity-key`. See [docs/providers.md](docs/providers.md#web-grounded-search-tool-perplexitysearch-3676--3677). - **NLWeb endpoint tool (`nlwebSearch`)** — `tools { +nlwebSearchTool(baseUrl = "https://example.com") }` lets an agent query an [NLWeb](https://github.com/nlweb-ai/NLWeb) endpoint — a website's natural-language interface over its **schema.org**-structured content — and fold the ranked, typed results into context (#4541, PRD §12.9). Like `perplexitySearch` it is `untrustedOutput = true` (fetched web content is treated as data, not instructions). `nlwebSearchOptions`-style args via `NlWebSearchOptions(site = "podcasts", mode = NlWebMode.GENERATE)`. NLWeb endpoints need no API key. (Every NLWeb endpoint is also an MCP server, so an NLWeb `/mcp` URL is equally consumable through the existing MCP client — this tool is the zero-wiring `/ask`-over-HTTP path.) - **Serve an NLWeb endpoint (`NlWebServer`)** — `NlWebServer.from(agent).start()` exposes the NLWeb `POST /ask` contract (`{query, site?, mode}` → ranked schema.org `results[]`), so agents.kt is consumable by NLWeb clients — the **serve** side to `nlwebSearch`'s **consume** side (#4542). Same `from(agent)` shape, loopback-only JDK-`HttpServer` posture, and threat model as `McpServer.from(agent)` / `A2AServer.from(agent)` (`127.0.0.1`, optional bearer, front with a gateway). The query is the agent's input; an `NlWebSearchResult` output is served verbatim (ranked schema.org results), any other output becomes the `summary` answer — back the agent's retrieval with the RAG `EmbeddingStore` seam (`:agents-kt-rag`) or whatever you like. agents.kt now serves the agentic web three ways: MCP, A2A, and NLWeb. +- **AGNTCY interop (OASF record + Identity badge)** — `agent.toOasfRecord(version, authors, locators)` exports an [AGNTCY](https://github.com/agntcy) [OASF](https://github.com/agntcy/oasf) 1.0.0 discovery record (the third exporter beside `agent.json` and the A2A AgentCard; skills carry taxonomy uids via the opt-in `.oasf("agent_orchestration/multi_agent_planning")` annotation against a vendored, drift-checked taxonomy — #4518, PRD §12.6). The trust side ships in the `:agents-kt-identity` module: `IdentityVerifier.verify(compactJws, jwks)` validates an AGNTCY Identity **badge** (a JOSE/JWS-secured W3C Verifiable Credential) against an issuer's `/.well-known/jwks.json`, fail-closed via `nimbus-jose-jwt` (rejects `alg: none`, `HS*` algorithm-confusion, expiry, tamper, wrong/unknown key — #4521). Verify-only; issuance is deferred. Remaining AGNTCY work (DIR directory client, OASF import) is tracked under epic #4517. - **Prompt caching across providers** — `agent { caching { enabled = true; cacheSystemPrompt = true; cacheToolDefs = true; cacheConversation = Rolling; ttl = 1.hours; cacheable("doc-id") { ... } } }`. Vendor-neutral DSL drives Anthropic's explicit `cache_control` breakpoints (#2658), OpenAI / DeepSeek automatic prefix caching with a stable `prompt_cache_key` routing hint (#2659 / #2661), Ollama / vLLM / SGLang engine-level KV-cache reuse (no-op hints, #2662), and surfaces cache reads + writes + hit-rate on `TokenUsage` (#2663). A prefix-stability guard (#2657) detects silent cache-busters — timestamps, UUIDs, non-deterministic ordering inside cacheable segments — and warns before you pay for a single non-cached run. Off by default; non-breaking. See [docs/caching.md](docs/caching.md). - **JSONL audit exporter** — `:agents-kt-observability` writes append-only, one-line-per-event audit rows with `requestId`, `sessionId`, `manifestHash`, agent/skill/tool ids, event type, provider, and model; raw arguments/results are omitted by default (#1914). See [docs/observability.md](docs/observability.md). - **ObservabilityBridge adapters** — `.observe(OtelBridge(tracer))` maps runtime events to OTel spans (#1908), `.observe(LangSmithBridge(apiKey, project))` maps the same events to LangSmith run trees (#1909), and `.observe(LangfuseBridge(publicKey, secretKey))` maps them to Langfuse traces, generations, spans, and events (#1910), while keeping core vendor-free. See [docs/observability.md](docs/observability.md). diff --git a/agents-kt-identity/build.gradle.kts b/agents-kt-identity/build.gradle.kts new file mode 100644 index 0000000..35918c9 --- /dev/null +++ b/agents-kt-identity/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + kotlin("jvm") +} + +group = "ai.deep-code" +version = rootProject.version + +repositories { + mavenCentral() +} + +dependencyLocking { + lockAllConfigurations() +} + +configurations.all { + resolutionStrategy { + force( + "org.bouncycastle:bcprov-jdk18on:1.84", + "org.bouncycastle:bcpg-jdk18on:1.84", + "org.bouncycastle:bcpkix-jdk18on:1.84", + "org.bouncycastle:bcutil-jdk18on:1.84", + ) + } +} + +dependencies { + api(project(":")) + + // #4521 — vetted JOSE/JWS + JWKS library; badge verification is trust-critical, so we do not + // hand-roll JWS signature verification (alg-confusion, ES256 P1363/DER, alg=none are real footguns). + api("com.nimbusds:nimbus-jose-jwt:10.3") + + testImplementation(kotlin("test")) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0") +} + +kotlin { + jvmToolchain(21) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/agents-kt-identity/gradle.lockfile b/agents-kt-identity/gradle.lockfile new file mode 100644 index 0000000..dc90cab --- /dev/null +++ b/agents-kt-identity/gradle.lockfile @@ -0,0 +1,41 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.nimbusds:nimbus-jose-jwt:10.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.4.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.4.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:2.4.0=kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.4.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-scripting-common:2.4.0=kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.4.0=kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.4.0=kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-scripting-jvm:2.4.0=kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest +org.jetbrains.kotlin:kotlin-stdlib:2.4.0=compileClasspath,kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-junit5:2.4.0=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-test:2.4.0=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-tooling-core:2.4.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.11.0=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0=testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=compileClasspath,kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath +org.jetbrains:annotations:23.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jline:jline:4.1.3=runtimeClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.10.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.10.1=testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.10.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.10.1=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.10.1=testRuntimeClasspath +org.junit:junit-bom:5.10.1=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +empty=annotationProcessor,implementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,kotlinScriptDefExtensions,testAnnotationProcessor,testImplementationDependenciesMetadata,testKotlinScriptDefExtensions diff --git a/agents-kt-identity/src/main/kotlin/agents_engine/agntcy/identity/BadgeVerificationException.kt b/agents-kt-identity/src/main/kotlin/agents_engine/agntcy/identity/BadgeVerificationException.kt new file mode 100644 index 0000000..6ef4eb9 --- /dev/null +++ b/agents-kt-identity/src/main/kotlin/agents_engine/agntcy/identity/BadgeVerificationException.kt @@ -0,0 +1,9 @@ +package agents_engine.agntcy.identity + +/** + * `agents_engine/agntcy/identity/BadgeVerificationException.kt` — #4521 (PRD §12.6). Thrown by + * [IdentityVerifier.verify] when a badge is **not** trustworthy: bad signature, unknown/missing key, + * a disallowed or `none` algorithm, malformed JWS, or a failed temporal claim (expired / not-yet-valid). + * Fail-closed: anything short of a fully validated signature is an exception, never a partial result. + */ +class BadgeVerificationException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) diff --git a/agents-kt-identity/src/main/kotlin/agents_engine/agntcy/identity/IdentityResolver.kt b/agents-kt-identity/src/main/kotlin/agents_engine/agntcy/identity/IdentityResolver.kt new file mode 100644 index 0000000..88b156f --- /dev/null +++ b/agents-kt-identity/src/main/kotlin/agents_engine/agntcy/identity/IdentityResolver.kt @@ -0,0 +1,65 @@ +package agents_engine.agntcy.identity + +import com.nimbusds.jose.jwk.JWKSet +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration + +/** + * `agents_engine/agntcy/identity/IdentityResolver.kt` — #4521 (PRD §12.6). The *resolve* half of badge + * verification: fetch an AGNTCY issuer's public keys (`/.well-known/jwks.json`) so [IdentityVerifier.verify] + * can validate a badge, and fetch the published credential / resolver metadata (`/.well-known/vcs.json`). + * + * Bounded by construction — connect/read timeouts and a response size cap — because these are network + * reads of attacker-influenceable endpoints. Any failure surfaces as [BadgeVerificationException]; the + * verifier still re-checks the signature, so a hostile JWKS host can at worst deny service, not forge trust. + */ +class IdentityResolver( + private val connectTimeout: Duration = Duration.ofSeconds(5), + private val readTimeout: Duration = Duration.ofSeconds(5), + private val maxResponseBytes: Int = DEFAULT_MAX_RESPONSE_BYTES, +) { + private val http: HttpClient = HttpClient.newBuilder().connectTimeout(connectTimeout).build() + + /** Fetch + parse the issuer JWKS at [jwksUrl] (typically `…/.well-known/jwks.json`). */ + fun fetchJwks(jwksUrl: String): JWKSet = + try { + JWKSet.parse(fetchText(jwksUrl)) + } catch (e: BadgeVerificationException) { + throw e + } catch (e: Exception) { + throw BadgeVerificationException("failed to fetch/parse JWKS from $jwksUrl: ${e.message}", e) + } + + /** + * Fetch the raw JSON at [url] (e.g. `…/.well-known/vcs.json` or resolver metadata). Returned verbatim: + * the AGNTCY VC envelope is still settling upstream, so the caller extracts the compact JWS to hand to + * [IdentityVerifier.verify] rather than this binding it to a not-yet-stable schema. + */ + fun fetchText(url: String): String { + val req = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(readTimeout) + .header("Accept", "application/json") + .GET().build() + val resp = try { + http.send(req, HttpResponse.BodyHandlers.ofString()) + } catch (e: Exception) { + throw BadgeVerificationException("failed to fetch $url: ${e.message}", e) + } + if (resp.statusCode() !in 200..299) { + throw BadgeVerificationException("fetch $url returned HTTP ${resp.statusCode()}") + } + val body = resp.body() + if (body.toByteArray(Charsets.UTF_8).size > maxResponseBytes) { + throw BadgeVerificationException("response from $url exceeds $maxResponseBytes bytes") + } + return body + } + + companion object { + const val DEFAULT_MAX_RESPONSE_BYTES: Int = 256 * 1024 + } +} diff --git a/agents-kt-identity/src/main/kotlin/agents_engine/agntcy/identity/IdentityVerifier.kt b/agents-kt-identity/src/main/kotlin/agents_engine/agntcy/identity/IdentityVerifier.kt new file mode 100644 index 0000000..0b9ab3b --- /dev/null +++ b/agents-kt-identity/src/main/kotlin/agents_engine/agntcy/identity/IdentityVerifier.kt @@ -0,0 +1,81 @@ +package agents_engine.agntcy.identity + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.jwk.JWKSet +import com.nimbusds.jose.jwk.source.ImmutableJWKSet +import com.nimbusds.jose.proc.JWSVerificationKeySelector +import com.nimbusds.jose.proc.SecurityContext +import com.nimbusds.jwt.proc.DefaultJWTProcessor + +/** + * `agents_engine/agntcy/identity/IdentityVerifier.kt` — #4521 (PRD §12.6). Verifies an + * [AGNTCY Identity](https://docs.agntcy.org/identity/) agent **badge** — a W3C Verifiable Credential + * secured with JOSE/JWS — against an issuer's JWKS (`/.well-known/jwks.json`). The trust pillar of the + * AGNTCY epic (#4517), beside the OASF discovery record (§12.6) and A2A invocation (§12.5). + * + * **Verify-only.** Issuance (key management, signing, vaults) is the heavy half and is deferred to the + * self-hosted stack (PRD §12.6). This is the cheap, high-value half: in a trust-gated network you accept + * work only from agents whose badge a known issuer signed. + * + * **Fail-closed and not hand-rolled.** Signature verification is trust-critical, so this delegates to the + * vetted [nimbus-jose-jwt](https://connect2id.com/products/nimbus-jose-jwt) processor rather than parsing + * JWS by hand. The key selector only admits the configured asymmetric algorithms, so `alg: none` and + * algorithm-confusion (verifying an HMAC `HS*` token with a public key) are rejected; `exp`/`nbf` are + * checked by the default claims verifier. Anything short of a fully validated signature throws + * [BadgeVerificationException] — there is no partial success. + * + * ```kotlin + * val jwks = IdentityResolver().fetchJwks("https://issuer.example/.well-known/jwks.json") + * val badge = IdentityVerifier.verify(compactJws, jwks) // throws if untrustworthy + * // badge.issuer / badge.subject / badge.credentialSubject are now safe to trust + * ``` + */ +object IdentityVerifier { + + /** + * The asymmetric signature algorithms a badge may use. Deliberately excludes `none` and all symmetric + * (`HS*`) algorithms — admitting an `HS*` would let an attacker forge a token using the public JWKS as + * the HMAC secret (the classic algorithm-confusion attack). + */ + val DEFAULT_ALGORITHMS: Set = setOf( + JWSAlgorithm.ES256, JWSAlgorithm.ES384, JWSAlgorithm.ES512, + JWSAlgorithm.RS256, JWSAlgorithm.RS384, JWSAlgorithm.RS512, + JWSAlgorithm.PS256, JWSAlgorithm.PS384, JWSAlgorithm.PS512, + JWSAlgorithm.EdDSA, + ) + + /** + * Verify [compactJws] (an AGNTCY badge as a compact JWS / VC-JWT) against [jwks], the issuer's key set + * (the key is selected by the JWS `kid`). Returns the validated [VerifiedBadge]; throws + * [BadgeVerificationException] on any failure. [allowedAlgorithms] defaults to [DEFAULT_ALGORITHMS]. + */ + fun verify( + compactJws: String, + jwks: JWKSet, + allowedAlgorithms: Set = DEFAULT_ALGORITHMS, + ): VerifiedBadge { + require(allowedAlgorithms.isNotEmpty()) { "allowedAlgorithms must not be empty" } + val processor = DefaultJWTProcessor().apply { + jwsKeySelector = JWSVerificationKeySelector(allowedAlgorithms, ImmutableJWKSet(jwks)) + } + val claims = try { + processor.process(compactJws, null) + } catch (e: Exception) { + throw BadgeVerificationException("badge verification failed: ${e.message}", e) + } + + @Suppress("UNCHECKED_CAST") + val vc = claims.getClaim("vc") as? Map + @Suppress("UNCHECKED_CAST") + val credentialSubject = (vc?.get("credentialSubject") as? Map) ?: emptyMap() + + return VerifiedBadge( + issuer = claims.issuer, + subject = claims.subject, + issuedAt = claims.issueTime?.toInstant(), + expiresAt = claims.expirationTime?.toInstant(), + credentialSubject = credentialSubject, + claims = claims.claims, + ) + } +} diff --git a/agents-kt-identity/src/main/kotlin/agents_engine/agntcy/identity/VerifiedBadge.kt b/agents-kt-identity/src/main/kotlin/agents_engine/agntcy/identity/VerifiedBadge.kt new file mode 100644 index 0000000..48745b4 --- /dev/null +++ b/agents-kt-identity/src/main/kotlin/agents_engine/agntcy/identity/VerifiedBadge.kt @@ -0,0 +1,25 @@ +package agents_engine.agntcy.identity + +import java.time.Instant + +/** + * `agents_engine/agntcy/identity/VerifiedBadge.kt` — #4521 (PRD §12.6). The result of a *successful* + * [IdentityVerifier.verify]: an AGNTCY Identity agent badge whose JOSE/JWS signature has already been + * validated against the issuer's JWKS. Construction implies cryptographic validity — there is no + * "invalid" `VerifiedBadge` (failures throw [BadgeVerificationException]). + * + * @property issuer the credential issuer (`iss`) — the identity you are extending trust to. + * @property subject the credential subject (`sub`) — typically the agent's unique id. + * @property issuedAt `iat`, if present. + * @property expiresAt `exp`, if present (already checked: an expired badge would not verify). + * @property credentialSubject the W3C VC `vc.credentialSubject` claims (the agent's attributes), if present. + * @property claims the full validated JWT claim set, for callers that need raw access. + */ +data class VerifiedBadge( + val issuer: String?, + val subject: String?, + val issuedAt: Instant?, + val expiresAt: Instant?, + val credentialSubject: Map, + val claims: Map, +) diff --git a/agents-kt-identity/src/test/kotlin/agents_engine/agntcy/identity/IdentityVerifierTest.kt b/agents-kt-identity/src/test/kotlin/agents_engine/agntcy/identity/IdentityVerifierTest.kt new file mode 100644 index 0000000..068ac4d --- /dev/null +++ b/agents-kt-identity/src/test/kotlin/agents_engine/agntcy/identity/IdentityVerifierTest.kt @@ -0,0 +1,127 @@ +package agents_engine.agntcy.identity + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.crypto.ECDSASigner +import com.nimbusds.jose.crypto.MACSigner +import com.nimbusds.jose.jwk.Curve +import com.nimbusds.jose.jwk.ECKey +import com.nimbusds.jose.jwk.JWKSet +import com.nimbusds.jose.jwk.gen.ECKeyGenerator +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.PlainJWT +import com.nimbusds.jwt.SignedJWT +import com.sun.net.httpserver.HttpServer +import java.net.InetSocketAddress +import java.util.Date +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +// #4521 (PRD §12.6) — AGNTCY Identity badge verify. Hermetic: mint badges with a freshly generated +// issuer key and assert the verifier accepts the genuine one and rejects every forgery class +// (tamper, expiry, alg=none, algorithm-confusion, wrong key, unknown kid). These negative cases ARE +// the value — a trust primitive that accepts a forgery is worse than none. +class IdentityVerifierTest { + + private val issuerKey: ECKey = ECKeyGenerator(Curve.P_256).keyID("issuer-1").generate() + private val jwks = JWKSet(issuerKey.toPublicJWK()) + + private fun mintBadge( + kid: String? = "issuer-1", + expiresInMillis: Long = 3_600_000, + key: ECKey = issuerKey, + ): String { + val now = System.currentTimeMillis() + val claims = JWTClaimsSet.Builder() + .issuer("https://issuer.example") + .subject("agent:catalog") + .issueTime(Date(now)) + .expirationTime(Date(now + expiresInMillis)) + .claim("vc", mapOf("credentialSubject" to mapOf("id" to "agent:catalog", "role" to "retriever"))) + .build() + val jwt = SignedJWT(JWSHeader.Builder(JWSAlgorithm.ES256).keyID(kid).build(), claims) + jwt.sign(ECDSASigner(key)) + return jwt.serialize() + } + + @Test + fun `a genuine badge verifies and exposes its claims`() { + val badge = IdentityVerifier.verify(mintBadge(), jwks) + assertEquals("https://issuer.example", badge.issuer) + assertEquals("agent:catalog", badge.subject) + assertEquals("retriever", badge.credentialSubject["role"]) + } + + @Test + fun `a tampered payload is rejected`() { + val parts = mintBadge().split(".") + // swap in a different (validly-encoded) payload, keep the original signature + val forgedPayload = com.nimbusds.jose.util.Base64URL.encode( + """{"iss":"https://attacker.example","sub":"agent:evil"}""", + ).toString() + val forged = "${parts[0]}.$forgedPayload.${parts[2]}" + assertFailsWith { IdentityVerifier.verify(forged, jwks) } + } + + @Test + fun `an expired badge is rejected`() { + val expired = mintBadge(expiresInMillis = -60_000) // expired a minute ago + assertFailsWith { IdentityVerifier.verify(expired, jwks) } + } + + @Test + fun `a badge signed by a different key is rejected`() { + val attackerKey = ECKeyGenerator(Curve.P_256).keyID("issuer-1").generate() // same kid, different key + assertFailsWith { IdentityVerifier.verify(mintBadge(key = attackerKey), jwks) } + } + + @Test + fun `an unknown kid is rejected`() { + assertFailsWith { IdentityVerifier.verify(mintBadge(kid = "unknown"), jwks) } + } + + @Test + fun `an unsecured alg=none token is rejected`() { + val now = System.currentTimeMillis() + val plain = PlainJWT( + JWTClaimsSet.Builder().issuer("https://attacker.example").subject("agent:evil") + .expirationTime(Date(now + 3_600_000)).build(), + ).serialize() + assertFailsWith { IdentityVerifier.verify(plain, jwks) } + } + + @Test + fun `an HMAC token is rejected (algorithm confusion)`() { + // Classic attack: sign HS256 using the public key bytes as the shared secret. + val secret = ByteArray(32) { it.toByte() } + val now = System.currentTimeMillis() + val jwt = SignedJWT( + JWSHeader.Builder(JWSAlgorithm.HS256).keyID("issuer-1").build(), + JWTClaimsSet.Builder().issuer("https://attacker.example").subject("agent:evil") + .expirationTime(Date(now + 3_600_000)).build(), + ) + jwt.sign(MACSigner(secret)) + assertFailsWith { IdentityVerifier.verify(jwt.serialize(), jwks) } + } + + @Test + fun `resolver fetches a JWKS that then verifies a badge`() { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + val body = jwks.toString().toByteArray(Charsets.UTF_8) // public JWKS JSON + server.createContext("/.well-known/jwks.json") { ex -> + ex.responseHeaders.add("Content-Type", "application/json") + ex.sendResponseHeaders(200, body.size.toLong()) + ex.responseBody.use { it.write(body) } + } + server.start() + try { + val url = "http://127.0.0.1:${server.address.port}/.well-known/jwks.json" + val fetched = IdentityResolver().fetchJwks(url) + val badge = IdentityVerifier.verify(mintBadge(), fetched) + assertEquals("agent:catalog", badge.subject) + } finally { + server.stop(0) + } + } +} diff --git a/docs/prd.md b/docs/prd.md index f150dfd..8043829 100644 --- a/docs/prd.md +++ b/docs/prd.md @@ -2855,7 +2855,7 @@ Any node in the delegation tree can be exported as an A2A endpoint: project.toAgentCard(url = "https://api.deep-code.ai/agents/project") ``` -### 12.6 AGNTCY Interoperability *(in progress — OASF record export shipped; DIR + Identity-verify planned)* +### 12.6 AGNTCY Interoperability *(in progress — OASF record export + Identity-verify shipped; DIR + OASF import planned)* [AGNTCY](https://github.com/agntcy) — the Linux Foundation "Internet of Agents" collective (Cisco/Outshift-led) — is the second cross-vendor interop stack alongside Google A2A (§12.5). Agents.KT targets **both**: A2A is the wire/invocation standard; AGNTCY adds a content-addressed **directory** and a **trust** layer. The native, typed `agent.json` (§12.2) stays the source of truth; AGNTCY support is a set of **exporters/clients over it**, exactly parallel to `toAgentCard()`. @@ -2894,7 +2894,7 @@ val cid = dir.push(specMaster.toOasfRecord(...)) // → content id val hits = dir.search(skill = "agent_orchestration/task_decomposition") ``` -**Identity — verify/resolve.** Badge verification is pure-JVM and high-value for trust-gated networks: fetch `/.well-known/vcs.json` + `/.well-known/jwks.json` and validate the JOSE/JWS verifiable credential with an off-the-shelf JVM JWT library. Issuance (vault, key management, signing) is the heavy half and is deferred to the self-hosted stack. +**Identity — verify/resolve *(shipped, #4521)*.** Badge verification is pure-JVM and high-value for trust-gated networks: `IdentityVerifier.verify(compactJws, jwks)` validates a JOSE/JWS verifiable credential against the issuer's `/.well-known/jwks.json` (resolved by `IdentityResolver`), returning a `VerifiedBadge` or throwing. It ships in a `agents-kt-identity` feature module so the `nimbus-jose-jwt` dependency stays out of core (the rag-module pattern); verification delegates to the vetted nimbus processor rather than hand-rolling JWS — fail-closed against `alg: none`, `HS*` algorithm-confusion, expiry, tamper, and wrong/unknown keys. Note: AGNTCY's badge envelope (`vcs.json`) is still settling upstream (W3C VCDM 2.0 securing mechanisms), so the resolver returns `vcs.json` raw and the caller extracts the compact JWS — the cryptographic verify is the stable, standards-grounded core. Issuance (vault, key management, signing) is the heavy half and is deferred to the self-hosted stack. **Deferred (documented, not built):** ACP REST adapter (only if forced to interop with already-deployed AGNTCY Workflow Servers), SLIM transport, OASF record signing + issuance, OASF modules (the standard module catalog is still empty in `1.0.0`). diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 4d5e358..f967005 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -267,6 +267,14 @@ + + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 0f455e2..e7b8c90 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,6 +9,9 @@ include(":agents-kt-manifest") include(":agents-kt-rag") include(":agents-kt-rag-langchain4j") include(":agents-kt-rag-spring-ai") +// #4521 (epic #4517): AGNTCY Identity badge verify/resolve (JOSE/JWS against issuer JWKS). +// A feature module so the nimbus-jose-jwt dependency stays out of core; verify-only (issuance deferred). +include(":agents-kt-identity") // #1923: standalone CLI (manifest generate / inspect / verify) — the "externally" // half of 0.7.0, so non-Gradle consumers (CI gates, ops, regulators) can produce and // verify the deterministic permission manifest from a binary. diff --git a/src/main/resources/internals-agent/agntcy/identity/IdentityVerifier.md b/src/main/resources/internals-agent/agntcy/identity/IdentityVerifier.md new file mode 100644 index 0000000..fd62cb9 --- /dev/null +++ b/src/main/resources/internals-agent/agntcy/identity/IdentityVerifier.md @@ -0,0 +1,58 @@ +--- +description: Source-file knowledge for the agents-kt-identity module (agents_engine.agntcy.identity) — AGNTCY Identity badge verify/resolve (#4521, PRD §12.6, epic #4517). IdentityVerifier.verify(compactJws, jwks) validates a W3C VC badge secured with JOSE/JWS against an issuer JWKS, fail-closed via nimbus-jose-jwt (rejects alg=none, HMAC algorithm-confusion, expired, wrong/unknown key). IdentityResolver fetches /.well-known/jwks.json (+ vcs.json raw). Verify-only — issuance deferred. Lives in a feature module so nimbus stays out of core. Call when the IDE LLM reasons about agent trust / badge verification. +--- + +# `agents-kt-identity` — AGNTCY Identity badge verify (#4521) + +The **trust** pillar of the AGNTCY epic (#4517), beside the OASF discovery record (`agents_engine.agntcy`, +§12.6) and A2A invocation (§12.5). In a trust-gated agent network you accept work only from agents whose +**badge** (a W3C Verifiable Credential) a known issuer signed. + +```kotlin +val jwks = IdentityResolver().fetchJwks("https://issuer.example/.well-known/jwks.json") +val badge = IdentityVerifier.verify(compactJws, jwks) // throws BadgeVerificationException if untrustworthy +// badge.issuer / badge.subject / badge.credentialSubject are now safe to trust +``` + +## Why a separate module + +`agents-kt-identity` carries the `nimbus-jose-jwt` dependency so **core stays dependency-free** (same +pattern as `agents-kt-rag`). Consumers who want badge verification add this module. + +## Why not hand-rolled + +Signature verification is trust-critical; a bug is a trust *bypass*. So verification delegates to the vetted +nimbus `DefaultJWTProcessor` + `JWSVerificationKeySelector` rather than parsing JWS by hand. The footguns it +closes, each covered by a negative test in `IdentityVerifierTest`: +- **`alg: none`** — an unsecured (plain) JWT is rejected (the key selector admits only signature algs). +- **algorithm confusion** — an `HS256` token signed with the public key as the HMAC secret is rejected + (`HS*` is not in `DEFAULT_ALGORITHMS`; admitting it is the classic forge-with-the-public-key attack). +- **tamper / wrong key / unknown `kid`** — signature fails or no key is selected. +- **expiry / not-before** — `exp`/`nbf` checked by the default claims verifier. + +`DEFAULT_ALGORITHMS` = ES256/384/512, RS256/384/512, PS256/384/512, EdDSA — asymmetric only. + +## Verify-only + +Issuance (key management, signing, vaults) is the heavy half and is **deferred** to the self-hosted stack +(PRD §12.6). This module is the cheap, high-value half. + +## Resolve + +`IdentityResolver.fetchJwks(url)` fetches + parses the issuer JWKS (bounded timeouts + response size cap, as +these are attacker-influenceable reads). `fetchText(url)` returns raw JSON for `vcs.json` / resolver metadata +— returned verbatim because the AGNTCY VC envelope is still settling upstream (the caller extracts the compact +JWS to hand to `verify`), so we don't bind to a not-yet-stable schema. A hostile JWKS host can at worst deny +service: `verify` always re-checks the signature. + +## Files + +- `IdentityVerifier.kt` — `verify()` (the fail-closed core). +- `IdentityResolver.kt` — `fetchJwks` / `fetchText`. +- `VerifiedBadge.kt` — the success result (construction implies validity). +- `BadgeVerificationException.kt` — every failure path. + +## Related + +- `agents_engine/agntcy/OasfRecord.kt` (core) — the OASF discovery sibling in the same epic. +- Remaining #4517 subtasks: DIR gRPC client (#4520), OASF import/validate (#4519).