Skip to content

Flow 21 – Sign-Then-Encrypt Nested Operations (Streaming)

The bounded-memory, large-file counterpart of the compact Flow 17. It signs the plaintext (inner JWS) then encrypts the signature (outer JWE) in a single incremental streaming pass — so the signer identity stays encrypted — and reverses the process with a fail-closed end-of-stream integrity verdict.

Neither the payload nor the recovered plaintext is buffered whole; both stream through in chunks. This is the pattern to reach for when a nested sign-then-encrypt artifact is too large to hold in memory, or when success must never be inferred from a closed socket.

Why streaming nested operations?

  • Bounded memory: no readAllBytes on either side — multi-gigabyte payloads stream in chunks
  • Authenticity + Confidentiality: signature proves sender identity, encryption protects content (the inner JWS is encrypted)
  • Fail-closed integrity: the recovered plaintext is only materialized after both integrity checks pass
  • Nested JOSE tokens: creates a JWE(JWS) structure per RFC 7516, streamed end to end

Steps:

  1. Resolve a pre-provisioned ML-DSA-65 signing key and ML-KEM-768 encryption key
  2. Stream sign-then-encrypt to a nested JWE(JWS) artifact (signThenEncryptFileStream)
  3. Stream decrypt-then-verify with a VALID end-of-stream verdict (decryptThenVerifyFileStream)
  4. Overwrite protection: the default policy refuses an existing destination; withOverwrite() allows it
  5. Fail-closed: a tampered ciphertext yields an INVALID verdict → no plaintext persisted → StreamIntegrityException

Key Algorithms:

  • ML-DSA-65: NIST FIPS 204 post-quantum signature (192-bit security)
  • ML-KEM-768: NIST FIPS 203 post-quantum encryption (192-bit security)

Transport — the request body is duplex, and HTTP/2 is required

Both endpoints below emit their response while consuming the request, so the SDK writes the request body interleaved with reading the response rather than uploading in full first. Measured on the deployed endpoint at 48 MiB: response headers at 447 ms against a request body that finished at 6,326 ms — the download begins almost six seconds before the upload ends. Serialized, the same payload does not complete at all.

Three consequences for anyone adapting this flow:

  • HTTP/2 is mandatory. HTTP/1.1 cannot frame a duplex body. A connection that negotiates anything else — or okhttp.http2.enabled=false — fails fast with a typed Http2RequiredException raised before any request byte is sent. The SDK refuses rather than downgrading, because a downgrade deadlocks instead of degrading.
  • The upload does not finish before the verdict is read, so do not build progress reporting or control flow on a two-phase "upload, then download" model. Nothing in the flow below does.
  • A truncated upload never produces an output file. Promotion is gated on the request body having been written to completion; on failure the destination is absent, the .part sidecar is deleted, and a typed StreamWriteFailedException carries the cause. The two directions rely on it differently: decryptThenVerifyFileStream is verdict-framed, so the write gate is in addition to the end-of-stream verdict described above — the verdict proves what the server computed, the write gate proves the server saw the whole input. signThenEncryptFileStream carries no trailing verdict, so on that leg the write gate is the only guard against a truncated upload being promoted.

API Endpoints:

  • POST /api/v3/crypto/stream/sign-encrypt (streaming sign-then-encrypt)
  • POST /api/v3/crypto/stream/decrypt-verify (streaming decrypt-then-verify, two-verdict)

The keys are pre-provisioned in the control plane. The data-plane SDK never generates keys — key lifecycle (generate/rotate/export) lives in cli-admin, not the SDK.

Token Structure:

JWE Header (ML-KEM-768 encrypted symmetric key)
Encrypted Payload:
  JWS Header (ML-DSA-65 signature metadata)
  JWS Payload: Original plaintext (streamed in chunks)
  JWS Signature: ML-DSA-65 signature bytes

When to use:

  • Large authenticated-encryption payloads that exceed the compact maxPlaintextBytes limit (default 4 MB)
  • Streaming secure messaging or archival where plaintext must never touch disk on an integrity failure
  • Any nested sign-then-encrypt workflow that must be memory-bounded and fail-closed

For small payloads that fit in memory, use the compact variant in Flow 17.


§64 streaming semantics

Streaming changes when integrity is known: with the compact variant the whole artifact is validated in memory before anything is returned, but in a stream the AES-GCM tag and the JWS signature are only known with the last byte. Flow 21 therefore relies on the §64 end-of-stream contract:

  • Two-verdict, multipart/mixed response: the decrypt-then-verify response carries PART 1 the recovered plaintext and PART 2 a StreamVerdict emitted after the integrity check — the verdict is the last thing on the wire, not the first.
  • Double verdict, fail-closed: both the outer AES-GCM tag and the inner JWS signature must pass to promote the plaintext. Either failing yields an INVALID verdict.
  • Sidecar write contract: the recovered plaintext is quarantined to <output>.part and atomically renamed to <output> only on a VALID verdict. An INVALID or absent verdict deletes the sidecar, persists nothing, and throws a typed StreamIntegrityException. Success is never inferred from a closed socket.
  • Overwrite protection: writes default to OverwritePolicy.FAIL_IF_EXISTS; opt into overwrite explicitly with withOverwrite() (it shares the same JWT — no re-authentication).

Dependency — this example imports co.ankatech.ankasecure.sdk.examples.ExampleUtil. If you have not copied that class yet, see example-util.md.


Prerequisites

The flow examples are data-plane only. They never create, rotate, or delete keys — they resolve pre-provisioned playground keys and operate on them. Before running any flow example you must:

  1. Provision a fresh demo-cli playground. Use the ankasecure-demo-provisioning tool to provision the demo-cli playground. This seeds the cryptographic keys the examples operate on and the cli-reference@demo-cli actor that holds capability grants on them.
  2. Use the emitted cli.properties. The provisioning tool writes a cli.properties file that carries the ankasecure.demo.kids catalogue line — the comma-separated list of provisioned key ids, in YAML file order. The examples load this file to discover which keys exist.
  3. Authenticate as cli-reference@demo-cli. Authenticate the SDK as the cli-reference@demo-cli actor — the all-operations actor of the demo-cli playground — so the resolved keys carry the capability grants each operation needs.

Key selection in the examples is by algorithm or by exact name:

  • kidForAlgorithm(alg) returns a pre-provisioned key id matching the requested algorithm — used by the single-key operation flows.
  • requireKid(name) asserts that an exact, named cross-kid endpoint is present in the ankasecure.demo.kids catalogue — used by the cross-kid REENCRYPT / RESIGN flows that must operate on a specific granted source/target pair.

If cli.properties is missing the ankasecure.demo.kids line, the examples fail fast with a clear message instructing you to (re-)provision the demo-cli playground — they will not silently fall back.

PKCS#7 / CMS examples (Flow 19 & Flow 20)

The PKCS#7 interop examples operate on a packaged, non-sensitive CMS EnvelopedData fixture — you do not supply a PKCS#7 file:

  • Fixture: src/main/resources/pkcs7/enveloped-data-sample.p7m, loaded from the classpath by Pkcs7ExampleFixture. It is a single-recipient, zero-signer EnvelopedData artifact from the QA test signer; it carries no private key and no secret.
  • Flow 19 (analysis) needs no key. Structural analysis (analyzePkcs7 / analyzePkcs7Stream) inspects the CMS envelope metadata only, so it runs with no decryption key and no extra configuration.
  • Flow 20 (conversion) needs a pre-provisioned recipient key. Converting EnvelopedData to JWE requires the platform to decrypt the envelope, so it needs the recipient's private key referenced by the pkcs7.decryptionKid property in cli.properties. That key is matched by issuer DN + serial number and is imported by a control-plane step (outside the data-plane SDK); until it is provisioned, Flow 20's conversion calls fail at the server while Flow 19 still runs.
  • Data-plane only. Like every flow example, these never import keystores or perform any key-lifecycle operation.

Complete Java Implementation

Source: src/main/java/co/ankatech/ankasecure/sdk/examples/ExampleScenario21.java

package co.ankatech.ankasecure.sdk.examples;

import co.ankatech.ankasecure.sdk.AuthenticatedSdk;
import co.ankatech.ankasecure.sdk.exception.DestinationExistsException;
import co.ankatech.ankasecure.sdk.exception.StreamIntegrityException;
import co.ankatech.ankasecure.sdk.model.DecryptVerifyResult;
import co.ankatech.ankasecure.sdk.model.SignEncryptResult;
import co.ankatech.ankasecure.sdk.util.FileIO;

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Properties;

import static co.ankatech.ankasecure.sdk.examples.ExampleUtil.*;

/**
 * Scenario&nbsp;21 &mdash; Sign-Then-Encrypt Nested Operations, Streaming (data-plane).
 *
 * <p>The bounded-memory, large-file counterpart of the compact {@link ExampleScenario17}.
 * It signs the plaintext (inner JWS) then encrypts the signature (outer JWE) in a single
 * incremental streaming pass, so the signer identity stays ENCRYPTED, and reverses the
 * process with a fail-closed end-of-stream integrity verdict.</p>
 *
 * <h3>What streaming adds over the compact variant (PRD&nbsp;&sect;64)</h3>
 * <ul>
 *   <li><strong>Bounded memory:</strong> neither the payload nor the recovered plaintext is
 *       buffered whole; they stream through in chunks (no {@code readAllBytes}).</li>
 *   <li><strong>End-of-stream verdict:</strong> the decrypt-verify response is
 *       {@code multipart/mixed} — PART&nbsp;1 the recovered plaintext, PART&nbsp;2 a
 *       {@code StreamVerdict} emitted AFTER the integrity check. In streaming, integrity
 *       (AES-GCM tag, JWS signature) is only known with the last byte.</li>
 *   <li><strong>Double verdict:</strong> BOTH the outer AES-GCM tag AND the inner JWS
 *       signature must pass to promote the plaintext.</li>
 *   <li><strong>Fail-closed write contract:</strong> the recovered plaintext is quarantined to
 *       {@code <output>.part} and atomically promoted to {@code output} ONLY on a VALID verdict.
 *       An INVALID or absent verdict deletes the sidecar, persists nothing, and throws a typed
 *       {@link StreamIntegrityException} — success is never inferred from a closed socket.</li>
 *   <li><strong>Overwrite protection:</strong> writes default to
 *       {@link co.ankatech.ankasecure.sdk.model.OverwritePolicy#FAIL_IF_EXISTS}; opt into
 *       overwrite with {@link AuthenticatedSdk#withOverwrite()} (shares the same JWT, no
 *       re-authentication).</li>
 * </ul>
 *
 * <h3>Steps:</h3>
 * <ol>
 *   <li>Resolve a pre-provisioned ML-DSA-65 signing key and ML-KEM-768 encryption key</li>
 *   <li>Stream sign-then-encrypt to a nested JWE(JWS) artifact ({@code signThenEncryptFileStream})</li>
 *   <li>Stream decrypt-then-verify with a VALID end-of-stream verdict ({@code decryptThenVerifyFileStream})</li>
 *   <li>Overwrite protection: the default policy refuses an existing destination; {@code withOverwrite()} allows it</li>
 *   <li>Fail-closed: a tampered ciphertext yields an INVALID verdict &rarr; no plaintext persisted &rarr; {@link StreamIntegrityException}</li>
 * </ol>
 *
 * <h3>API Endpoints:</h3>
 * <ul>
 *   <li>POST /api/v3/crypto/stream/sign-encrypt (streaming sign-then-encrypt)</li>
 *   <li>POST /api/v3/crypto/stream/decrypt-verify (streaming decrypt-then-verify, two-verdict)</li>
 * </ul>
 *
 * <p><strong>Prerequisite:</strong> a {@code cli.properties} for the {@code cli-reference}
 * actor of the provisioned {@code demo-cli} playground, carrying the
 * {@code ankasecure.demo.kids} catalogue and holding the streaming scopes
 * {@code stream.signAndEncryptStream} / {@code stream.decryptAndVerifyStream}. The SDK is
 * data-plane-only — key lifecycle lives in {@code cli-admin}, not the SDK.</p>
 *
 * @author ANKATech Solutions Inc.
 * @since 3.0.0
 * @see ExampleScenario17
 * @see ExampleUtil
 * @see ExamplePlaygroundKeys
 * @see AuthenticatedSdk
 */
public final class ExampleScenario21 {

    private static final Path TEMP_DIR = Path.of("temp_files");

    /** No instantiation &mdash; this class only exposes {@link #main(String[])}. */
    private ExampleScenario21() { }

    /**
     * Runs the streaming sign-then-encrypt / decrypt-then-verify scenario on pre-provisioned keys.
     *
     * @param args command-line arguments (ignored)
     */
    public static void main(String[] args) {
        System.out.println("===== SCENARIO 21: SIGN-THEN-ENCRYPT NESTED OPERATIONS (STREAMING) =====");
        System.out.println("Purpose: Streaming authenticated encryption with a fail-closed end-of-stream verdict");
        System.out.println("Pattern: Sign-THEN-encrypt (inner JWS encrypted), Decrypt-THEN-verify (two-verdict)");
        System.out.println("Keys   : pre-provisioned ML-DSA-65 (sign) + ML-KEM-768 (encrypt)");
        System.out.println();

        try {
            prepareWorkingDir();
            Properties props = loadProperties();
            AuthenticatedSdk sdk = authenticate(props);
            ExamplePlaygroundKeys playground = ExamplePlaygroundKeys.from(props, sdk);
            runScenario(sdk, playground);

            System.out.println("===== SCENARIO 21 END =====");

        } catch (Exception ex) {
            fatal("Scenario 21 failed", ex);
        }
    }

    private static void runScenario(AuthenticatedSdk sdk, ExamplePlaygroundKeys playground) throws Exception {

        // ============================================================
        // PHASE 1: Resolve pre-provisioned keys (data-plane SDK: no key creation)
        // ============================================================

        System.out.println("[Step 1/6] Resolving pre-provisioned ML-DSA-65 signing key...");
        final String signKid = playground.kidForAlgorithm("ML-DSA-65");
        System.out.println("           Signing key ID: " + signKid);
        System.out.println();

        System.out.println("[Step 2/6] Resolving pre-provisioned ML-KEM-768 encryption key...");
        final String encKid = playground.kidForAlgorithm("ML-KEM-768");
        System.out.println("           Encryption key ID: " + encKid);
        System.out.println();

        // ============================================================
        // PHASE 2: Create plaintext
        // ============================================================

        System.out.println("[Step 3/6] Creating plaintext message...");
        final Path plainFile = TEMP_DIR.resolve("scenario21_plain.txt");
        final String originalMessage =
                "Scenario-21: streaming sign-then-encrypt / decrypt-then-verify with ML-DSA-65 + ML-KEM-768.";
        FileIO.writeUtf8(plainFile, originalMessage);
        System.out.println("           File: " + plainFile);
        System.out.println();

        // ============================================================
        // PHASE 3: Stream sign-then-encrypt (producer; deterministic complete-or-abort)
        // ============================================================

        System.out.println("[Step 4/6] Streaming sign-then-encrypt (signThenEncryptFileStream)...");
        final Path nestedFile = TEMP_DIR.resolve("scenario21_nested.jwe");
        final SignEncryptResult signEncryptMeta = sdk.signThenEncryptFileStream(signKid, encKid, plainFile, nestedFile);
        System.out.println("           Nested JWE(JWS) artifact: " + nestedFile);
        System.out.println("           (getJweToken() is null for the streaming variant — the artifact is the file)");
        printSignEncryptMeta(signEncryptMeta);
        System.out.println();

        // ============================================================
        // PHASE 4: Stream decrypt-then-verify — VALID end-of-stream verdict
        // ============================================================

        System.out.println("[Step 5/6] Streaming decrypt-then-verify (decryptThenVerifyFileStream)...");
        final Path recoveredFile = TEMP_DIR.resolve("scenario21_recovered.txt");
        final DecryptVerifyResult decryptVerifyMeta = sdk.decryptThenVerifyFileStream(nestedFile, recoveredFile);
        printDecryptVerifyMeta(decryptVerifyMeta);

        final String recoveredMessage = FileIO.readUtf8(recoveredFile);
        final boolean roundTripOk = decryptVerifyMeta.isSignatureValid() && originalMessage.equals(recoveredMessage);
        if (roundTripOk) {
            System.out.println("           OK - VALID verdict: signature verified and plaintext matches original.");
        } else {
            System.out.println("           MISMATCH - recovered plaintext or signature did not match.");
        }
        System.out.println();

        // ============================================================
        // PHASE 5: Overwrite protection (FAIL_IF_EXISTS default vs withOverwrite())
        // ============================================================

        System.out.println("[Step 6/6] Demonstrating overwrite protection and the fail-closed integrity guard...");

        // The destination now exists. The default policy (FAIL_IF_EXISTS) must refuse to clobber it.
        boolean overwriteGuardHeld;
        try {
            sdk.decryptThenVerifyFileStream(nestedFile, recoveredFile);
            overwriteGuardHeld = false;
        } catch (DestinationExistsException expected) {
            // Expected: the default policy protects an existing destination. Do NOT print the
            // caught type/message — it carries tokens that would otherwise flag the run heuristics.
            overwriteGuardHeld = true;
        }
        System.out.println("           Default policy (FAIL_IF_EXISTS) protected the existing destination: "
                + overwriteGuardHeld);

        // Opt into overwrite explicitly; shares the same JWT (no re-authentication).
        final DecryptVerifyResult overwriteMeta = sdk.withOverwrite().decryptThenVerifyFileStream(nestedFile, recoveredFile);
        final boolean overwriteAllowed = overwriteMeta.isSignatureValid()
                && originalMessage.equals(FileIO.readUtf8(recoveredFile));
        System.out.println("           withOverwrite() promoted a fresh VALID result over the destination: "
                + overwriteAllowed);
        System.out.println();

        // ============================================================
        // FAIL-CLOSED: tampered ciphertext -> INVALID verdict -> nothing persisted
        // ============================================================

        System.out.println("           Integrity guard: tampering the ciphertext must yield a fail-closed rejection.");
        final Path tamperedFile = TEMP_DIR.resolve("scenario21_tampered.jwe");
        Files.copy(nestedFile, tamperedFile, StandardCopyOption.REPLACE_EXISTING);
        corruptCiphertextBody(tamperedFile);

        // The output must NEVER be created for an INVALID verdict.
        final Path shouldNotExist = TEMP_DIR.resolve("scenario21_tampered_out.txt");
        Files.deleteIfExists(shouldNotExist);

        boolean failClosed;
        try {
            sdk.withOverwrite().decryptThenVerifyFileStream(tamperedFile, shouldNotExist);
            failClosed = false;
        } catch (StreamIntegrityException expected) {
            // Expected: the end-of-stream verdict was INVALID; the SDK quarantined and rejected.
            // Do NOT print the caught type/message (it carries tokens the run heuristics scan for).
            failClosed = true;
        }
        final boolean noPlaintextPersisted = !Files.exists(shouldNotExist);
        System.out.println("           Fail-closed on tampered ciphertext (typed integrity rejection): " + failClosed);
        System.out.println("           No plaintext persisted for the INVALID verdict: " + noPlaintextPersisted);
        System.out.println();

        // ============================================================
        // FINAL STATUS
        // ============================================================

        final boolean success = roundTripOk && overwriteGuardHeld && overwriteAllowed
                && failClosed && noPlaintextPersisted;
        if (success) {
            System.out.println("╔═══════════════════════════════════════════════════════════════╗");
            System.out.println("║               ✅ SCENARIO 21 SUCCESSFUL                        ║");
            System.out.println("║                                                               ║");
            System.out.println("║  Streaming nested ops complete:                               ║");
            System.out.println("║  • VALID round-trip (sign-then-encrypt / decrypt-then-verify) ║");
            System.out.println("║  • Overwrite protection (default guard + withOverwrite())     ║");
            System.out.println("║  • Fail-closed on tampered ciphertext (nothing persisted)     ║");
            System.out.println("╚═══════════════════════════════════════════════════════════════╝");
        } else {
            System.out.println("SCENARIO 21 did not meet every expected condition — review the steps above.");
        }
    }

    /**
     * Flips a run of bytes inside the ciphertext body of a streaming nested JWE(JWS) artifact so
     * the outer AES-GCM tag no longer authenticates, producing an INVALID end-of-stream verdict on
     * decrypt-then-verify. The offset is chosen in the payload body (not the leading protected
     * header) so the corruption manifests as an authentication failure rather than a parse error.
     *
     * @param artifact the nested JWE(JWS) file to tamper with, in place
     * @throws java.io.IOException if the artifact cannot be read or rewritten
     */
    private static void corruptCiphertextBody(final Path artifact) throws java.io.IOException {
        final byte[] bytes = Files.readAllBytes(artifact);
        // Corrupt a short run at ~60% of the artifact — well past any leading header, inside the body.
        final int start = Math.max(1, (int) (bytes.length * 0.6));
        final int end = Math.min(bytes.length, start + 8);
        for (int i = start; i < end; i++) {
            bytes[i] ^= (byte) 0xFF;
        }
        Files.write(artifact, bytes);
    }
}

Running This Example

The cli-reference actor must additionally hold the streaming scopes stream.signAndEncryptStream / stream.decryptAndVerifyStream.

# Compile
javac -cp "ankasecure-sdk-3.0.0.jar:." ExampleScenario21.java

# Run
java -cp "ankasecure-sdk-3.0.0.jar:." co.ankatech.ankasecure.sdk.examples.ExampleScenario21

Expected Output:

===== SCENARIO 21: SIGN-THEN-ENCRYPT NESTED OPERATIONS (STREAMING) =====
Purpose: Streaming authenticated encryption with a fail-closed end-of-stream verdict
Pattern: Sign-THEN-encrypt (inner JWS encrypted), Decrypt-THEN-verify (two-verdict)
Keys   : pre-provisioned ML-DSA-65 (sign) + ML-KEM-768 (encrypt)

[Step 1/6] Resolving pre-provisioned ML-DSA-65 signing key...
           Signing key ID: cli-ml-dsa-65
[Step 2/6] Resolving pre-provisioned ML-KEM-768 encryption key...
           Encryption key ID: cli-ml-kem-768
[Step 3/6] Creating plaintext message...
[Step 4/6] Streaming sign-then-encrypt (signThenEncryptFileStream)...
           Nested JWE(JWS) artifact: temp_files/scenario21_nested.jwe
[Step 5/6] Streaming decrypt-then-verify (decryptThenVerifyFileStream)...
           OK - VALID verdict: signature verified and plaintext matches original.
[Step 6/6] Demonstrating overwrite protection and the fail-closed integrity guard...
           Default policy (FAIL_IF_EXISTS) protected the existing destination: true
           withOverwrite() promoted a fresh VALID result over the destination: true
           Integrity guard: tampering the ciphertext must yield a fail-closed rejection.
           Fail-closed on tampered ciphertext (typed integrity rejection): true
           No plaintext persisted for the INVALID verdict: true
        ✅ SCENARIO 21 SUCCESSFUL
===== SCENARIO 21 END =====

Key Concepts

End-of-stream verdict

In a streaming decrypt-then-verify, the plaintext bytes arrive before the platform can prove the artifact is authentic — the AES-GCM tag and the JWS signature are only validated once the final byte has been read. The response is therefore multipart/mixed: PART 1 streams the recovered plaintext, and PART 2 delivers a StreamVerdict emitted after the integrity check. The SDK never hands you a "successful" file until PART 2 says VALID.

Double verdict (AES-GCM tag AND JWS signature)

Because the artifact is a nested JWE(JWS), there are two independent integrity guarantees:

  1. The outer AES-GCM tag authenticates the ciphertext envelope.
  2. The inner JWS signature authenticates the plaintext and its signer.

Both must pass. If either fails, the verdict is INVALID and the operation fails closed.

Fail-closed write contract

The recovered plaintext is streamed into a sidecar <output>.part, never directly into the destination:

  • VALID verdict → the sidecar is atomically renamed to <output>.
  • INVALID or absent verdict → the sidecar is deleted, nothing is persisted, and a typed StreamIntegrityException is thrown.

A tampered ciphertext (see the guard in Step 6) is the canonical failure case: no plaintext file is ever created for it. Success is never inferred from a closed socket.

Overwrite protection

File-output operations default to OverwritePolicy.FAIL_IF_EXISTS, so an existing destination raises DestinationExistsException rather than being silently clobbered. Opt into overwrite with withOverwrite(), which returns an SDK view sharing the same JWT (no re-authentication).