diff --git a/README.md b/README.md index b6c15fc..f319bce 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,50 @@ The agent picks its behaviour from a prefix on the message text -- `stream: ` runs a cancellable long task, and anything else gets a plain message reply. See `EchoAgentExecutor`. +## Identity and Node Connections + +`SlimHelper.createServer(instance, org, ns, secret, serverAddr)` and the matching +`createChannel` overload are shortcuts for a shared-secret identity over an insecure +connection. With a shared secret, anyone holding the secret can claim any SLIM name, +so production deployments should use per-agent identities. + +The general overloads take the identity as an `IdentityProviderConfig` / +`IdentityVerifierConfig` pair (shared secret, JWT, static JWT or SPIRE) and the node +connection as a `ClientConfig`: + +```java +Name local = new Name("agntcy", "demo", "server"); + +// Sign our tokens with an Ed25519 key; kid = sub = did:key works for a did:key mesh. +IdentityProviderConfig provider = new IdentityProviderConfig.Jwt(new ClientJwtAuth( + new JwtKeyType.Encoding(new JwtKeyConfig(JwtAlgorithm.ED_DSA, JwtKeyFormat.PEM, + new JwtKeyData.File("/etc/agent/ed25519.pem"))), + List.of("slim-a2a"), "my-issuer", "did:key:z6Mk...", Duration.ofHours(1))); + +// Accept peers whose public keys are in a JWKS file (acts as a trust allowlist). +IdentityVerifierConfig verifier = new IdentityVerifierConfig.Jwt(new JwtAuth( + new JwtKeyType.Decoding(new JwtKeyConfig(JwtAlgorithm.ED_DSA, JwtKeyFormat.JWKS, + new JwtKeyData.File("/etc/agent/trusted.jwks.json"))), + List.of("slim-a2a"), "my-issuer", null, Duration.ofHours(1))); + +// TLS to the node (system CA roots) plus a bearer token read from a file. +ClientConfig node = SlimBindings.newSecureClientConfig("https://slim.example.com:443"); +node.setAuth(new ClientAuthenticationConfig.StaticJwt( + new StaticJwtAuth("/etc/agent/node-token.jwt", Duration.ofMinutes(5)))); + +Server server = SlimHelper.createServer(local, provider, verifier, node); +Channel channel = SlimHelper.createChannel( + new Name("agntcy", "demo", "client"), local, provider, verifier, node); +``` + +Every member of a SLIM session needs a compatible identity type, so the server and +its clients must agree on the provider/verifier kind. `SlimHelper.sharedSecretProvider` +and `sharedSecretVerifier` build the shared-secret configs used by the shortcut overloads. + +With slim-bindings-java 2.0, the SlimRPC handler context does not expose the caller's +verified identity, so `SlimA2AHandler` still reports an anonymous user to the A2A +`RequestHandler`. + ## Architecture The library follows the same adapter pattern as diff --git a/pom.xml b/pom.xml index 3e33a85..abec57b 100644 --- a/pom.xml +++ b/pom.xml @@ -48,6 +48,7 @@ 1.1.0.Final 4.33.2 5.14.0 + 5.11.4 @@ -87,6 +88,11 @@ jna ${jna.version} + + org.junit.jupiter + junit-jupiter + ${junit.version} + @@ -105,6 +111,15 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + --enable-preview --enable-native-access=ALL-UNNAMED + + org.codehaus.mojo build-helper-maven-plugin diff --git a/slim-a2a/pom.xml b/slim-a2a/pom.xml index c49f425..89f98a4 100644 --- a/slim-a2a/pom.xml +++ b/slim-a2a/pom.xml @@ -58,6 +58,11 @@ net.java.dev.jna jna + + org.junit.jupiter + junit-jupiter + test + diff --git a/slim-a2a/src/main/java/io/agntcy/slim/a2a/SlimHelper.java b/slim-a2a/src/main/java/io/agntcy/slim/a2a/SlimHelper.java index 43b082a..a9609a0 100644 --- a/slim-a2a/src/main/java/io/agntcy/slim/a2a/SlimHelper.java +++ b/slim-a2a/src/main/java/io/agntcy/slim/a2a/SlimHelper.java @@ -3,8 +3,12 @@ package io.agntcy.slim.a2a; +import java.util.Objects; + import io.agntcy.slim.bindings.App; import io.agntcy.slim.bindings.ClientConfig; +import io.agntcy.slim.bindings.IdentityProviderConfig; +import io.agntcy.slim.bindings.IdentityVerifierConfig; import io.agntcy.slim.bindings.Name; import io.agntcy.slim.bindings.Service; import io.agntcy.slim.bindings.SlimBindings; @@ -13,6 +17,13 @@ /** * Convenience helpers for bootstrapping SLIM RPC server and client instances. + * + *

The general overloads take the SLIM app identity as an + * {@link IdentityProviderConfig} / {@link IdentityVerifierConfig} pair (shared + * secret, JWT, static JWT or SPIRE) and the node connection as a + * {@link ClientConfig} (plain, TLS, mTLS, with optional basic/JWT auth). The + * {@code secret}/{@code serverAddr} overloads are shortcuts for a shared-secret + * identity over an insecure connection. */ public final class SlimHelper { @@ -23,7 +34,25 @@ public final class SlimHelper { private SlimHelper() {} /** - * Bootstraps a SLIM RPC server. + * Bootstraps a SLIM RPC server with a caller-supplied identity and node connection. + * + * @param localName the SLIM name this server registers under + * @param identityProvider how this app proves its identity to peers + * @param identityVerifier how this app verifies the identity of peers + * @param clientConfig how to connect to the SLIM node (endpoint, TLS, auth) + * @return a ready-to-register {@link Server} + */ + public static Server createServer(Name localName, + IdentityProviderConfig identityProvider, + IdentityVerifierConfig identityVerifier, + ClientConfig clientConfig) throws Exception { + Objects.requireNonNull(localName, "localName"); + ConnectedApp connected = connect(localName, identityProvider, identityVerifier, clientConfig); + return Server.newWithConnection(connected.app(), localName, connected.connId()); + } + + /** + * Bootstraps a SLIM RPC server using a shared secret over an insecure connection. * * @param instanceName unique instance name for this server (e.g. "server") * @param org the SLIM name org component @@ -34,17 +63,35 @@ private SlimHelper() {} */ public static Server createServer(String instanceName, String org, String ns, String secret, String serverAddr) throws Exception { - Service service = SlimBindings.getGlobalService(); Name localName = new Name(org, ns, instanceName); - App app = service.createAppWithSecret(localName, secret); - ClientConfig clientConfig = SlimBindings.newInsecureClientConfig(serverAddr); - long connId = service.connect(clientConfig); - app.subscribe(app.name(), connId); - return Server.newWithConnection(app, localName, connId); + return createServer(localName, + sharedSecretProvider(localName, secret), + sharedSecretVerifier(localName, secret), + SlimBindings.newInsecureClientConfig(serverAddr)); + } + + /** + * Bootstraps a SLIM RPC channel (client) with a caller-supplied identity and node connection. + * + * @param localName the SLIM name of this client + * @param remoteName the SLIM name of the remote server + * @param identityProvider how this app proves its identity to peers + * @param identityVerifier how this app verifies the identity of peers + * @param clientConfig how to connect to the SLIM node (endpoint, TLS, auth) + * @return a connected {@link Channel} + */ + public static Channel createChannel(Name localName, Name remoteName, + IdentityProviderConfig identityProvider, + IdentityVerifierConfig identityVerifier, + ClientConfig clientConfig) throws Exception { + Objects.requireNonNull(localName, "localName"); + Objects.requireNonNull(remoteName, "remoteName"); + ConnectedApp connected = connect(localName, identityProvider, identityVerifier, clientConfig); + return Channel.newWithConnection(connected.app(), remoteName, connected.connId()); } /** - * Bootstraps a SLIM RPC channel (client). + * Bootstraps a SLIM RPC channel (client) using a shared secret over an insecure connection. * * @param instanceName unique instance name for this client (e.g. "client") * @param org the SLIM name org component @@ -56,14 +103,31 @@ public static Server createServer(String instanceName, String org, String ns, */ public static Channel createChannel(String instanceName, String org, String ns, String remoteName, String secret, String serverAddr) throws Exception { - Service service = SlimBindings.getGlobalService(); Name localName = new Name(org, ns, instanceName); - Name remote = new Name(org, ns, remoteName); - App app = service.createAppWithSecret(localName, secret); - ClientConfig clientConfig = SlimBindings.newInsecureClientConfig(serverAddr); - long connId = service.connect(clientConfig); - app.subscribe(app.name(), connId); - return Channel.newWithConnection(app, remote, connId); + return createChannel(localName, new Name(org, ns, remoteName), + sharedSecretProvider(localName, secret), + sharedSecretVerifier(localName, secret), + SlimBindings.newInsecureClientConfig(serverAddr)); + } + + /** + * Shared-secret identity provider for {@code name}, equivalent to what + * {@code Service.createAppWithSecret} uses. + */ + public static IdentityProviderConfig sharedSecretProvider(Name name, String secret) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(secret, "secret"); + return new IdentityProviderConfig.SharedSecret(name.toString(), secret); + } + + /** + * Shared-secret identity verifier for {@code name}, equivalent to what + * {@code Service.createAppWithSecret} uses. + */ + public static IdentityVerifierConfig sharedSecretVerifier(Name name, String secret) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(secret, "secret"); + return new IdentityVerifierConfig.SharedSecret(name.toString(), secret); } /** @@ -73,4 +137,20 @@ public static Channel createChannel(String instanceName, String org, String ns, public static void initializeDefaults() { SlimBindings.initializeWithDefaults(); } + + private record ConnectedApp(App app, long connId) {} + + private static ConnectedApp connect(Name localName, + IdentityProviderConfig identityProvider, + IdentityVerifierConfig identityVerifier, + ClientConfig clientConfig) throws Exception { + Objects.requireNonNull(identityProvider, "identityProvider"); + Objects.requireNonNull(identityVerifier, "identityVerifier"); + Objects.requireNonNull(clientConfig, "clientConfig"); + Service service = SlimBindings.getGlobalService(); + App app = service.createApp(localName, identityProvider, identityVerifier); + long connId = service.connect(clientConfig); + app.subscribe(app.name(), connId); + return new ConnectedApp(app, connId); + } } diff --git a/slim-a2a/src/test/java/io/agntcy/slim/a2a/SlimHelperTest.java b/slim-a2a/src/test/java/io/agntcy/slim/a2a/SlimHelperTest.java new file mode 100644 index 0000000..faf4f08 --- /dev/null +++ b/slim-a2a/src/test/java/io/agntcy/slim/a2a/SlimHelperTest.java @@ -0,0 +1,145 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package io.agntcy.slim.a2a; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.time.Duration; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import io.agntcy.slim.bindings.App; +import io.agntcy.slim.bindings.ClientConfig; +import io.agntcy.slim.bindings.ClientJwtAuth; +import io.agntcy.slim.bindings.IdentityProviderConfig; +import io.agntcy.slim.bindings.IdentityVerifierConfig; +import io.agntcy.slim.bindings.JwtAlgorithm; +import io.agntcy.slim.bindings.JwtAuth; +import io.agntcy.slim.bindings.JwtKeyConfig; +import io.agntcy.slim.bindings.JwtKeyData; +import io.agntcy.slim.bindings.JwtKeyFormat; +import io.agntcy.slim.bindings.JwtKeyType; +import io.agntcy.slim.bindings.Name; +import io.agntcy.slim.bindings.SlimBindings; +import io.agntcy.slim.bindings.SlimException; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; + +class SlimHelperTest { + + private static final String SECRET = "a-shared-secret-that-is-long-enough-for-slim"; + + @BeforeAll + static void init() { + SlimHelper.initializeDefaults(); + } + + @Test + void sharedSecretConfigsMatchCreateAppWithSecret() { + Name name = new Name("org", "ns", "agent"); + + var provider = assertInstanceOf(IdentityProviderConfig.SharedSecret.class, + SlimHelper.sharedSecretProvider(name, SECRET)); + var verifier = assertInstanceOf(IdentityVerifierConfig.SharedSecret.class, + SlimHelper.sharedSecretVerifier(name, SECRET)); + + assertEquals(name.toString(), provider.id()); + assertEquals(SECRET, provider.data()); + assertEquals(name.toString(), verifier.id()); + assertEquals(SECRET, verifier.data()); + } + + @Test + void sharedSecretConfigsAreAcceptedByBindings() throws Exception { + Name name = new Name("org", "ns", "shared-secret-app"); + App app = SlimBindings.getGlobalService().createApp(name, + SlimHelper.sharedSecretProvider(name, SECRET), + SlimHelper.sharedSecretVerifier(name, SECRET)); + assertNotNull(app); + } + + @Test + void jwtEdDsaWithJwksConfigsAreAcceptedByBindings(@TempDir Path dir) throws Exception { + KeyPair keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + String kid = "did:key:test-agent"; + + Path privateKey = dir.resolve("agent.pem"); + Files.writeString(privateKey, "-----BEGIN PRIVATE KEY-----\n" + + Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.US_ASCII)) + .encodeToString(keyPair.getPrivate().getEncoded()) + + "\n-----END PRIVATE KEY-----\n"); + + // An X.509 Ed25519 public key ends with the 32-byte raw key. + byte[] spki = keyPair.getPublic().getEncoded(); + byte[] raw = Arrays.copyOfRange(spki, spki.length - 32, spki.length); + Path jwks = dir.resolve("trusted.jwks.json"); + Files.writeString(jwks, """ + {"keys":[{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"%s","x":"%s"}]} + """.formatted(kid, Base64.getUrlEncoder().withoutPadding().encodeToString(raw))); + + Name name = new Name("org", "ns", "jwt-app"); + IdentityProviderConfig provider = new IdentityProviderConfig.Jwt(new ClientJwtAuth( + new JwtKeyType.Encoding(new JwtKeyConfig(JwtAlgorithm.ED_DSA, JwtKeyFormat.PEM, + new JwtKeyData.File(privateKey.toString()))), + List.of("slim"), "test-issuer", kid, Duration.ofMinutes(10))); + IdentityVerifierConfig verifier = new IdentityVerifierConfig.Jwt(new JwtAuth( + new JwtKeyType.Decoding(new JwtKeyConfig(JwtAlgorithm.ED_DSA, JwtKeyFormat.JWKS, + new JwtKeyData.File(jwks.toString()))), + List.of("slim"), "test-issuer", null, Duration.ofMinutes(10))); + + App app = SlimBindings.getGlobalService().createApp(name, provider, verifier); + assertNotNull(app); + } + + @Test + @Timeout(value = 30, unit = TimeUnit.SECONDS) + void createServerUsesSuppliedIdentityBeforeConnecting(@TempDir Path dir) throws Exception { + // A provider with an invalid signing key must fail at app creation, + // proving the supplied identity is used and no node connection is needed. + Path badKey = Files.writeString(dir.resolve("bad.pem"), "not a key"); + IdentityProviderConfig provider = new IdentityProviderConfig.Jwt(new ClientJwtAuth( + new JwtKeyType.Encoding(new JwtKeyConfig(JwtAlgorithm.ED_DSA, JwtKeyFormat.PEM, + new JwtKeyData.File(badKey.toString()))), + List.of("slim"), "test-issuer", "did:key:missing", Duration.ofMinutes(10))); + Name name = new Name("org", "ns", "server"); + ClientConfig clientConfig = SlimBindings.newSecureClientConfig("127.0.0.1:1"); + + assertThrows(SlimException.class, () -> SlimHelper.createServer(name, provider, + SlimHelper.sharedSecretVerifier(name, SECRET), clientConfig)); + assertThrows(SlimException.class, () -> SlimHelper.createChannel(name, + new Name("org", "ns", "remote"), provider, + SlimHelper.sharedSecretVerifier(name, SECRET), clientConfig)); + } + + @Test + void rejectsNullArguments() { + Name name = new Name("org", "ns", "agent"); + var provider = SlimHelper.sharedSecretProvider(name, SECRET); + var verifier = SlimHelper.sharedSecretVerifier(name, SECRET); + var clientConfig = SlimBindings.newInsecureClientConfig("127.0.0.1:1"); + + assertThrows(NullPointerException.class, + () -> SlimHelper.createServer(null, provider, verifier, clientConfig)); + assertThrows(NullPointerException.class, + () -> SlimHelper.createServer(name, null, verifier, clientConfig)); + assertThrows(NullPointerException.class, + () -> SlimHelper.createServer(name, provider, null, clientConfig)); + assertThrows(NullPointerException.class, + () -> SlimHelper.createServer(name, provider, verifier, null)); + assertThrows(NullPointerException.class, + () -> SlimHelper.createChannel(name, null, provider, verifier, clientConfig)); + } +}