Skip to content

Encryption

codec-crypto provides EnvelopeCodec, a Codec<byte[]> transform that layers AES-256-GCM envelope encryption onto any codec through andThen — the same composition seam used for compression.

Quickstart

Add the module:

<dependency>
    <groupId>org.jwcarman.codec</groupId>
    <artifactId>codec-crypto</artifactId>
</dependency>

codec-crypto has zero external dependencies — all cryptography is JCE, built into the JDK. It ships one in-process key provider, JceDataKeyProvider, for consumers without a KMS. It wraps each DEK with AES key-wrap (RFC 3394) under a KEK you supply, and returns a wrapped blob laid out as [scheme:1][wrapped key] — a one-byte wrap-scheme tag (0x01 = AES-KW) followed by the 40-byte AES-KW output, 41 bytes total for a 32-byte DEK. The tag is invisible to EnvelopeCodec, which treats the whole blob as opaque; it exists so this provider has its own wrap-algorithm migration story, the way a KMS-backed provider gets one for free from its own versioned ciphertext format.

A KEK is any AES-256 SecretKey you manage — generate one once and store it securely, or load one from configuration:

KeyGenerator generator = KeyGenerator.getInstance("AES");
generator.init(256);
SecretKey kek = generator.generateKey();   // generate once, store securely

byte[] keyBytes = HexCodec.lowerCase().decode(hex.getBytes(StandardCharsets.US_ASCII));
SecretKey fromConfig = new SecretKeySpec(keyBytes, "AES");   // or load from configuration

HexCodec lives in codec-transforms; add it beside codec-crypto if you use it.

DataKeyProvider provider =
    new JceDataKeyProvider("kek-2026-08", Map.of("kek-2026-08", kek));

Codec<Order> codec =
    codecFactory.create(Order.class)
        .andThen(new GzipCodec())
        .andThen(EnvelopeCodec.builder(provider).build());

byte[] wire = codec.encode(order);
Order restored = codec.decode(wire);

EnvelopeCodec is built through EnvelopeCodec.builder(provider) — the option set (key-acquisition strategy, AAD, keyId allowlist, SecureRandom) makes a plain constructor unworkable:

EnvelopeCodec envelope =
    EnvelopeCodec.builder(provider)
        .aad("orders-v1".getBytes(StandardCharsets.UTF_8))   // binds this codec's context
        .allowedKeyIds(Set.of("kek-2026-08", "kek-2026-09")::contains)
        .build();

aad binds every message this codec produces to a fixed context — see Ciphertext substitution for what that does and does not protect against. allowedKeyIds overrides the provider's own allowsKeyId as the admission check; leave it unset to defer to the provider, which is what JceDataKeyProvider already does from its KEK map (see Key rotation).

The envelope model

Every message gets its own data-encryption key (DEK), an AES-256 key generated fresh (or reused, depending on strategy — see below). The DEK encrypts the payload with AES-256-GCM. The DEK itself is then wrapped by a key-encryption key (KEK) held by a DataKeyProvider, and the wrapped form travels alongside the ciphertext in the message. Decode reverses this: unwrap the DEK using the KEK identified by the message's keyId, then GCM-decrypt the payload.

This is standard envelope encryption. It means the KEK itself never appears in the message, and a DataKeyProvider backed by a KMS never sees plaintext payload data — only the small wrapped DEK.

What travels in the message: the format version and algorithm id, a keyId identifying which KEK wrapped the DEK, the wrapped DEK itself, the GCM nonce, and the ciphertext plus its authentication tag. See Wire format below for the exact layout.

Key rotation via keyIds

Rotation happens at the KEK level, using keyIds — not by re-keying every message. JceDataKeyProvider is constructed with a Map<String, SecretKey> of every KEK it trusts, plus the id of the current KEK for wrapping new DEKs:

DataKeyProvider provider =
    new JceDataKeyProvider(
        "kek-2026-09",
        Map.of(
            "kek-2026-08", oldKek,
            "kek-2026-09", newKek));

New messages wrap their DEK under kek-2026-09. Messages already encrypted under kek-2026-08 still decode, because unwrap accepts any keyId present in the map. Once every message wrapped under an old KEK has been re-encrypted (or has aged out), drop it from the map.

The KEK map doubles as the provider's admission allowlist: unwrap and allowsKeyId both consult it directly, so a keyId absent from the map is rejected before decryption is attempted.

Choosing a data-key strategy

DataKeyStrategy decides how a DEK is acquired for each message. Two ship with the module.

DirectDataKeyStrategy is the default: it calls provider.newDataKey() on every encode. Every message gets an independent DEK, so there is no shared key under which two nonces could ever collide — this is the safest option and the right default.

The default costs one KMS round trip per message

If your DataKeyProvider is backed by a remote KMS, DirectDataKeyStrategy means one network call to generate a DEK on every encode, and one more to unwrap it on every decode. For high-volume or latency-sensitive paths, that cost — and the wrapped-DEK bytes added to every message — adds up fast. Don't discover this on your first KMS bill; decide deliberately.

BoundedDataKeyStrategy is the opt-in alternative: it caches a DEK and rolls to a fresh one after a message cap or a duration, whichever comes first. Both bounds are required at construction:

EnvelopeCodec envelope =
    EnvelopeCodec.builder(provider)
        .strategy(new BoundedDataKeyStrategy(1 << 20, Duration.ofMinutes(5)))
        .build();

The message cap is validated to [1, 2^24]. 2^20 (roughly one million messages, ~2^-57 collision probability) is the documented recommended value. The cap is deliberately capped below the 2^32 NIST SP 800-38D random-nonce limit for a single key — even at the 2^24 ceiling the collision probability is only about 2^-49, but the class refuses to be configured any closer to that cliff edge. A consumer that genuinely needs a higher cap implements DataKeyStrategy directly.

Not safe under VM/container snapshot-and-clone

If your runtime may snapshot and clone a live process (some serverless or sandboxed platforms do this), a clone can resume with duplicated SecureRandom state. Under this strategy the clones share the cached DEK and repeat its nonces. DirectDataKeyStrategy does not fix this when the DEK comes from JceDataKeyProvider: it is drawn from the same cloned RNG, so both clones generate the same DEK and the same nonce. Use a KMS-backed provider (the DEK's entropy then comes from outside the process), reseed or replace the SecureRandom and roll this strategy's key on resume, or rely on a platform that reseeds on VM-generation change.

Choosing a JCE provider

Both EnvelopeCodec.builder(provider).provider(Provider) and JceDataKeyProvider.builder(currentKeyId, keks).provider(Provider) accept an optional java.security.Provider. When set, every Cipher.getInstance call the codec (AES/GCM/NoPadding) or the provider (AES/KW/NoPadding) makes resolves against that provider instead of the JDK's default provider lookup:

Provider fips = ...; // e.g. a BC-FIPS provider instance

DataKeyProvider provider =
    JceDataKeyProvider.builder("kek-2026-08", Map.of("kek-2026-08", kek))
        .provider(fips)
        .build();

EnvelopeCodec codec =
    EnvelopeCodec.builder(provider)
        .provider(fips)
        .build();

Both builders resolve their transform against the given provider at build/construction time and fail fast: if the provider cannot supply the transform, build() throws IllegalStateException naming the transform, rather than letting the misconfiguration surface later as a DecryptionException on the first message.

Why this matters for FIPS

A FIPS-validated provider (e.g. BC-FIPS) can be pinned to a single codec or provider instance this way, without installing it as the JVM's globally highest-priority provider. Every primitive this module uses — AES-256-GCM and AES key wrap — is FIPS-approved.

Implementing DataKeyProvider against a KMS

DataKeyProvider is the SPI you implement to back EnvelopeCodec with a remote KMS (AWS KMS, HashiCorp Vault, GCP Cloud KMS, ...). codec-crypto ships no KMS bindings — this keeps the module free of any KMS SDK dependency, on top of its own no-dependencies-beyond-codec-core baseline (see the Quickstart).

public interface DataKeyProvider {
  DataKey newDataKey();
  SecretKey unwrap(String keyId, byte[] wrapped);
  default boolean allowsKeyId(String keyId) { return true; }
}

newDataKey maps onto operations that return a plaintext DEK and its wrapped form in one round trip — AWS KMS GenerateDataKey, Vault transit/datakey/plaintext. unwrap maps onto the corresponding decrypt operation.

Security contract — MUST, not SHOULD

unwrap receives a keyId and wrapped blob read from unauthenticated ciphertext: the GCM tag can't be verified until the DEK is recovered, so at the time of this call both arguments are attacker-controlled. Implementations MUST:

  • pass the supplied keyId to the KMS as the key restriction for the decrypt call (e.g. the KeyId parameter of AWS KMS Decrypt) — never let the wrapped blob select its own unwrapping key; and
  • reject any keyId outside the set of KEKs the application intends to trust.

Skip either one and an attacker who knows the plaintext of any DEK wrapped under any KEK your application is permitted to decrypt can forge messages that verify. EnvelopeCodec enforces its own allowlist before calling unwrap (see Key rotation), but that does not make this contract optional — a provider used outside EnvelopeCodec must not be silently unsafe on its own.

unwrap implementations MAY cache results, keyed by (keyId, hash(wrapped)) or the wrapped bytes themselves, to avoid a network round trip per decode — any such cache MUST be bounded in both entries and time. This is what makes provider-side caching a legitimate way to blunt the round-trip cost noted above, without codec-crypto having to ship and maintain a caching decorator.

GCP Cloud KMS has no data-key operation

GCP's documented envelope pattern is generate-the-DEK-locally, then call Encrypt to wrap it. That's still a single round trip, and it fits entirely inside your newDataKey() implementation — you're using the SPI as intended, not working around it.

Implementations must also be thread-safe: a single provider instance is shared across both encode and decode, potentially from multiple threads at once.

Wire format

Version 1. Permanent once anyone persists ciphertext — this format is proprietary to codec-crypto. Nothing outside this module reads it, cross-language readers are a non-goal, and the frozen test vector (WireFormatVectorTest) is the format's conformance spec: a refactor that changes the wire format fails that test. All multi-byte integers are big-endian; length fields are unsigned 16-bit values.

offset field bytes
0 magic 0x4A 0x43 ("JC") 2
2 format version (0x01) 1
3 algorithm id (0x01 = AES-256-GCM) 1
4 keyId length (uint16, ≥ 1) 2
6 keyId (UTF-8) k
6+k wrapped DEK length (uint16, ≥ 1) 2
8+k wrapped DEK w
8+k+w nonce 12
20+k+w ciphertext ‖ GCM tag n+16

Overhead is 36 + k + w bytes — roughly 80 bytes for a JCE provider with a short keyId, and up to ~295 bytes for a KMS ARN plus a 184-byte wrapped DEK. On small, frequent messages the wrapped DEK dominates that overhead, which is part of why BoundedDataKeyStrategy and provider-side unwrap caching exist.

Decode checks the magic bytes as soon as the first 2 bytes are available and rejects non-envelope input immediately, before the full minimum-length check runs — so feeding decode something that isn't an EnvelopeCodec message (a plain gzip stream, for example) fails fast on bad magic, not on a generic length error.

AES-GCM, algorithm id 0x01, is not key-committing: a sophisticated attacker who controls both the ciphertext and (in multi-party scenarios) more than one candidate key can in principle construct a single ciphertext that decrypts successfully under two different DEKs, each to different plaintext. In this design the DEK a decryptor uses is pinned by the deterministic AES-KW unwrap of the wrapped blob under a fixed KEK, with its 64-bit integrity check — so a salamander needs two decryptors holding different KEKs under the same keyId, plus a wrapped blob whose unwrap passes the check under both, or a provider that lies. The keyId allowlist limits which KEKs decode will consult at all. Algorithm id 0x02 is reserved for a future key-committing suite, should a consumer's threat model require one.

Ordering and composition

Compress before you encrypt

.andThen(gzip).andThen(encryption) — never the other order. Encrypted output is high-entropy ciphertext; it does not compress. Encrypting first makes the compression step pointless.

CRIME/BREACH: don't compress secrets next to attacker input

Compressing attacker-influenced plaintext in the same stream as a secret can leak information about that secret through the compressed length (the CRIME/BREACH class of attacks). codec-crypto does not defend against this — it's a property of compressing before encrypting, not of this module — so evaluate that risk independently before compressing untrusted input alongside sensitive data.

Ciphertext substitution: AAD is fixed per instance, not per message

AAD is fixed when you build the EnvelopeCodec — there is no per-message context parameter; the Codec<byte[]> seam has no place to carry one. The standard defense against ciphertext substitution — binding each message to the record or context it belongs to — is therefore your responsibility. Without it, an attacker with write access to a datastore can swap one row's encrypted field into another row under the same codec and KEK, and decode will accept it cleanly. If rows must not be interchangeable, either construct a distinct codec per context, or bind identity inside the plaintext and verify it after decode.

keyIds are not secret

The keyId travels in cleartext in every message — anyone who can read the ciphertext can read the keyId. Never encode secrets into a keyId.

Error taxonomy

codec-crypto reports failures through four exceptions — three of its own, all in org.jwcarman.codec.crypto, plus the SPI's UnsupportedFormatException — and never logs. Each slots into one of the SPI's families, which is what you catch:

  • DecryptionException — an InvalidPayloadException: "this data is bad." Covers bad magic, bounds violations, a disallowed keyId, and cryptographic rejection (GCM tag mismatch, or a provider affirmatively rejecting the wrapped DEK). Cryptographic rejections all share the exact message "Unable to decrypt data", deliberately indistinguishable from each other; the exception's cause is preserved for diagnosis and does differ by stage.
  • UnsupportedFormatException (the SPI's own, not a crypto subclass) — the envelope names a format version or algorithm id this build does not know. A newer writer produced it; hold it or route it, do not quarantine it.
  • KeyAccessException — a TransientCodecException: "the key infrastructure is unavailable, or misbehaving": timeouts, throttling, credential expiry, a provider whose unwrap returns a key that violates its contract, a key the JCE provider cannot use. The cause is preserved.
  • EncryptionException — a TransientCodecException: a provider or strategy failure during encode, wrapping the cause.

Never quarantine or discard data on a TransientCodecException

KeyAccessException means the KMS was unreachable or the key was unusable, not that the data is invalid. A pipeline that quarantines or discards ciphertext on decryption failure must catch InvalidPayloadException (which DecryptionException is) and nothing wider — conflating the families turns a transient KMS outage into permanent data loss. Retain the encrypted data and retry.

This distinction is normative on DataKeyProvider: unwrap MUST throw DecryptionException only when the KMS or JCE layer has affirmatively rejected the blob as invalid; every other failure — including plain availability failures — must propagate as an ordinary runtime exception, which EnvelopeCodec wraps as KeyAccessException.

Assurance

codec-crypto is checked against external references and mechanical adversaries, not only against its own tests. The ci Maven profile runs known-answer tests against NIST and RFC vectors, mutation testing (PIT, 85% mutation / 90% line-coverage thresholds), and static security analysis (SpotBugs with the findsecbugs plugin) for this module; the fuzz profile runs the decoder's Jazzer fuzz targets live for a bounded duration. See the Threat Model for what this program does and does not cover, what's defended and how, and an independent-review checklist for an outside cryptographer.

Where next