From 8e4e0b3123fc68183933e2bb43d8232d0163c32e Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Tue, 13 Oct 2026 09:54:14 -0700 Subject: [PATCH 1/3] 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/3] 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/3] 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.