conductor-oss/conductor · error · IllegalStateException

Unable to sign file-storage URL

Error message

Unable to sign file-storage URL

What it means

Thrown by FileStorageUrlSigner.hmac() as a catch-all wrapper around the three JCA operations it performs: Mac.getInstance("HmacSHA256"), mac.init(new SecretKeySpec(...)), and mac.doFinal(...). Any exception from those calls (NoSuchProviderException/NoSuchAlgorithmException for a missing HmacSHA256, InvalidKeyException for a bad key, or a runtime failure in doFinal) is rethrown as IllegalStateException with the original as the cause. It signals that signing was enabled and a key was selected, but the cryptographic step itself broke.

Source

Thrown at core/src/main/java/org/conductoross/conductor/core/storage/FileStorageUrlSigner.java:168

                "\n",
                VERSION,
                operation.getValue(),
                nullToEmpty(workflowId),
                nullToEmpty(fileId),
                String.valueOf(expirationEpochSeconds),
                nullToEmpty(uploadId),
                partNumber == null ? "" : String.valueOf(partNumber));
    }

    private byte[] hmac(String canonical, ConductorFileStorageProperties.Key key) {
        try {
            Mac mac = Mac.getInstance(HMAC_SHA_256);
            mac.init(
                    new SecretKeySpec(
                            key.getSecret().getBytes(StandardCharsets.UTF_8), HMAC_SHA_256));
            return mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8));
        } catch (Exception exception) {
            throw new IllegalStateException("Unable to sign file-storage URL", exception);
        }
    }

    private boolean isBlank(String value) {
        return value == null || value.isBlank();
    }

    private String nullToEmpty(String value) {
        return value == null ? "" : value;
    }

    /** Operations encoded into signed content URLs. */
    public enum Operation {
        UPLOAD("upload", "PUT"),
        DOWNLOAD("download", "GET");

        private final String value;
        private final String httpMethod;

View on GitHub (pinned to cf7c3e4a8a)

Solutions

  1. Inspect the attached cause (exception.getCause()) in the logs first: NoSuchAlgorithmException points to a missing provider, InvalidKeyException points to the key bytes.
  2. Confirm the signing key secret is non-empty at runtime: log key.getId() and key.getSecret().length() (never the value) from the calling path; if zero, fix the env var / config source feeding conductor.file-storage.conductor.signing.keys[*].secret.
  3. If running on GraalVM native-image, add HmacSHA256 and SecretKeySpec to reachability metadata (reflection-config / resource-config) or use the native-image hints so the SunJCE provider is retained.
  4. On a custom/stripped JDK, ensure the SunJCE provider is present (java.security lists it) or register an alternative provider (e.g. BouncyCastle) that exposes HmacSHA256.
  5. If keys can change at runtime, rebuild the FileStorageUrlSigner bean on config refresh instead of mutating the Key objects in place, so validate() re-runs against the new secret.
  6. Wrap the calling controller/service so this surfaces as a 5xx with the cause class name rather than propagating an opaque IllegalStateException to the client.

Example fix

// before — env var unset, Spring binds empty secret past constructor validation
conductor:
  file-storage:
    conductor:
      signing:
        enabled: true
        keys:
          - id: k1
            secret: ${SIGNING_SECRET}   # SIGNING_SECRET not exported -> ""

// after — fail fast with a placeholder check in the env, or use a non-blank default
conductor:
  file-storage:
    conductor:
      signing:
        enabled: true
        keys:
          - id: k1
            secret: ${SIGNING_SECRET:}
# plus a startup guard:
// if (signerKey.getSecret() == null || signerKey.getSecret().isBlank()) {
//     throw new IllegalStateException("SIGNING_SECRET must be set");
// }
Defensive patterns

Strategy: try-catch

Validate before calling

// Pre-flight the crypto + key before delegating to signer.sign()
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;

public boolean signingStackIsHealthy(ConductorFileStorageProperties.Key key) {
    if (key == null || key.getSecret() == null || key.getSecret().isBlank()) {
        return false;
    }
    try {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(
            key.getSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        mac.doFinal(new byte[]{0x00});
        return true;
    } catch (Exception e) {
        return false;
    }
}

// usage: only call signer.sign(...) when signingStackIsHealthy(activeKey) is true

Try / catch

// Distinguish the crypto failure from other IllegalStateExceptions and surface the cause class
try {
    SignedUrl signed = signer.sign(op, workflowId, fileId, exp, uploadId, partNumber);
} catch (IllegalStateException e) {
    if ("Unable to sign file-storage URL".equals(e.getMessage())) {
        Throwable cause = e.getCause();
        log.error("HMAC signing failed (cause={})", cause == null ? "unknown" : cause.getClass().getName(), e);
        // Do NOT retry the same key: it is either empty or the provider is gone.
        // Return 500 and page; recovery is a config/key fix, not a retry.
        throw new FileStorageSigningUnavailableException(cause);
    }
    throw e;
}

Prevention

When it happens

Trigger: Called transitively from sign() (signing IS enabled and keys is non-empty) when: (a) the JCE provider backing HmacSHA256 is unavailable (minimal/stripped JRE, or a native-image build missing reflection/resource metadata for the Mac algorithm); (b) the selected key's secret is empty or produces a zero-length/invalid SecretKeySpec after construction-time validation was bypassed (e.g. the Key bean was mutated after FileStorageUrlSigner was built, or @ConfigurationProperties relax-binding turned a missing env var into an empty string); (c) doFinal throws on an unexpected encoding or provider state error.

Common situations: Running under GraalVM native-image without registering HmacSHA256 in reflection/resource config; production secret env var (e.g. ${SIGNING_SECRET}) unset so Spring binds an empty string, bypassing the constructor's validate() because the bean was already built; deploying on a JMOD-trimmed JDK that dropped the SunJCE provider; a config-reload/Cloud event mutated the Key.secret to null at runtime; misconfigured BouncyCastle-only setup where HmacSHA256 was not registered under that name.

Related errors


AI-assisted analysis of conductor-oss/conductor@cf7c3e4a8a (2026-08-14). Data as JSON: /api/errors/812a9ba735bdf87d. Report an issue: GitHub.