From 8e4e0b3123fc68183933e2bb43d8232d0163c32e Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Tue, 13 Oct 2026 09:54:14 -0700 Subject: [PATCH 1/5] Add Nexus secure credential propagation sample Propagate an end-user JWT from a client, through a caller Workflow, to a Nexus Operation handler without exposing it in history or headers. Each credential header is encrypted with Tink HPKE to the receiving hop and bound to the hop's context and to a digest of the exact argument ciphertexts it accompanies, so no sender-side key can decrypt or repurpose it and no input needs deterministic serialization. The handler validates the JWT, authorizes the input against a client-sealed intent, derives the backing Workflow ID from the principal and business identity, and signs its operation tokens. Includes end-to-end tests against the test server, covering start-path, continue-as-new, replay, substitution, and authorization cases, plus unit tests of the cryptographic components. --- README.md | 2 + core/build.gradle | 3 + .../README.MD | 196 +++++ .../caller/CallerStarter.java | 82 ++ .../caller/CallerWorker.java | 52 ++ .../CredentialCallerWorkerInterceptor.java | 256 +++++++ .../caller/CredentialContext.java | 31 + .../CredentialWorkflowClientInterceptor.java | 154 ++++ .../caller/ReadTrackingHeaderMap.java | 74 ++ .../caller/TransferWorkflow.java | 11 + .../caller/TransferWorkflowImpl.java | 28 + .../common/ArgsDigest.java | 124 +++ .../common/ContextAwareTinkPayloadCodec.java | 192 +++++ .../common/CredentialEnvelope.java | 59 ++ .../common/CredentialIntent.java | 128 ++++ .../common/DemoIdentityProvider.java | 57 ++ .../common/EndUserPrincipal.java | 48 ++ .../common/Frame.java | 97 +++ .../common/GenerateDemoKeys.java | 10 + .../common/SampleConfig.java | 33 + .../common/SampleKeysets.java | 197 +++++ .../common/SecureCredentialHeaders.java | 178 +++++ .../CredentialHandlerWorkerInterceptor.java | 168 ++++ .../handler/HandlerWorker.java | 45 ++ .../handler/IntentPolicy.java | 28 + .../handler/JwtVerifier.java | 62 ++ .../handler/RequestPrincipal.java | 26 + .../handler/SignedTokenOperationHandler.java | 116 +++ .../handler/TransferHandlerWorkflow.java | 56 ++ .../handler/TransferHandlerWorkflowImpl.java | 16 + .../handler/TransferIntentPolicy.java | 50 ++ .../handler/TransferNexusServiceImpl.java | 80 ++ .../service/TransferService.java | 109 +++ .../CryptoComponentsTest.java | 461 +++++++++++ .../SecureCredentialPropagationTest.java | 724 ++++++++++++++++++ .../TestWorkflows.java | 259 +++++++ .../handler/TokenProbe.java | 20 + 37 files changed, 4232 insertions(+) create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CallerStarter.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CallerWorker.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CredentialCallerWorkerInterceptor.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CredentialContext.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CredentialWorkflowClientInterceptor.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/ReadTrackingHeaderMap.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/TransferWorkflow.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/TransferWorkflowImpl.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/ArgsDigest.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/ContextAwareTinkPayloadCodec.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/CredentialEnvelope.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/CredentialIntent.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/DemoIdentityProvider.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/EndUserPrincipal.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/Frame.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/GenerateDemoKeys.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/SampleConfig.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/SampleKeysets.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/SecureCredentialHeaders.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/CredentialHandlerWorkerInterceptor.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/HandlerWorker.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/IntentPolicy.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/JwtVerifier.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/RequestPrincipal.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/SignedTokenOperationHandler.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferHandlerWorkflow.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferHandlerWorkflowImpl.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferIntentPolicy.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferNexusServiceImpl.java create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/service/TransferService.java create mode 100644 core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/CryptoComponentsTest.java create mode 100644 core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/SecureCredentialPropagationTest.java create mode 100644 core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/TestWorkflows.java create mode 100644 core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TokenProbe.java diff --git a/README.md b/README.md index 7a19472a..58f825cf 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,8 @@ Load client configuration from TOML files with programmatic overrides. - [**Context/Header Propagation**](/core/src/main/java/io/temporal/samples/nexuscontextpropagation): Demonstrates how to propagate context through Nexus operation headers. +- [**Secure Credential Propagation**](/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation): Demonstrates how to propagate an end-user JWT from a client through a Workflow to a Nexus handler, encrypted to each hop and bound to its arguments, without exposing it in history. + - [**Nexus Messaging**](/core/src/main/java/io/temporal/samples/nexusmessaging): Demonstrates how to send signal, update and query messages through Nexus. This contains two samples, one sending messages to an existing workflow and a second that creates a workflow through Nexus and sends messages to it. diff --git a/core/build.gradle b/core/build.gradle index a06ff7f5..f7681830 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -43,6 +43,9 @@ dependencies { implementation("software.amazon.awssdk:kms") implementation("software.amazon.awssdk:dynamodb") + // Used in the Nexus secure credential propagation sample + implementation 'com.google.crypto.tink:tink:1.23.0' + // we don't update it to 2.1.0 because 2.1.0 requires Java 11 implementation 'com.codingrodent:jackson-json-crypto:1.1.0' diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD new file mode 100644 index 00000000..a0d084d3 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD @@ -0,0 +1,196 @@ +# Nexus Secure Credential Propagation + +This sample propagates an end user's JWT from a client, through a caller Workflow, to a Nexus Operation handler that +authorizes the operation on the user's behalf. The JWT never appears in plaintext in Workflow history, Temporal +headers, or Nexus headers. + +The security claim is **context-bound, receiver-encrypted credential propagation with replay-safe idempotent +execution**. The scheme does not detect that a valid ciphertext was accepted before, so it is not statelessly +replay-proof, and it does not protect against compromised Workers. + +> The demo stores keysets unencrypted on local disk and issues its own JWTs. Production deployments load keysets +> encrypted with a KMS key, give each process only the keys its role needs, and obtain tokens from a real identity +> provider. + +## How it works + +``` +Client Caller Workflow Worker Temporal / Nexus Handler Worker + | start(args) | | | + | header = HPKE(wf.pub, | | | + | jwt+intent, | | | + | ns|type|wid|D(args)) | | | + |------------------------->| open with wf.priv, recompute | | + | | D(args) from received ciphertexts | | + | | | | + | | Nexus input ciphertext C | | + | | header = HPKE(nexus.pub, | | + | | jwt+intent, | | + | | endpoint|svc|op|D(C)) | | + | |---------------------------------->|------------------------->| + | | | route from dispatch, | + | | | D(C) from received C, | + | | | open with nexus.priv, | + | | | verify JWT, check | + | | | input against intent | +``` + +`D(...)` is the **args digest**: SHA-256 over the transmitted argument ciphertexts, their positions, and their count. +Binding the credential to it means the credential only opens next to the exact arguments it was sent with. Nothing +hashes or canonicalizes the business input, so serialization does not need to be deterministic. + +### Keys + +| Keyset | Type | Starter | Caller Worker | Handler Worker | +|---|---|---|---|---| +| Payload | AEAD (symmetric) | yes | yes | yes | +| Workflow credential | HPKE | public | private + public | - | +| Nexus credential | HPKE | - | public | private | +| Operation token | MAC | - | - | yes | +| Demo identity provider | JWT ES256 | private | - | public | + +The credential headers use hybrid public-key encryption to the receiving hop, so read access exists only where the +credential is consumed. Clients and caller Workers hold only public keys for the hops they send on, so a sender-side +compromise reveals no credential in history. Hybrid encryption does not authenticate the sender. That is fine here: +every header is bound to an args digest that cannot be forged without the payload key, and must contain a JWT that +the handler validates. Holding the payload key alone does not let an attacker repurpose a credential either: changing +any argument changes the digest. + +Payloads stay symmetric because both ends of a payload hop must read them. The codec binds each payload to its +serialization context (Nexus endpoint/service/operation, or Workflow namespace/ID), and never falls back to weaker +associated data. + +### Wire formats + +``` +Temporal header "end-user-credential": + v1.start. + v1.can. + +Nexus header "x-temporal-end-user-credential": + v1. + +plaintext = frame("credential-envelope", v1, compactJwt, intent) +``` + +`frame` is a length-prefixed encoding. The context is visible to anyone who can read history; only the plaintext is +secret. + +The Java SDK builds the continue-as-new header before encoding the new run's arguments, so continue-as-new headers +are bound to `firstExecutionRunId` instead of an args digest. The server preserves that ID across continue-as-new, +retries, cron, and reset, and assigns a fresh one to every new start, so a copied continue-as-new header is rejected +outside its original chain. + +### Codec handoff + +The payload codec knows nothing about credentials. It reports the digest of every list it encodes or decodes to +`ArgsDigest` through a thread local: + +- When sending, the interceptor arms a pending seal before calling into the SDK. The SDK encodes the arguments first, + which completes the seal into the live header map, and only then serializes the headers. +- When receiving, the SDK decodes the arguments immediately before invoking the interceptor, which takes the digest. + +This depends on the SDK encoding and decoding arguments synchronously on the interceptor's thread, and on the +credential interceptors being innermost when sending (`CredentialWorkflowClientInterceptor` registered **first** on +the client, `CredentialCallerWorkerInterceptor` registered **last** on the Worker). Every failure mode fails closed: +an unbound start is reported by the client and has no credential at the Worker, an unbound Nexus call fails the +Workflow task, and a missing or mismatched header is rejected by the receiver. The tests pin these orderings. + +### Authorization and steering + +The handler gets the route from the actual Nexus request, never from custom headers. It decrypts the credential, +validates the JWT (signature, algorithm, issuer, audience, type, `exp`, `nbf`), then: + +- authorizes the principal against the input: the token subject and tenant are authoritative, and the input's + `userId` and `tenantId` are only assertions to check; +- requires the input to fall within the **intent** the client sealed next to the JWT (exact payment ID and + beneficiary, maximum amount). + +The args digest proves only that the caller Worker produced the input. Signals, updates, and activity results can +steer a caller Workflow into choosing different inputs. The intent bounds that: the Workflow carries it unchanged and +cannot alter it. Authorizing the steering messages themselves needs a separate mechanism, such as: + +- **identity-provider-issued intent**, for example RFC 9396 `authorization_details` or transaction tokens, which also + survive a caller Worker compromise (recommended for production); +- **authenticated steering messages** that carry the sender's own credential to the handler; +- **Temporal-layer restrictions** on who may signal or update these Workflows; +- **provenance discipline**, deriving Nexus input only from start arguments (a review rule, not a control). + +Failures are non-retryable and use generic messages: malformed or unbindable input is `BAD_REQUEST`, a missing or +invalid credential is `UNAUTHENTICATED`, and a valid identity without permission is `UNAUTHORIZED`. + +### Replay and idempotency + +| Case | Outcome | +|---|---| +| Workflow replay | Interceptors decrypt again; regenerated ciphertexts are not compared by Temporal. | +| Nexus retry | Same input and request ID; resolves to the same backing Workflow. | +| Exact replay of a Nexus input/header pair from another caller | Attaches to the same backing Workflow while it runs; refused once it has closed. No second effect. | +| Exact replay of a start into a new run of the same ID | Re-runs the victim's own arguments and intent; its Nexus calls are exact replays. Bounded by JWT expiry. | + +The backing Workflow ID is derived from the verified principal and the payment ID, never from the Nexus request ID. +Including the principal stops another user with identical input from attaching to someone else's transfer. Workflow +ID reuse protection is bounded by retention; enforce uniqueness beyond that in a durable store. + +### Cancellation and the access-layer boundary + +Operation tokens are signed with a handler-only key, so no caller can forge a token naming another user's backing +Workflow. Any holder of an issued token can cancel, including an exact replayer. A replayer presents byte-identical +input, headers, and token, so nothing at the handler can tell them apart from the legitimate caller. Anyone who can +both read caller history and reach the endpoint can replay starts (harmless) and cancels (effective). Restrict that at +the access layer: allowed callers on the Nexus endpoint, and client authentication for direct Nexus calls. + +### Scope + +- Only start paths (start, signal-with-start, update-with-start) carry a credential; a Workflow acts for one principal. +- The credential survives continue-as-new, including to a different Workflow type and through retries. +- Child Workflows never receive it. +- Protected Workflows and operations must take at least one argument. +- The Temporal Service is trusted for routing integrity (endpoint dispatch, request IDs, run lineage), not for + confidentiality. +- Handler-side endpoint context requires Temporal Server 1.30 or newer; protected operations fail closed without it. +- Use short-lived, audience- and scope-limited tokens obtained by a token exchange at the client edge. Never forward + a general login token or a refresh token through a long-running Workflow. Only the handler validates the token, so + narrowing it adds no work at intermediate hops. + +## Running the sample + +Set up the namespaces and the local development server as in the main [Nexus sample](../nexus/README.MD), then create +the endpoint for this sample: + +``` +temporal operator nexus endpoint create \ + --name nexus-secure-credential-endpoint \ + --target-namespace my-target-namespace \ + --target-task-queue nexus-secure-credential-handler +``` + +In production, also restrict the endpoint's allowed callers. + +Generate the demo keysets (written to `build/nexus-secure-credential-keys`; override with +`-Dnexus.secure.credential.keyDir=...`): + +``` +./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexussecurecredentialpropagation.common.GenerateDemoKeys +``` + +Then, in separate terminal windows: + +``` +./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexussecurecredentialpropagation.handler.HandlerWorker \ + --args="-target-host localhost:7233 -namespace my-target-namespace" +``` + +``` +./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexussecurecredentialpropagation.caller.CallerWorker \ + --args="-target-host localhost:7233 -namespace my-caller-namespace" +``` + +``` +./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexussecurecredentialpropagation.caller.CallerStarter \ + --args="-target-host localhost:7233 -namespace my-caller-namespace" +``` + +The starter logs `Transfer payment-1001 SETTLED for alice`. Inspect the caller Workflow's history +(`temporal workflow show -w transfer-payment-1001 -n my-caller-namespace --output json`): the start header and the +Nexus header contain only ciphertext. diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CallerStarter.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CallerStarter.java new file mode 100644 index 00000000..ad7205de --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CallerStarter.java @@ -0,0 +1,82 @@ +package io.temporal.samples.nexussecurecredentialpropagation.caller; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.client.WorkflowOptions; +import io.temporal.samples.nexus.options.ClientOptions; +import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialEnvelope; +import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialIntent; +import io.temporal.samples.nexussecurecredentialpropagation.common.DemoIdentityProvider; +import io.temporal.samples.nexussecurecredentialpropagation.common.SampleConfig; +import io.temporal.samples.nexussecurecredentialpropagation.common.SampleKeysets; +import io.temporal.samples.nexussecurecredentialpropagation.service.TransferService; +import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class CallerStarter { + private static final Logger logger = LoggerFactory.getLogger(CallerStarter.class); + + public static void main(String[] args) { + // The starter holds only the caller Worker's public key: it can seal credentials for the + // Workflow but cannot read any captured from history. + SampleKeysets keys = SampleKeysets.fromDefaultDirectory(); + WorkflowClient base = + ClientOptions.getWorkflowClient( + args, + WorkflowClientOptions.newBuilder() + .setDataConverter(SampleConfig.dataConverter(keys.payloadAead()))); + WorkflowClient client = + WorkflowClient.newInstance( + base.getWorkflowServiceStubs(), + WorkflowClientOptions.newBuilder(base.getOptions()) + // Must be registered first; see CredentialWorkflowClientInterceptor. + .setInterceptors( + new CredentialWorkflowClientInterceptor( + base.getOptions().getNamespace(), keys.workflowCredentialEncrypt())) + .build()); + + TransferService.TransferInput input = + new TransferService.TransferInput("acme", "alice", "payment-1001", "bob", 25_00); + + // Stand-in for a token exchange at the client edge: a short-lived token restricted to the + // handler's audience. + String jwt = + new DemoIdentityProvider(keys.demoIdentityProviderSigner()) + .issue("alice", "acme", TransferService.REQUIRED_SCOPE, Duration.ofMinutes(5)); + + // The intent is what the user approved. The handler rejects any input outside it. + Map constraints = new HashMap<>(); + constraints.put(TransferService.CONSTRAINT_PAYMENT_ID, input.getPaymentId()); + constraints.put(TransferService.CONSTRAINT_BENEFICIARY, input.getBeneficiary()); + constraints.put(TransferService.CONSTRAINT_MAX_AMOUNT_CENTS, "5000"); + CredentialIntent intent = + new CredentialIntent( + Collections.singletonList( + new CredentialIntent.RouteGrant( + SampleConfig.ENDPOINT, + TransferService.NAME, + TransferService.TRANSFER, + constraints))); + + TransferWorkflow workflow = + client.newWorkflowStub( + TransferWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId("transfer-" + input.getPaymentId()) + .setTaskQueue(SampleConfig.CALLER_TASK_QUEUE) + .build()); + CredentialContext.callWith( + new CredentialEnvelope(jwt, intent), () -> WorkflowClient.start(workflow::transfer, input)); + TransferService.TransferOutput output = workflow.transfer(input); + logger.info( + "Transfer {} {} for {}", + output.getPaymentId(), + output.getStatus(), + output.getApprovedFor()); + System.exit(0); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CallerWorker.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CallerWorker.java new file mode 100644 index 00000000..af1b9ccb --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CallerWorker.java @@ -0,0 +1,52 @@ +package io.temporal.samples.nexussecurecredentialpropagation.caller; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.samples.nexus.options.ClientOptions; +import io.temporal.samples.nexussecurecredentialpropagation.common.SampleConfig; +import io.temporal.samples.nexussecurecredentialpropagation.common.SampleKeysets; +import io.temporal.samples.nexussecurecredentialpropagation.service.TransferService; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactory; +import io.temporal.worker.WorkerFactoryOptions; +import io.temporal.worker.WorkflowImplementationOptions; +import io.temporal.workflow.NexusServiceOptions; +import java.util.Collections; + +public class CallerWorker { + public static void main(String[] args) { + // The caller Worker holds the Workflow credential private key (it receives the start hop) and + // only the public key of the Nexus handler (it sends the Nexus hop). + SampleKeysets keys = SampleKeysets.fromDefaultDirectory(); + WorkflowClient client = + ClientOptions.getWorkflowClient( + args, + WorkflowClientOptions.newBuilder() + .setDataConverter(SampleConfig.dataConverter(keys.payloadAead()))); + + WorkerFactory factory = + WorkerFactory.newInstance( + client, + WorkerFactoryOptions.newBuilder() + // Must be registered last; see CredentialCallerWorkerInterceptor. + .setWorkerInterceptors( + new CredentialCallerWorkerInterceptor( + keys.workflowCredentialDecrypt(), + keys.workflowCredentialEncrypt(), + keys.nexusCredentialEncrypt(), + SampleConfig.PROTECTED_ROUTES)) + .build()); + + Worker worker = factory.newWorker(SampleConfig.CALLER_TASK_QUEUE); + worker.registerWorkflowImplementationTypes( + WorkflowImplementationOptions.newBuilder() + .setNexusServiceOptions( + Collections.singletonMap( + TransferService.NAME, + NexusServiceOptions.newBuilder().setEndpoint(SampleConfig.ENDPOINT).build())) + .build(), + TransferWorkflowImpl.class); + + factory.start(); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CredentialCallerWorkerInterceptor.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CredentialCallerWorkerInterceptor.java new file mode 100644 index 00000000..12da0295 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CredentialCallerWorkerInterceptor.java @@ -0,0 +1,256 @@ +package io.temporal.samples.nexussecurecredentialpropagation.caller; + +import com.google.crypto.tink.HybridDecrypt; +import com.google.crypto.tink.HybridEncrypt; +import io.temporal.api.common.v1.Payload; +import io.temporal.common.interceptors.Header; +import io.temporal.common.interceptors.WorkerInterceptorBase; +import io.temporal.common.interceptors.WorkflowInboundCallsInterceptor; +import io.temporal.common.interceptors.WorkflowInboundCallsInterceptorBase; +import io.temporal.common.interceptors.WorkflowOutboundCallsInterceptor; +import io.temporal.common.interceptors.WorkflowOutboundCallsInterceptorBase; +import io.temporal.failure.ApplicationFailure; +import io.temporal.samples.nexussecurecredentialpropagation.common.ArgsDigest; +import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialEnvelope; +import io.temporal.samples.nexussecurecredentialpropagation.common.SecureCredentialHeaders; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInfo; +import java.security.GeneralSecurityException; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.slf4j.Logger; + +/** + * Caller Worker side of the credential protocol. + * + *
    + *
  • Opens the start or continue-as-new credential header and keeps the credential in the + * per-Workflow interceptor instance. Workflow code never sees it. + *
  • Re-seals the credential for the next run on continue-as-new. + *
  • Never propagates it to child Workflows. + *
  • Seals it to the Nexus handler for protected Nexus Operations, bound to the digest of the + * exact input ciphertext. + *
+ * + *

Register this interceptor last in {@code WorkerFactoryOptions.setWorkerInterceptors}. That + * makes it the outermost inbound interceptor, so it takes the argument digest immediately after the + * SDK decodes the arguments, and the innermost outbound interceptor, so the header map it seals + * into is the one the SDK reads after encoding. + */ +public class CredentialCallerWorkerInterceptor extends WorkerInterceptorBase { + public static final String CREDENTIAL_REJECTED = "CredentialRejected"; + + private static final Logger log = Workflow.getLogger(CredentialCallerWorkerInterceptor.class); + + private final HybridDecrypt workflowCredentialDecrypt; + private final HybridEncrypt workflowCredentialEncrypt; + private final HybridEncrypt nexusCredentialEncrypt; + private final Set protectedRoutes; + + /** + * @param protectedRoutes Nexus routes, as {@code service/operation}, that receive the credential + */ + public CredentialCallerWorkerInterceptor( + HybridDecrypt workflowCredentialDecrypt, + HybridEncrypt workflowCredentialEncrypt, + HybridEncrypt nexusCredentialEncrypt, + Set protectedRoutes) { + this.workflowCredentialDecrypt = workflowCredentialDecrypt; + this.workflowCredentialEncrypt = workflowCredentialEncrypt; + this.nexusCredentialEncrypt = nexusCredentialEncrypt; + this.protectedRoutes = Collections.unmodifiableSet(new HashSet<>(protectedRoutes)); + } + + public static String route(String service, String operation) { + return service + "/" + operation; + } + + @Override + public WorkflowInboundCallsInterceptor interceptWorkflow(WorkflowInboundCallsInterceptor next) { + return new Inbound(next); + } + + private final class Inbound extends WorkflowInboundCallsInterceptorBase { + private CredentialEnvelope credential; + + Inbound(WorkflowInboundCallsInterceptor next) { + super(next); + } + + @Override + public void init(WorkflowOutboundCallsInterceptor outboundCalls) { + super.init(new Outbound(outboundCalls, this)); + } + + @Override + public WorkflowOutput execute(WorkflowInput input) { + // Take the digest first: the SDK decoded the arguments immediately before this call. + ArgsDigest.Received received = ArgsDigest.take(); + Payload header = input.getHeader().getValues().get(SecureCredentialHeaders.WORKFLOW_HEADER); + if (header != null) { + try { + Object[] args = input.getArguments(); + credential = open(header, received, args == null ? 0 : args.length); + } catch (GeneralSecurityException e) { + // Fail the run rather than its Workflow task, so a rejected start ends deterministically. + // The message is generic; details stay in the Worker log. + log.warn("Rejected end-user credential: {}", e.getMessage()); + throw ApplicationFailure.newNonRetryableFailure( + "End-user credential rejected", CREDENTIAL_REJECTED); + } + } + return super.execute(input); + } + + private CredentialEnvelope open(Payload header, ArgsDigest.Received received, int argCount) + throws GeneralSecurityException { + SecureCredentialHeaders.WorkflowHeader parsed = + SecureCredentialHeaders.parseWorkflowHeader(header); + WorkflowInfo info = Workflow.getInfo(); + byte[] context; + if (parsed.getKind() == SecureCredentialHeaders.WorkflowHeaderKind.START) { + if (received == null || argCount == 0) { + throw new GeneralSecurityException("No arguments to bind the credential to"); + } + if (received.getCount() != argCount) { + // The SDK fills in missing arguments with defaults, so compare against what was sent. + throw new GeneralSecurityException("Argument count mismatch"); + } + context = + SecureCredentialHeaders.startContext( + info.getNamespace(), + info.getWorkflowType(), + info.getWorkflowId(), + received.getDigest()); + } else { + // Only runs in the original chain share its first execution run ID; a fresh start that + // copies this header gets a new one. + context = + SecureCredentialHeaders.continueAsNewContext( + info.getNamespace(), + info.getWorkflowType(), + info.getWorkflowId(), + info.getFirstExecutionRunId()); + } + return SecureCredentialHeaders.open( + workflowCredentialDecrypt, parsed.getCiphertext(), context); + } + } + + private final class Outbound extends WorkflowOutboundCallsInterceptorBase { + private final Inbound inbound; + + Outbound(WorkflowOutboundCallsInterceptor next, Inbound inbound) { + super(next); + this.inbound = inbound; + } + + @Override + public ExecuteNexusOperationOutput executeNexusOperation( + ExecuteNexusOperationInput input) { + // The SDK copies the headers into a mutable map when the input is constructed and reads that + // same map after encoding the operation input. + Map headers = input.getHeaders(); + if (headers.containsKey(SecureCredentialHeaders.NEXUS_HEADER)) { + throw new IllegalArgumentException("The credential header name is reserved"); + } + if (!protectedRoutes.contains(route(input.getService(), input.getOperation()))) { + return super.executeNexusOperation(input); + } + if (inbound.credential == null) { + throw ApplicationFailure.newNonRetryableFailure( + "No end-user credential for a protected Nexus Operation", CREDENTIAL_REJECTED); + } + if (input.getArg() == null) { + throw new IllegalArgumentException("Protected Nexus Operations require an input"); + } + NexusSeal seal = new NexusSeal(input, inbound.credential); + ArgsDigest.arm(seal); + ExecuteNexusOperationOutput output; + try { + output = super.executeNexusOperation(input); + } finally { + ArgsDigest.disarm(); + } + if (!seal.isCompleted()) { + // Fails the Workflow task, so the command is never sent without a bound credential. + throw new IllegalStateException( + "The credential header was not bound to the Nexus operation input"); + } + return output; + } + + @Override + public void continueAsNew(ContinueAsNewInput input) { + Map values = withoutCredential(input.getHeader()); + if (inbound.credential != null) { + WorkflowInfo info = Workflow.getInfo(); + String workflowType = + input.getWorkflowType() != null ? input.getWorkflowType() : info.getWorkflowType(); + byte[] context = + SecureCredentialHeaders.continueAsNewContext( + info.getNamespace(), + workflowType, + info.getWorkflowId(), + info.getFirstExecutionRunId()); + values.put( + SecureCredentialHeaders.WORKFLOW_HEADER, + SecureCredentialHeaders.toWorkflowHeaderPayload( + SecureCredentialHeaders.WorkflowHeaderKind.CONTINUE_AS_NEW, + SecureCredentialHeaders.seal( + workflowCredentialEncrypt, inbound.credential, context))); + } + super.continueAsNew( + new ContinueAsNewInput( + input.getWorkflowType(), input.getOptions(), input.getArgs(), new Header(values))); + } + + @Override + public ChildWorkflowOutput executeChildWorkflow(ChildWorkflowInput input) { + // Child Workflows never receive the credential. + return super.executeChildWorkflow( + new ChildWorkflowInput<>( + input.getWorkflowId(), + input.getWorkflowType(), + input.getResultClass(), + input.getResultType(), + input.getArgs(), + input.getOptions(), + new Header(withoutCredential(input.getHeader())))); + } + } + + private static Map withoutCredential(Header header) { + Map values = new HashMap<>(header.getValues()); + values.remove(SecureCredentialHeaders.WORKFLOW_HEADER); + return values; + } + + private final class NexusSeal extends ArgsDigest.PendingSeal { + private final WorkflowOutboundCallsInterceptor.ExecuteNexusOperationInput input; + private final CredentialEnvelope credential; + + NexusSeal( + WorkflowOutboundCallsInterceptor.ExecuteNexusOperationInput input, + CredentialEnvelope credential) { + this.input = input; + this.credential = credential; + } + + @Override + protected void seal(byte[] digest, int count) { + byte[] context = + SecureCredentialHeaders.nexusContext( + input.getEndpoint(), input.getService(), input.getOperation(), digest); + input + .getHeaders() + .put( + SecureCredentialHeaders.NEXUS_HEADER, + SecureCredentialHeaders.toNexusHeaderValue( + SecureCredentialHeaders.seal(nexusCredentialEncrypt, credential, context))); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CredentialContext.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CredentialContext.java new file mode 100644 index 00000000..f8c4fc83 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CredentialContext.java @@ -0,0 +1,31 @@ +package io.temporal.samples.nexussecurecredentialpropagation.caller; + +import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialEnvelope; +import java.util.function.Supplier; + +/** + * Short-lived client-side holder for the credential to attach to Workflow starts made on the + * current thread. + */ +public final class CredentialContext { + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + private CredentialContext() {} + + /** Runs {@code action} with the credential attached to any Workflow start it makes. */ + public static T callWith(CredentialEnvelope credential, Supplier action) { + if (CURRENT.get() != null) { + throw new IllegalStateException("A credential is already in scope on this thread"); + } + CURRENT.set(credential); + try { + return action.get(); + } finally { + CURRENT.remove(); + } + } + + static CredentialEnvelope current() { + return CURRENT.get(); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CredentialWorkflowClientInterceptor.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CredentialWorkflowClientInterceptor.java new file mode 100644 index 00000000..919e6fea --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CredentialWorkflowClientInterceptor.java @@ -0,0 +1,154 @@ +package io.temporal.samples.nexussecurecredentialpropagation.caller; + +import com.google.crypto.tink.HybridEncrypt; +import io.temporal.common.interceptors.Header; +import io.temporal.common.interceptors.WorkflowClientCallsInterceptor; +import io.temporal.common.interceptors.WorkflowClientCallsInterceptorBase; +import io.temporal.common.interceptors.WorkflowClientInterceptorBase; +import io.temporal.samples.nexussecurecredentialpropagation.common.ArgsDigest; +import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialEnvelope; +import io.temporal.samples.nexussecurecredentialpropagation.common.SecureCredentialHeaders; +import java.util.function.Supplier; + +/** + * Attaches the credential in {@link CredentialContext} to every Workflow start path: start, + * signal-with-start, and update-with-start. + * + *

The header is sealed to the caller Worker's public key and bound to the digest of the exact + * start-argument ciphertexts. Register this interceptor first in {@code + * WorkflowClientOptions.setInterceptors} so it is the innermost one: the header map it passes on + * must be the one the SDK reads after encoding the arguments. + */ +public class CredentialWorkflowClientInterceptor extends WorkflowClientInterceptorBase { + private final String namespace; + private final HybridEncrypt workflowCredentialEncrypt; + + public CredentialWorkflowClientInterceptor( + String namespace, HybridEncrypt workflowCredentialEncrypt) { + this.namespace = namespace; + this.workflowCredentialEncrypt = workflowCredentialEncrypt; + } + + @Override + public WorkflowClientCallsInterceptor workflowClientCallsInterceptor( + WorkflowClientCallsInterceptor next) { + return new WorkflowClientCallsInterceptorBase(next) { + @Override + public WorkflowStartOutput start(WorkflowStartInput input) { + CredentialEnvelope credential = CredentialContext.current(); + if (credential == null) { + return super.start(rejectReservedHeader(input)); + } + StartSeal seal = newSeal(input, credential); + return sealed(seal, () -> super.start(seal.input)); + } + + @Override + public WorkflowSignalWithStartOutput signalWithStart(WorkflowSignalWithStartInput input) { + CredentialEnvelope credential = CredentialContext.current(); + if (credential == null) { + rejectReservedHeader(input.getWorkflowStartInput()); + return super.signalWithStart(input); + } + StartSeal seal = newSeal(input.getWorkflowStartInput(), credential); + return sealed( + seal, + () -> + super.signalWithStart( + new WorkflowSignalWithStartInput( + seal.input, input.getSignalName(), input.getSignalArguments()))); + } + + @Override + public WorkflowUpdateWithStartOutput updateWithStart( + WorkflowUpdateWithStartInput input) { + CredentialEnvelope credential = CredentialContext.current(); + if (credential == null) { + rejectReservedHeader(input.getWorkflowStartInput()); + return super.updateWithStart(input); + } + StartSeal seal = newSeal(input.getWorkflowStartInput(), credential); + return sealed( + seal, + () -> + super.updateWithStart( + new WorkflowUpdateWithStartInput<>(seal.input, input.getStartUpdateInput()))); + } + }; + } + + private StartSeal newSeal( + WorkflowClientCallsInterceptor.WorkflowStartInput input, CredentialEnvelope credential) { + if (input.getArguments() == null || input.getArguments().length == 0) { + // With no arguments there is nothing to bind the credential to. + throw new IllegalArgumentException( + "Workflows started with an end-user credential must take at least one argument"); + } + rejectReservedHeader(input); + ReadTrackingHeaderMap values = new ReadTrackingHeaderMap(input.getHeader().getValues()); + WorkflowClientCallsInterceptor.WorkflowStartInput withHeader = + new WorkflowClientCallsInterceptor.WorkflowStartInput( + input.getWorkflowId(), + input.getWorkflowType(), + new Header(values), + input.getArguments(), + input.getOptions()); + return new StartSeal(withHeader, values, credential); + } + + private static T sealed(StartSeal seal, Supplier call) { + ArgsDigest.arm(seal); + T result; + try { + result = call.get(); + } finally { + ArgsDigest.disarm(); + } + if (!seal.isCompleted() || !seal.headerValues.wasReadAfterSeal()) { + // The SDK did not send the sealed header, for example because an inner interceptor copied + // the header map before the arguments were encoded. The Workflow starts without a + // credential and its protected calls fail; surface the misconfiguration here. + throw new IllegalStateException("The credential header was not bound to the start arguments"); + } + return result; + } + + private static WorkflowClientCallsInterceptor.WorkflowStartInput rejectReservedHeader( + WorkflowClientCallsInterceptor.WorkflowStartInput input) { + if (input.getHeader().getValues().containsKey(SecureCredentialHeaders.WORKFLOW_HEADER)) { + throw new IllegalArgumentException("The credential header name is reserved"); + } + return input; + } + + private final class StartSeal extends ArgsDigest.PendingSeal { + private final WorkflowClientCallsInterceptor.WorkflowStartInput input; + private final ReadTrackingHeaderMap headerValues; + private final CredentialEnvelope credential; + + StartSeal( + WorkflowClientCallsInterceptor.WorkflowStartInput input, + ReadTrackingHeaderMap headerValues, + CredentialEnvelope credential) { + this.input = input; + this.headerValues = headerValues; + this.credential = credential; + } + + @Override + protected void seal(byte[] digest, int count) { + if (count != input.getArguments().length) { + throw new IllegalStateException("Encoded argument count does not match the start input"); + } + byte[] context = + SecureCredentialHeaders.startContext( + namespace, input.getWorkflowType(), input.getWorkflowId(), digest); + headerValues.put( + SecureCredentialHeaders.WORKFLOW_HEADER, + SecureCredentialHeaders.toWorkflowHeaderPayload( + SecureCredentialHeaders.WorkflowHeaderKind.START, + SecureCredentialHeaders.seal(workflowCredentialEncrypt, credential, context))); + headerValues.markSealed(); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/ReadTrackingHeaderMap.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/ReadTrackingHeaderMap.java new file mode 100644 index 00000000..47672ae2 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/ReadTrackingHeaderMap.java @@ -0,0 +1,74 @@ +package io.temporal.samples.nexussecurecredentialpropagation.caller; + +import io.temporal.api.common.v1.Payload; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; + +/** + * Header map that records whether it was read after the credential was sealed into it. + * + *

The SDK serializes the header after encoding the arguments. If the map is never read after the + * seal, the sealed header was not sent, typically because an inner interceptor copied the map + * earlier. + */ +final class ReadTrackingHeaderMap extends HashMap { + private static final long serialVersionUID = 1L; + + private boolean sealed; + private boolean readAfterSeal; + + ReadTrackingHeaderMap(Map values) { + super(values); + } + + void markSealed() { + sealed = true; + } + + boolean wasReadAfterSeal() { + return readAfterSeal; + } + + private void recordRead() { + if (sealed) { + readAfterSeal = true; + } + } + + @Override + public Set> entrySet() { + recordRead(); + return super.entrySet(); + } + + @Override + public Set keySet() { + recordRead(); + return super.keySet(); + } + + @Override + public Collection values() { + recordRead(); + return super.values(); + } + + @Override + public void forEach(BiConsumer action) { + recordRead(); + super.forEach(action); + } + + @Override + public boolean equals(Object o) { + return super.equals(o); + } + + @Override + public int hashCode() { + return super.hashCode(); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/TransferWorkflow.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/TransferWorkflow.java new file mode 100644 index 00000000..2f38c71b --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/TransferWorkflow.java @@ -0,0 +1,11 @@ +package io.temporal.samples.nexussecurecredentialpropagation.caller; + +import io.temporal.samples.nexussecurecredentialpropagation.service.TransferService; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +@WorkflowInterface +public interface TransferWorkflow { + @WorkflowMethod + TransferService.TransferOutput transfer(TransferService.TransferInput input); +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/TransferWorkflowImpl.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/TransferWorkflowImpl.java new file mode 100644 index 00000000..43bf39cc --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/TransferWorkflowImpl.java @@ -0,0 +1,28 @@ +package io.temporal.samples.nexussecurecredentialpropagation.caller; + +import io.temporal.samples.nexussecurecredentialpropagation.service.TransferService; +import io.temporal.workflow.NexusOperationOptions; +import io.temporal.workflow.NexusServiceOptions; +import io.temporal.workflow.Workflow; +import java.time.Duration; + +/** + * Calls the protected Nexus Operation. The Workflow never handles the credential: the caller Worker + * interceptor attaches it. + */ +public class TransferWorkflowImpl implements TransferWorkflow { + private final TransferService transferService = + Workflow.newNexusServiceStub( + TransferService.class, + NexusServiceOptions.newBuilder() + .setOperationOptions( + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofMinutes(5)) + .build()) + .build()); + + @Override + public TransferService.TransferOutput transfer(TransferService.TransferInput input) { + return transferService.transfer(input); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/ArgsDigest.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/ArgsDigest.java new file mode 100644 index 00000000..894024c8 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/ArgsDigest.java @@ -0,0 +1,124 @@ +package io.temporal.samples.nexussecurecredentialpropagation.common; + +import io.temporal.api.common.v1.Payload; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; + +/** + * Hands the digest of an argument list between the payload codec and the credential interceptors + * through thread locals. + * + *

The codec knows nothing about credentials. It reports every list it encodes or decodes, and + * this class decides what to do with the digest: + * + *

    + *
  • Sending side: an interceptor arms a {@link PendingSeal} before calling {@code next}. The + * first list encoded while armed (the arguments, since the SDK encodes them before memo or + * signal payloads) completes the seal, which writes the credential header into the live + * header map that the SDK reads after encoding. + *
  • Receiving side: every decoded list publishes its digest, overwriting the previous one. The + * receiving interceptor takes it immediately after the SDK decodes the arguments. + *
+ * + *

This relies on the SDK encoding and decoding arguments synchronously on the interceptor's + * thread. The sample's tests pin each such ordering. + */ +public final class ArgsDigest { + private static final ThreadLocal PENDING = new ThreadLocal<>(); + private static final ThreadLocal RECEIVED = new ThreadLocal<>(); + + private ArgsDigest() {} + + /** + * Computes the digest of a list of encoded payloads exactly as transmitted. It covers every + * ciphertext, its position, and the count. + */ + public static byte[] compute(List encoded) { + Frame frame = Frame.of("args-digest").add("v1").add(encoded.size()); + for (Payload payload : encoded) { + frame.add(payload.getData().toByteArray()); + } + try { + return MessageDigest.getInstance("SHA-256").digest(frame.toByteArray()); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + /** Arms a pending seal on the current thread. */ + public static void arm(PendingSeal seal) { + if (PENDING.get() != null) { + throw new IllegalStateException("A credential seal is already pending on this thread"); + } + PENDING.set(seal); + } + + /** Removes any pending seal from the current thread. */ + public static void disarm() { + PENDING.remove(); + } + + /** Called by the codec after encoding a list of payloads. */ + public static void onEncoded(List encoded) { + PendingSeal seal = PENDING.get(); + if (seal == null) { + return; + } + // Only the first list encoded while armed is the argument list. + PENDING.remove(); + seal.complete(compute(encoded), encoded.size()); + } + + /** Called by the codec after decoding a list of payloads. */ + public static void onDecoded(List encoded) { + RECEIVED.set(new Received(compute(encoded), encoded.size())); + } + + /** Takes and clears the digest of the most recently decoded list, or null if there is none. */ + public static Received take() { + Received received = RECEIVED.get(); + RECEIVED.remove(); + return received; + } + + /** Digest and argument count of a decoded list. */ + public static final class Received { + private final byte[] digest; + private final int count; + + Received(byte[] digest, int count) { + this.digest = digest; + this.count = count; + } + + public byte[] getDigest() { + return digest.clone(); + } + + public int getCount() { + return count; + } + } + + /** + * A credential header waiting for the digest of the arguments it must be bound to. Subclasses + * seal the credential and write the header into the map the SDK reads after encoding. + */ + public abstract static class PendingSeal { + private boolean completed; + + final void complete(byte[] digest, int count) { + seal(digest, count); + completed = true; + } + + /** Seals the credential for the given argument digest and writes the header. */ + protected abstract void seal(byte[] digest, int count); + + /** Whether the seal was completed, meaning the header was written. */ + public boolean isCompleted() { + return completed; + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/ContextAwareTinkPayloadCodec.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/ContextAwareTinkPayloadCodec.java new file mode 100644 index 00000000..1815e6d2 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/ContextAwareTinkPayloadCodec.java @@ -0,0 +1,192 @@ +package io.temporal.samples.nexussecurecredentialpropagation.common; + +import com.google.crypto.tink.Aead; +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; +import io.temporal.api.common.v1.Payload; +import io.temporal.common.converter.EncodingKeys; +import io.temporal.failure.ApplicationFailure; +import io.temporal.payload.codec.PayloadCodec; +import io.temporal.payload.codec.PayloadCodecException; +import io.temporal.payload.context.HasWorkflowSerializationContext; +import io.temporal.payload.context.NexusSerializationContext; +import io.temporal.payload.context.SerializationContext; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Encrypts payloads with Tink AEAD, binding each ciphertext to the serialization context it was + * produced for. + * + *

The context descriptor is stored in the payload metadata and authenticated as associated data, + * so a ciphertext cannot be relabelled with a different context. Decoding requires the descriptor + * to match the decoder's own context, with one exception: the result or failure of an asynchronous + * Nexus Operation is produced by the handler Workflow under its Workflow context and decoded by the + * caller under the Nexus context, so a Nexus decoder accepts Workflow-context payloads. + * + *

The codec also reports every encoded and decoded list to {@link ArgsDigest}. It must be the + * outermost codec so that digests cover the bytes actually transmitted. + */ +public final class ContextAwareTinkPayloadCodec implements PayloadCodec { + static final String ENCODING = "binary/tink-aead"; + static final String PROFILE_METADATA_KEY = "tink-profile"; + static final String CONTEXT_METADATA_KEY = "tink-context"; + static final String PROFILE_CONTEXT = "v1.context"; + static final String PROFILE_CONTEXT_FREE = "v1.context-free"; + + /** Error type the SDK reports as a non-retryable Nexus {@code BAD_REQUEST}. */ + static final String PAYLOAD_VALIDATION_ERROR = "PayloadValidationError"; + + private static final String NEXUS = "nexus"; + private static final String WORKFLOW = "workflow"; + private static final int MAX_CONTEXT_LENGTH = 4 * 1024; + + private final Aead aead; + private final @Nullable byte[] context; + + public ContextAwareTinkPayloadCodec(Aead aead) { + this(aead, null); + } + + private ContextAwareTinkPayloadCodec(Aead aead, @Nullable byte[] context) { + this.aead = aead; + this.context = context; + } + + @Override + @Nonnull + public PayloadCodec withContext(@Nonnull SerializationContext serializationContext) { + if (serializationContext instanceof NexusSerializationContext) { + NexusSerializationContext nexus = (NexusSerializationContext) serializationContext; + return new ContextAwareTinkPayloadCodec( + aead, + Frame.of(NEXUS) + .add(nexus.getEndpoint()) + .add(nexus.getService()) + .add(nexus.getOperation()) + .toByteArray()); + } + if (serializationContext instanceof HasWorkflowSerializationContext) { + HasWorkflowSerializationContext workflow = + (HasWorkflowSerializationContext) serializationContext; + return new ContextAwareTinkPayloadCodec( + aead, + Frame.of(WORKFLOW) + .add(workflow.getNamespace()) + .add(workflow.getWorkflowId()) + .toByteArray()); + } + return new ContextAwareTinkPayloadCodec(aead, null); + } + + @Override + @Nonnull + public List encode(@Nonnull List payloads) { + String profile = context == null ? PROFILE_CONTEXT_FREE : PROFILE_CONTEXT; + byte[] descriptor = context == null ? new byte[0] : context; + List encoded = new ArrayList<>(payloads.size()); + for (Payload payload : payloads) { + byte[] ciphertext; + try { + ciphertext = aead.encrypt(payload.toByteArray(), associatedData(profile, descriptor)); + } catch (GeneralSecurityException e) { + throw new PayloadCodecException(e); + } + Payload.Builder builder = + Payload.newBuilder() + .putMetadata(EncodingKeys.METADATA_ENCODING_KEY, ByteString.copyFromUtf8(ENCODING)) + .putMetadata(PROFILE_METADATA_KEY, ByteString.copyFromUtf8(profile)) + .setData(ByteString.copyFrom(ciphertext)); + if (context != null) { + builder.putMetadata(CONTEXT_METADATA_KEY, ByteString.copyFrom(context)); + } + encoded.add(builder.build()); + } + ArgsDigest.onEncoded(encoded); + return encoded; + } + + @Override + @Nonnull + public List decode(@Nonnull List payloads) { + List decoded = new ArrayList<>(payloads.size()); + for (Payload payload : payloads) { + decoded.add(decodeOne(payload)); + } + ArgsDigest.onDecoded(payloads); + return decoded; + } + + private Payload decodeOne(Payload payload) { + String encoding = + payload + .getMetadataOrDefault(EncodingKeys.METADATA_ENCODING_KEY, ByteString.EMPTY) + .toStringUtf8(); + if (!ENCODING.equals(encoding)) { + throw rejected("Expected a Tink AEAD payload"); + } + String profile = + payload.getMetadataOrDefault(PROFILE_METADATA_KEY, ByteString.EMPTY).toStringUtf8(); + byte[] descriptor; + if (PROFILE_CONTEXT.equals(profile)) { + ByteString stored = payload.getMetadataOrDefault(CONTEXT_METADATA_KEY, null); + if (stored == null || stored.isEmpty() || stored.size() > MAX_CONTEXT_LENGTH) { + throw rejected("Missing or invalid payload context"); + } + descriptor = stored.toByteArray(); + if (context != null + && !Arrays.equals(context, descriptor) + && !isAsyncNexusResult(descriptor)) { + throw rejected("Payload context does not match"); + } + } else if (PROFILE_CONTEXT_FREE.equals(profile)) { + if (context != null) { + // Never accept weaker associated data where a context is known. + throw rejected("Context-free payload where a context is required"); + } + descriptor = new byte[0]; + } else { + throw rejected("Unknown payload profile"); + } + try { + byte[] plaintext = + aead.decrypt(payload.getData().toByteArray(), associatedData(profile, descriptor)); + return Payload.parseFrom(plaintext); + } catch (GeneralSecurityException | InvalidProtocolBufferException e) { + throw rejected("Payload failed authentication"); + } + } + + /** + * A payload this codec will never accept. Thrown as a non-retryable {@code + * PayloadValidationError} so that a Nexus handler reports it as a non-retryable {@code + * BAD_REQUEST} instead of retrying it as a codec outage. + */ + private static ApplicationFailure rejected(String reason) { + return ApplicationFailure.newNonRetryableFailure(reason, PAYLOAD_VALIDATION_ERROR); + } + + /** + * A Nexus decoder accepts Workflow-context payloads: they are results and failures of + * asynchronous operations, produced by the handler Workflow. + */ + private boolean isAsyncNexusResult(byte[] descriptor) { + return startsWithLabel(context, NEXUS) && startsWithLabel(descriptor, WORKFLOW); + } + + private static boolean startsWithLabel(byte[] frame, String label) { + try { + return label.equals(new Frame.Reader(frame).nextString(label.length())); + } catch (GeneralSecurityException e) { + return false; + } + } + + private static byte[] associatedData(String profile, byte[] descriptor) { + return Frame.of("payload").add(profile).add(descriptor).toByteArray(); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/CredentialEnvelope.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/CredentialEnvelope.java new file mode 100644 index 00000000..936f70ee --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/CredentialEnvelope.java @@ -0,0 +1,59 @@ +package io.temporal.samples.nexussecurecredentialpropagation.common; + +import java.security.GeneralSecurityException; +import java.util.Objects; + +/** + * The plaintext sealed into every credential header: the end user's compact JWT and the intent the + * client sealed alongside it. + * + *

Workflow code treats this as opaque. {@link #toString()} never reveals its contents. + */ +public final class CredentialEnvelope { + private static final String LABEL = "credential-envelope"; + private static final String VERSION = "v1"; + static final int MAX_JWT_LENGTH = 8 * 1024; + + private final String compactJwt; + private final CredentialIntent intent; + + public CredentialEnvelope(String compactJwt, CredentialIntent intent) { + if (compactJwt.isEmpty() || compactJwt.length() > MAX_JWT_LENGTH) { + throw new IllegalArgumentException("Invalid JWT length"); + } + this.compactJwt = compactJwt; + this.intent = Objects.requireNonNull(intent); + } + + public String getCompactJwt() { + return compactJwt; + } + + public CredentialIntent getIntent() { + return intent; + } + + public byte[] toBytes() { + Frame frame = Frame.of(LABEL).add(VERSION).add(compactJwt); + intent.writeTo(frame); + return frame.toByteArray(); + } + + public static CredentialEnvelope fromBytes(byte[] bytes) throws GeneralSecurityException { + Frame.Reader reader = new Frame.Reader(bytes); + reader.expect(LABEL); + reader.expect(VERSION); + String jwt = reader.nextString(MAX_JWT_LENGTH); + if (jwt.isEmpty()) { + throw new GeneralSecurityException("Empty JWT"); + } + CredentialIntent intent = CredentialIntent.readFrom(reader); + reader.expectEnd(); + return new CredentialEnvelope(jwt, intent); + } + + @Override + public String toString() { + return "CredentialEnvelope{}"; + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/CredentialIntent.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/CredentialIntent.java new file mode 100644 index 00000000..4a340a6d --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/CredentialIntent.java @@ -0,0 +1,128 @@ +package io.temporal.samples.nexussecurecredentialpropagation.common; + +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.TreeMap; + +/** + * What the end user authorized the Workflow to do, sealed by the client alongside the credential. + * + *

The caller Workflow carries the intent unchanged and never parses it. The handler requires the + * route it serves to appear in the intent and the typed operation input to satisfy that route's + * constraints. This bounds what signals, updates, or activity results can steer the Workflow into + * sending. + */ +public final class CredentialIntent { + private static final int MAX_GRANTS = 16; + private static final int MAX_CONSTRAINTS = 32; + private static final int MAX_FIELD_LENGTH = 1024; + + private final List grants; + + public CredentialIntent(List grants) { + if (grants.isEmpty() || grants.size() > MAX_GRANTS) { + throw new IllegalArgumentException("An intent must contain between 1 and 16 grants"); + } + this.grants = Collections.unmodifiableList(new ArrayList<>(grants)); + } + + public List getGrants() { + return grants; + } + + /** Returns the grant for the given route, if the intent contains one. */ + public Optional grantFor(String endpoint, String service, String operation) { + for (RouteGrant grant : grants) { + if (grant.endpoint.equals(endpoint) + && grant.service.equals(service) + && grant.operation.equals(operation)) { + return Optional.of(grant); + } + } + return Optional.empty(); + } + + void writeTo(Frame frame) { + frame.add(grants.size()); + for (RouteGrant grant : grants) { + frame.add(grant.endpoint).add(grant.service).add(grant.operation); + frame.add(grant.constraints.size()); + for (Map.Entry constraint : grant.constraints.entrySet()) { + frame.add(constraint.getKey()).add(constraint.getValue()); + } + } + } + + static CredentialIntent readFrom(Frame.Reader reader) throws GeneralSecurityException { + int grantCount = readCount(reader, MAX_GRANTS); + if (grantCount == 0) { + throw new GeneralSecurityException("Empty intent"); + } + List grants = new ArrayList<>(grantCount); + for (int i = 0; i < grantCount; i++) { + String endpoint = reader.nextString(MAX_FIELD_LENGTH); + String service = reader.nextString(MAX_FIELD_LENGTH); + String operation = reader.nextString(MAX_FIELD_LENGTH); + int constraintCount = readCount(reader, MAX_CONSTRAINTS); + Map constraints = new TreeMap<>(); + for (int j = 0; j < constraintCount; j++) { + String key = reader.nextString(MAX_FIELD_LENGTH); + String value = reader.nextString(MAX_FIELD_LENGTH); + if (constraints.put(key, value) != null) { + throw new GeneralSecurityException("Duplicate intent constraint"); + } + } + grants.add(new RouteGrant(endpoint, service, operation, constraints)); + } + return new CredentialIntent(grants); + } + + private static int readCount(Frame.Reader reader, int max) throws GeneralSecurityException { + long count = reader.nextLong(); + if (count < 0 || count > max) { + throw new GeneralSecurityException("Count out of range"); + } + return (int) count; + } + + /** Permission to call one protected Nexus route, with constraints on its input. */ + public static final class RouteGrant { + private final String endpoint; + private final String service; + private final String operation; + private final Map constraints; + + public RouteGrant( + String endpoint, String service, String operation, Map constraints) { + this.endpoint = Objects.requireNonNull(endpoint); + this.service = Objects.requireNonNull(service); + this.operation = Objects.requireNonNull(operation); + if (constraints.size() > MAX_CONSTRAINTS) { + throw new IllegalArgumentException("Too many intent constraints"); + } + // Sorted so the encoding is independent of the caller's map ordering. + this.constraints = Collections.unmodifiableMap(new TreeMap<>(constraints)); + } + + public String getEndpoint() { + return endpoint; + } + + public String getService() { + return service; + } + + public String getOperation() { + return operation; + } + + public Map getConstraints() { + return constraints; + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/DemoIdentityProvider.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/DemoIdentityProvider.java new file mode 100644 index 00000000..0f363774 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/DemoIdentityProvider.java @@ -0,0 +1,57 @@ +package io.temporal.samples.nexussecurecredentialpropagation.common; + +import com.google.crypto.tink.jwt.JwtPublicKeySign; +import com.google.crypto.tink.jwt.RawJwt; +import java.security.GeneralSecurityException; +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; + +/** + * Stands in for a real identity provider. It issues short-lived, audience-restricted access tokens + * for the handler, as a token exchange at the client edge would. + */ +public final class DemoIdentityProvider { + public static final String ISSUER = "https://idp.example.com"; + public static final String HANDLER_AUDIENCE = "transfer-service"; + public static final String TOKEN_TYPE = "at+jwt"; + public static final String TENANT_CLAIM = "tenant"; + public static final String SCOPE_CLAIM = "scope"; + + private final JwtPublicKeySign signer; + + public DemoIdentityProvider(JwtPublicKeySign signer) { + this.signer = signer; + } + + public String issue(String subject, String tenant, String scope, Duration lifetime) { + return issue(subject, tenant, scope, HANDLER_AUDIENCE, Instant.now(), lifetime); + } + + public String issue( + String subject, + String tenant, + String scope, + String audience, + Instant notBefore, + Duration lifetime) { + RawJwt jwt = + RawJwt.newBuilder() + .setTypeHeader(TOKEN_TYPE) + .setIssuer(ISSUER) + .setAudience(audience) + .setSubject(subject) + .setJwtId(UUID.randomUUID().toString()) + .setIssuedAt(Instant.now()) + .setNotBefore(notBefore) + .setExpiration(notBefore.plus(lifetime)) + .addStringClaim(TENANT_CLAIM, tenant) + .addStringClaim(SCOPE_CLAIM, scope) + .build(); + try { + return signer.signAndEncode(jwt); + } catch (GeneralSecurityException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/EndUserPrincipal.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/EndUserPrincipal.java new file mode 100644 index 00000000..3b71c81f --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/EndUserPrincipal.java @@ -0,0 +1,48 @@ +package io.temporal.samples.nexussecurecredentialpropagation.common; + +import java.util.Collections; +import java.util.Set; +import java.util.TreeSet; + +/** The verified end user on whose behalf a Nexus Operation runs. */ +public final class EndUserPrincipal { + private final String issuer; + private final String subject; + private final String tenant; + private final Set scopes; + + public EndUserPrincipal(String issuer, String subject, String tenant, Set scopes) { + this.issuer = issuer; + this.subject = subject; + this.tenant = tenant; + this.scopes = Collections.unmodifiableSet(new TreeSet<>(scopes)); + } + + public String getIssuer() { + return issuer; + } + + public String getSubject() { + return subject; + } + + public String getTenant() { + return tenant; + } + + public Set getScopes() { + return scopes; + } + + @Override + public String toString() { + // Identifiers only; never the token or other claims. + return "EndUserPrincipal{issuer='" + + issuer + + "', subject='" + + subject + + "', tenant='" + + tenant + + "'}"; + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/Frame.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/Frame.java new file mode 100644 index 00000000..e52ca751 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/Frame.java @@ -0,0 +1,97 @@ +package io.temporal.samples.nexussecurecredentialpropagation.common; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; + +/** + * Unambiguous length-prefixed encoding used for associated data, digests, and envelopes. + * + *

Every field is written as a 4-byte big-endian length followed by its bytes, so no two + * different field sequences produce the same encoding. + */ +public final class Frame { + private final ByteArrayOutputStream out = new ByteArrayOutputStream(); + + private Frame() {} + + /** Starts a frame whose first field is the given purpose label. */ + public static Frame of(String label) { + return new Frame().add(label); + } + + public Frame add(String value) { + if (value == null) { + throw new IllegalArgumentException("Frame fields must not be null"); + } + return add(value.getBytes(StandardCharsets.UTF_8)); + } + + public Frame add(byte[] value) { + if (value == null) { + throw new IllegalArgumentException("Frame fields must not be null"); + } + out.write(ByteBuffer.allocate(4).putInt(value.length).array(), 0, 4); + out.write(value, 0, value.length); + return this; + } + + public Frame add(long value) { + return add(ByteBuffer.allocate(8).putLong(value).array()); + } + + public byte[] toByteArray() { + return out.toByteArray(); + } + + /** Strict reader for frames produced by {@link Frame}. */ + public static final class Reader { + private final ByteBuffer buffer; + + public Reader(byte[] bytes) { + this.buffer = ByteBuffer.wrap(bytes); + } + + public byte[] next(int maxLength) throws GeneralSecurityException { + if (buffer.remaining() < 4) { + throw new GeneralSecurityException("Truncated frame"); + } + int length = buffer.getInt(); + if (length < 0 || length > maxLength || length > buffer.remaining()) { + throw new GeneralSecurityException("Invalid frame field length"); + } + byte[] value = new byte[length]; + buffer.get(value); + return value; + } + + public String nextString(int maxLength) throws GeneralSecurityException { + return new String(next(maxLength), StandardCharsets.UTF_8); + } + + public long nextLong() throws GeneralSecurityException { + byte[] value = next(8); + if (value.length != 8) { + throw new GeneralSecurityException("Invalid frame integer"); + } + return ByteBuffer.wrap(value).getLong(); + } + + public void expect(String label) throws GeneralSecurityException { + if (!label.equals(nextString(label.length()))) { + throw new GeneralSecurityException("Unexpected frame label"); + } + } + + public boolean hasRemaining() { + return buffer.hasRemaining(); + } + + public void expectEnd() throws GeneralSecurityException { + if (buffer.hasRemaining()) { + throw new GeneralSecurityException("Trailing bytes in frame"); + } + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/GenerateDemoKeys.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/GenerateDemoKeys.java new file mode 100644 index 00000000..4f4f6aa4 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/GenerateDemoKeys.java @@ -0,0 +1,10 @@ +package io.temporal.samples.nexussecurecredentialpropagation.common; + +/** Generates the demo keysets shared by the starter, caller Worker, and handler Worker. */ +public class GenerateDemoKeys { + public static void main(String[] args) throws Exception { + SampleKeysets.fromDefaultDirectory().generate(); + System.out.println( + "Generated demo keysets. They are stored unencrypted and are for local demos only."); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/SampleConfig.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/SampleConfig.java new file mode 100644 index 00000000..5983bdf6 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/SampleConfig.java @@ -0,0 +1,33 @@ +package io.temporal.samples.nexussecurecredentialpropagation.common; + +import com.google.crypto.tink.Aead; +import io.temporal.common.converter.CodecDataConverter; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.samples.nexussecurecredentialpropagation.service.TransferService; +import java.util.Collections; +import java.util.Set; + +/** Names and wiring shared by the sample's processes. */ +public final class SampleConfig { + public static final String ENDPOINT = "nexus-secure-credential-endpoint"; + public static final String CALLER_TASK_QUEUE = "nexus-secure-credential-caller"; + public static final String HANDLER_TASK_QUEUE = "nexus-secure-credential-handler"; + + /** Nexus routes, as {@code service/operation}, that require the end-user credential. */ + public static final Set PROTECTED_ROUTES = + Collections.singleton(TransferService.NAME + "/" + TransferService.TRANSFER); + + private SampleConfig() {} + + /** + * Data converter that encrypts every payload, including failure messages and stack traces. The + * context-aware codec must be the outermost (here, the only) codec. + */ + public static DataConverter dataConverter(Aead payloadAead) { + return new CodecDataConverter( + DefaultDataConverter.newDefaultInstance(), + Collections.singletonList(new ContextAwareTinkPayloadCodec(payloadAead)), + true); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/SampleKeysets.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/SampleKeysets.java new file mode 100644 index 00000000..988480f7 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/SampleKeysets.java @@ -0,0 +1,197 @@ +package io.temporal.samples.nexussecurecredentialpropagation.common; + +import com.google.crypto.tink.Aead; +import com.google.crypto.tink.HybridDecrypt; +import com.google.crypto.tink.HybridEncrypt; +import com.google.crypto.tink.InsecureSecretKeyAccess; +import com.google.crypto.tink.KeyTemplates; +import com.google.crypto.tink.KeysetHandle; +import com.google.crypto.tink.Mac; +import com.google.crypto.tink.RegistryConfiguration; +import com.google.crypto.tink.TinkJsonProtoKeysetFormat; +import com.google.crypto.tink.aead.AeadConfig; +import com.google.crypto.tink.aead.PredefinedAeadParameters; +import com.google.crypto.tink.hybrid.HybridConfig; +import com.google.crypto.tink.jwt.JwtPublicKeySign; +import com.google.crypto.tink.jwt.JwtPublicKeyVerify; +import com.google.crypto.tink.jwt.JwtSignatureConfig; +import com.google.crypto.tink.mac.MacConfig; +import com.google.crypto.tink.mac.PredefinedMacParameters; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.GeneralSecurityException; + +/** + * Demo keysets for the sample. + * + *

Keysets are stored as cleartext Tink JSON in a local directory so the three demo processes can + * share them. This is for demonstration only. In production, keysets are encrypted with a KMS key + * ({@code TinkJsonProtoKeysetFormat.parseEncryptedKeyset}) and each process is given only the + * keysets its role requires: + * + * + * + * + * + * + * + * + * + *
Keysets by role
KeysetStarterCaller WorkerHandler Worker
payload (AEAD)yesyesyes
workflow credential (HPKE)publicprivate + public-
nexus credential (HPKE)-publicprivate
operation token (MAC)--yes
demo identity provider (JWT ES256)private-public
+ */ +public final class SampleKeysets { + public static final String KEY_DIR_PROPERTY = "nexus.secure.credential.keyDir"; + private static final String DEFAULT_KEY_DIR = "build/nexus-secure-credential-keys"; + + static final String PAYLOAD = "payload.json"; + static final String WORKFLOW_CREDENTIAL_PRIVATE = "workflow-credential.private.json"; + static final String WORKFLOW_CREDENTIAL_PUBLIC = "workflow-credential.public.json"; + static final String NEXUS_CREDENTIAL_PRIVATE = "nexus-credential.private.json"; + static final String NEXUS_CREDENTIAL_PUBLIC = "nexus-credential.public.json"; + static final String OPERATION_TOKEN = "operation-token.json"; + static final String JWT_SIGNING_PRIVATE = "jwt-signing.private.json"; + static final String JWT_SIGNING_PUBLIC = "jwt-signing.public.json"; + + private static final String HPKE_TEMPLATE = "DHKEM_X25519_HKDF_SHA256_HKDF_SHA256_AES_256_GCM"; + private static final String JWT_TEMPLATE = "JWT_ES256"; + + private static volatile boolean registered; + + private final Path dir; + + private SampleKeysets(Path dir) { + this.dir = dir; + } + + /** Registers the Tink primitives used by the sample. Safe to call repeatedly. */ + public static synchronized void register() { + if (registered) { + return; + } + try { + AeadConfig.register(); + HybridConfig.register(); + MacConfig.register(); + JwtSignatureConfig.register(); + } catch (GeneralSecurityException e) { + throw new IllegalStateException(e); + } + registered = true; + } + + /** Opens the key directory configured by the {@value #KEY_DIR_PROPERTY} system property. */ + public static SampleKeysets fromDefaultDirectory() { + return fromDirectory(Paths.get(System.getProperty(KEY_DIR_PROPERTY, DEFAULT_KEY_DIR))); + } + + public static SampleKeysets fromDirectory(Path dir) { + register(); + return new SampleKeysets(dir); + } + + /** Generates every keyset into the directory, replacing any existing files. */ + public void generate() throws IOException, GeneralSecurityException { + Files.createDirectories(dir); + writeSecret(PAYLOAD, KeysetHandle.generateNew(PredefinedAeadParameters.AES256_GCM)); + writePair( + WORKFLOW_CREDENTIAL_PRIVATE, + WORKFLOW_CREDENTIAL_PUBLIC, + KeysetHandle.generateNew(KeyTemplates.get(HPKE_TEMPLATE))); + writePair( + NEXUS_CREDENTIAL_PRIVATE, + NEXUS_CREDENTIAL_PUBLIC, + KeysetHandle.generateNew(KeyTemplates.get(HPKE_TEMPLATE))); + writeSecret( + OPERATION_TOKEN, KeysetHandle.generateNew(PredefinedMacParameters.HMAC_SHA256_256BITTAG)); + writePair( + JWT_SIGNING_PRIVATE, + JWT_SIGNING_PUBLIC, + KeysetHandle.generateNew(KeyTemplates.get(JWT_TEMPLATE))); + } + + public Aead payloadAead() { + return primitive(readSecret(PAYLOAD), Aead.class); + } + + public HybridEncrypt workflowCredentialEncrypt() { + return primitive(readPublic(WORKFLOW_CREDENTIAL_PUBLIC), HybridEncrypt.class); + } + + public HybridDecrypt workflowCredentialDecrypt() { + return primitive(readSecret(WORKFLOW_CREDENTIAL_PRIVATE), HybridDecrypt.class); + } + + public HybridEncrypt nexusCredentialEncrypt() { + return primitive(readPublic(NEXUS_CREDENTIAL_PUBLIC), HybridEncrypt.class); + } + + public HybridDecrypt nexusCredentialDecrypt() { + return primitive(readSecret(NEXUS_CREDENTIAL_PRIVATE), HybridDecrypt.class); + } + + public Mac operationTokenMac() { + return primitive(readSecret(OPERATION_TOKEN), Mac.class); + } + + public JwtPublicKeySign demoIdentityProviderSigner() { + return primitive(readSecret(JWT_SIGNING_PRIVATE), JwtPublicKeySign.class); + } + + public JwtPublicKeyVerify demoIdentityProviderVerifier() { + return primitive(readPublic(JWT_SIGNING_PUBLIC), JwtPublicKeyVerify.class); + } + + private void writeSecret(String name, KeysetHandle handle) + throws IOException, GeneralSecurityException { + write(name, TinkJsonProtoKeysetFormat.serializeKeyset(handle, InsecureSecretKeyAccess.get())); + } + + private void writePair(String privateName, String publicName, KeysetHandle handle) + throws IOException, GeneralSecurityException { + writeSecret(privateName, handle); + write( + publicName, + TinkJsonProtoKeysetFormat.serializeKeysetWithoutSecret(handle.getPublicKeysetHandle())); + } + + private void write(String name, String json) throws IOException { + Files.write(dir.resolve(name), json.getBytes(StandardCharsets.UTF_8)); + } + + private KeysetHandle readSecret(String name) { + try { + return TinkJsonProtoKeysetFormat.parseKeyset(read(name), InsecureSecretKeyAccess.get()); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Invalid keyset " + name, e); + } + } + + private KeysetHandle readPublic(String name) { + try { + return TinkJsonProtoKeysetFormat.parseKeysetWithoutSecret(read(name)); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Invalid keyset " + name, e); + } + } + + private String read(String name) { + Path path = dir.resolve(name); + try { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException( + "Missing keyset " + path + ". Run GenerateDemoKeys first.", e); + } + } + + private static

P primitive(KeysetHandle handle, Class

type) { + try { + return handle.getPrimitive(RegistryConfiguration.get(), type); + } catch (GeneralSecurityException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/SecureCredentialHeaders.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/SecureCredentialHeaders.java new file mode 100644 index 00000000..4148ee87 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/SecureCredentialHeaders.java @@ -0,0 +1,178 @@ +package io.temporal.samples.nexussecurecredentialpropagation.common; + +import com.google.crypto.tink.HybridDecrypt; +import com.google.crypto.tink.HybridEncrypt; +import com.google.protobuf.ByteString; +import io.temporal.api.common.v1.Payload; +import io.temporal.common.converter.EncodingKeys; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.Base64; + +/** + * Wire formats and associated data for the credential headers. + * + *

Every header carries an HPKE ciphertext of a {@link CredentialEnvelope} encrypted to the + * receiving hop. The HPKE context info binds it to the hop's context and, where available, to the + * digest of the exact argument ciphertexts it accompanied. Holding a public key or the payload key + * is not enough to decrypt a header or to pair it with other arguments. + */ +public final class SecureCredentialHeaders { + /** Temporal header carrying the Workflow credential on start paths and continue-as-new. */ + public static final String WORKFLOW_HEADER = "end-user-credential"; + + /** Nexus header carrying the credential to the Nexus handler. */ + public static final String NEXUS_HEADER = "x-temporal-end-user-credential"; + + static final String START_PREFIX = "v1.start."; + static final String CAN_PREFIX = "v1.can."; + static final String NEXUS_PREFIX = "v1."; + + static final int MAX_HEADER_LENGTH = 16 * 1024; + private static final String VERSION = "v1"; + + private SecureCredentialHeaders() {} + + /** Kinds of Workflow credential header. */ + public enum WorkflowHeaderKind { + START, + CONTINUE_AS_NEW + } + + /** A parsed Workflow credential header. */ + public static final class WorkflowHeader { + private final WorkflowHeaderKind kind; + private final byte[] ciphertext; + + WorkflowHeader(WorkflowHeaderKind kind, byte[] ciphertext) { + this.kind = kind; + this.ciphertext = ciphertext; + } + + public WorkflowHeaderKind getKind() { + return kind; + } + + public byte[] getCiphertext() { + return ciphertext.clone(); + } + } + + public static byte[] startContext( + String namespace, String workflowType, String workflowId, byte[] argsDigest) { + return Frame.of("workflow-credential-start") + .add(VERSION) + .add(namespace) + .add(workflowType) + .add(workflowId) + .add(argsDigest) + .toByteArray(); + } + + public static byte[] continueAsNewContext( + String namespace, String workflowType, String workflowId, String firstExecutionRunId) { + return Frame.of("workflow-credential-can") + .add(VERSION) + .add(namespace) + .add(workflowType) + .add(workflowId) + .add(firstExecutionRunId) + .toByteArray(); + } + + public static byte[] nexusContext( + String endpoint, String service, String operation, byte[] argsDigest) { + return Frame.of("nexus-credential") + .add(VERSION) + .add(endpoint) + .add(service) + .add(operation) + .add(argsDigest) + .toByteArray(); + } + + public static byte[] seal(HybridEncrypt encrypt, CredentialEnvelope envelope, byte[] context) { + try { + return encrypt.encrypt(envelope.toBytes(), context); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to seal credential", e); + } + } + + public static CredentialEnvelope open(HybridDecrypt decrypt, byte[] ciphertext, byte[] context) + throws GeneralSecurityException { + return CredentialEnvelope.fromBytes(decrypt.decrypt(ciphertext, context)); + } + + public static Payload toWorkflowHeaderPayload(WorkflowHeaderKind kind, byte[] ciphertext) { + String prefix = kind == WorkflowHeaderKind.START ? START_PREFIX : CAN_PREFIX; + return Payload.newBuilder() + .putMetadata(EncodingKeys.METADATA_ENCODING_KEY, ByteString.copyFromUtf8("binary/plain")) + .setData(ByteString.copyFromUtf8(prefix + encode(ciphertext))) + .build(); + } + + public static WorkflowHeader parseWorkflowHeader(Payload payload) + throws GeneralSecurityException { + if (payload.getData().size() > MAX_HEADER_LENGTH) { + throw new GeneralSecurityException("Credential header too large"); + } + String value = payload.getData().toString(StandardCharsets.UTF_8); + if (value.startsWith(START_PREFIX)) { + return new WorkflowHeader( + WorkflowHeaderKind.START, decode(value.substring(START_PREFIX.length()))); + } + if (value.startsWith(CAN_PREFIX)) { + return new WorkflowHeader( + WorkflowHeaderKind.CONTINUE_AS_NEW, decode(value.substring(CAN_PREFIX.length()))); + } + throw new GeneralSecurityException("Unknown credential header version"); + } + + public static String toNexusHeaderValue(byte[] ciphertext) { + return NEXUS_PREFIX + encode(ciphertext); + } + + public static byte[] parseNexusHeaderValue(String value) throws GeneralSecurityException { + if (value.length() > MAX_HEADER_LENGTH) { + throw new GeneralSecurityException("Credential header too large"); + } + if (!value.startsWith(NEXUS_PREFIX)) { + throw new GeneralSecurityException("Unknown credential header version"); + } + return decode(value.substring(NEXUS_PREFIX.length())); + } + + static String encode(byte[] bytes) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + /** Strict unpadded Base64URL decoding. */ + static byte[] decode(String value) throws GeneralSecurityException { + if (value.isEmpty()) { + throw new GeneralSecurityException("Empty credential header"); + } + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + boolean valid = + (c >= 'A' && c <= 'Z') + || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') + || c == '-' + || c == '_'; + if (!valid) { + throw new GeneralSecurityException("Invalid Base64URL in credential header"); + } + } + try { + byte[] decoded = Base64.getUrlDecoder().decode(value); + // Reject non-canonical encodings, whose unused trailing bits are not zero. + if (!encode(decoded).equals(value)) { + throw new GeneralSecurityException("Non-canonical Base64URL in credential header"); + } + return decoded; + } catch (IllegalArgumentException e) { + throw new GeneralSecurityException("Invalid Base64URL in credential header", e); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/CredentialHandlerWorkerInterceptor.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/CredentialHandlerWorkerInterceptor.java new file mode 100644 index 00000000..0301bea4 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/CredentialHandlerWorkerInterceptor.java @@ -0,0 +1,168 @@ +package io.temporal.samples.nexussecurecredentialpropagation.handler; + +import com.google.crypto.tink.HybridDecrypt; +import io.nexusrpc.OperationException; +import io.nexusrpc.handler.HandlerException; +import io.nexusrpc.handler.OperationContext; +import io.temporal.common.interceptors.NexusOperationInboundCallsInterceptor; +import io.temporal.common.interceptors.NexusOperationInboundCallsInterceptorBase; +import io.temporal.common.interceptors.NexusOperationOutboundCallsInterceptor; +import io.temporal.common.interceptors.WorkerInterceptorBase; +import io.temporal.samples.nexussecurecredentialpropagation.common.ArgsDigest; +import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialEnvelope; +import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialIntent; +import io.temporal.samples.nexussecurecredentialpropagation.common.EndUserPrincipal; +import io.temporal.samples.nexussecurecredentialpropagation.common.SecureCredentialHeaders; +import java.security.GeneralSecurityException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Authenticates and authorizes protected Nexus Operations before their handlers run. + * + *

Route context comes from the actual Nexus request, never from custom headers. The credential + * header decrypts only with this route and the digest of the exact input ciphertext the payload + * codec just decoded. Failures return generic messages; details are logged without claims. + */ +public class CredentialHandlerWorkerInterceptor extends WorkerInterceptorBase { + private static final Logger logger = + LoggerFactory.getLogger(CredentialHandlerWorkerInterceptor.class); + + private final HybridDecrypt nexusCredentialDecrypt; + private final JwtVerifier jwtVerifier; + private final Map> policies; + + /** + * @param policies intent policy per protected route, keyed by {@code service/operation} + */ + public CredentialHandlerWorkerInterceptor( + HybridDecrypt nexusCredentialDecrypt, + JwtVerifier jwtVerifier, + Map> policies) { + this.nexusCredentialDecrypt = nexusCredentialDecrypt; + this.jwtVerifier = jwtVerifier; + this.policies = Collections.unmodifiableMap(new HashMap<>(policies)); + } + + @Override + public NexusOperationInboundCallsInterceptor interceptNexusOperation( + OperationContext context, NexusOperationInboundCallsInterceptor next) { + return new Inbound(next); + } + + private final class Inbound extends NexusOperationInboundCallsInterceptorBase { + private String endpoint; + + Inbound(NexusOperationInboundCallsInterceptor next) { + super(next); + } + + @Override + public void init(NexusOperationOutboundCallsInterceptor outboundCalls) { + endpoint = outboundCalls.getInfo().getEndpoint(); + super.init(outboundCalls); + } + + @Override + public StartOperationOutput startOperation(StartOperationInput input) + throws OperationException { + // Take the digest first: the SDK decoded the input immediately before this call. + ArgsDigest.Received received = ArgsDigest.take(); + OperationContext context = input.getOperationContext(); + String route = context.getService() + "/" + context.getOperation(); + String header = context.getHeaders().get(SecureCredentialHeaders.NEXUS_HEADER); + IntentPolicy policy = policies.get(route); + if (policy == null) { + if (header != null) { + throw reject( + HandlerException.ErrorType.BAD_REQUEST, + route, + "credential sent to an unprotected route"); + } + return super.startOperation(input); + } + + EndUserPrincipal principal = authenticate(input, received, route, header, policy); + RequestPrincipal.set(principal); + try { + return super.startOperation(input); + } finally { + RequestPrincipal.clear(); + } + } + + private EndUserPrincipal authenticate( + StartOperationInput input, + ArgsDigest.Received received, + String route, + String header, + IntentPolicy policy) { + OperationContext context = input.getOperationContext(); + if (endpoint == null || endpoint.isEmpty()) { + // Servers before 1.30 do not report the endpoint; fail closed instead of weakening the + // binding. + throw reject(HandlerException.ErrorType.BAD_REQUEST, route, "endpoint unavailable"); + } + if (received == null || received.getCount() != 1 || input.getInput() == null) { + throw reject(HandlerException.ErrorType.BAD_REQUEST, route, "no input to bind to"); + } + if (header == null) { + throw reject(HandlerException.ErrorType.UNAUTHENTICATED, route, "missing credential"); + } + + CredentialEnvelope envelope; + EndUserPrincipal principal; + try { + envelope = + SecureCredentialHeaders.open( + nexusCredentialDecrypt, + SecureCredentialHeaders.parseNexusHeaderValue(header), + SecureCredentialHeaders.nexusContext( + endpoint, context.getService(), context.getOperation(), received.getDigest())); + principal = jwtVerifier.verify(envelope.getCompactJwt()); + } catch (GeneralSecurityException e) { + throw reject(HandlerException.ErrorType.UNAUTHENTICATED, route, e.getMessage()); + } + + Optional grant = + envelope.getIntent().grantFor(endpoint, context.getService(), context.getOperation()); + if (!grant.isPresent()) { + throw reject(HandlerException.ErrorType.UNAUTHORIZED, route, "route outside intent"); + } + try { + authorize(policy, principal, grant.get(), input.getInput()); + } catch (IntentPolicy.AuthorizationDeniedException e) { + throw reject(HandlerException.ErrorType.UNAUTHORIZED, route, e.getMessage()); + } + return principal; + } + } + + private static void authorize( + IntentPolicy policy, + EndUserPrincipal principal, + CredentialIntent.RouteGrant grant, + Object input) + throws IntentPolicy.AuthorizationDeniedException { + if (!policy.inputType().isInstance(input)) { + throw new IntentPolicy.AuthorizationDeniedException("Unexpected input type"); + } + policy.authorize(principal, grant, policy.inputType().cast(input)); + } + + private static HandlerException reject( + HandlerException.ErrorType type, String route, String reason) { + logger.warn("Rejected Nexus request for {}: {} ({})", route, type, reason); + // No cause: it would be serialized into the caller's history. + return new HandlerException( + type, + type.name().toLowerCase(Locale.ROOT), + (Throwable) null, + HandlerException.RetryBehavior.NON_RETRYABLE); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/HandlerWorker.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/HandlerWorker.java new file mode 100644 index 00000000..ef3abb42 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/HandlerWorker.java @@ -0,0 +1,45 @@ +package io.temporal.samples.nexussecurecredentialpropagation.handler; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.samples.nexus.options.ClientOptions; +import io.temporal.samples.nexussecurecredentialpropagation.common.DemoIdentityProvider; +import io.temporal.samples.nexussecurecredentialpropagation.common.SampleConfig; +import io.temporal.samples.nexussecurecredentialpropagation.common.SampleKeysets; +import io.temporal.samples.nexussecurecredentialpropagation.service.TransferService; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactory; +import io.temporal.worker.WorkerFactoryOptions; +import java.util.Collections; + +public class HandlerWorker { + public static void main(String[] args) { + // The handler is the only holder of the Nexus credential private key and the operation token + // key. + SampleKeysets keys = SampleKeysets.fromDefaultDirectory(); + WorkflowClient client = + ClientOptions.getWorkflowClient( + args, + WorkflowClientOptions.newBuilder() + .setDataConverter(SampleConfig.dataConverter(keys.payloadAead()))); + + CredentialHandlerWorkerInterceptor interceptor = + new CredentialHandlerWorkerInterceptor( + keys.nexusCredentialDecrypt(), + new JwtVerifier( + keys.demoIdentityProviderVerifier(), + DemoIdentityProvider.ISSUER, + DemoIdentityProvider.HANDLER_AUDIENCE), + Collections.singletonMap( + TransferService.NAME + "/" + TransferService.TRANSFER, new TransferIntentPolicy())); + + WorkerFactory factory = + WorkerFactory.newInstance( + client, WorkerFactoryOptions.newBuilder().setWorkerInterceptors(interceptor).build()); + Worker worker = factory.newWorker(SampleConfig.HANDLER_TASK_QUEUE); + worker.registerWorkflowImplementationTypes(TransferHandlerWorkflowImpl.class); + worker.registerNexusServiceImplementation( + new TransferNexusServiceImpl(keys.operationTokenMac())); + factory.start(); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/IntentPolicy.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/IntentPolicy.java new file mode 100644 index 00000000..4b06fc85 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/IntentPolicy.java @@ -0,0 +1,28 @@ +package io.temporal.samples.nexussecurecredentialpropagation.handler; + +import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialIntent; +import io.temporal.samples.nexussecurecredentialpropagation.common.EndUserPrincipal; + +/** + * Authorizes the typed input of one protected operation against the verified principal and the + * client-sealed intent. Implementations compare typed fields; they never canonicalize the input. + */ +public interface IntentPolicy { + Class inputType(); + + /** + * Authorizes {@code input} for {@code principal} within {@code grant}. + * + * @throws AuthorizationDeniedException if the principal may not perform this input, or the input + * falls outside the intent + */ + void authorize(EndUserPrincipal principal, CredentialIntent.RouteGrant grant, T input) + throws AuthorizationDeniedException; + + /** Thrown when a verified principal is not authorized. */ + final class AuthorizationDeniedException extends Exception { + public AuthorizationDeniedException(String message) { + super(message); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/JwtVerifier.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/JwtVerifier.java new file mode 100644 index 00000000..fbf8f935 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/JwtVerifier.java @@ -0,0 +1,62 @@ +package io.temporal.samples.nexussecurecredentialpropagation.handler; + +import com.google.crypto.tink.jwt.JwtPublicKeyVerify; +import com.google.crypto.tink.jwt.JwtValidator; +import com.google.crypto.tink.jwt.VerifiedJwt; +import io.temporal.samples.nexussecurecredentialpropagation.common.DemoIdentityProvider; +import io.temporal.samples.nexussecurecredentialpropagation.common.EndUserPrincipal; +import java.security.GeneralSecurityException; +import java.time.Clock; +import java.time.Duration; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * Validates the end user's access token at the handler, the only place it is validated. + * + *

The algorithm allowlist is the verification keyset itself: Tink accepts only tokens whose + * {@code alg} header matches a key in the keyset (ES256 here). Tink also enforces {@code exp} and + * {@code nbf}. + */ +public final class JwtVerifier { + private final JwtPublicKeyVerify verify; + private final JwtValidator validator; + + public JwtVerifier(JwtPublicKeyVerify verify, String issuer, String audience) { + this(verify, issuer, audience, Clock.systemUTC()); + } + + public JwtVerifier(JwtPublicKeyVerify verify, String issuer, String audience, Clock clock) { + this.verify = verify; + this.validator = + JwtValidator.newBuilder() + .expectTypeHeader(DemoIdentityProvider.TOKEN_TYPE) + .expectIssuer(issuer) + .expectAudience(audience) + .expectIssuedInThePast() + .setClock(clock) + .setClockSkew(Duration.ofSeconds(30)) + .build(); + } + + public EndUserPrincipal verify(String compactJwt) throws GeneralSecurityException { + VerifiedJwt jwt = verify.verifyAndDecode(compactJwt, validator); + if (!jwt.hasSubject() || jwt.getSubject().isEmpty()) { + throw new GeneralSecurityException("Token has no subject"); + } + if (!jwt.hasStringClaim(DemoIdentityProvider.TENANT_CLAIM)) { + throw new GeneralSecurityException("Token has no tenant"); + } + Set scopes = new HashSet<>(); + if (jwt.hasStringClaim(DemoIdentityProvider.SCOPE_CLAIM)) { + scopes.addAll( + Arrays.asList(jwt.getStringClaim(DemoIdentityProvider.SCOPE_CLAIM).trim().split("\\s+"))); + } + return new EndUserPrincipal( + jwt.getIssuer(), + jwt.getSubject(), + jwt.getStringClaim(DemoIdentityProvider.TENANT_CLAIM), + scopes); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/RequestPrincipal.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/RequestPrincipal.java new file mode 100644 index 00000000..bbf95692 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/RequestPrincipal.java @@ -0,0 +1,26 @@ +package io.temporal.samples.nexussecurecredentialpropagation.handler; + +import io.temporal.samples.nexussecurecredentialpropagation.common.EndUserPrincipal; + +/** The verified principal of the Nexus request being handled on the current thread. */ +public final class RequestPrincipal { + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + private RequestPrincipal() {} + + public static EndUserPrincipal current() { + EndUserPrincipal principal = CURRENT.get(); + if (principal == null) { + throw new IllegalStateException("No verified principal for this request"); + } + return principal; + } + + static void set(EndUserPrincipal principal) { + CURRENT.set(principal); + } + + static void clear() { + CURRENT.remove(); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/SignedTokenOperationHandler.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/SignedTokenOperationHandler.java new file mode 100644 index 00000000..e5b90157 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/SignedTokenOperationHandler.java @@ -0,0 +1,116 @@ +package io.temporal.samples.nexussecurecredentialpropagation.handler; + +import com.google.crypto.tink.Mac; +import io.nexusrpc.Header; +import io.nexusrpc.OperationException; +import io.nexusrpc.handler.HandlerException; +import io.nexusrpc.handler.OperationCancelDetails; +import io.nexusrpc.handler.OperationContext; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationStartDetails; +import io.nexusrpc.handler.OperationStartResult; +import io.temporal.nexus.Nexus; +import io.temporal.nexus.WorkflowHandle; +import io.temporal.nexus.WorkflowRunOperation; +import io.temporal.samples.nexussecurecredentialpropagation.common.EndUserPrincipal; +import io.temporal.samples.nexussecurecredentialpropagation.common.Frame; +import java.security.GeneralSecurityException; +import java.util.Base64; + +/** + * Runs an operation as a Workflow whose ID is derived from the verified principal and the input, + * and returns an operation token signed with a handler-only key. + * + *

Every caller that presents the same principal and business input attaches to the same + * Workflow, so retries and exact replays cause no second effect. The signed token stops a caller + * from forging a token that names another user's Workflow in order to cancel it. Any holder of an + * issued token can cancel, including an exact replayer; restricting holders is the job of the + * endpoint's allowed callers. + */ +public final class SignedTokenOperationHandler implements OperationHandler { + private static final String LABEL = "operation-token"; + private static final String VERSION = "v1"; + private static final int MAX_TOKEN_LENGTH = 1024; + private static final int MAX_ID_LENGTH = 512; + + /** Derives the backing Workflow ID. Must not use the Nexus request ID. */ + public interface ResourceIdFunction { + String apply(EndUserPrincipal principal, T input); + } + + /** Creates the handle that starts, or attaches to, the backing Workflow. */ + public interface HandleFactory { + WorkflowHandle create(String workflowId, EndUserPrincipal principal, T input); + } + + private final Mac mac; + private final ResourceIdFunction resourceIds; + private final HandleFactory handles; + + public SignedTokenOperationHandler( + Mac mac, ResourceIdFunction resourceIds, HandleFactory handles) { + this.mac = mac; + this.resourceIds = resourceIds; + this.handles = handles; + } + + @Override + public OperationStartResult start( + OperationContext context, OperationStartDetails details, T input) throws OperationException { + EndUserPrincipal principal = RequestPrincipal.current(); + String workflowId = resourceIds.apply(principal, input); + String token = sign(workflowId); + // The SDK only adds its own (unsigned) token to the completion callback headers when they are + // absent. The server uses these if the completion arrives before the start response, so they + // must carry the signed token too. + OperationStartDetails withToken = + OperationStartDetails.newBuilder(details) + .putCallbackHeader(Header.OPERATION_TOKEN, token) + .putCallbackHeader("Nexus-Operation-Id", token) + .build(); + OperationHandler delegate = + WorkflowRunOperation.fromWorkflowHandle( + (ctx, startDetails, in) -> handles.create(workflowId, principal, in)); + delegate.start(context, withToken, input); + return OperationStartResult.async(token); + } + + @Override + public void cancel(OperationContext context, OperationCancelDetails details) { + String workflowId = verify(details.getOperationToken()); + Nexus.getOperationContext().getWorkflowClient().newUntypedWorkflowStub(workflowId).cancel(); + } + + String sign(String workflowId) { + byte[] signed = Frame.of(LABEL).add(VERSION).add(workflowId).toByteArray(); + try { + byte[] tag = mac.computeMac(signed); + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(Frame.of(VERSION).add(workflowId).add(tag).toByteArray()); + } catch (GeneralSecurityException e) { + throw new IllegalStateException(e); + } + } + + String verify(String token) { + try { + if (token == null || token.isEmpty() || token.length() > MAX_TOKEN_LENGTH) { + throw new GeneralSecurityException("Invalid token length"); + } + Frame.Reader reader = new Frame.Reader(Base64.getUrlDecoder().decode(token)); + reader.expect(VERSION); + String workflowId = reader.nextString(MAX_ID_LENGTH); + byte[] tag = reader.next(64); + reader.expectEnd(); + mac.verifyMac(tag, Frame.of(LABEL).add(VERSION).add(workflowId).toByteArray()); + return workflowId; + } catch (GeneralSecurityException | IllegalArgumentException e) { + throw new HandlerException( + HandlerException.ErrorType.BAD_REQUEST, + "invalid operation token", + (Throwable) null, + HandlerException.RetryBehavior.NON_RETRYABLE); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferHandlerWorkflow.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferHandlerWorkflow.java new file mode 100644 index 00000000..577466eb --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferHandlerWorkflow.java @@ -0,0 +1,56 @@ +package io.temporal.samples.nexussecurecredentialpropagation.handler; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.temporal.samples.nexussecurecredentialpropagation.service.TransferService; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +@WorkflowInterface +public interface TransferHandlerWorkflow { + /** + * Only the minimal authorized identifiers reach the backing Workflow. The token and its claims + * never enter the handler's history. + */ + class Settlement { + private final String paymentId; + private final String beneficiary; + private final long amountCents; + private final String approvedFor; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public Settlement( + @JsonProperty("paymentId") String paymentId, + @JsonProperty("beneficiary") String beneficiary, + @JsonProperty("amountCents") long amountCents, + @JsonProperty("approvedFor") String approvedFor) { + this.paymentId = paymentId; + this.beneficiary = beneficiary; + this.amountCents = amountCents; + this.approvedFor = approvedFor; + } + + @JsonProperty("paymentId") + public String getPaymentId() { + return paymentId; + } + + @JsonProperty("beneficiary") + public String getBeneficiary() { + return beneficiary; + } + + @JsonProperty("amountCents") + public long getAmountCents() { + return amountCents; + } + + @JsonProperty("approvedFor") + public String getApprovedFor() { + return approvedFor; + } + } + + @WorkflowMethod + TransferService.TransferOutput settle(Settlement settlement); +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferHandlerWorkflowImpl.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferHandlerWorkflowImpl.java new file mode 100644 index 00000000..10ae6244 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferHandlerWorkflowImpl.java @@ -0,0 +1,16 @@ +package io.temporal.samples.nexussecurecredentialpropagation.handler; + +import io.temporal.samples.nexussecurecredentialpropagation.service.TransferService; +import io.temporal.workflow.Workflow; +import java.time.Duration; + +public class TransferHandlerWorkflowImpl implements TransferHandlerWorkflow { + @Override + public TransferService.TransferOutput settle(Settlement settlement) { + // Stand-in for settling the payment with a downstream system, which would receive the same + // business resource ID as its idempotency key. + Workflow.sleep(Duration.ofSeconds(5)); + return new TransferService.TransferOutput( + settlement.getPaymentId(), "SETTLED", settlement.getApprovedFor()); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferIntentPolicy.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferIntentPolicy.java new file mode 100644 index 00000000..46cf7328 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferIntentPolicy.java @@ -0,0 +1,50 @@ +package io.temporal.samples.nexussecurecredentialpropagation.handler; + +import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialIntent; +import io.temporal.samples.nexussecurecredentialpropagation.common.EndUserPrincipal; +import io.temporal.samples.nexussecurecredentialpropagation.service.TransferService; +import java.util.Map; + +/** Authorizes a transfer against the token and the intent the user approved. */ +public final class TransferIntentPolicy implements IntentPolicy { + @Override + public Class inputType() { + return TransferService.TransferInput.class; + } + + @Override + public void authorize( + EndUserPrincipal principal, + CredentialIntent.RouteGrant grant, + TransferService.TransferInput input) + throws AuthorizationDeniedException { + if (!principal.getScopes().contains(TransferService.REQUIRED_SCOPE)) { + throw new AuthorizationDeniedException("Missing required scope"); + } + // The token subject is authoritative; the input's user and tenant are only assertions. + if (!principal.getTenant().equals(input.getTenantId())) { + throw new AuthorizationDeniedException("Tenant mismatch"); + } + if (!principal.getSubject().equals(input.getUserId())) { + throw new AuthorizationDeniedException("Subject mismatch"); + } + + Map constraints = grant.getConstraints(); + if (!input.getPaymentId().equals(constraints.get(TransferService.CONSTRAINT_PAYMENT_ID))) { + throw new AuthorizationDeniedException("Payment outside intent"); + } + if (!input.getBeneficiary().equals(constraints.get(TransferService.CONSTRAINT_BENEFICIARY))) { + throw new AuthorizationDeniedException("Beneficiary outside intent"); + } + String max = constraints.get(TransferService.CONSTRAINT_MAX_AMOUNT_CENTS); + long maxAmountCents; + try { + maxAmountCents = Long.parseLong(max); + } catch (NumberFormatException e) { + throw new AuthorizationDeniedException("Invalid amount constraint"); + } + if (input.getAmountCents() <= 0 || input.getAmountCents() > maxAmountCents) { + throw new AuthorizationDeniedException("Amount outside intent"); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferNexusServiceImpl.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferNexusServiceImpl.java new file mode 100644 index 00000000..548fac7f --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferNexusServiceImpl.java @@ -0,0 +1,80 @@ +package io.temporal.samples.nexussecurecredentialpropagation.handler; + +import com.google.crypto.tink.Mac; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.api.enums.v1.WorkflowIdConflictPolicy; +import io.temporal.api.enums.v1.WorkflowIdReusePolicy; +import io.temporal.client.WorkflowOptions; +import io.temporal.nexus.Nexus; +import io.temporal.nexus.WorkflowHandle; +import io.temporal.samples.nexussecurecredentialpropagation.common.EndUserPrincipal; +import io.temporal.samples.nexussecurecredentialpropagation.common.Frame; +import io.temporal.samples.nexussecurecredentialpropagation.service.TransferService; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +@ServiceImpl(service = TransferService.class) +public class TransferNexusServiceImpl { + private final Mac operationTokenMac; + + public TransferNexusServiceImpl(Mac operationTokenMac) { + this.operationTokenMac = operationTokenMac; + } + + /** + * The backing Workflow ID is derived from the verified principal and the business identity of the + * transfer, never from the Nexus request ID. Including the principal stops a second user with + * identical input from attaching to the first user's transfer. Hashing keeps user identifiers out + * of Workflow IDs. + */ + public static String resourceId(EndUserPrincipal principal, TransferService.TransferInput input) { + byte[] identity = + Frame.of("transfer-resource") + .add(principal.getIssuer()) + .add(principal.getTenant()) + .add(principal.getSubject()) + .add(input.getPaymentId()) + .toByteArray(); + try { + StringBuilder id = new StringBuilder("transfer-"); + for (byte b : MessageDigest.getInstance("SHA-256").digest(identity)) { + id.append(String.format("%02x", b)); + } + return id.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + @OperationImpl + public OperationHandler + transfer() { + return new SignedTokenOperationHandler<>( + operationTokenMac, + TransferNexusServiceImpl::resourceId, + (workflowId, principal, input) -> + WorkflowHandle.fromWorkflowMethod( + Nexus.getOperationContext() + .getWorkflowClient() + .newWorkflowStub( + TransferHandlerWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId(workflowId) + // Attach to the existing transfer instead of failing or running it + // again. Uniqueness beyond retention needs an external store. + .setWorkflowIdConflictPolicy( + WorkflowIdConflictPolicy + .WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING) + .setWorkflowIdReusePolicy( + WorkflowIdReusePolicy.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE) + .build()) + ::settle, + new TransferHandlerWorkflow.Settlement( + input.getPaymentId(), + input.getBeneficiary(), + input.getAmountCents(), + principal.getSubject()))); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/service/TransferService.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/service/TransferService.java new file mode 100644 index 00000000..64c8dd61 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/service/TransferService.java @@ -0,0 +1,109 @@ +package io.temporal.samples.nexussecurecredentialpropagation.service; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.nexusrpc.Operation; +import io.nexusrpc.Service; + +/** A Nexus service whose operation runs on behalf of an end user. */ +@Service +public interface TransferService { + String NAME = "TransferService"; + String TRANSFER = "transfer"; + + /** Intent constraint: the exact payment ID the user approved. */ + String CONSTRAINT_PAYMENT_ID = "paymentId"; + + /** Intent constraint: the exact beneficiary the user approved. */ + String CONSTRAINT_BENEFICIARY = "beneficiary"; + + /** Intent constraint: the maximum amount, in cents, the user approved. */ + String CONSTRAINT_MAX_AMOUNT_CENTS = "maxAmountCents"; + + /** Scope the end user's token must carry. */ + String REQUIRED_SCOPE = "payments:transfer"; + + class TransferInput { + private final String tenantId; + private final String userId; + private final String paymentId; + private final String beneficiary; + private final long amountCents; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public TransferInput( + @JsonProperty("tenantId") String tenantId, + @JsonProperty("userId") String userId, + @JsonProperty("paymentId") String paymentId, + @JsonProperty("beneficiary") String beneficiary, + @JsonProperty("amountCents") long amountCents) { + this.tenantId = tenantId; + this.userId = userId; + this.paymentId = paymentId; + this.beneficiary = beneficiary; + this.amountCents = amountCents; + } + + @JsonProperty("tenantId") + public String getTenantId() { + return tenantId; + } + + /** The user the transfer is for. Verified against the token subject, never trusted alone. */ + @JsonProperty("userId") + public String getUserId() { + return userId; + } + + /** Business identity of the transfer, used for idempotency. */ + @JsonProperty("paymentId") + public String getPaymentId() { + return paymentId; + } + + @JsonProperty("beneficiary") + public String getBeneficiary() { + return beneficiary; + } + + @JsonProperty("amountCents") + public long getAmountCents() { + return amountCents; + } + } + + class TransferOutput { + private final String paymentId; + private final String status; + private final String approvedFor; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public TransferOutput( + @JsonProperty("paymentId") String paymentId, + @JsonProperty("status") String status, + @JsonProperty("approvedFor") String approvedFor) { + this.paymentId = paymentId; + this.status = status; + this.approvedFor = approvedFor; + } + + @JsonProperty("paymentId") + public String getPaymentId() { + return paymentId; + } + + @JsonProperty("status") + public String getStatus() { + return status; + } + + /** Subject of the verified principal the transfer ran for. */ + @JsonProperty("approvedFor") + public String getApprovedFor() { + return approvedFor; + } + } + + @Operation + TransferOutput transfer(TransferInput input); +} diff --git a/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/CryptoComponentsTest.java b/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/CryptoComponentsTest.java new file mode 100644 index 00000000..fdac3d7d --- /dev/null +++ b/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/CryptoComponentsTest.java @@ -0,0 +1,461 @@ +package io.temporal.samples.nexussecurecredentialpropagation; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.crypto.tink.Aead; +import com.google.crypto.tink.HybridDecrypt; +import com.google.crypto.tink.HybridEncrypt; +import com.google.protobuf.ByteString; +import io.nexusrpc.handler.HandlerException; +import io.temporal.api.common.v1.Payload; +import io.temporal.failure.ApplicationFailure; +import io.temporal.payload.codec.PayloadCodec; +import io.temporal.payload.context.NexusSerializationContext; +import io.temporal.payload.context.WorkflowSerializationContext; +import io.temporal.samples.nexussecurecredentialpropagation.common.ArgsDigest; +import io.temporal.samples.nexussecurecredentialpropagation.common.ContextAwareTinkPayloadCodec; +import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialEnvelope; +import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialIntent; +import io.temporal.samples.nexussecurecredentialpropagation.common.DemoIdentityProvider; +import io.temporal.samples.nexussecurecredentialpropagation.common.EndUserPrincipal; +import io.temporal.samples.nexussecurecredentialpropagation.common.Frame; +import io.temporal.samples.nexussecurecredentialpropagation.common.SampleKeysets; +import io.temporal.samples.nexussecurecredentialpropagation.common.SecureCredentialHeaders; +import io.temporal.samples.nexussecurecredentialpropagation.handler.IntentPolicy; +import io.temporal.samples.nexussecurecredentialpropagation.handler.JwtVerifier; +import io.temporal.samples.nexussecurecredentialpropagation.handler.TokenProbe; +import io.temporal.samples.nexussecurecredentialpropagation.handler.TransferIntentPolicy; +import io.temporal.samples.nexussecurecredentialpropagation.handler.TransferNexusServiceImpl; +import io.temporal.samples.nexussecurecredentialpropagation.service.TransferService; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Unit tests of the sample's cryptographic building blocks. */ +public class CryptoComponentsTest { + private static final byte[] DIGEST = new byte[32]; + + @TempDir Path keyDir; + + private SampleKeysets keys; + private CredentialEnvelope envelope; + + @BeforeEach + public void setUp() throws Exception { + keys = SampleKeysets.fromDirectory(keyDir); + keys.generate(); + envelope = new CredentialEnvelope("header.payload.signature", intent("5000")); + ArgsDigest.take(); + } + + // Frame. + + @Test + public void frameEncodingRespectsFieldBoundaries() { + assertFalse( + Arrays.equals( + Frame.of("x").add("ab").add("c").toByteArray(), + Frame.of("x").add("a").add("bc").toByteArray())); + } + + @Test + public void frameReaderRejectsTruncationAndTrailingBytes() { + byte[] bytes = Frame.of("x").add("value").toByteArray(); + Frame.Reader truncated = new Frame.Reader(Arrays.copyOf(bytes, bytes.length - 1)); + assertThrows( + GeneralSecurityException.class, + () -> { + truncated.expect("x"); + truncated.nextString(100); + }); + Frame.Reader trailing = new Frame.Reader(Arrays.copyOf(bytes, bytes.length + 1)); + assertThrows( + GeneralSecurityException.class, + () -> { + trailing.expect("x"); + trailing.nextString(100); + trailing.expectEnd(); + }); + } + + // Credential headers. + + @Test + public void credentialHeaderOpensOnlyUnderItsExactContext() throws Exception { + HybridEncrypt encrypt = keys.nexusCredentialEncrypt(); + HybridDecrypt decrypt = keys.nexusCredentialDecrypt(); + byte[] context = SecureCredentialHeaders.nexusContext("ep", "svc", "op", DIGEST); + byte[] sealed = SecureCredentialHeaders.seal(encrypt, envelope, context); + + assertEquals( + envelope.getCompactJwt(), + SecureCredentialHeaders.open(decrypt, sealed, context).getCompactJwt()); + + byte[] otherDigest = DIGEST.clone(); + otherDigest[0] = 1; + List wrongContexts = + Arrays.asList( + SecureCredentialHeaders.nexusContext("other", "svc", "op", DIGEST), + SecureCredentialHeaders.nexusContext("ep", "other", "op", DIGEST), + SecureCredentialHeaders.nexusContext("ep", "svc", "other", DIGEST), + SecureCredentialHeaders.nexusContext("ep", "svc", "op", otherDigest), + // Same values under another purpose. + SecureCredentialHeaders.startContext("ep", "svc", "op", DIGEST)); + for (byte[] wrong : wrongContexts) { + assertThrows( + GeneralSecurityException.class, + () -> SecureCredentialHeaders.open(decrypt, sealed, wrong)); + } + } + + @Test + public void workflowCredentialKeysCannotOpenNexusHeaders() { + byte[] context = SecureCredentialHeaders.nexusContext("ep", "svc", "op", DIGEST); + byte[] sealed = SecureCredentialHeaders.seal(keys.nexusCredentialEncrypt(), envelope, context); + // The caller Worker holds the Workflow credential private key, not the Nexus one. + assertThrows( + GeneralSecurityException.class, + () -> SecureCredentialHeaders.open(keys.workflowCredentialDecrypt(), sealed, context)); + } + + @Test + public void continueAsNewHeaderIsBoundToTheRunChain() throws Exception { + byte[] context = SecureCredentialHeaders.continueAsNewContext("ns", "Type", "wid", "first-run"); + byte[] sealed = + SecureCredentialHeaders.seal(keys.workflowCredentialEncrypt(), envelope, context); + SecureCredentialHeaders.open(keys.workflowCredentialDecrypt(), sealed, context); + assertThrows( + GeneralSecurityException.class, + () -> + SecureCredentialHeaders.open( + keys.workflowCredentialDecrypt(), + sealed, + SecureCredentialHeaders.continueAsNewContext("ns", "Type", "wid", "fresh-run"))); + } + + @Test + public void nexusHeaderParsingIsStrict() { + String valid = SecureCredentialHeaders.toNexusHeaderValue(new byte[] {1, 2, 3, 4, 5}); + assertTrue(valid.startsWith("v1.")); + List invalid = + Arrays.asList( + valid + "=", + valid.replace("v1.", "v2."), + valid + "+", + "v1.", + "v1." + repeat('A', 20_000)); + for (String value : invalid) { + assertThrows( + GeneralSecurityException.class, + () -> SecureCredentialHeaders.parseNexusHeaderValue(value), + value.length() > 40 ? value.substring(0, 40) : value); + } + } + + @Test + public void envelopeRoundTripsAndRejectsTrailingData() throws Exception { + byte[] bytes = envelope.toBytes(); + CredentialEnvelope decoded = CredentialEnvelope.fromBytes(bytes); + assertEquals(envelope.getCompactJwt(), decoded.getCompactJwt()); + assertEquals( + envelope.getIntent().getGrants().get(0).getConstraints(), + decoded.getIntent().getGrants().get(0).getConstraints()); + assertThrows( + GeneralSecurityException.class, + () -> CredentialEnvelope.fromBytes(Arrays.copyOf(bytes, bytes.length + 1))); + assertFalse(envelope.toString().contains(envelope.getCompactJwt())); + } + + // Args digest. + + @Test + public void argsDigestCoversEveryCiphertextPositionAndCount() { + Payload a = payload("a"); + Payload b = payload("b"); + byte[] original = ArgsDigest.compute(Arrays.asList(a, b)); + assertArrayEquals(original, ArgsDigest.compute(Arrays.asList(a, b))); + assertDifferent(original, ArgsDigest.compute(Arrays.asList(b, a))); + assertDifferent(original, ArgsDigest.compute(Arrays.asList(a, a))); + assertDifferent(original, ArgsDigest.compute(Collections.singletonList(a))); + assertDifferent(original, ArgsDigest.compute(Arrays.asList(a, b, b))); + assertDifferent(original, ArgsDigest.compute(Arrays.asList(a, payload("c")))); + } + + @Test + public void onlyTheFirstListEncodedWhileArmedIsDigested() { + List sealedDigests = new ArrayList<>(); + ArgsDigest.PendingSeal seal = + new ArgsDigest.PendingSeal() { + @Override + protected void seal(byte[] digest, int count) { + sealedDigests.add(digest); + } + }; + ArgsDigest.arm(seal); + try { + ArgsDigest.onEncoded(Collections.singletonList(payload("args"))); + ArgsDigest.onEncoded(Collections.singletonList(payload("memo"))); + } finally { + ArgsDigest.disarm(); + } + assertTrue(seal.isCompleted()); + assertEquals(1, sealedDigests.size()); + assertArrayEquals( + ArgsDigest.compute(Collections.singletonList(payload("args"))), sealedDigests.get(0)); + } + + @Test + public void receivedDigestIsTakenOnce() { + ArgsDigest.onDecoded(Collections.singletonList(payload("x"))); + assertEquals(1, ArgsDigest.take().getCount()); + assertNull(ArgsDigest.take()); + } + + // Payload codec. + + @Test + public void payloadsAreBoundToTheirContext() { + Aead aead = keys.payloadAead(); + PayloadCodec base = new ContextAwareTinkPayloadCodec(aead); + PayloadCodec nexus = base.withContext(new NexusSerializationContext("ep", "svc", "op")); + List encoded = nexus.encode(Collections.singletonList(plain("hello"))); + + assertEquals("hello", nexus.decode(encoded).get(0).getData().toStringUtf8()); + assertRejected( + () -> + base.withContext(new NexusSerializationContext("other", "svc", "op")).decode(encoded)); + assertRejected( + () -> base.withContext(new WorkflowSerializationContext("ns", "wid")).decode(encoded)); + + // Relabelling the stored context fails authentication. + Payload relabelled = + encoded.get(0).toBuilder() + .putMetadata( + "tink-context", + ByteString.copyFrom(Frame.of("nexus").add("ep").add("svc").add("x").toByteArray())) + .build(); + assertRejected(() -> nexus.decode(Collections.singletonList(relabelled))); + } + + @Test + public void contextFreePayloadsAreRejectedWhereAContextIsKnown() { + PayloadCodec base = new ContextAwareTinkPayloadCodec(keys.payloadAead()); + List contextFree = base.encode(Collections.singletonList(plain("x"))); + assertEquals("x", base.decode(contextFree).get(0).getData().toStringUtf8()); + assertRejected( + () -> + base.withContext(new NexusSerializationContext("ep", "svc", "op")).decode(contextFree)); + } + + @Test + public void asyncNexusResultsFromTheHandlerWorkflowAreAccepted() { + PayloadCodec base = new ContextAwareTinkPayloadCodec(keys.payloadAead()); + List result = + base.withContext(new WorkflowSerializationContext("handler-ns", "backing-wid")) + .encode(Collections.singletonList(plain("result"))); + assertEquals( + "result", + base.withContext(new NexusSerializationContext("ep", "svc", "op")) + .decode(result) + .get(0) + .getData() + .toStringUtf8()); + } + + @Test + public void tamperedCiphertextIsRejected() { + PayloadCodec codec = + new ContextAwareTinkPayloadCodec(keys.payloadAead()) + .withContext(new NexusSerializationContext("ep", "svc", "op")); + Payload encoded = codec.encode(Collections.singletonList(plain("x"))).get(0); + byte[] data = encoded.getData().toByteArray(); + data[data.length - 1] ^= 1; + Payload tampered = encoded.toBuilder().setData(ByteString.copyFrom(data)).build(); + assertRejected(() -> codec.decode(Collections.singletonList(tampered))); + } + + // JWT validation. + + @Test + public void jwtValidationRejectsWrongIssuerAudienceAndExpiredTokens() throws Exception { + DemoIdentityProvider idp = new DemoIdentityProvider(keys.demoIdentityProviderSigner()); + JwtVerifier verifier = + new JwtVerifier( + keys.demoIdentityProviderVerifier(), + DemoIdentityProvider.ISSUER, + DemoIdentityProvider.HANDLER_AUDIENCE); + + EndUserPrincipal principal = + verifier.verify(idp.issue("alice", "acme", "payments:transfer", Duration.ofMinutes(5))); + assertEquals("alice", principal.getSubject()); + assertEquals("acme", principal.getTenant()); + + JwtVerifier otherIssuer = + new JwtVerifier( + keys.demoIdentityProviderVerifier(), + "https://other-idp.example.com", + DemoIdentityProvider.HANDLER_AUDIENCE); + String token = idp.issue("alice", "acme", "payments:transfer", Duration.ofMinutes(5)); + assertThrows(GeneralSecurityException.class, () -> otherIssuer.verify(token)); + + String otherAudience = + idp.issue( + "alice", "acme", "payments:transfer", "other", Instant.now(), Duration.ofMinutes(5)); + assertThrows(GeneralSecurityException.class, () -> verifier.verify(otherAudience)); + + String expired = + idp.issue( + "alice", + "acme", + "payments:transfer", + DemoIdentityProvider.HANDLER_AUDIENCE, + Instant.now().minus(Duration.ofHours(2)), + Duration.ofMinutes(5)); + assertThrows(GeneralSecurityException.class, () -> verifier.verify(expired)); + + String notYetValid = + idp.issue( + "alice", + "acme", + "payments:transfer", + DemoIdentityProvider.HANDLER_AUDIENCE, + Instant.now().plus(Duration.ofHours(1)), + Duration.ofMinutes(5)); + assertThrows(GeneralSecurityException.class, () -> verifier.verify(notYetValid)); + + int signatureStart = token.lastIndexOf('.') + 1; + // Change the first signature character to a different one. + char replacement = token.charAt(signatureStart) == 'A' ? 'B' : 'A'; + String forged = + token.substring(0, signatureStart) + replacement + token.substring(signatureStart + 1); + assertThrows(GeneralSecurityException.class, () -> verifier.verify(forged)); + } + + // Authorization and idempotency. + + @Test + public void intentPolicyEnforcesPrincipalAndConstraints() throws Exception { + TransferIntentPolicy policy = new TransferIntentPolicy(); + EndUserPrincipal alice = + new EndUserPrincipal( + DemoIdentityProvider.ISSUER, + "alice", + "acme", + Collections.singleton(TransferService.REQUIRED_SCOPE)); + CredentialIntent.RouteGrant grant = intent("5000").getGrants().get(0); + + policy.authorize(alice, grant, transfer("acme", "alice", "p-1", "bob", 50_00)); + + List denied = + Arrays.asList( + transfer("other", "alice", "p-1", "bob", 10_00), + transfer("acme", "bob", "p-1", "bob", 10_00), + transfer("acme", "alice", "p-2", "bob", 10_00), + transfer("acme", "alice", "p-1", "mallory", 10_00), + transfer("acme", "alice", "p-1", "bob", 50_01), + transfer("acme", "alice", "p-1", "bob", 0)); + for (TransferService.TransferInput input : denied) { + assertThrows( + IntentPolicy.AuthorizationDeniedException.class, + () -> policy.authorize(alice, grant, input)); + } + + EndUserPrincipal noScope = + new EndUserPrincipal(DemoIdentityProvider.ISSUER, "alice", "acme", Collections.emptySet()); + assertThrows( + IntentPolicy.AuthorizationDeniedException.class, + () -> policy.authorize(noScope, grant, transfer("acme", "alice", "p-1", "bob", 10_00))); + } + + @Test + public void resourceIdDependsOnPrincipalAndBusinessIdentityOnly() { + EndUserPrincipal alice = + new EndUserPrincipal(DemoIdentityProvider.ISSUER, "alice", "acme", Collections.emptySet()); + EndUserPrincipal bob = + new EndUserPrincipal(DemoIdentityProvider.ISSUER, "bob", "acme", Collections.emptySet()); + TransferService.TransferInput input = transfer("acme", "alice", "p-1", "bob", 10_00); + + assertEquals( + TransferNexusServiceImpl.resourceId(alice, input), + TransferNexusServiceImpl.resourceId(alice, transfer("acme", "alice", "p-1", "bob", 20_00))); + assertNotEquals( + TransferNexusServiceImpl.resourceId(alice, input), + TransferNexusServiceImpl.resourceId(bob, input)); + assertFalse(TransferNexusServiceImpl.resourceId(alice, input).contains("alice")); + } + + @Test + public void operationTokensCannotBeForged() throws Exception { + SampleKeysets otherKeys = SampleKeysets.fromDirectory(keyDir.resolve("other")); + otherKeys.generate(); + TokenProbe ours = new TokenProbe(keys); + TokenProbe theirs = new TokenProbe(otherKeys); + + String token = ours.sign("transfer-123"); + assertEquals("transfer-123", ours.verify(token)); + assertThrows(HandlerException.class, () -> ours.verify(theirs.sign("transfer-123"))); + assertThrows(HandlerException.class, () -> ours.verify(token.substring(0, token.length() - 2))); + assertThrows(HandlerException.class, () -> ours.verify("not a token")); + } + + // Helpers. + + private static CredentialIntent intent(String maxAmountCents) { + Map constraints = new HashMap<>(); + constraints.put(TransferService.CONSTRAINT_PAYMENT_ID, "p-1"); + constraints.put(TransferService.CONSTRAINT_BENEFICIARY, "bob"); + constraints.put(TransferService.CONSTRAINT_MAX_AMOUNT_CENTS, maxAmountCents); + return new CredentialIntent( + Collections.singletonList( + new CredentialIntent.RouteGrant( + "ep", TransferService.NAME, TransferService.TRANSFER, constraints))); + } + + private static TransferService.TransferInput transfer( + String tenant, String user, String paymentId, String beneficiary, long amountCents) { + return new TransferService.TransferInput(tenant, user, paymentId, beneficiary, amountCents); + } + + private static Payload payload(String data) { + return Payload.newBuilder().setData(ByteString.copyFromUtf8(data)).build(); + } + + private static Payload plain(String data) { + return Payload.newBuilder() + .putMetadata("encoding", ByteString.copyFromUtf8("binary/plain")) + .setData(ByteString.copyFromUtf8(data)) + .build(); + } + + private static void assertDifferent(byte[] expected, byte[] actual) { + assertFalse(Arrays.equals(expected, actual)); + } + + private static void assertRejected(Runnable decode) { + ApplicationFailure failure = assertThrows(ApplicationFailure.class, decode::run); + assertTrue(failure.isNonRetryable()); + assertEquals("PayloadValidationError", failure.getType()); + } + + private static String repeat(char c, int count) { + char[] chars = new char[count]; + Arrays.fill(chars, c); + return new String(chars); + } +} diff --git a/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/SecureCredentialPropagationTest.java b/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/SecureCredentialPropagationTest.java new file mode 100644 index 00000000..374bb110 --- /dev/null +++ b/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/SecureCredentialPropagationTest.java @@ -0,0 +1,724 @@ +package io.temporal.samples.nexussecurecredentialpropagation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.nexusrpc.handler.HandlerException; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.api.common.v1.WorkflowType; +import io.temporal.api.enums.v1.EventType; +import io.temporal.api.enums.v1.WorkflowIdReusePolicy; +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.api.history.v1.NexusOperationScheduledEventAttributes; +import io.temporal.api.history.v1.WorkflowExecutionStartedEventAttributes; +import io.temporal.api.taskqueue.v1.TaskQueue; +import io.temporal.api.workflowservice.v1.StartWorkflowExecutionRequest; +import io.temporal.api.workflowservice.v1.StartWorkflowExecutionResponse; +import io.temporal.client.BatchRequest; +import io.temporal.client.UpdateOptions; +import io.temporal.client.WithStartWorkflowOperation; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.client.WorkflowFailedException; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.client.WorkflowUpdateStage; +import io.temporal.common.RetryOptions; +import io.temporal.common.interceptors.Header; +import io.temporal.common.interceptors.WorkflowClientCallsInterceptor; +import io.temporal.common.interceptors.WorkflowClientCallsInterceptorBase; +import io.temporal.common.interceptors.WorkflowClientInterceptorBase; +import io.temporal.failure.ApplicationFailure; +import io.temporal.samples.nexussecurecredentialpropagation.caller.CredentialCallerWorkerInterceptor; +import io.temporal.samples.nexussecurecredentialpropagation.caller.CredentialContext; +import io.temporal.samples.nexussecurecredentialpropagation.caller.CredentialWorkflowClientInterceptor; +import io.temporal.samples.nexussecurecredentialpropagation.caller.TransferWorkflow; +import io.temporal.samples.nexussecurecredentialpropagation.caller.TransferWorkflowImpl; +import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialEnvelope; +import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialIntent; +import io.temporal.samples.nexussecurecredentialpropagation.common.DemoIdentityProvider; +import io.temporal.samples.nexussecurecredentialpropagation.common.SampleConfig; +import io.temporal.samples.nexussecurecredentialpropagation.common.SampleKeysets; +import io.temporal.samples.nexussecurecredentialpropagation.common.SecureCredentialHeaders; +import io.temporal.samples.nexussecurecredentialpropagation.handler.CredentialHandlerWorkerInterceptor; +import io.temporal.samples.nexussecurecredentialpropagation.handler.JwtVerifier; +import io.temporal.samples.nexussecurecredentialpropagation.handler.TransferIntentPolicy; +import io.temporal.samples.nexussecurecredentialpropagation.handler.TransferNexusServiceImpl; +import io.temporal.samples.nexussecurecredentialpropagation.service.TransferService; +import io.temporal.testing.TestEnvironmentOptions; +import io.temporal.testing.TestWorkflowEnvironment; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactory; +import io.temporal.worker.WorkerFactoryOptions; +import io.temporal.worker.WorkflowImplementationOptions; +import io.temporal.workflow.NexusServiceOptions; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** End-to-end tests of the credential protocol against the in-memory test server. */ +public class SecureCredentialPropagationTest { + private static final String NAMESPACE = "default"; + private static final String OTHER_ENDPOINT = "other-endpoint"; + private static final String ATTACKER_TASK_QUEUE = "attacker"; + + @TempDir Path keyDir; + + private SampleKeysets keys; + private TestWorkflowEnvironment env; + private WorkflowClient client; + private WorkerFactory attackerFactory; + private DemoIdentityProvider idp; + + @BeforeEach + public void setUp() throws Exception { + keys = SampleKeysets.fromDirectory(keyDir); + keys.generate(); + idp = new DemoIdentityProvider(keys.demoIdentityProviderSigner()); + + env = + TestWorkflowEnvironment.newInstance( + TestEnvironmentOptions.newBuilder() + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder() + .setNamespace(NAMESPACE) + .setDataConverter(SampleConfig.dataConverter(keys.payloadAead())) + .setInterceptors( + new CredentialWorkflowClientInterceptor( + NAMESPACE, keys.workflowCredentialEncrypt())) + .build()) + .setWorkerFactoryOptions( + WorkerFactoryOptions.newBuilder() + .setWorkerInterceptors( + new CredentialHandlerWorkerInterceptor( + keys.nexusCredentialDecrypt(), + new JwtVerifier( + keys.demoIdentityProviderVerifier(), + DemoIdentityProvider.ISSUER, + DemoIdentityProvider.HANDLER_AUDIENCE), + Collections.singletonMap( + TransferService.NAME + "/" + TransferService.TRANSFER, + new TransferIntentPolicy())), + // Registered last: outermost inbound, innermost outbound. + new CredentialCallerWorkerInterceptor( + keys.workflowCredentialDecrypt(), + keys.workflowCredentialEncrypt(), + keys.nexusCredentialEncrypt(), + SampleConfig.PROTECTED_ROUTES)) + .build()) + .build()); + client = env.getWorkflowClient(); + env.createNexusEndpoint(SampleConfig.ENDPOINT, SampleConfig.HANDLER_TASK_QUEUE); + env.createNexusEndpoint(OTHER_ENDPOINT, SampleConfig.HANDLER_TASK_QUEUE); + + Worker caller = env.newWorker(SampleConfig.CALLER_TASK_QUEUE); + caller.registerWorkflowImplementationTypes( + WorkflowImplementationOptions.newBuilder() + .setNexusServiceOptions( + Collections.singletonMap( + TransferService.NAME, + NexusServiceOptions.newBuilder().setEndpoint(SampleConfig.ENDPOINT).build())) + .build(), + TransferWorkflowImpl.class, + TestWorkflows.SteerableTransferWorkflowImpl.class, + TestWorkflows.TwoArgWorkflowImpl.class, + TestWorkflows.NoArgWorkflowImpl.class, + TestWorkflows.ContinuingWorkflowImpl.class, + TestWorkflows.FlakyTransferWorkflowImpl.class, + TestWorkflows.ParentWorkflowImpl.class, + TestWorkflows.CancellingTransferWorkflowImpl.class); + + Worker handler = env.newWorker(SampleConfig.HANDLER_TASK_QUEUE); + handler.registerWorkflowImplementationTypes(TestWorkflows.QuickSettleWorkflowImpl.class); + handler.registerNexusServiceImplementation( + new TransferNexusServiceImpl(keys.operationTokenMac())); + + // An attacker holding no keys, with a converter that sends captured bytes unchanged. + WorkflowClient attackerClient = + WorkflowClient.newInstance( + env.getWorkflowServiceStubs(), + WorkflowClientOptions.newBuilder().setNamespace(NAMESPACE).build()); + attackerFactory = + WorkerFactory.newInstance( + attackerClient, + WorkerFactoryOptions.newBuilder() + .setWorkerInterceptors(new TestWorkflows.AttackerHeaderInterceptor()) + .build()); + attackerFactory + .newWorker(ATTACKER_TASK_QUEUE) + .registerWorkflowImplementationTypes(TestWorkflows.AttackerWorkflowImpl.class); + + env.start(); + attackerFactory.start(); + } + + @AfterEach + public void tearDown() { + TestWorkflows.AttackerHeaderInterceptor.HEADERS.clear(); + attackerFactory.shutdownNow(); + env.close(); + } + + // --------------------------------------------------------------------------------------------- + // Happy paths. + + @Test + public void transfersOnBehalfOfVerifiedPrincipal() { + TransferService.TransferInput input = transferInput("p-1"); + TransferService.TransferOutput output = runTransfer("caller-1", input, credentialFor(input)); + assertEquals("SETTLED", output.getStatus()); + assertEquals("alice", output.getApprovedFor()); + } + + @Test + public void historiesContainNoPlaintextCredential() { + TransferService.TransferInput input = transferInput("p-plain"); + CredentialEnvelope credential = credentialFor(input); + runTransfer("caller-plain", input, credential); + + String jwt = credential.getCompactJwt(); + String jwtPayloadSegment = jwt.substring(jwt.indexOf('.') + 1, jwt.lastIndexOf('.')); + List histories = new ArrayList<>(); + histories.add(historyBytes("caller-plain")); + histories.add(historyBytes(TransferNexusServiceImpl.resourceId(principal("alice"), input))); + for (String history : histories) { + assertFalse(history.contains(credential.getCompactJwt())); + assertFalse(history.contains(jwtPayloadSegment)); + assertFalse(history.contains("alice")); + assertFalse(history.contains("acme")); + } + } + + @Test + public void signalWithStartCarriesCredential() { + TransferService.TransferInput input = transferInput("p-sws"); + TestWorkflows.SteerableTransferWorkflow workflow = + client.newWorkflowStub( + TestWorkflows.SteerableTransferWorkflow.class, callerOptions("caller-sws")); + CredentialContext.callWith( + credentialFor(input), + () -> { + BatchRequest request = client.newSignalWithStartRequest(); + request.add(workflow::transfer, input); + request.add(workflow::ping, "hello"); + return client.signalWithStart(request); + }); + assertEquals( + "SETTLED", + WorkflowStub.fromTyped(workflow) + .getResult(TransferService.TransferOutput.class) + .getStatus()); + } + + @Test + public void updateWithStartCarriesCredential() { + TransferService.TransferInput input = transferInput("p-uws"); + TestWorkflows.SteerableTransferWorkflow workflow = + client.newWorkflowStub( + TestWorkflows.SteerableTransferWorkflow.class, + WorkflowOptions.newBuilder(callerOptions("caller-uws")) + .setWorkflowIdConflictPolicy( + io.temporal.api.enums.v1.WorkflowIdConflictPolicy + .WORKFLOW_ID_CONFLICT_POLICY_FAIL) + .build()); + CredentialContext.callWith( + credentialFor(input), + () -> + WorkflowClient.startUpdateWithStart( + workflow::ack, + "hello", + UpdateOptions.newBuilder() + .setWaitForStage(WorkflowUpdateStage.COMPLETED) + .build(), + new WithStartWorkflowOperation<>(workflow::transfer, input))); + assertEquals( + "SETTLED", + WorkflowStub.fromTyped(workflow) + .getResult(TransferService.TransferOutput.class) + .getStatus()); + } + + @Test + public void credentialSurvivesContinueAsNewToAnotherTypeAndRetry() { + TransferService.TransferInput input = transferInput("p-can"); + TestWorkflows.ContinuingWorkflow workflow = + client.newWorkflowStub( + TestWorkflows.ContinuingWorkflow.class, + WorkflowOptions.newBuilder(callerOptions("caller-can")) + .setRetryOptions( + RetryOptions.newBuilder() + .setInitialInterval(Duration.ofSeconds(1)) + .setMaximumAttempts(3) + .build()) + .build()); + CredentialContext.callWith( + credentialFor(input), () -> WorkflowClient.start(workflow::transfer, input)); + TransferService.TransferOutput output = + client.newUntypedWorkflowStub("caller-can").getResult(TransferService.TransferOutput.class); + assertEquals("SETTLED", output.getStatus()); + // The final run is the retry of the continued-as-new run, carrying the continue-as-new header. + WorkflowExecutionStartedEventAttributes last = startedEvent("caller-can"); + assertEquals("FlakyTransferWorkflow", last.getWorkflowType().getName()); + assertEquals(2, last.getAttempt()); + } + + @Test + public void childWorkflowsDoNotReceiveTheCredential() { + TestWorkflows.ParentWorkflow parent = + client.newWorkflowStub(TestWorkflows.ParentWorkflow.class, callerOptions("parent")); + CredentialContext.callWith(credentialFor(transferInput("p-child")), () -> parent.run("child")); + assertTrue( + startedEvent("parent").getHeader().containsFields(SecureCredentialHeaders.WORKFLOW_HEADER)); + assertFalse( + startedEvent("child").getHeader().containsFields(SecureCredentialHeaders.WORKFLOW_HEADER)); + } + + @Test + public void cancellationUsesTheSignedToken() { + TransferService.TransferInput input = transferInput("hold-1"); + TestWorkflows.CancellingTransferWorkflow workflow = + client.newWorkflowStub( + TestWorkflows.CancellingTransferWorkflow.class, callerOptions("caller-cancel")); + String result = + CredentialContext.callWith(credentialFor(input), () -> workflow.transferThenCancel(input)); + assertTrue(result.startsWith("cancelled:"), result); + + String backingId = TransferNexusServiceImpl.resourceId(principal("alice"), input); + WorkflowFailedException failure = + assertThrows( + WorkflowFailedException.class, + () -> client.newUntypedWorkflowStub(backingId).getResult(Object.class)); + assertTrue(failure.getCause() instanceof io.temporal.failure.CanceledFailure); + } + + // --------------------------------------------------------------------------------------------- + // Start hop attacks. + + @Test + public void startHeaderIsBoundToTheExactArgumentList() { + TestWorkflows.TwoArgWorkflow workflow = + client.newWorkflowStub(TestWorkflows.TwoArgWorkflow.class, callerOptions("two-args")); + assertEquals( + "a,b", + CredentialContext.callWith( + credentialFor(transferInput("p-args")), () -> workflow.join("a", "b"))); + + WorkflowExecutionStartedEventAttributes victim = startedEvent("two-args"); + List args = victim.getInput().getPayloadsList(); + + // An exact copy into a new run of the same ID is accepted (replay case 4). + assertEquals( + "a,b", rawStart("two-args", "TwoArgWorkflow", victim, args).getResult(String.class)); + + List> tampered = new ArrayList<>(); + tampered.add(listOf(args.get(1), args.get(0))); // Reordered. + tampered.add(listOf(args.get(0), args.get(0))); // Duplicated. + tampered.add(listOf(args.get(0))); // Truncated. + tampered.add(listOf(args.get(0), reencrypt("two-args", "b"))); // Substituted. + for (List payloads : tampered) { + WorkflowFailedException failure = + assertThrows( + WorkflowFailedException.class, + () -> + rawStart("two-args", "TwoArgWorkflow", victim, payloads).getResult(String.class)); + assertEquals( + CredentialCallerWorkerInterceptor.CREDENTIAL_REJECTED, + ((ApplicationFailure) failure.getCause()).getType()); + } + } + + @Test + public void copiedContinueAsNewHeaderIsRejectedInAFreshStart() { + credentialSurvivesContinueAsNewToAnotherTypeAndRetry(); + // The last run in the chain was started by continue-as-new or retry and carries a CAN header. + WorkflowExecutionStartedEventAttributes continued = startedEvent("caller-can"); + assertTrue( + continued + .getHeader() + .getFieldsOrThrow(SecureCredentialHeaders.WORKFLOW_HEADER) + .getData() + .toStringUtf8() + .startsWith("v1.can.")); + WorkflowFailedException failure = + assertThrows( + WorkflowFailedException.class, + () -> + rawStart( + "caller-can", + "FlakyTransferWorkflow", + continued, + continued.getInput().getPayloadsList()) + .getResult(Object.class)); + assertEquals( + CredentialCallerWorkerInterceptor.CREDENTIAL_REJECTED, + ((ApplicationFailure) failure.getCause()).getType()); + } + + @Test + public void startsWithoutArgumentsCannotCarryACredential() { + TestWorkflows.NoArgWorkflow workflow = + client.newWorkflowStub(TestWorkflows.NoArgWorkflow.class, callerOptions("no-args")); + RuntimeException e = + assertThrows( + RuntimeException.class, + () -> + CredentialContext.callWith( + credentialFor(transferInput("p-noargs")), + () -> WorkflowClient.start(workflow::run))); + assertTrue(hasCause(e, IllegalArgumentException.class), () -> chain(e)); + } + + @Test + public void innerInterceptorCopyingHeadersFailsClosed() { + // An interceptor inside the credential interceptor that replaces the header map means the SDK + // never sees the sealed header. + WorkflowClient misconfigured = + WorkflowClient.newInstance( + env.getWorkflowServiceStubs(), + WorkflowClientOptions.newBuilder(client.getOptions()) + .setInterceptors( + new HeaderCopyingClientInterceptor(), + new CredentialWorkflowClientInterceptor( + NAMESPACE, keys.workflowCredentialEncrypt())) + .build()); + TransferService.TransferInput input = transferInput("p-copy"); + TransferWorkflow workflow = + misconfigured.newWorkflowStub(TransferWorkflow.class, callerOptions("caller-copy")); + RuntimeException e = + assertThrows( + RuntimeException.class, + () -> + CredentialContext.callWith( + credentialFor(input), () -> WorkflowClient.start(workflow::transfer, input))); + assertTrue(hasCause(e, IllegalStateException.class)); + } + + // --------------------------------------------------------------------------------------------- + // Nexus hop attacks. + + @Test + public void exactNexusReplayWhileRunningAttachesToTheSameResource() throws InterruptedException { + TransferService.TransferInput input = transferInput("hold-replay"); + TransferWorkflow victim = + client.newWorkflowStub(TransferWorkflow.class, callerOptions("victim-running")); + CredentialContext.callWith( + credentialFor(input), () -> WorkflowClient.start(victim::transfer, input)); + String victimToken = awaitStartedToken("victim-running"); + NexusOperationScheduledEventAttributes scheduled = scheduledEvent("victim-running"); + + TestWorkflows.AttackerHeaderInterceptor.HEADERS.putAll(scheduled.getNexusHeaderMap()); + assertEquals("started:" + victimToken, attack(SampleConfig.ENDPOINT, scheduled.getInput())); + assertEquals(1, backingStarts(input)); + } + + @Test + public void exactNexusReplayAfterCompletionCausesNoSecondEffect() { + TransferService.TransferInput input = transferInput("p-replay"); + runTransfer("victim-replay", input, credentialFor(input)); + NexusOperationScheduledEventAttributes victim = scheduledEvent("victim-replay"); + + TestWorkflows.AttackerHeaderInterceptor.HEADERS.putAll(victim.getNexusHeaderMap()); + // The backing Workflow ID cannot be reused, so the replay is refused instead of re-running. + assertEquals("rejected:BAD_REQUEST", attack(SampleConfig.ENDPOINT, victim.getInput())); + assertEquals(1, backingStarts(input)); + } + + @Test + public void nexusHeaderCannotBePairedWithAnotherInput() { + TransferService.TransferInput inputA = transferInput("p-a"); + TransferService.TransferInput inputB = transferInput("p-b"); + runTransfer("victim-a", inputA, credentialFor(inputA)); + runTransfer("victim-b", inputB, credentialFor(inputB)); + + TestWorkflows.AttackerHeaderInterceptor.HEADERS.putAll( + scheduledEvent("victim-a").getNexusHeaderMap()); + assertEquals( + "rejected:UNAUTHENTICATED", + attack(SampleConfig.ENDPOINT, scheduledEvent("victim-b").getInput())); + } + + @Test + public void exactNexusPairCannotMoveToAnotherEndpoint() { + TransferService.TransferInput input = transferInput("p-route"); + runTransfer("victim-route", input, credentialFor(input)); + NexusOperationScheduledEventAttributes victim = scheduledEvent("victim-route"); + + TestWorkflows.AttackerHeaderInterceptor.HEADERS.putAll(victim.getNexusHeaderMap()); + // The input ciphertext is bound to its endpoint, so the codec refuses it. + assertEquals("rejected:BAD_REQUEST", attack(OTHER_ENDPOINT, victim.getInput())); + } + + @Test + public void nexusInputWithoutCredentialIsRejected() { + TransferService.TransferInput input = transferInput("p-nocred"); + runTransfer("victim-nocred", input, credentialFor(input)); + assertEquals( + "rejected:UNAUTHENTICATED", + attack(SampleConfig.ENDPOINT, scheduledEvent("victim-nocred").getInput())); + } + + // --------------------------------------------------------------------------------------------- + // Authorization. + + @Test + public void inputOutsideTheIntentIsUnauthorized() { + TransferService.TransferInput approved = transferInput("p-intent"); + TransferService.TransferInput steered = + new TransferService.TransferInput("acme", "alice", "p-intent", "mallory", 25_00); + assertEquals( + HandlerException.ErrorType.UNAUTHORIZED, + transferFailure("caller-intent", steered, credentialFor(approved))); + } + + @Test + public void amountAboveTheIntentIsUnauthorized() { + TransferService.TransferInput approved = transferInput("p-amount"); + TransferService.TransferInput steered = + new TransferService.TransferInput("acme", "alice", "p-amount", "bob", 99_00); + assertEquals( + HandlerException.ErrorType.UNAUTHORIZED, + transferFailure("caller-amount", steered, credentialFor(approved))); + } + + @Test + public void subjectMismatchIsUnauthorized() { + TransferService.TransferInput input = + new TransferService.TransferInput("acme", "bob", "p-subject", "bob", 25_00); + assertEquals( + HandlerException.ErrorType.UNAUTHORIZED, + transferFailure("caller-subject", input, credentialFor(input))); + } + + @Test + public void tokenForAnotherAudienceIsUnauthenticated() { + TransferService.TransferInput input = transferInput("p-aud"); + String jwt = + idp.issue( + "alice", + "acme", + TransferService.REQUIRED_SCOPE, + "some-other-service", + java.time.Instant.now(), + Duration.ofMinutes(5)); + assertEquals( + HandlerException.ErrorType.UNAUTHENTICATED, + transferFailure("caller-aud", input, new CredentialEnvelope(jwt, intentFor(input)))); + } + + // --------------------------------------------------------------------------------------------- + // Helpers. + + private static TransferService.TransferInput transferInput(String paymentId) { + return new TransferService.TransferInput("acme", "alice", paymentId, "bob", 25_00); + } + + private static CredentialIntent intentFor(TransferService.TransferInput input) { + Map constraints = new HashMap<>(); + constraints.put(TransferService.CONSTRAINT_PAYMENT_ID, input.getPaymentId()); + constraints.put(TransferService.CONSTRAINT_BENEFICIARY, input.getBeneficiary()); + constraints.put(TransferService.CONSTRAINT_MAX_AMOUNT_CENTS, "5000"); + return new CredentialIntent( + Collections.singletonList( + new CredentialIntent.RouteGrant( + SampleConfig.ENDPOINT, + TransferService.NAME, + TransferService.TRANSFER, + constraints))); + } + + /** A credential for alice, approving exactly the given input. */ + private CredentialEnvelope credentialFor(TransferService.TransferInput input) { + String jwt = idp.issue("alice", "acme", TransferService.REQUIRED_SCOPE, Duration.ofMinutes(5)); + return new CredentialEnvelope(jwt, intentFor(input)); + } + + private static io.temporal.samples.nexussecurecredentialpropagation.common.EndUserPrincipal + principal(String subject) { + return new io.temporal.samples.nexussecurecredentialpropagation.common.EndUserPrincipal( + DemoIdentityProvider.ISSUER, + subject, + "acme", + Collections.singleton(TransferService.REQUIRED_SCOPE)); + } + + private static WorkflowOptions callerOptions(String workflowId) { + return WorkflowOptions.newBuilder() + .setWorkflowId(workflowId) + .setTaskQueue(SampleConfig.CALLER_TASK_QUEUE) + .build(); + } + + private TransferService.TransferOutput runTransfer( + String workflowId, TransferService.TransferInput input, CredentialEnvelope credential) { + TransferWorkflow workflow = + client.newWorkflowStub(TransferWorkflow.class, callerOptions(workflowId)); + return CredentialContext.callWith(credential, () -> workflow.transfer(input)); + } + + private HandlerException.ErrorType transferFailure( + String workflowId, TransferService.TransferInput input, CredentialEnvelope credential) { + WorkflowFailedException failure = + assertThrows( + WorkflowFailedException.class, () -> runTransfer(workflowId, input, credential)); + for (Throwable t = failure; t != null; t = t.getCause()) { + if (t instanceof HandlerException) { + return ((HandlerException) t).getErrorType(); + } + } + throw new AssertionError("No handler failure in " + failure); + } + + private String attack(String endpoint, Payload capturedInput) { + WorkflowClient attackerClient = + WorkflowClient.newInstance( + env.getWorkflowServiceStubs(), + WorkflowClientOptions.newBuilder().setNamespace(NAMESPACE).build()); + TestWorkflows.AttackerWorkflow attacker = + attackerClient.newWorkflowStub( + TestWorkflows.AttackerWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId("attacker-" + UUID.randomUUID()) + .setTaskQueue(ATTACKER_TASK_QUEUE) + .build()); + return attacker.replay(endpoint, capturedInput.toByteArray()); + } + + private String historyBytes(String workflowId) { + return new String( + client.fetchHistory(workflowId).getHistory().toByteArray(), StandardCharsets.ISO_8859_1); + } + + private List events(String workflowId) { + return client.fetchHistory(workflowId).getEvents(); + } + + private WorkflowExecutionStartedEventAttributes startedEvent(String workflowId) { + return events(workflowId).get(0).getWorkflowExecutionStartedEventAttributes(); + } + + private NexusOperationScheduledEventAttributes scheduledEvent(String workflowId) { + for (HistoryEvent event : events(workflowId)) { + if (event.hasNexusOperationScheduledEventAttributes()) { + return event.getNexusOperationScheduledEventAttributes(); + } + } + throw new AssertionError("No Nexus operation scheduled in " + workflowId); + } + + private String awaitStartedToken(String workflowId) throws InterruptedException { + long deadline = System.currentTimeMillis() + 10_000; + while (System.currentTimeMillis() < deadline) { + for (HistoryEvent event : events(workflowId)) { + if (event.hasNexusOperationStartedEventAttributes()) { + return event.getNexusOperationStartedEventAttributes().getOperationToken(); + } + } + Thread.sleep(100); + } + throw new AssertionError("No Nexus operation started in " + workflowId); + } + + private long backingStarts(TransferService.TransferInput input) { + String backingId = TransferNexusServiceImpl.resourceId(principal("alice"), input); + return events(backingId).stream() + .filter(e -> e.getEventType() == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED) + .count(); + } + + /** Starts a run directly through the service, as an attacker copying history would. */ + private WorkflowStub rawStart( + String workflowId, + String workflowType, + WorkflowExecutionStartedEventAttributes copiedFrom, + List args) { + StartWorkflowExecutionResponse response = + env.getWorkflowServiceStubs() + .blockingStub() + .startWorkflowExecution( + StartWorkflowExecutionRequest.newBuilder() + .setNamespace(NAMESPACE) + .setWorkflowId(workflowId) + .setWorkflowType(WorkflowType.newBuilder().setName(workflowType)) + .setTaskQueue(TaskQueue.newBuilder().setName(SampleConfig.CALLER_TASK_QUEUE)) + .setInput(Payloads.newBuilder().addAllPayloads(args)) + .setHeader(copiedFrom.getHeader()) + .setRequestId(UUID.randomUUID().toString()) + .setIdentity("attacker") + .setWorkflowIdReusePolicy( + WorkflowIdReusePolicy.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE) + .build()); + return client.newUntypedWorkflowStub( + WorkflowExecution.newBuilder() + .setWorkflowId(workflowId) + .setRunId(response.getRunId()) + .build(), + java.util.Optional.of(workflowType)); + } + + private Payload reencrypt(String workflowId, String value) { + return client + .getOptions() + .getDataConverter() + .withContext( + new io.temporal.payload.context.WorkflowSerializationContext(NAMESPACE, workflowId)) + .toPayload(value) + .get(); + } + + private static List listOf(Payload... payloads) { + List list = new ArrayList<>(); + Collections.addAll(list, payloads); + return list; + } + + private static String chain(Throwable e) { + StringBuilder b = new StringBuilder(); + for (Throwable t = e; t != null; t = t.getCause()) { + b.append(t.getClass().getName()).append(": ").append(t.getMessage()).append(" <- "); + } + return b.toString(); + } + + private static boolean hasCause(Throwable e, Class type) { + for (Throwable t = e; t != null; t = t.getCause()) { + if (type.isInstance(t)) { + return true; + } + } + return false; + } + + /** Replaces the header with a copy, as a misordered propagation interceptor might. */ + private static final class HeaderCopyingClientInterceptor extends WorkflowClientInterceptorBase { + @Override + public WorkflowClientCallsInterceptor workflowClientCallsInterceptor( + WorkflowClientCallsInterceptor next) { + return new WorkflowClientCallsInterceptorBase(next) { + @Override + public WorkflowStartOutput start(WorkflowStartInput input) { + return super.start( + new WorkflowStartInput( + input.getWorkflowId(), + input.getWorkflowType(), + new Header(new HashMap<>(input.getHeader().getValues())), + input.getArguments(), + input.getOptions())); + } + }; + } + } +} diff --git a/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/TestWorkflows.java b/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/TestWorkflows.java new file mode 100644 index 00000000..bfa1bc92 --- /dev/null +++ b/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/TestWorkflows.java @@ -0,0 +1,259 @@ +package io.temporal.samples.nexussecurecredentialpropagation; + +import io.nexusrpc.handler.HandlerException; +import io.temporal.common.converter.RawValue; +import io.temporal.common.interceptors.WorkerInterceptorBase; +import io.temporal.common.interceptors.WorkflowInboundCallsInterceptor; +import io.temporal.common.interceptors.WorkflowInboundCallsInterceptorBase; +import io.temporal.common.interceptors.WorkflowOutboundCallsInterceptor; +import io.temporal.common.interceptors.WorkflowOutboundCallsInterceptorBase; +import io.temporal.failure.ApplicationFailure; +import io.temporal.samples.nexussecurecredentialpropagation.handler.TransferHandlerWorkflow; +import io.temporal.samples.nexussecurecredentialpropagation.service.TransferService; +import io.temporal.workflow.CancellationScope; +import io.temporal.workflow.ChildWorkflowOptions; +import io.temporal.workflow.NexusOperationHandle; +import io.temporal.workflow.NexusOperationOptions; +import io.temporal.workflow.NexusServiceOptions; +import io.temporal.workflow.NexusServiceStub; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.UpdateMethod; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** Workflows used only by the tests. */ +final class TestWorkflows { + private TestWorkflows() {} + + static TransferService newTransferStub() { + return Workflow.newNexusServiceStub( + TransferService.class, + NexusServiceOptions.newBuilder() + .setOperationOptions( + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofMinutes(1)) + .build()) + .build()); + } + + /** Settles immediately, or waits until cancelled when the payment ID starts with "hold-". */ + public static class QuickSettleWorkflowImpl implements TransferHandlerWorkflow { + @Override + public TransferService.TransferOutput settle(Settlement settlement) { + if (settlement.getPaymentId().startsWith("hold-")) { + Workflow.await(() -> false); + } + return new TransferService.TransferOutput( + settlement.getPaymentId(), "SETTLED", settlement.getApprovedFor()); + } + } + + @WorkflowInterface + public interface SteerableTransferWorkflow { + @WorkflowMethod + TransferService.TransferOutput transfer(TransferService.TransferInput input); + + @SignalMethod + void ping(String value); + + @UpdateMethod + String ack(String value); + } + + public static class SteerableTransferWorkflowImpl implements SteerableTransferWorkflow { + private final TransferService transferService = newTransferStub(); + + @Override + public TransferService.TransferOutput transfer(TransferService.TransferInput input) { + return transferService.transfer(input); + } + + @Override + public void ping(String value) {} + + @Override + public String ack(String value) { + return value; + } + } + + @WorkflowInterface + public interface TwoArgWorkflow { + @WorkflowMethod + String join(String first, String second); + } + + public static class TwoArgWorkflowImpl implements TwoArgWorkflow { + @Override + public String join(String first, String second) { + return first + "," + second; + } + } + + @WorkflowInterface + public interface NoArgWorkflow { + @WorkflowMethod + String run(); + } + + public static class NoArgWorkflowImpl implements NoArgWorkflow { + @Override + public String run() { + return "ok"; + } + } + + /** Continues as new into {@link FlakyTransferWorkflow}, a different Workflow type. */ + @WorkflowInterface + public interface ContinuingWorkflow { + @WorkflowMethod + TransferService.TransferOutput transfer(TransferService.TransferInput input); + } + + public static class ContinuingWorkflowImpl implements ContinuingWorkflow { + @Override + public TransferService.TransferOutput transfer(TransferService.TransferInput input) { + return Workflow.newContinueAsNewStub(FlakyTransferWorkflow.class).transfer(input); + } + } + + /** Fails its first attempt, so the credential must also survive a retry after continue-as-new. */ + @WorkflowInterface + public interface FlakyTransferWorkflow { + @WorkflowMethod + TransferService.TransferOutput transfer(TransferService.TransferInput input); + } + + public static class FlakyTransferWorkflowImpl implements FlakyTransferWorkflow { + private final TransferService transferService = newTransferStub(); + + @Override + public TransferService.TransferOutput transfer(TransferService.TransferInput input) { + if (Workflow.getInfo().getAttempt() == 1) { + throw ApplicationFailure.newFailure("first attempt fails", "Flaky"); + } + return transferService.transfer(input); + } + } + + @WorkflowInterface + public interface ParentWorkflow { + @WorkflowMethod + String run(String childId); + } + + public static class ParentWorkflowImpl implements ParentWorkflow { + @Override + public String run(String childId) { + return Workflow.newChildWorkflowStub( + NoArgWorkflow.class, ChildWorkflowOptions.newBuilder().setWorkflowId(childId).build()) + .run(); + } + } + + @WorkflowInterface + public interface CancellingTransferWorkflow { + @WorkflowMethod + String transferThenCancel(TransferService.TransferInput input); + } + + public static class CancellingTransferWorkflowImpl implements CancellingTransferWorkflow { + private final TransferService transferService = newTransferStub(); + + @Override + public String transferThenCancel(TransferService.TransferInput input) { + NexusOperationHandle[] handle = new NexusOperationHandle[1]; + CancellationScope scope = + Workflow.newCancellationScope( + () -> handle[0] = Workflow.startNexusOperation(transferService::transfer, input)); + scope.run(); + String token = handle[0].getExecution().get().getOperationToken().orElse(""); + scope.cancel(); + try { + handle[0].getResult().get(); + return "completed"; + } catch (RuntimeException e) { + return "cancelled:" + token; + } + } + } + + /** + * A caller with no keys that replays captured Nexus input bytes and headers. It uses a data + * converter without the codec, so the captured ciphertext is sent unchanged. + */ + @WorkflowInterface + public interface AttackerWorkflow { + @WorkflowMethod + String replay(String endpoint, byte[] serializedInput); + } + + public static class AttackerWorkflowImpl implements AttackerWorkflow { + @Override + public String replay(String endpoint, byte[] serializedInput) { + NexusServiceStub stub = + Workflow.newUntypedNexusServiceStub( + TransferService.NAME, + NexusServiceOptions.newBuilder() + .setEndpoint(endpoint) + .setOperationOptions( + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofMinutes(1)) + .build()) + .build()); + RawValue input; + try { + input = new RawValue(io.temporal.api.common.v1.Payload.parseFrom(serializedInput)); + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw new IllegalArgumentException(e); + } + try { + NexusOperationHandle handle = + stub.start(TransferService.TRANSFER, Object.class, input); + return "started:" + handle.getExecution().get().getOperationToken().orElse(""); + } catch (RuntimeException e) { + for (Throwable t = e; t != null; t = t.getCause()) { + if (t instanceof HandlerException) { + return "rejected:" + ((HandlerException) t).getErrorType(); + } + } + StringBuilder chain = new StringBuilder("failed:"); + for (Throwable t = e; t != null; t = t.getCause()) { + chain + .append(t.getClass().getSimpleName()) + .append('(') + .append(t.getMessage()) + .append(")<-"); + } + return chain.toString(); + } + } + } + + /** Adds attacker-chosen Nexus headers to every Nexus call the attacker makes. */ + static final class AttackerHeaderInterceptor extends WorkerInterceptorBase { + static final Map HEADERS = new ConcurrentHashMap<>(); + + @Override + public WorkflowInboundCallsInterceptor interceptWorkflow(WorkflowInboundCallsInterceptor next) { + return new WorkflowInboundCallsInterceptorBase(next) { + @Override + public void init(WorkflowOutboundCallsInterceptor outboundCalls) { + super.init( + new WorkflowOutboundCallsInterceptorBase(outboundCalls) { + @Override + public ExecuteNexusOperationOutput executeNexusOperation( + ExecuteNexusOperationInput input) { + input.getHeaders().putAll(HEADERS); + return super.executeNexusOperation(input); + } + }); + } + }; + } + } +} diff --git a/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TokenProbe.java b/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TokenProbe.java new file mode 100644 index 00000000..6912e2e4 --- /dev/null +++ b/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TokenProbe.java @@ -0,0 +1,20 @@ +package io.temporal.samples.nexussecurecredentialpropagation.handler; + +import io.temporal.samples.nexussecurecredentialpropagation.common.SampleKeysets; + +/** Exposes the operation token signing of {@link SignedTokenOperationHandler} to tests. */ +public final class TokenProbe { + private final SignedTokenOperationHandler handler; + + public TokenProbe(SampleKeysets keys) { + this.handler = new SignedTokenOperationHandler<>(keys.operationTokenMac(), null, null); + } + + public String sign(String workflowId) { + return handler.sign(workflowId); + } + + public String verify(String token) { + return handler.verify(token); + } +} From 0d6fa802e5459cf95c7d38f0dd09df695f2839fe Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Tue, 13 Oct 2026 16:57:01 -0700 Subject: [PATCH 2/5] Explain public keys and intent limits in the credential sample README Public HPKE keys need not be secret: they only let a holder seal a header around a token they already have. Document that sender authentication requires a signature, and that the client-sealed intent does not replace server-side authorization. --- .../README.MD | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD index a0d084d3..0ef9131a 100644 --- a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD @@ -60,6 +60,28 @@ Payloads stay symmetric because both ends of a payload hop must read them. The c serialization context (Nexus endpoint/service/operation, or Workflow namespace/ID), and never falls back to weaker associated data. +### Public keys are public + +The HPKE public keys do not need to be kept secret. A public key lets its holder seal a credential header, but not +open one, and not move an existing header to another context or other arguments: that binding is authenticated by +HPKE and can only be produced from the plaintext. So the only header a public-key holder can create carries a JWT +they already possess, and they act as themselves. The handler validates that JWT and authorizes the principal against +the input no matter who sealed the header, and the Temporal access layer still decides who may start Workflows or +call the endpoint. + +What a public key does not give the handler is **sender authentication**: it cannot tell a header created by a +legitimate caller Worker from one created by anyone else holding a valid token. Keeping the public key private adds +some defence in depth (for example against a leaked token being injected through Temporal by a party that is already +an allowed caller), but it is not the right control. To accept only headers created by legitimate caller Workers, +sign them: + +- The caller Worker signs `frame(context, headerCiphertext)` with a Tink `PublicKeySign` key held only by caller + Workers, and sends the signature alongside the header. +- The handler verifies it with the corresponding `PublicKeyVerify` key before opening the header. + +The secret is then the signing key. Tink's `HybridEncrypt` uses HPKE's base mode and does not expose its authenticated +mode, so a separate signature is the practical way to add this. The sample does not implement it. + ### Wire formats ``` @@ -108,7 +130,14 @@ validates the JWT (signature, algorithm, issuer, audience, type, `exp`, `nbf`), The args digest proves only that the caller Worker produced the input. Signals, updates, and activity results can steer a caller Workflow into choosing different inputs. The intent bounds that: the Workflow carries it unchanged and -cannot alter it. Authorizing the steering messages themselves needs a separate mechanism, such as: +cannot alter it. + +The intent protects a user from having their own Workflow steered; it does not constrain a user acting with their +own token. Anyone holding a valid token and a public key can seal any intent they like, so the intent is never a +substitute for server-side authorization. `TransferIntentPolicy` checks scope, subject, and tenant independently of +the intent for this reason. + +Authorizing the steering messages themselves needs a separate mechanism, such as: - **identity-provider-issued intent**, for example RFC 9396 `authorization_details` or transaction tokens, which also survive a caller Worker compromise (recommended for production); From 577368ed99d503d26ea8427c68c5487071426be6 Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Wed, 14 Oct 2026 22:38:17 -0700 Subject: [PATCH 3/5] Add a verifier for credential header binding VerifyCredentialBinding reads a Workflow history and, for each credential header, recomputes the args digest from the event's input ciphertexts (or uses firstExecutionRunId for continue-as-new headers), opens the header with that context, and confirms that a context with one bit changed is rejected and that the digest is not present in the header bytes. It prints only the credential's subject and issuer and exits non-zero on failure. --- .../README.MD | 19 + .../tools/VerifyCredentialBinding.java | 333 ++++++++++++++++++ .../SecureCredentialPropagationTest.java | 51 +++ 3 files changed, 403 insertions(+) create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/tools/VerifyCredentialBinding.java diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD index 0ef9131a..3433b6ce 100644 --- a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD @@ -223,3 +223,22 @@ Then, in separate terminal windows: The starter logs `Transfer payment-1001 SETTLED for alice`. Inspect the caller Workflow's history (`temporal workflow show -w transfer-payment-1001 -n my-caller-namespace --output json`): the start header and the Nexus header contain only ciphertext. + +### Verify the binding + +The args digest is not a field in the header; it is authenticated associated data that the receiver recomputes. To +check the binding in a real history, run the verifier against the caller namespace: + +``` +./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexussecurecredentialpropagation.tools.VerifyCredentialBinding \ + --args="-target-host localhost:7233 -namespace my-caller-namespace" +``` + +For each `WorkflowExecutionStarted` and `NexusOperationScheduled` event that carries a credential header, it +recomputes the args digest from the event's input ciphertexts (or uses `firstExecutionRunId` for continue-as-new +headers), opens the header with that context, confirms that the same context with one bit changed is rejected, and +confirms the digest does not appear in the header bytes. It exits non-zero if any check fails. Set +`-Dnexus.secure.credential.workflowId=...` (for example via `JAVA_TOOL_OPTIONS`) to check another Workflow. + +The verifier needs the receivers' private keys, so treat it as an operator tool. It prints only each credential's +subject and issuer, never the token. diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/tools/VerifyCredentialBinding.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/tools/VerifyCredentialBinding.java new file mode 100644 index 00000000..ac3ff097 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/tools/VerifyCredentialBinding.java @@ -0,0 +1,333 @@ +package io.temporal.samples.nexussecurecredentialpropagation.tools; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.crypto.tink.HybridDecrypt; +import com.google.protobuf.ByteString; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.api.history.v1.NexusOperationScheduledEventAttributes; +import io.temporal.api.history.v1.WorkflowExecutionStartedEventAttributes; +import io.temporal.client.WorkflowClient; +import io.temporal.samples.nexus.options.ClientOptions; +import io.temporal.samples.nexussecurecredentialpropagation.common.ArgsDigest; +import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialEnvelope; +import io.temporal.samples.nexussecurecredentialpropagation.common.SampleKeysets; +import io.temporal.samples.nexussecurecredentialpropagation.common.SecureCredentialHeaders; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; + +/** + * Checks, against a real Workflow history, that every credential header is bound to its context. + * + *

For each {@code WorkflowExecutionStarted} and {@code NexusOperationScheduled} event that + * carries a credential header, it rebuilds the context the receiver would use (recomputing the args + * digest from the event's input ciphertexts, or using {@code firstExecutionRunId} for + * continue-as-new headers), opens the header with it, and confirms that the same context with one + * bit changed is rejected. It also confirms the digest never appears in the header bytes: the + * digest is authenticated associated data, not a transmitted field. + * + *

Opening a header requires the receivers' private keys, so this is an operator debugging tool. + * It prints only the subject and issuer of each credential, never the token. + */ +public final class VerifyCredentialBinding { + public static final String WORKFLOW_ID_PROPERTY = "nexus.secure.credential.workflowId"; + private static final String DEFAULT_WORKFLOW_ID = "transfer-payment-1001"; + private static final ObjectMapper JSON = new ObjectMapper(); + + private final HybridDecrypt workflowCredentialDecrypt; + private final HybridDecrypt nexusCredentialDecrypt; + + public VerifyCredentialBinding( + HybridDecrypt workflowCredentialDecrypt, HybridDecrypt nexusCredentialDecrypt) { + this.workflowCredentialDecrypt = workflowCredentialDecrypt; + this.nexusCredentialDecrypt = nexusCredentialDecrypt; + } + + /** Outcome of verifying one credential header. */ + public static final class Result { + private final long eventId; + private final String eventType; + private final List details; + private final boolean opensWithContext; + private final boolean rejectsTamperedContext; + private final boolean bindingValueHidden; + + Result( + long eventId, + String eventType, + List details, + boolean opensWithContext, + boolean rejectsTamperedContext, + boolean bindingValueHidden) { + this.eventId = eventId; + this.eventType = eventType; + this.details = details; + this.opensWithContext = opensWithContext; + this.rejectsTamperedContext = rejectsTamperedContext; + this.bindingValueHidden = bindingValueHidden; + } + + public long getEventId() { + return eventId; + } + + public String getEventType() { + return eventType; + } + + public boolean passed() { + return opensWithContext && rejectsTamperedContext && bindingValueHidden; + } + + String describe() { + StringBuilder out = new StringBuilder(); + out.append("=== Event ").append(eventId).append(' ').append(eventType).append('\n'); + for (String detail : details) { + out.append(" ").append(detail).append('\n'); + } + out.append(" binding value absent from header bytes: ") + .append(bindingValueHidden) + .append('\n'); + out.append(" opens with recomputed context: ") + .append(opensWithContext) + .append('\n'); + out.append(" rejects context with one bit changed: ") + .append(rejectsTamperedContext) + .append('\n'); + out.append(" ").append(passed() ? "PASS" : "FAIL").append('\n'); + return out.toString(); + } + } + + /** + * Verifies every credential header in one run's history. + * + * @param namespace namespace the history belongs to + * @param workflowId Workflow ID of the run + */ + public List verify(String namespace, String workflowId, List events) { + List results = new ArrayList<>(); + for (HistoryEvent event : events) { + if (event.hasWorkflowExecutionStartedEventAttributes()) { + WorkflowExecutionStartedEventAttributes started = + event.getWorkflowExecutionStartedEventAttributes(); + Payload header = + started.getHeader().getFieldsMap().get(SecureCredentialHeaders.WORKFLOW_HEADER); + if (header != null) { + results.add(verifyStart(event.getEventId(), namespace, workflowId, started, header)); + } + } + if (event.hasNexusOperationScheduledEventAttributes()) { + NexusOperationScheduledEventAttributes scheduled = + event.getNexusOperationScheduledEventAttributes(); + String header = scheduled.getNexusHeaderMap().get(SecureCredentialHeaders.NEXUS_HEADER); + if (header != null) { + results.add(verifyNexus(event.getEventId(), scheduled, header)); + } + } + } + return results; + } + + private Result verifyStart( + long eventId, + String namespace, + String workflowId, + WorkflowExecutionStartedEventAttributes started, + Payload header) { + List details = new ArrayList<>(); + String type = started.getWorkflowType().getName(); + details.add("header end-user-credential = " + abbreviate(header.getData().toStringUtf8())); + SecureCredentialHeaders.WorkflowHeader parsed; + try { + parsed = SecureCredentialHeaders.parseWorkflowHeader(header); + } catch (GeneralSecurityException e) { + details.add("unparseable header: " + e.getMessage()); + return new Result(eventId, "WorkflowExecutionStarted", details, false, false, false); + } + byte[] ciphertext = parsed.getCiphertext(); + byte[] binding; + byte[] context; + byte[] tampered; + if (parsed.getKind() == SecureCredentialHeaders.WorkflowHeaderKind.START) { + List input = started.getInput().getPayloadsList(); + describeInput(input, details); + binding = ArgsDigest.compute(input); + details.add("args digest recomputed from input = " + hex(binding)); + context = SecureCredentialHeaders.startContext(namespace, type, workflowId, binding); + tampered = SecureCredentialHeaders.startContext(namespace, type, workflowId, flip(binding)); + } else { + String firstRunId = started.getFirstExecutionRunId(); + binding = firstRunId.getBytes(StandardCharsets.UTF_8); + details.add("continue-as-new header bound to firstExecutionRunId = " + firstRunId); + context = + SecureCredentialHeaders.continueAsNewContext(namespace, type, workflowId, firstRunId); + tampered = + SecureCredentialHeaders.continueAsNewContext( + namespace, type, workflowId, new String(flip(binding), StandardCharsets.UTF_8)); + } + return finish( + eventId, + "WorkflowExecutionStarted", + details, + workflowCredentialDecrypt, + ciphertext, + context, + tampered, + binding); + } + + private Result verifyNexus( + long eventId, NexusOperationScheduledEventAttributes scheduled, String header) { + List details = new ArrayList<>(); + details.add( + "route = " + + scheduled.getEndpoint() + + " / " + + scheduled.getService() + + " / " + + scheduled.getOperation()); + details.add("header " + SecureCredentialHeaders.NEXUS_HEADER + " = " + abbreviate(header)); + byte[] ciphertext; + try { + ciphertext = SecureCredentialHeaders.parseNexusHeaderValue(header); + } catch (GeneralSecurityException e) { + details.add("unparseable header: " + e.getMessage()); + return new Result(eventId, "NexusOperationScheduled", details, false, false, false); + } + List input = Collections.singletonList(scheduled.getInput()); + describeInput(input, details); + byte[] digest = ArgsDigest.compute(input); + details.add("args digest recomputed from input = " + hex(digest)); + return finish( + eventId, + "NexusOperationScheduled", + details, + nexusCredentialDecrypt, + ciphertext, + SecureCredentialHeaders.nexusContext( + scheduled.getEndpoint(), scheduled.getService(), scheduled.getOperation(), digest), + SecureCredentialHeaders.nexusContext( + scheduled.getEndpoint(), + scheduled.getService(), + scheduled.getOperation(), + flip(digest)), + digest); + } + + private static Result finish( + long eventId, + String eventType, + List details, + HybridDecrypt decrypt, + byte[] ciphertext, + byte[] context, + byte[] tamperedContext, + byte[] binding) { + boolean opens; + try { + CredentialEnvelope envelope = SecureCredentialHeaders.open(decrypt, ciphertext, context); + opens = true; + details.add("credential: " + summarize(envelope.getCompactJwt())); + } catch (GeneralSecurityException e) { + opens = false; + } + boolean rejectsTampered; + try { + SecureCredentialHeaders.open(decrypt, ciphertext, tamperedContext); + rejectsTampered = false; + } catch (GeneralSecurityException e) { + rejectsTampered = true; + } + return new Result( + eventId, eventType, details, opens, rejectsTampered, !contains(ciphertext, binding)); + } + + private static void describeInput(List input, List details) { + for (int i = 0; i < input.size(); i++) { + Payload payload = input.get(i); + details.add( + "input[" + + i + + "] encoding=" + + payload.getMetadataOrDefault("encoding", ByteString.EMPTY).toStringUtf8() + + ", ciphertext " + + payload.getData().size() + + " bytes"); + } + } + + /** Subject and issuer only; the token itself is never printed. */ + private static String summarize(String compactJwt) { + try { + JsonNode claims = + JSON.readTree( + Base64.getUrlDecoder() + .decode( + compactJwt.substring( + compactJwt.indexOf('.') + 1, compactJwt.lastIndexOf('.')))); + return "sub=" + claims.path("sub").asText() + ", iss=" + claims.path("iss").asText(); + } catch (Exception e) { + return ""; + } + } + + private static byte[] flip(byte[] value) { + byte[] copy = value.clone(); + copy[0] ^= 1; + return copy; + } + + private static boolean contains(byte[] haystack, byte[] needle) { + outer: + for (int i = 0; i + needle.length <= haystack.length; i++) { + for (int j = 0; j < needle.length; j++) { + if (haystack[i + j] != needle[j]) { + continue outer; + } + } + return true; + } + return false; + } + + private static String abbreviate(String value) { + return value.length() <= 60 + ? value + : value.substring(0, 48) + "...(" + value.length() + " chars)"; + } + + private static String hex(byte[] bytes) { + StringBuilder out = new StringBuilder(); + for (byte b : bytes) { + out.append(Character.forDigit((b >> 4) & 0xf, 16)).append(Character.forDigit(b & 0xf, 16)); + } + return out.toString(); + } + + public static void main(String[] args) { + SampleKeysets keys = SampleKeysets.fromDefaultDirectory(); + WorkflowClient client = ClientOptions.getWorkflowClient(args); + String namespace = client.getOptions().getNamespace(); + String workflowId = System.getProperty(WORKFLOW_ID_PROPERTY, DEFAULT_WORKFLOW_ID); + + List results = + new VerifyCredentialBinding(keys.workflowCredentialDecrypt(), keys.nexusCredentialDecrypt()) + .verify(namespace, workflowId, client.fetchHistory(workflowId).getEvents()); + boolean allPassed = !results.isEmpty(); + for (Result result : results) { + System.out.print(result.describe()); + allPassed &= result.passed(); + } + if (results.isEmpty()) { + System.out.println("No credential headers found in " + workflowId); + } + System.out.println(allPassed ? "All credential headers are bound." : "Verification FAILED."); + System.exit(allPassed ? 0 : 1); + } +} diff --git a/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/SecureCredentialPropagationTest.java b/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/SecureCredentialPropagationTest.java index 374bb110..8fab7a0d 100644 --- a/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/SecureCredentialPropagationTest.java +++ b/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/SecureCredentialPropagationTest.java @@ -49,6 +49,7 @@ import io.temporal.samples.nexussecurecredentialpropagation.handler.TransferIntentPolicy; import io.temporal.samples.nexussecurecredentialpropagation.handler.TransferNexusServiceImpl; import io.temporal.samples.nexussecurecredentialpropagation.service.TransferService; +import io.temporal.samples.nexussecurecredentialpropagation.tools.VerifyCredentialBinding; import io.temporal.testing.TestEnvironmentOptions; import io.temporal.testing.TestWorkflowEnvironment; import io.temporal.worker.Worker; @@ -305,6 +306,56 @@ public void cancellationUsesTheSignedToken() { assertTrue(failure.getCause() instanceof io.temporal.failure.CanceledFailure); } + // --------------------------------------------------------------------------------------------- + // Verifier tool. + + @Test + public void verifierConfirmsBindingInRealHistory() { + TransferService.TransferInput input = transferInput("p-verify"); + runTransfer("caller-verify", input, credentialFor(input)); + + List results = + verifier().verify(NAMESPACE, "caller-verify", events("caller-verify")); + assertEquals(2, results.size()); + assertEquals("WorkflowExecutionStarted", results.get(0).getEventType()); + assertEquals("NexusOperationScheduled", results.get(1).getEventType()); + assertTrue(results.stream().allMatch(VerifyCredentialBinding.Result::passed)); + } + + @Test + public void verifierConfirmsContinueAsNewBinding() { + credentialSurvivesContinueAsNewToAnotherTypeAndRetry(); + List results = + verifier().verify(NAMESPACE, "caller-can", events("caller-can")); + assertEquals(2, results.size()); + assertTrue(results.stream().allMatch(VerifyCredentialBinding.Result::passed)); + } + + @Test + public void verifierDetectsSubstitutedArguments() { + TransferService.TransferInput input = transferInput("p-verify-tamper"); + runTransfer("caller-verify-tamper", input, credentialFor(input)); + + List history = new ArrayList<>(events("caller-verify-tamper")); + HistoryEvent started = history.get(0); + WorkflowExecutionStartedEventAttributes attributes = + started.getWorkflowExecutionStartedEventAttributes().toBuilder() + .setInput(Payloads.newBuilder().addPayloads(reencrypt("caller-verify-tamper", "x"))) + .build(); + history.set( + 0, started.toBuilder().setWorkflowExecutionStartedEventAttributes(attributes).build()); + + List results = + verifier().verify(NAMESPACE, "caller-verify-tamper", history); + assertFalse(results.get(0).passed()); + assertTrue(results.get(1).passed()); + } + + private VerifyCredentialBinding verifier() { + return new VerifyCredentialBinding( + keys.workflowCredentialDecrypt(), keys.nexusCredentialDecrypt()); + } + // --------------------------------------------------------------------------------------------- // Start hop attacks. From 56cf2f3ccf4024fdf356921f0d85ce0ae072ad7a Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Thu, 22 Oct 2026 16:35:20 -0700 Subject: [PATCH 4/5] Describe the header context as HPKE info Tink's HybridEncrypt passes contextInfo to HPKE as the info parameter, which is mixed into key derivation, and seals with empty associated data. The docs called the context authenticated associated data, which is inaccurate. Describe it as HPKE info and state that it is never transmitted, only rebuilt by the receiver. --- .../samples/nexussecurecredentialpropagation/README.MD | 9 ++++++--- .../nexussecurecredentialpropagation/common/Frame.java | 3 ++- .../common/SecureCredentialHeaders.java | 2 +- .../tools/VerifyCredentialBinding.java | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD index 3433b6ce..5e1023f6 100644 --- a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD @@ -95,7 +95,10 @@ Nexus header "x-temporal-end-user-credential": plaintext = frame("credential-envelope", v1, compactJwt, intent) ``` -`frame` is a length-prefixed encoding. The context is visible to anyone who can read history; only the plaintext is +`frame` is a length-prefixed encoding. The context is passed to Tink as HPKE `info`, which is mixed into the key +derivation (Tink's HPKE uses empty AEAD associated data). It is never transmitted: the receiver rebuilds it from the +dispatched route, its Workflow info, and the argument ciphertexts it received, and a different context derives a +different key, so decryption fails. Its components are visible to anyone who can read history; only the plaintext is secret. The Java SDK builds the continue-as-new header before encoding the new run's arguments, so continue-as-new headers @@ -226,8 +229,8 @@ Nexus header contain only ciphertext. ### Verify the binding -The args digest is not a field in the header; it is authenticated associated data that the receiver recomputes. To -check the binding in a real history, run the verifier against the caller namespace: +The args digest is not a field in the header; it is part of the HPKE `info` that the receiver recomputes. To check +the binding in a real history, run the verifier against the caller namespace: ``` ./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexussecurecredentialpropagation.tools.VerifyCredentialBinding \ diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/Frame.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/Frame.java index e52ca751..8d4d6607 100644 --- a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/Frame.java +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/Frame.java @@ -6,7 +6,8 @@ import java.security.GeneralSecurityException; /** - * Unambiguous length-prefixed encoding used for associated data, digests, and envelopes. + * Unambiguous length-prefixed encoding used for associated data, HPKE context info, digests, and + * envelopes. * *

Every field is written as a 4-byte big-endian length followed by its bytes, so no two * different field sequences produce the same encoding. diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/SecureCredentialHeaders.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/SecureCredentialHeaders.java index 4148ee87..c4e30108 100644 --- a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/SecureCredentialHeaders.java +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/SecureCredentialHeaders.java @@ -10,7 +10,7 @@ import java.util.Base64; /** - * Wire formats and associated data for the credential headers. + * Wire formats and HPKE context info for the credential headers. * *

Every header carries an HPKE ciphertext of a {@link CredentialEnvelope} encrypted to the * receiving hop. The HPKE context info binds it to the hop's context and, where available, to the diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/tools/VerifyCredentialBinding.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/tools/VerifyCredentialBinding.java index ac3ff097..d98a197a 100644 --- a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/tools/VerifyCredentialBinding.java +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/tools/VerifyCredentialBinding.java @@ -29,7 +29,7 @@ * digest from the event's input ciphertexts, or using {@code firstExecutionRunId} for * continue-as-new headers), opens the header with it, and confirms that the same context with one * bit changed is rejected. It also confirms the digest never appears in the header bytes: the - * digest is authenticated associated data, not a transmitted field. + * digest is part of the HPKE {@code info} mixed into key derivation, not a transmitted field. * *

Opening a header requires the receivers' private keys, so this is an operator debugging tool. * It prints only the subject and issuer of each credential, never the token. From c93d9a3d22f4f978e02e10f131aa764c01f004ba Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Thu, 22 Oct 2026 16:51:49 -0700 Subject: [PATCH 5/5] Remove the client-sealed intent The intent only protected an honest caller Worker from being steered into sending a different Nexus input. It adds nothing against a compromised Worker or a user acting with their own token, and an action-scoped token from the identity provider covers the same ground more strongly. It also added a third of the sample's authorization surface. The credential envelope now carries only the JWT, and the handler authorizes the principal against the input through an AuthorizationPolicy. The README explains that each header is bound to its own hop's args digest, that nothing links the start arguments to the Nexus input, and points to action-scoped tokens and the other mechanisms for steering. --- .../README.MD | 54 ++++---- .../caller/CallerStarter.java | 20 +-- .../common/CredentialEnvelope.java | 19 +-- .../common/CredentialIntent.java | 128 ------------------ ...ntPolicy.java => AuthorizationPolicy.java} | 15 +- .../CredentialHandlerWorkerInterceptor.java | 32 ++--- .../handler/HandlerWorker.java | 3 +- .../handler/TransferAuthorizationPolicy.java | 31 +++++ .../handler/TransferIntentPolicy.java | 50 ------- .../service/TransferService.java | 9 -- .../CryptoComponentsTest.java | 42 ++---- .../SecureCredentialPropagationTest.java | 89 ++++-------- 12 files changed, 116 insertions(+), 376 deletions(-) delete mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/CredentialIntent.java rename core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/{IntentPolicy.java => AuthorizationPolicy.java} (51%) create mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferAuthorizationPolicy.java delete mode 100644 core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferIntentPolicy.java diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD index 5e1023f6..7d972c7d 100644 --- a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/README.MD @@ -18,26 +18,32 @@ replay-proof, and it does not protect against compromised Workers. Client Caller Workflow Worker Temporal / Nexus Handler Worker | start(args) | | | | header = HPKE(wf.pub, | | | - | jwt+intent, | | | + | jwt, | | | | ns|type|wid|D(args)) | | | |------------------------->| open with wf.priv, recompute | | | | D(args) from received ciphertexts | | | | | | | | Nexus input ciphertext C | | | | header = HPKE(nexus.pub, | | - | | jwt+intent, | | + | | jwt, | | | | endpoint|svc|op|D(C)) | | | |---------------------------------->|------------------------->| | | | route from dispatch, | | | | D(C) from received C, | | | | open with nexus.priv, | - | | | verify JWT, check | - | | | input against intent | + | | | verify JWT, authorize | + | | | principal against input | ``` `D(...)` is the **args digest**: SHA-256 over the transmitted argument ciphertexts, their positions, and their count. -Binding the credential to it means the credential only opens next to the exact arguments it was sent with. Nothing -hashes or canonicalizes the business input, so serialization does not need to be deterministic. +Every header is bound to the digest of the arguments on its own hop: the client binds the start header to the start +argument ciphertexts, and the caller Worker binds the Nexus header to the Nexus input ciphertext. The credential only +opens next to the exact arguments it was sent with. Nothing hashes or canonicalizes the business input, so +serialization does not need to be deterministic. + +The two digests are independent. The Nexus input is a new value computed by Workflow code and encrypted by the caller +Worker with a fresh nonce, so the client cannot bind the credential to it in advance. See +[Authorization and steering](#authorization-and-steering). ### Keys @@ -92,7 +98,7 @@ Temporal header "end-user-credential": Nexus header "x-temporal-end-user-credential": v1. -plaintext = frame("credential-envelope", v1, compactJwt, intent) +plaintext = frame("credential-envelope", v1, compactJwt) ``` `frame` is a length-prefixed encoding. The context is passed to Tink as HPKE `info`, which is mixed into the key @@ -124,26 +130,18 @@ Workflow task, and a missing or mismatched header is rejected by the receiver. T ### Authorization and steering The handler gets the route from the actual Nexus request, never from custom headers. It decrypts the credential, -validates the JWT (signature, algorithm, issuer, audience, type, `exp`, `nbf`), then: - -- authorizes the principal against the input: the token subject and tenant are authoritative, and the input's - `userId` and `tenantId` are only assertions to check; -- requires the input to fall within the **intent** the client sealed next to the JWT (exact payment ID and - beneficiary, maximum amount). - -The args digest proves only that the caller Worker produced the input. Signals, updates, and activity results can -steer a caller Workflow into choosing different inputs. The intent bounds that: the Workflow carries it unchanged and -cannot alter it. - -The intent protects a user from having their own Workflow steered; it does not constrain a user acting with their -own token. Anyone holding a valid token and a public key can seal any intent they like, so the intent is never a -substitute for server-side authorization. `TransferIntentPolicy` checks scope, subject, and tenant independently of -the intent for this reason. - -Authorizing the steering messages themselves needs a separate mechanism, such as: - -- **identity-provider-issued intent**, for example RFC 9396 `authorization_details` or transaction tokens, which also - survive a caller Worker compromise (recommended for production); +validates the JWT (signature, algorithm, issuer, audience, type, `exp`, `nbf`), then authorizes the principal against +the input: the token's scope is required, the token subject and tenant are authoritative, and the input's `userId` and +`tenantId` are only assertions to check. + +The Nexus args digest proves only that the caller Worker produced the input, not that the end user wanted that input. +Nothing links the start arguments to the Nexus input, so signals, updates, and activity results can steer a caller +Workflow into sending different inputs under the user's credential, as long as the handler's authorization allows +them. Scoping the credential to a specific action needs a separate mechanism, such as: + +- **identity-provider-issued, action-scoped tokens**, for example RFC 9396 `authorization_details` or transaction + tokens, which the handler checks against the input and which also survive a caller Worker compromise (recommended + for production); - **authenticated steering messages** that carry the sender's own credential to the handler; - **Temporal-layer restrictions** on who may signal or update these Workflows; - **provenance discipline**, deriving Nexus input only from start arguments (a review rule, not a control). @@ -158,7 +156,7 @@ invalid credential is `UNAUTHENTICATED`, and a valid identity without permission | Workflow replay | Interceptors decrypt again; regenerated ciphertexts are not compared by Temporal. | | Nexus retry | Same input and request ID; resolves to the same backing Workflow. | | Exact replay of a Nexus input/header pair from another caller | Attaches to the same backing Workflow while it runs; refused once it has closed. No second effect. | -| Exact replay of a start into a new run of the same ID | Re-runs the victim's own arguments and intent; its Nexus calls are exact replays. Bounded by JWT expiry. | +| Exact replay of a start into a new run of the same ID | Re-runs the victim's own arguments; its Nexus calls are exact replays. Bounded by JWT expiry. | The backing Workflow ID is derived from the verified principal and the payment ID, never from the Nexus request ID. Including the principal stops another user with identical input from attaching to someone else's transfer. Workflow diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CallerStarter.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CallerStarter.java index ad7205de..5338a058 100644 --- a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CallerStarter.java +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/caller/CallerStarter.java @@ -5,15 +5,11 @@ import io.temporal.client.WorkflowOptions; import io.temporal.samples.nexus.options.ClientOptions; import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialEnvelope; -import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialIntent; import io.temporal.samples.nexussecurecredentialpropagation.common.DemoIdentityProvider; import io.temporal.samples.nexussecurecredentialpropagation.common.SampleConfig; import io.temporal.samples.nexussecurecredentialpropagation.common.SampleKeysets; import io.temporal.samples.nexussecurecredentialpropagation.service.TransferService; import java.time.Duration; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -48,20 +44,6 @@ public static void main(String[] args) { new DemoIdentityProvider(keys.demoIdentityProviderSigner()) .issue("alice", "acme", TransferService.REQUIRED_SCOPE, Duration.ofMinutes(5)); - // The intent is what the user approved. The handler rejects any input outside it. - Map constraints = new HashMap<>(); - constraints.put(TransferService.CONSTRAINT_PAYMENT_ID, input.getPaymentId()); - constraints.put(TransferService.CONSTRAINT_BENEFICIARY, input.getBeneficiary()); - constraints.put(TransferService.CONSTRAINT_MAX_AMOUNT_CENTS, "5000"); - CredentialIntent intent = - new CredentialIntent( - Collections.singletonList( - new CredentialIntent.RouteGrant( - SampleConfig.ENDPOINT, - TransferService.NAME, - TransferService.TRANSFER, - constraints))); - TransferWorkflow workflow = client.newWorkflowStub( TransferWorkflow.class, @@ -70,7 +52,7 @@ public static void main(String[] args) { .setTaskQueue(SampleConfig.CALLER_TASK_QUEUE) .build()); CredentialContext.callWith( - new CredentialEnvelope(jwt, intent), () -> WorkflowClient.start(workflow::transfer, input)); + new CredentialEnvelope(jwt), () -> WorkflowClient.start(workflow::transfer, input)); TransferService.TransferOutput output = workflow.transfer(input); logger.info( "Transfer {} {} for {}", diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/CredentialEnvelope.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/CredentialEnvelope.java index 936f70ee..bbd9324e 100644 --- a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/CredentialEnvelope.java +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/CredentialEnvelope.java @@ -1,11 +1,9 @@ package io.temporal.samples.nexussecurecredentialpropagation.common; import java.security.GeneralSecurityException; -import java.util.Objects; /** - * The plaintext sealed into every credential header: the end user's compact JWT and the intent the - * client sealed alongside it. + * The plaintext sealed into every credential header: the end user's compact JWT. * *

Workflow code treats this as opaque. {@link #toString()} never reveals its contents. */ @@ -15,28 +13,20 @@ public final class CredentialEnvelope { static final int MAX_JWT_LENGTH = 8 * 1024; private final String compactJwt; - private final CredentialIntent intent; - public CredentialEnvelope(String compactJwt, CredentialIntent intent) { + public CredentialEnvelope(String compactJwt) { if (compactJwt.isEmpty() || compactJwt.length() > MAX_JWT_LENGTH) { throw new IllegalArgumentException("Invalid JWT length"); } this.compactJwt = compactJwt; - this.intent = Objects.requireNonNull(intent); } public String getCompactJwt() { return compactJwt; } - public CredentialIntent getIntent() { - return intent; - } - public byte[] toBytes() { - Frame frame = Frame.of(LABEL).add(VERSION).add(compactJwt); - intent.writeTo(frame); - return frame.toByteArray(); + return Frame.of(LABEL).add(VERSION).add(compactJwt).toByteArray(); } public static CredentialEnvelope fromBytes(byte[] bytes) throws GeneralSecurityException { @@ -47,9 +37,8 @@ public static CredentialEnvelope fromBytes(byte[] bytes) throws GeneralSecurityE if (jwt.isEmpty()) { throw new GeneralSecurityException("Empty JWT"); } - CredentialIntent intent = CredentialIntent.readFrom(reader); reader.expectEnd(); - return new CredentialEnvelope(jwt, intent); + return new CredentialEnvelope(jwt); } @Override diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/CredentialIntent.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/CredentialIntent.java deleted file mode 100644 index 4a340a6d..00000000 --- a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/common/CredentialIntent.java +++ /dev/null @@ -1,128 +0,0 @@ -package io.temporal.samples.nexussecurecredentialpropagation.common; - -import java.security.GeneralSecurityException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.TreeMap; - -/** - * What the end user authorized the Workflow to do, sealed by the client alongside the credential. - * - *

The caller Workflow carries the intent unchanged and never parses it. The handler requires the - * route it serves to appear in the intent and the typed operation input to satisfy that route's - * constraints. This bounds what signals, updates, or activity results can steer the Workflow into - * sending. - */ -public final class CredentialIntent { - private static final int MAX_GRANTS = 16; - private static final int MAX_CONSTRAINTS = 32; - private static final int MAX_FIELD_LENGTH = 1024; - - private final List grants; - - public CredentialIntent(List grants) { - if (grants.isEmpty() || grants.size() > MAX_GRANTS) { - throw new IllegalArgumentException("An intent must contain between 1 and 16 grants"); - } - this.grants = Collections.unmodifiableList(new ArrayList<>(grants)); - } - - public List getGrants() { - return grants; - } - - /** Returns the grant for the given route, if the intent contains one. */ - public Optional grantFor(String endpoint, String service, String operation) { - for (RouteGrant grant : grants) { - if (grant.endpoint.equals(endpoint) - && grant.service.equals(service) - && grant.operation.equals(operation)) { - return Optional.of(grant); - } - } - return Optional.empty(); - } - - void writeTo(Frame frame) { - frame.add(grants.size()); - for (RouteGrant grant : grants) { - frame.add(grant.endpoint).add(grant.service).add(grant.operation); - frame.add(grant.constraints.size()); - for (Map.Entry constraint : grant.constraints.entrySet()) { - frame.add(constraint.getKey()).add(constraint.getValue()); - } - } - } - - static CredentialIntent readFrom(Frame.Reader reader) throws GeneralSecurityException { - int grantCount = readCount(reader, MAX_GRANTS); - if (grantCount == 0) { - throw new GeneralSecurityException("Empty intent"); - } - List grants = new ArrayList<>(grantCount); - for (int i = 0; i < grantCount; i++) { - String endpoint = reader.nextString(MAX_FIELD_LENGTH); - String service = reader.nextString(MAX_FIELD_LENGTH); - String operation = reader.nextString(MAX_FIELD_LENGTH); - int constraintCount = readCount(reader, MAX_CONSTRAINTS); - Map constraints = new TreeMap<>(); - for (int j = 0; j < constraintCount; j++) { - String key = reader.nextString(MAX_FIELD_LENGTH); - String value = reader.nextString(MAX_FIELD_LENGTH); - if (constraints.put(key, value) != null) { - throw new GeneralSecurityException("Duplicate intent constraint"); - } - } - grants.add(new RouteGrant(endpoint, service, operation, constraints)); - } - return new CredentialIntent(grants); - } - - private static int readCount(Frame.Reader reader, int max) throws GeneralSecurityException { - long count = reader.nextLong(); - if (count < 0 || count > max) { - throw new GeneralSecurityException("Count out of range"); - } - return (int) count; - } - - /** Permission to call one protected Nexus route, with constraints on its input. */ - public static final class RouteGrant { - private final String endpoint; - private final String service; - private final String operation; - private final Map constraints; - - public RouteGrant( - String endpoint, String service, String operation, Map constraints) { - this.endpoint = Objects.requireNonNull(endpoint); - this.service = Objects.requireNonNull(service); - this.operation = Objects.requireNonNull(operation); - if (constraints.size() > MAX_CONSTRAINTS) { - throw new IllegalArgumentException("Too many intent constraints"); - } - // Sorted so the encoding is independent of the caller's map ordering. - this.constraints = Collections.unmodifiableMap(new TreeMap<>(constraints)); - } - - public String getEndpoint() { - return endpoint; - } - - public String getService() { - return service; - } - - public String getOperation() { - return operation; - } - - public Map getConstraints() { - return constraints; - } - } -} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/IntentPolicy.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/AuthorizationPolicy.java similarity index 51% rename from core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/IntentPolicy.java rename to core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/AuthorizationPolicy.java index 4b06fc85..8a8446ec 100644 --- a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/IntentPolicy.java +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/AuthorizationPolicy.java @@ -1,23 +1,20 @@ package io.temporal.samples.nexussecurecredentialpropagation.handler; -import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialIntent; import io.temporal.samples.nexussecurecredentialpropagation.common.EndUserPrincipal; /** - * Authorizes the typed input of one protected operation against the verified principal and the - * client-sealed intent. Implementations compare typed fields; they never canonicalize the input. + * Authorizes the typed input of one protected operation against the verified principal. + * Implementations compare typed fields; they never canonicalize the input. */ -public interface IntentPolicy { +public interface AuthorizationPolicy { Class inputType(); /** - * Authorizes {@code input} for {@code principal} within {@code grant}. + * Authorizes {@code input} for {@code principal}. * - * @throws AuthorizationDeniedException if the principal may not perform this input, or the input - * falls outside the intent + * @throws AuthorizationDeniedException if the principal may not perform this input */ - void authorize(EndUserPrincipal principal, CredentialIntent.RouteGrant grant, T input) - throws AuthorizationDeniedException; + void authorize(EndUserPrincipal principal, T input) throws AuthorizationDeniedException; /** Thrown when a verified principal is not authorized. */ final class AuthorizationDeniedException extends Exception { diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/CredentialHandlerWorkerInterceptor.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/CredentialHandlerWorkerInterceptor.java index 0301bea4..7996eacc 100644 --- a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/CredentialHandlerWorkerInterceptor.java +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/CredentialHandlerWorkerInterceptor.java @@ -10,7 +10,6 @@ import io.temporal.common.interceptors.WorkerInterceptorBase; import io.temporal.samples.nexussecurecredentialpropagation.common.ArgsDigest; import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialEnvelope; -import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialIntent; import io.temporal.samples.nexussecurecredentialpropagation.common.EndUserPrincipal; import io.temporal.samples.nexussecurecredentialpropagation.common.SecureCredentialHeaders; import java.security.GeneralSecurityException; @@ -18,7 +17,6 @@ import java.util.HashMap; import java.util.Locale; import java.util.Map; -import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,15 +33,15 @@ public class CredentialHandlerWorkerInterceptor extends WorkerInterceptorBase { private final HybridDecrypt nexusCredentialDecrypt; private final JwtVerifier jwtVerifier; - private final Map> policies; + private final Map> policies; /** - * @param policies intent policy per protected route, keyed by {@code service/operation} + * @param policies authorization policy per protected route, keyed by {@code service/operation} */ public CredentialHandlerWorkerInterceptor( HybridDecrypt nexusCredentialDecrypt, JwtVerifier jwtVerifier, - Map> policies) { + Map> policies) { this.nexusCredentialDecrypt = nexusCredentialDecrypt; this.jwtVerifier = jwtVerifier; this.policies = Collections.unmodifiableMap(new HashMap<>(policies)); @@ -76,7 +74,7 @@ public StartOperationOutput startOperation(StartOperationInput input) OperationContext context = input.getOperationContext(); String route = context.getService() + "/" + context.getOperation(); String header = context.getHeaders().get(SecureCredentialHeaders.NEXUS_HEADER); - IntentPolicy policy = policies.get(route); + AuthorizationPolicy policy = policies.get(route); if (policy == null) { if (header != null) { throw reject( @@ -101,7 +99,7 @@ private EndUserPrincipal authenticate( ArgsDigest.Received received, String route, String header, - IntentPolicy policy) { + AuthorizationPolicy policy) { OperationContext context = input.getOperationContext(); if (endpoint == null || endpoint.isEmpty()) { // Servers before 1.30 do not report the endpoint; fail closed instead of weakening the @@ -129,14 +127,9 @@ private EndUserPrincipal authenticate( throw reject(HandlerException.ErrorType.UNAUTHENTICATED, route, e.getMessage()); } - Optional grant = - envelope.getIntent().grantFor(endpoint, context.getService(), context.getOperation()); - if (!grant.isPresent()) { - throw reject(HandlerException.ErrorType.UNAUTHORIZED, route, "route outside intent"); - } try { - authorize(policy, principal, grant.get(), input.getInput()); - } catch (IntentPolicy.AuthorizationDeniedException e) { + authorize(policy, principal, input.getInput()); + } catch (AuthorizationPolicy.AuthorizationDeniedException e) { throw reject(HandlerException.ErrorType.UNAUTHORIZED, route, e.getMessage()); } return principal; @@ -144,15 +137,12 @@ private EndUserPrincipal authenticate( } private static void authorize( - IntentPolicy policy, - EndUserPrincipal principal, - CredentialIntent.RouteGrant grant, - Object input) - throws IntentPolicy.AuthorizationDeniedException { + AuthorizationPolicy policy, EndUserPrincipal principal, Object input) + throws AuthorizationPolicy.AuthorizationDeniedException { if (!policy.inputType().isInstance(input)) { - throw new IntentPolicy.AuthorizationDeniedException("Unexpected input type"); + throw new AuthorizationPolicy.AuthorizationDeniedException("Unexpected input type"); } - policy.authorize(principal, grant, policy.inputType().cast(input)); + policy.authorize(principal, policy.inputType().cast(input)); } private static HandlerException reject( diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/HandlerWorker.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/HandlerWorker.java index ef3abb42..77ce2978 100644 --- a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/HandlerWorker.java +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/HandlerWorker.java @@ -31,7 +31,8 @@ public static void main(String[] args) { DemoIdentityProvider.ISSUER, DemoIdentityProvider.HANDLER_AUDIENCE), Collections.singletonMap( - TransferService.NAME + "/" + TransferService.TRANSFER, new TransferIntentPolicy())); + TransferService.NAME + "/" + TransferService.TRANSFER, + new TransferAuthorizationPolicy())); WorkerFactory factory = WorkerFactory.newInstance( diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferAuthorizationPolicy.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferAuthorizationPolicy.java new file mode 100644 index 00000000..2dd68e2f --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferAuthorizationPolicy.java @@ -0,0 +1,31 @@ +package io.temporal.samples.nexussecurecredentialpropagation.handler; + +import io.temporal.samples.nexussecurecredentialpropagation.common.EndUserPrincipal; +import io.temporal.samples.nexussecurecredentialpropagation.service.TransferService; + +/** Authorizes a transfer against the verified token. */ +public final class TransferAuthorizationPolicy + implements AuthorizationPolicy { + @Override + public Class inputType() { + return TransferService.TransferInput.class; + } + + @Override + public void authorize(EndUserPrincipal principal, TransferService.TransferInput input) + throws AuthorizationDeniedException { + if (!principal.getScopes().contains(TransferService.REQUIRED_SCOPE)) { + throw new AuthorizationDeniedException("Missing required scope"); + } + // The token subject is authoritative; the input's user and tenant are only assertions. + if (!principal.getTenant().equals(input.getTenantId())) { + throw new AuthorizationDeniedException("Tenant mismatch"); + } + if (!principal.getSubject().equals(input.getUserId())) { + throw new AuthorizationDeniedException("Subject mismatch"); + } + if (input.getAmountCents() <= 0) { + throw new AuthorizationDeniedException("Invalid amount"); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferIntentPolicy.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferIntentPolicy.java deleted file mode 100644 index 46cf7328..00000000 --- a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/handler/TransferIntentPolicy.java +++ /dev/null @@ -1,50 +0,0 @@ -package io.temporal.samples.nexussecurecredentialpropagation.handler; - -import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialIntent; -import io.temporal.samples.nexussecurecredentialpropagation.common.EndUserPrincipal; -import io.temporal.samples.nexussecurecredentialpropagation.service.TransferService; -import java.util.Map; - -/** Authorizes a transfer against the token and the intent the user approved. */ -public final class TransferIntentPolicy implements IntentPolicy { - @Override - public Class inputType() { - return TransferService.TransferInput.class; - } - - @Override - public void authorize( - EndUserPrincipal principal, - CredentialIntent.RouteGrant grant, - TransferService.TransferInput input) - throws AuthorizationDeniedException { - if (!principal.getScopes().contains(TransferService.REQUIRED_SCOPE)) { - throw new AuthorizationDeniedException("Missing required scope"); - } - // The token subject is authoritative; the input's user and tenant are only assertions. - if (!principal.getTenant().equals(input.getTenantId())) { - throw new AuthorizationDeniedException("Tenant mismatch"); - } - if (!principal.getSubject().equals(input.getUserId())) { - throw new AuthorizationDeniedException("Subject mismatch"); - } - - Map constraints = grant.getConstraints(); - if (!input.getPaymentId().equals(constraints.get(TransferService.CONSTRAINT_PAYMENT_ID))) { - throw new AuthorizationDeniedException("Payment outside intent"); - } - if (!input.getBeneficiary().equals(constraints.get(TransferService.CONSTRAINT_BENEFICIARY))) { - throw new AuthorizationDeniedException("Beneficiary outside intent"); - } - String max = constraints.get(TransferService.CONSTRAINT_MAX_AMOUNT_CENTS); - long maxAmountCents; - try { - maxAmountCents = Long.parseLong(max); - } catch (NumberFormatException e) { - throw new AuthorizationDeniedException("Invalid amount constraint"); - } - if (input.getAmountCents() <= 0 || input.getAmountCents() > maxAmountCents) { - throw new AuthorizationDeniedException("Amount outside intent"); - } - } -} diff --git a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/service/TransferService.java b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/service/TransferService.java index 64c8dd61..a961b474 100644 --- a/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/service/TransferService.java +++ b/core/src/main/java/io/temporal/samples/nexussecurecredentialpropagation/service/TransferService.java @@ -11,15 +11,6 @@ public interface TransferService { String NAME = "TransferService"; String TRANSFER = "transfer"; - /** Intent constraint: the exact payment ID the user approved. */ - String CONSTRAINT_PAYMENT_ID = "paymentId"; - - /** Intent constraint: the exact beneficiary the user approved. */ - String CONSTRAINT_BENEFICIARY = "beneficiary"; - - /** Intent constraint: the maximum amount, in cents, the user approved. */ - String CONSTRAINT_MAX_AMOUNT_CENTS = "maxAmountCents"; - /** Scope the end user's token must carry. */ String REQUIRED_SCOPE = "payments:transfer"; diff --git a/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/CryptoComponentsTest.java b/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/CryptoComponentsTest.java index fdac3d7d..294e3964 100644 --- a/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/CryptoComponentsTest.java +++ b/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/CryptoComponentsTest.java @@ -21,16 +21,15 @@ import io.temporal.samples.nexussecurecredentialpropagation.common.ArgsDigest; import io.temporal.samples.nexussecurecredentialpropagation.common.ContextAwareTinkPayloadCodec; import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialEnvelope; -import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialIntent; import io.temporal.samples.nexussecurecredentialpropagation.common.DemoIdentityProvider; import io.temporal.samples.nexussecurecredentialpropagation.common.EndUserPrincipal; import io.temporal.samples.nexussecurecredentialpropagation.common.Frame; import io.temporal.samples.nexussecurecredentialpropagation.common.SampleKeysets; import io.temporal.samples.nexussecurecredentialpropagation.common.SecureCredentialHeaders; -import io.temporal.samples.nexussecurecredentialpropagation.handler.IntentPolicy; +import io.temporal.samples.nexussecurecredentialpropagation.handler.AuthorizationPolicy; import io.temporal.samples.nexussecurecredentialpropagation.handler.JwtVerifier; import io.temporal.samples.nexussecurecredentialpropagation.handler.TokenProbe; -import io.temporal.samples.nexussecurecredentialpropagation.handler.TransferIntentPolicy; +import io.temporal.samples.nexussecurecredentialpropagation.handler.TransferAuthorizationPolicy; import io.temporal.samples.nexussecurecredentialpropagation.handler.TransferNexusServiceImpl; import io.temporal.samples.nexussecurecredentialpropagation.service.TransferService; import java.nio.file.Path; @@ -40,9 +39,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -60,7 +57,7 @@ public class CryptoComponentsTest { public void setUp() throws Exception { keys = SampleKeysets.fromDirectory(keyDir); keys.generate(); - envelope = new CredentialEnvelope("header.payload.signature", intent("5000")); + envelope = new CredentialEnvelope("header.payload.signature"); ArgsDigest.take(); } @@ -173,9 +170,6 @@ public void envelopeRoundTripsAndRejectsTrailingData() throws Exception { byte[] bytes = envelope.toBytes(); CredentialEnvelope decoded = CredentialEnvelope.fromBytes(bytes); assertEquals(envelope.getCompactJwt(), decoded.getCompactJwt()); - assertEquals( - envelope.getIntent().getGrants().get(0).getConstraints(), - decoded.getIntent().getGrants().get(0).getConstraints()); assertThrows( GeneralSecurityException.class, () -> CredentialEnvelope.fromBytes(Arrays.copyOf(bytes, bytes.length + 1))); @@ -350,37 +344,32 @@ public void jwtValidationRejectsWrongIssuerAudienceAndExpiredTokens() throws Exc // Authorization and idempotency. @Test - public void intentPolicyEnforcesPrincipalAndConstraints() throws Exception { - TransferIntentPolicy policy = new TransferIntentPolicy(); + public void authorizationPolicyEnforcesPrincipal() throws Exception { + TransferAuthorizationPolicy policy = new TransferAuthorizationPolicy(); EndUserPrincipal alice = new EndUserPrincipal( DemoIdentityProvider.ISSUER, "alice", "acme", Collections.singleton(TransferService.REQUIRED_SCOPE)); - CredentialIntent.RouteGrant grant = intent("5000").getGrants().get(0); - - policy.authorize(alice, grant, transfer("acme", "alice", "p-1", "bob", 50_00)); + policy.authorize(alice, transfer("acme", "alice", "p-1", "bob", 50_00)); List denied = Arrays.asList( transfer("other", "alice", "p-1", "bob", 10_00), transfer("acme", "bob", "p-1", "bob", 10_00), - transfer("acme", "alice", "p-2", "bob", 10_00), - transfer("acme", "alice", "p-1", "mallory", 10_00), - transfer("acme", "alice", "p-1", "bob", 50_01), transfer("acme", "alice", "p-1", "bob", 0)); for (TransferService.TransferInput input : denied) { assertThrows( - IntentPolicy.AuthorizationDeniedException.class, - () -> policy.authorize(alice, grant, input)); + AuthorizationPolicy.AuthorizationDeniedException.class, + () -> policy.authorize(alice, input)); } EndUserPrincipal noScope = new EndUserPrincipal(DemoIdentityProvider.ISSUER, "alice", "acme", Collections.emptySet()); assertThrows( - IntentPolicy.AuthorizationDeniedException.class, - () -> policy.authorize(noScope, grant, transfer("acme", "alice", "p-1", "bob", 10_00))); + AuthorizationPolicy.AuthorizationDeniedException.class, + () -> policy.authorize(noScope, transfer("acme", "alice", "p-1", "bob", 10_00))); } @Test @@ -416,17 +405,6 @@ public void operationTokensCannotBeForged() throws Exception { // Helpers. - private static CredentialIntent intent(String maxAmountCents) { - Map constraints = new HashMap<>(); - constraints.put(TransferService.CONSTRAINT_PAYMENT_ID, "p-1"); - constraints.put(TransferService.CONSTRAINT_BENEFICIARY, "bob"); - constraints.put(TransferService.CONSTRAINT_MAX_AMOUNT_CENTS, maxAmountCents); - return new CredentialIntent( - Collections.singletonList( - new CredentialIntent.RouteGrant( - "ep", TransferService.NAME, TransferService.TRANSFER, constraints))); - } - private static TransferService.TransferInput transfer( String tenant, String user, String paymentId, String beneficiary, long amountCents) { return new TransferService.TransferInput(tenant, user, paymentId, beneficiary, amountCents); diff --git a/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/SecureCredentialPropagationTest.java b/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/SecureCredentialPropagationTest.java index 8fab7a0d..704266df 100644 --- a/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/SecureCredentialPropagationTest.java +++ b/core/src/test/java/io/temporal/samples/nexussecurecredentialpropagation/SecureCredentialPropagationTest.java @@ -39,14 +39,13 @@ import io.temporal.samples.nexussecurecredentialpropagation.caller.TransferWorkflow; import io.temporal.samples.nexussecurecredentialpropagation.caller.TransferWorkflowImpl; import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialEnvelope; -import io.temporal.samples.nexussecurecredentialpropagation.common.CredentialIntent; import io.temporal.samples.nexussecurecredentialpropagation.common.DemoIdentityProvider; import io.temporal.samples.nexussecurecredentialpropagation.common.SampleConfig; import io.temporal.samples.nexussecurecredentialpropagation.common.SampleKeysets; import io.temporal.samples.nexussecurecredentialpropagation.common.SecureCredentialHeaders; import io.temporal.samples.nexussecurecredentialpropagation.handler.CredentialHandlerWorkerInterceptor; import io.temporal.samples.nexussecurecredentialpropagation.handler.JwtVerifier; -import io.temporal.samples.nexussecurecredentialpropagation.handler.TransferIntentPolicy; +import io.temporal.samples.nexussecurecredentialpropagation.handler.TransferAuthorizationPolicy; import io.temporal.samples.nexussecurecredentialpropagation.handler.TransferNexusServiceImpl; import io.temporal.samples.nexussecurecredentialpropagation.service.TransferService; import io.temporal.samples.nexussecurecredentialpropagation.tools.VerifyCredentialBinding; @@ -64,7 +63,6 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.UUID; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -113,7 +111,7 @@ public void setUp() throws Exception { DemoIdentityProvider.HANDLER_AUDIENCE), Collections.singletonMap( TransferService.NAME + "/" + TransferService.TRANSFER, - new TransferIntentPolicy())), + new TransferAuthorizationPolicy())), // Registered last: outermost inbound, innermost outbound. new CredentialCallerWorkerInterceptor( keys.workflowCredentialDecrypt(), @@ -180,7 +178,7 @@ public void tearDown() { @Test public void transfersOnBehalfOfVerifiedPrincipal() { TransferService.TransferInput input = transferInput("p-1"); - TransferService.TransferOutput output = runTransfer("caller-1", input, credentialFor(input)); + TransferService.TransferOutput output = runTransfer("caller-1", input, aliceCredential()); assertEquals("SETTLED", output.getStatus()); assertEquals("alice", output.getApprovedFor()); } @@ -188,7 +186,7 @@ public void transfersOnBehalfOfVerifiedPrincipal() { @Test public void historiesContainNoPlaintextCredential() { TransferService.TransferInput input = transferInput("p-plain"); - CredentialEnvelope credential = credentialFor(input); + CredentialEnvelope credential = aliceCredential(); runTransfer("caller-plain", input, credential); String jwt = credential.getCompactJwt(); @@ -211,7 +209,7 @@ public void signalWithStartCarriesCredential() { client.newWorkflowStub( TestWorkflows.SteerableTransferWorkflow.class, callerOptions("caller-sws")); CredentialContext.callWith( - credentialFor(input), + aliceCredential(), () -> { BatchRequest request = client.newSignalWithStartRequest(); request.add(workflow::transfer, input); @@ -237,7 +235,7 @@ public void updateWithStartCarriesCredential() { .WORKFLOW_ID_CONFLICT_POLICY_FAIL) .build()); CredentialContext.callWith( - credentialFor(input), + aliceCredential(), () -> WorkflowClient.startUpdateWithStart( workflow::ack, @@ -267,7 +265,7 @@ public void credentialSurvivesContinueAsNewToAnotherTypeAndRetry() { .build()) .build()); CredentialContext.callWith( - credentialFor(input), () -> WorkflowClient.start(workflow::transfer, input)); + aliceCredential(), () -> WorkflowClient.start(workflow::transfer, input)); TransferService.TransferOutput output = client.newUntypedWorkflowStub("caller-can").getResult(TransferService.TransferOutput.class); assertEquals("SETTLED", output.getStatus()); @@ -281,7 +279,7 @@ public void credentialSurvivesContinueAsNewToAnotherTypeAndRetry() { public void childWorkflowsDoNotReceiveTheCredential() { TestWorkflows.ParentWorkflow parent = client.newWorkflowStub(TestWorkflows.ParentWorkflow.class, callerOptions("parent")); - CredentialContext.callWith(credentialFor(transferInput("p-child")), () -> parent.run("child")); + CredentialContext.callWith(aliceCredential(), () -> parent.run("child")); assertTrue( startedEvent("parent").getHeader().containsFields(SecureCredentialHeaders.WORKFLOW_HEADER)); assertFalse( @@ -295,7 +293,7 @@ public void cancellationUsesTheSignedToken() { client.newWorkflowStub( TestWorkflows.CancellingTransferWorkflow.class, callerOptions("caller-cancel")); String result = - CredentialContext.callWith(credentialFor(input), () -> workflow.transferThenCancel(input)); + CredentialContext.callWith(aliceCredential(), () -> workflow.transferThenCancel(input)); assertTrue(result.startsWith("cancelled:"), result); String backingId = TransferNexusServiceImpl.resourceId(principal("alice"), input); @@ -312,7 +310,7 @@ public void cancellationUsesTheSignedToken() { @Test public void verifierConfirmsBindingInRealHistory() { TransferService.TransferInput input = transferInput("p-verify"); - runTransfer("caller-verify", input, credentialFor(input)); + runTransfer("caller-verify", input, aliceCredential()); List results = verifier().verify(NAMESPACE, "caller-verify", events("caller-verify")); @@ -334,7 +332,7 @@ public void verifierConfirmsContinueAsNewBinding() { @Test public void verifierDetectsSubstitutedArguments() { TransferService.TransferInput input = transferInput("p-verify-tamper"); - runTransfer("caller-verify-tamper", input, credentialFor(input)); + runTransfer("caller-verify-tamper", input, aliceCredential()); List history = new ArrayList<>(events("caller-verify-tamper")); HistoryEvent started = history.get(0); @@ -364,9 +362,7 @@ public void startHeaderIsBoundToTheExactArgumentList() { TestWorkflows.TwoArgWorkflow workflow = client.newWorkflowStub(TestWorkflows.TwoArgWorkflow.class, callerOptions("two-args")); assertEquals( - "a,b", - CredentialContext.callWith( - credentialFor(transferInput("p-args")), () -> workflow.join("a", "b"))); + "a,b", CredentialContext.callWith(aliceCredential(), () -> workflow.join("a", "b"))); WorkflowExecutionStartedEventAttributes victim = startedEvent("two-args"); List args = victim.getInput().getPayloadsList(); @@ -428,8 +424,7 @@ public void startsWithoutArgumentsCannotCarryACredential() { RuntimeException.class, () -> CredentialContext.callWith( - credentialFor(transferInput("p-noargs")), - () -> WorkflowClient.start(workflow::run))); + aliceCredential(), () -> WorkflowClient.start(workflow::run))); assertTrue(hasCause(e, IllegalArgumentException.class), () -> chain(e)); } @@ -454,7 +449,7 @@ public void innerInterceptorCopyingHeadersFailsClosed() { RuntimeException.class, () -> CredentialContext.callWith( - credentialFor(input), () -> WorkflowClient.start(workflow::transfer, input))); + aliceCredential(), () -> WorkflowClient.start(workflow::transfer, input))); assertTrue(hasCause(e, IllegalStateException.class)); } @@ -467,7 +462,7 @@ public void exactNexusReplayWhileRunningAttachesToTheSameResource() throws Inter TransferWorkflow victim = client.newWorkflowStub(TransferWorkflow.class, callerOptions("victim-running")); CredentialContext.callWith( - credentialFor(input), () -> WorkflowClient.start(victim::transfer, input)); + aliceCredential(), () -> WorkflowClient.start(victim::transfer, input)); String victimToken = awaitStartedToken("victim-running"); NexusOperationScheduledEventAttributes scheduled = scheduledEvent("victim-running"); @@ -479,7 +474,7 @@ public void exactNexusReplayWhileRunningAttachesToTheSameResource() throws Inter @Test public void exactNexusReplayAfterCompletionCausesNoSecondEffect() { TransferService.TransferInput input = transferInput("p-replay"); - runTransfer("victim-replay", input, credentialFor(input)); + runTransfer("victim-replay", input, aliceCredential()); NexusOperationScheduledEventAttributes victim = scheduledEvent("victim-replay"); TestWorkflows.AttackerHeaderInterceptor.HEADERS.putAll(victim.getNexusHeaderMap()); @@ -492,8 +487,8 @@ public void exactNexusReplayAfterCompletionCausesNoSecondEffect() { public void nexusHeaderCannotBePairedWithAnotherInput() { TransferService.TransferInput inputA = transferInput("p-a"); TransferService.TransferInput inputB = transferInput("p-b"); - runTransfer("victim-a", inputA, credentialFor(inputA)); - runTransfer("victim-b", inputB, credentialFor(inputB)); + runTransfer("victim-a", inputA, aliceCredential()); + runTransfer("victim-b", inputB, aliceCredential()); TestWorkflows.AttackerHeaderInterceptor.HEADERS.putAll( scheduledEvent("victim-a").getNexusHeaderMap()); @@ -505,7 +500,7 @@ public void nexusHeaderCannotBePairedWithAnotherInput() { @Test public void exactNexusPairCannotMoveToAnotherEndpoint() { TransferService.TransferInput input = transferInput("p-route"); - runTransfer("victim-route", input, credentialFor(input)); + runTransfer("victim-route", input, aliceCredential()); NexusOperationScheduledEventAttributes victim = scheduledEvent("victim-route"); TestWorkflows.AttackerHeaderInterceptor.HEADERS.putAll(victim.getNexusHeaderMap()); @@ -516,7 +511,7 @@ public void exactNexusPairCannotMoveToAnotherEndpoint() { @Test public void nexusInputWithoutCredentialIsRejected() { TransferService.TransferInput input = transferInput("p-nocred"); - runTransfer("victim-nocred", input, credentialFor(input)); + runTransfer("victim-nocred", input, aliceCredential()); assertEquals( "rejected:UNAUTHENTICATED", attack(SampleConfig.ENDPOINT, scheduledEvent("victim-nocred").getInput())); @@ -525,33 +520,13 @@ public void nexusInputWithoutCredentialIsRejected() { // --------------------------------------------------------------------------------------------- // Authorization. - @Test - public void inputOutsideTheIntentIsUnauthorized() { - TransferService.TransferInput approved = transferInput("p-intent"); - TransferService.TransferInput steered = - new TransferService.TransferInput("acme", "alice", "p-intent", "mallory", 25_00); - assertEquals( - HandlerException.ErrorType.UNAUTHORIZED, - transferFailure("caller-intent", steered, credentialFor(approved))); - } - - @Test - public void amountAboveTheIntentIsUnauthorized() { - TransferService.TransferInput approved = transferInput("p-amount"); - TransferService.TransferInput steered = - new TransferService.TransferInput("acme", "alice", "p-amount", "bob", 99_00); - assertEquals( - HandlerException.ErrorType.UNAUTHORIZED, - transferFailure("caller-amount", steered, credentialFor(approved))); - } - @Test public void subjectMismatchIsUnauthorized() { TransferService.TransferInput input = new TransferService.TransferInput("acme", "bob", "p-subject", "bob", 25_00); assertEquals( HandlerException.ErrorType.UNAUTHORIZED, - transferFailure("caller-subject", input, credentialFor(input))); + transferFailure("caller-subject", input, aliceCredential())); } @Test @@ -567,7 +542,7 @@ public void tokenForAnotherAudienceIsUnauthenticated() { Duration.ofMinutes(5)); assertEquals( HandlerException.ErrorType.UNAUTHENTICATED, - transferFailure("caller-aud", input, new CredentialEnvelope(jwt, intentFor(input)))); + transferFailure("caller-aud", input, new CredentialEnvelope(jwt))); } // --------------------------------------------------------------------------------------------- @@ -577,24 +552,10 @@ private static TransferService.TransferInput transferInput(String paymentId) { return new TransferService.TransferInput("acme", "alice", paymentId, "bob", 25_00); } - private static CredentialIntent intentFor(TransferService.TransferInput input) { - Map constraints = new HashMap<>(); - constraints.put(TransferService.CONSTRAINT_PAYMENT_ID, input.getPaymentId()); - constraints.put(TransferService.CONSTRAINT_BENEFICIARY, input.getBeneficiary()); - constraints.put(TransferService.CONSTRAINT_MAX_AMOUNT_CENTS, "5000"); - return new CredentialIntent( - Collections.singletonList( - new CredentialIntent.RouteGrant( - SampleConfig.ENDPOINT, - TransferService.NAME, - TransferService.TRANSFER, - constraints))); - } - - /** A credential for alice, approving exactly the given input. */ - private CredentialEnvelope credentialFor(TransferService.TransferInput input) { + /** A credential for alice with the scope the handler requires. */ + private CredentialEnvelope aliceCredential() { String jwt = idp.issue("alice", "acme", TransferService.REQUIRED_SCOPE, Duration.ofMinutes(5)); - return new CredentialEnvelope(jwt, intentFor(input)); + return new CredentialEnvelope(jwt); } private static io.temporal.samples.nexussecurecredentialpropagation.common.EndUserPrincipal