security(lib): enforce single-use encryption contexts

This commit is contained in:
2026-07-29 17:28:32 +02:00
parent 8af75a9508
commit 9bbcab7522
18 changed files with 673 additions and 160 deletions

View File

@@ -34,10 +34,12 @@
package zeroecho.core.alg.aes;
import java.io.IOException;
import java.io.FilterInputStream;
import java.io.InputStream;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -54,7 +56,6 @@ import zeroecho.core.context.EncryptionContext;
import zeroecho.core.err.ProviderFailureException;
import zeroecho.core.io.CipherTransformInputStreamBuilder;
import zeroecho.core.spi.ContextAware;
import zeroecho.core.util.Strings;
import zeroecho.sdk.util.RandomSupport;
/**
@@ -62,9 +63,9 @@ import zeroecho.sdk.util.RandomSupport;
*
* <p>
* IV and optional AAD are exchanged via a {@link conflux.CtxInterface} set with
* {@link #setContext(conflux.CtxInterface)}. On ENCRYPT, a fresh IV is
* generated if absent and stored back; on DECRYPT, IV must be present (from
* context or header).
* {@link #setContext(conflux.CtxInterface)}. Encryption always generates a fresh
* IV after atomically claiming the context; a caller-provided IV is never used
* for encryption. Decryption requires the IV from the context or encoded header.
* </p>
*
* <p>
@@ -86,6 +87,7 @@ public final class AesCipherContext implements EncryptionContext, ContextAware {
private final boolean encrypt;
private final AesSpec spec;
private final SecureRandom rnd;
private final AtomicReference<OperationState> operationState = new AtomicReference<>(OperationState.NEW);
private volatile CtxInterface ctx; // NOPMD
@@ -172,6 +174,8 @@ public final class AesCipherContext implements EncryptionContext, ContextAware {
@Override
public InputStream attach(InputStream upstream) throws IOException {
Objects.requireNonNull(upstream, "upstream must not be null");
claimEncryption();
boolean attached = false;
try {
// If both spec.header() and ctx are present, let this context read/write the
// header.
@@ -186,8 +190,7 @@ public final class AesCipherContext implements EncryptionContext, ContextAware {
}
Cipher cipher = Cipher.getInstance(jcaTransform(spec));
initCipher(cipher); // consumes IV/AAD from ctx if present; generates IV on ENCRYPT and may store it
// back; sets tagBits for GCM
initCipher(cipher);
InputStream out = // new Stream(in, cipher, spec);
CipherTransformInputStreamBuilder.builder().withCipher(cipher).withUpstream(in)
@@ -200,9 +203,14 @@ public final class AesCipherContext implements EncryptionContext, ContextAware {
header.writeHeader(hdr, algorithm, ctx);
out = new java.io.SequenceInputStream(new java.io.ByteArrayInputStream(hdr.toByteArray()), out);
}
return out;
attached = true;
return encrypt ? new LifecycleInputStream(out) : out;
} catch (GeneralSecurityException e) {
throw new ProviderFailureException("AES attach/init failed", e);
} finally {
if (!attached) {
failEncryption();
}
}
}
@@ -221,21 +229,18 @@ public final class AesCipherContext implements EncryptionContext, ContextAware {
};
}
private void initCipher(Cipher cipher) throws GeneralSecurityException, IOException { // NOPMD
private void initCipher(Cipher cipher) throws GeneralSecurityException, IOException {
final String id = algorithm.id();
byte[] iv = getCtxBytes(ConfluxKeys.iv(id));
byte[] iv;
final int ivLen = (spec.mode() == AesSpec.Mode.GCM) ? GCM_DEFAULT_IV_BYTES : AES_BLOCK;
if (encrypt) {
if (iv == null) {
iv = new byte[ivLen];
rnd.nextBytes(iv);
putCtxBytes(ConfluxKeys.iv(id), iv);
} else if (iv.length != ivLen) {
throw new IOException("IV length mismatch: expected " + ivLen + " bytes, got " + iv.length);
}
iv = new byte[ivLen];
rnd.nextBytes(iv);
putCtxBytes(ConfluxKeys.iv(id), iv.clone());
} else {
iv = getCtxBytes(ConfluxKeys.iv(id));
if (iv == null) {
throw new IOException("IV not found in context for AES " + spec.mode() + " decryption");
}
@@ -267,10 +272,7 @@ public final class AesCipherContext implements EncryptionContext, ContextAware {
}
cipher.updateAAD(aad);
if (LOG.isLoggable(Level.FINE)) {
LOG.log(Level.FINE, "GCM setup: tagBits={0} iv={1} aad={2}",
new Object[] { tagBits, Strings.toShortHexString(iv), Strings.toShortHexString(aad) });
}
LOG.log(Level.FINE, "GCM setup complete: tagBits={0}", tagBits);
break;
}
@@ -294,4 +296,86 @@ public final class AesCipherContext implements EncryptionContext, ContextAware {
ctx.put(key, value);
}
}
private void claimEncryption() throws IOException {
if (encrypt && !operationState.compareAndSet(OperationState.NEW, OperationState.ENCRYPTING)) {
throw new IOException("AES encryption context is single-use");
}
}
private void failEncryption() {
if (encrypt) {
operationState.set(OperationState.FAILED);
}
}
/** Single-use encryption lifecycle states. */
private enum OperationState {
NEW,
ENCRYPTING,
COMPLETED,
FAILED
}
/** Marks the owning encryption context terminal as its stream is consumed. */
private final class LifecycleInputStream extends FilterInputStream {
private boolean endOfInput;
private LifecycleInputStream(InputStream delegate) {
super(delegate);
}
@Override
public int read() throws IOException {
boolean successful = false;
try {
int value = super.read();
successful = true;
completeAtEnd(value < 0);
return value;
} finally {
if (!successful) {
failEncryption();
}
}
}
@Override
public int read(byte[] bytes, int offset, int length) throws IOException {
boolean successful = false;
try {
int count = super.read(bytes, offset, length);
successful = true;
completeAtEnd(count < 0);
return count;
} finally {
if (!successful) {
failEncryption();
}
}
}
@Override
public void close() throws IOException {
boolean successful = false;
try {
super.close();
successful = true;
if (!endOfInput) {
failEncryption();
}
} finally {
if (!successful) {
failEncryption();
}
}
}
private void completeAtEnd(boolean ended) {
if (ended) {
endOfInput = true;
operationState.compareAndSet(OperationState.ENCRYPTING, OperationState.COMPLETED);
}
}
}
}

View File

@@ -35,11 +35,13 @@ package zeroecho.core.alg.chacha;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.SequenceInputStream;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.concurrent.atomic.AtomicReference;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
@@ -52,6 +54,7 @@ import zeroecho.core.context.EncryptionContext;
import zeroecho.core.err.ProviderFailureException;
import zeroecho.core.io.CipherTransformInputStreamBuilder;
import zeroecho.core.spi.ContextAware;
import zeroecho.sdk.util.RandomSupport;
/**
* <h2>Abstract streaming cipher context for ChaCha algorithms</h2>
@@ -109,6 +112,7 @@ abstract class AbstractChaChaCipherContext<S extends ChaChaBaseSpec> implements
protected final S spec;
/** Secure random source for nonce generation. */
protected final SecureRandom rnd;
private final AtomicReference<OperationState> operationState = new AtomicReference<>(OperationState.NEW);
/** Optional per-operation context for exchanging headers, IVs, etc. */
protected CtxInterface ctx; // optional
@@ -127,7 +131,7 @@ abstract class AbstractChaChaCipherContext<S extends ChaChaBaseSpec> implements
this.key = key;
this.encrypt = encrypt;
this.spec = spec;
this.rnd = (rnd != null ? rnd : new SecureRandom());
this.rnd = (rnd != null ? rnd : RandomSupport.getRandom());
}
/** {@inheritDoc} */
@@ -188,6 +192,9 @@ abstract class AbstractChaChaCipherContext<S extends ChaChaBaseSpec> implements
*/
@Override
public InputStream attach(InputStream upstream) throws IOException {
java.util.Objects.requireNonNull(upstream, "upstream must not be null");
claimEncryption();
boolean attached = false;
try {
final SymmetricHeaderCodec header = spec.header();
final boolean hasCtxHeader = ctx != null && header != null;
@@ -198,7 +205,7 @@ abstract class AbstractChaChaCipherContext<S extends ChaChaBaseSpec> implements
}
final Cipher cipher = Cipher.getInstance(jceName());
final byte[] nonce = ensureNonce(); // generate or require from ctx
final byte[] nonce = ensureNonce();
initCipher(cipher, nonce);
InputStream out = // new Stream(in, cipher, jceName()); // same stream pattern as AES
@@ -210,9 +217,14 @@ abstract class AbstractChaChaCipherContext<S extends ChaChaBaseSpec> implements
header.writeHeader(hdr, algorithm, ctx);
out = new SequenceInputStream(new ByteArrayInputStream(hdr.toByteArray()), out);
}
return out;
attached = true;
return encrypt ? new LifecycleInputStream(out) : out;
} catch (GeneralSecurityException e) {
throw new ProviderFailureException(jceName() + " attach/init failed", e);
} finally {
if (!attached) {
failEncryption();
}
}
}
@@ -231,8 +243,8 @@ abstract class AbstractChaChaCipherContext<S extends ChaChaBaseSpec> implements
* Ensures a nonce is available in the context.
*
* <ul>
* <li>For encryption, generates a new nonce if absent and stores it in
* context.</li>
* <li>For encryption, always generates a new nonce after the context has
* been atomically claimed and stores a copy in the context.</li>
* <li>For decryption, validates presence and correct length.</li>
* </ul>
*
@@ -241,22 +253,101 @@ abstract class AbstractChaChaCipherContext<S extends ChaChaBaseSpec> implements
*/
private byte[] ensureNonce() throws IOException {
final String id = algorithm.id();
byte[] nonce = (ctx == null) ? null : ctx.get(ConfluxKeys.iv(id));
byte[] nonce;
if (encrypt) {
if (nonce == null) {
nonce = new byte[NONCE_LEN];
rnd.nextBytes(nonce);
if (ctx != null) { // NOPMD
ctx.put(ConfluxKeys.iv(id), nonce);
}
} else if (nonce.length != NONCE_LEN) {
throw new IOException("Nonce length mismatch: expected 12 bytes, got " + nonce.length);
nonce = new byte[NONCE_LEN];
rnd.nextBytes(nonce);
if (ctx != null) {
ctx.put(ConfluxKeys.iv(id), nonce.clone());
}
} else {
nonce = (ctx == null) ? null : ctx.get(ConfluxKeys.iv(id));
if (nonce == null || nonce.length != NONCE_LEN) {
throw new IOException("Nonce missing/invalid for " + jceName() + " decryption");
}
}
return nonce;
}
private void claimEncryption() throws IOException {
if (encrypt && !operationState.compareAndSet(OperationState.NEW, OperationState.ENCRYPTING)) {
throw new IOException(jceName() + " encryption context is single-use");
}
}
private void failEncryption() {
if (encrypt) {
operationState.set(OperationState.FAILED);
}
}
/** Single-use encryption lifecycle states. */
private enum OperationState {
NEW,
ENCRYPTING,
COMPLETED,
FAILED
}
/** Marks the owning encryption context terminal as its stream is consumed. */
private final class LifecycleInputStream extends FilterInputStream {
private boolean endOfInput;
private LifecycleInputStream(InputStream delegate) {
super(delegate);
}
@Override
public int read() throws IOException {
boolean successful = false;
try {
int value = super.read();
successful = true;
completeAtEnd(value < 0);
return value;
} finally {
if (!successful) {
failEncryption();
}
}
}
@Override
public int read(byte[] bytes, int offset, int length) throws IOException {
boolean successful = false;
try {
int count = super.read(bytes, offset, length);
successful = true;
completeAtEnd(count < 0);
return count;
} finally {
if (!successful) {
failEncryption();
}
}
}
@Override
public void close() throws IOException {
boolean successful = false;
try {
super.close();
successful = true;
if (!endOfInput) {
failEncryption();
}
} finally {
if (!successful) {
failEncryption();
}
}
}
private void completeAtEnd(boolean ended) {
if (ended) {
endOfInput = true;
operationState.compareAndSet(OperationState.ENCRYPTING, OperationState.COMPLETED);
}
}
}
}

View File

@@ -34,6 +34,7 @@
package zeroecho.core.alg.chacha;
import zeroecho.core.SymmetricHeaderCodec;
import zeroecho.sdk.util.RandomSupport;
/**
* <h2>ChaCha20-Poly1305 (AEAD) algorithm</h2>
@@ -101,12 +102,12 @@ public final class ChaCha20Poly1305Algorithm extends AbstractChaChaAlgorithm {
super("CHACHA20-POLY1305", "ChaCha20-Poly1305 (AEAD)");
capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.ENCRYPT,
zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class, ChaCha20Poly1305Spec.class,
(k, s) -> new ChaCha20Poly1305CipherContext(this, k, true, s, new java.security.SecureRandom()),
(k, s) -> new ChaCha20Poly1305CipherContext(this, k, true, s, RandomSupport.getRandom()),
() -> ChaCha20Poly1305Spec.builder().header(null).build());
capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.DECRYPT,
zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class, ChaCha20Poly1305Spec.class,
(k, s) -> new ChaCha20Poly1305CipherContext(this, k, false, s, new java.security.SecureRandom()),
(k, s) -> new ChaCha20Poly1305CipherContext(this, k, false, s, RandomSupport.getRandom()),
() -> ChaCha20Poly1305Spec.builder().header(null).build());
// VoidSpec defaults like AES-GCM
@@ -114,14 +115,14 @@ public final class ChaCha20Poly1305Algorithm extends AbstractChaChaAlgorithm {
zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class,
zeroecho.core.spec.VoidSpec.class,
(k, v) -> new ChaCha20Poly1305CipherContext(this, k, true,
ChaCha20Poly1305Spec.builder().header(null).build(), new java.security.SecureRandom()),
ChaCha20Poly1305Spec.builder().header(null).build(), RandomSupport.getRandom()),
() -> zeroecho.core.spec.VoidSpec.INSTANCE);
capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.DECRYPT,
zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class,
zeroecho.core.spec.VoidSpec.class,
(k, v) -> new ChaCha20Poly1305CipherContext(this, k, false,
ChaCha20Poly1305Spec.builder().header(null).build(), new java.security.SecureRandom()),
ChaCha20Poly1305Spec.builder().header(null).build(), RandomSupport.getRandom()),
() -> zeroecho.core.spec.VoidSpec.INSTANCE);
}
}

View File

@@ -67,10 +67,10 @@ import zeroecho.core.CryptoAlgorithm;
* ChaCha20Poly1305Spec spec = ChaCha20Poly1305Spec.builder().header(null).build();
*
* // Encrypt
* EncryptionContext enc = new ChaCha20Poly1305CipherContext(alg, key, true, spec, new SecureRandom());
* EncryptionContext enc = new ChaCha20Poly1305CipherContext(alg, key, true, spec, null);
*
* // Decrypt
* EncryptionContext dec = new ChaCha20Poly1305CipherContext(alg, key, false, spec, new SecureRandom());
* EncryptionContext dec = new ChaCha20Poly1305CipherContext(alg, key, false, spec, null);
* }</pre>
*
* @since 1.0

View File

@@ -33,6 +33,7 @@
******************************************************************************/
package zeroecho.core.alg.chacha;
import zeroecho.sdk.util.RandomSupport;
/**
* <h2>ChaCha20 (stream) algorithm</h2>
*
@@ -82,12 +83,12 @@ public final class ChaChaAlgorithm extends AbstractChaChaAlgorithm {
// ENCRYPT
capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.ENCRYPT,
zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class, ChaChaSpec.class,
(k, s) -> new ChaChaCipherContext(this, k, true, s, new java.security.SecureRandom()),
(k, s) -> new ChaChaCipherContext(this, k, true, s, RandomSupport.getRandom()),
() -> ChaChaSpec.builder().initialCounter(1).header(null).build());
// DECRYPT
capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.DECRYPT,
zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class, ChaChaSpec.class,
(k, s) -> new ChaChaCipherContext(this, k, false, s, new java.security.SecureRandom()),
(k, s) -> new ChaChaCipherContext(this, k, false, s, RandomSupport.getRandom()),
() -> ChaChaSpec.builder().initialCounter(1).header(null).build());
// VoidSpec defaults (mirrors AES)
@@ -95,14 +96,14 @@ public final class ChaChaAlgorithm extends AbstractChaChaAlgorithm {
zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class,
zeroecho.core.spec.VoidSpec.class,
(k, v) -> new ChaChaCipherContext(this, k, true,
ChaChaSpec.builder().initialCounter(1).header(null).build(), new java.security.SecureRandom()),
ChaChaSpec.builder().initialCounter(1).header(null).build(), RandomSupport.getRandom()),
() -> zeroecho.core.spec.VoidSpec.INSTANCE);
capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.DECRYPT,
zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class,
zeroecho.core.spec.VoidSpec.class,
(k, v) -> new ChaChaCipherContext(this, k, false,
ChaChaSpec.builder().initialCounter(1).header(null).build(), new java.security.SecureRandom()),
ChaChaSpec.builder().initialCounter(1).header(null).build(), RandomSupport.getRandom()),
() -> zeroecho.core.spec.VoidSpec.INSTANCE);
}
}

View File

@@ -78,23 +78,22 @@
* <h2>Runtime parameters and context exchange</h2>
* <p>
* Streaming contexts exchange ephemeral parameters through a Conflux session
* context using namespaced keys. For ChaCha20 and ChaCha20-Poly1305, a 12-byte
* nonce is required for each operation. On encryption, if the session context
* does not provide a nonce, the context generates a fresh value and stores it
* back into the session; on decryption the nonce must already be present and
* have the correct length. ChaCha20 also uses an initial counter sourced from
* {@link ChaChaSpec} and optionally overridden by the session context. When a
* header codec is configured and a session context is set, encryption prepends
* a minimal header and decryption reads it first to hydrate the session before
* initializing the cipher.
* context using namespaced keys. Each ChaCha20 and ChaCha20-Poly1305 encryption
* context is single-use: after an atomic claim it generates a fresh 12-byte
* nonce and stores it back into the session. On decryption the nonce must
* already be present and have the correct length. ChaCha20 also uses an initial
* counter sourced from {@link ChaChaSpec} and optionally overridden by the
* session context. When a header codec is configured and a session context is
* set, encryption prepends a minimal header and decryption reads it first to
* hydrate the session before initializing the cipher.
* </p>
*
* <h2>Safety and validation</h2>
* <ul>
* <li><b>Nonce uniqueness:</b> Applications must ensure nonces are unique per
* key. The contexts will generate nonces for encryption, but cross-process
* uniqueness is the caller's responsibility. Decryption fails if a nonce is
* missing or has an unexpected size.</li>
* <li><b>Nonce lifecycle:</b> Each encryption context is single-use and
* generates its nonce internally after an atomic operation claim. Decryption
* consumes the encoded or out-of-band nonce and fails if it is missing or has
* an unexpected size.</li>
*
* <li><b>Counter policy (ChaCha20):</b> The default initial counter is 1. A
* context may override the spec value through the session key dedicated to

View File

@@ -94,11 +94,11 @@ import zeroecho.sdk.content.api.PlainContent;
* try (InputStream s = dec.getStream()) { s.transferTo(Files.newOutputStream(ptPath)); }
* }</pre>
*
* <h2>Runtime parameters</h2> Optional IV and AAD can be provided via
* {@link #withIv(byte[])} and {@link #withAad(byte[])}. If a Conflux context is
* present (set via {@link #context(conflux.CtxInterface)} or implied by a
* header), a fresh IV is generated on encrypt when absent and stored back into
* the context. For GCM, AAD defaults to empty.
* <h2>Runtime parameters</h2> Optional AAD can be provided via
* {@link #withAad(byte[])}. Encryption always generates its IV internally.
* Headerless decryption may receive an out-of-band IV through
* {@link #withDecryptionIv(byte[])}; that configuration is rejected when
* building an encrypting pipeline. For GCM, AAD defaults to empty.
*
* <h2>Threadsafety</h2> The builder is not threadsafe. Built
* {@code DataContent} instances are independent.
@@ -114,7 +114,7 @@ public final class AesDataContentBuilder implements DataContentBuilder<DataConte
private final AesSpec.Builder _spec = AesSpec.builder();
private byte[] iv; // optional
private byte[] decryptionIv; // optional
private byte[] aad; // optional
private SymmetricHeaderCodec headerCodec; // optional; carried by AesSpec
@@ -264,15 +264,14 @@ public final class AesDataContentBuilder implements DataContentBuilder<DataConte
}
/**
* Supplies an explicit IV/nonce. If omitted during ENCRYPT and a context is
* present, a fresh IV is generated and stored in the context.
* Supplies an out-of-band IV for decryption without an encoded header.
*
* @param iv the IV (12 bytes for GCM, 16 bytes for CBC/CTR)
* @return this builder
* @throws IllegalArgumentException if the length does not match the mode
* @throws NullPointerException if {@code iv} is {@code null}
*/
public AesDataContentBuilder withIv(byte[] iv) {
this.iv = iv;
public AesDataContentBuilder withDecryptionIv(byte[] iv) {
this.decryptionIv = Objects.requireNonNull(iv, "iv must not be null").clone();
return this;
}
@@ -330,7 +329,7 @@ public final class AesDataContentBuilder implements DataContentBuilder<DataConte
* <li>Resolves the key from {@link #withKey(SecretKey)} or the selected
* generate/import spec.</li>
* <li>Finalizes {@link AesSpec} (injecting the header if configured).</li>
* <li>Propagates IV/AAD into the context if supplied.</li>
* <li>Propagates a decryption IV and AAD into the context when supplied.</li>
* </ul>
*
* @param encrypt {@code true} for encryption; {@code false} for decryption
@@ -340,6 +339,9 @@ public final class AesDataContentBuilder implements DataContentBuilder<DataConte
*/
@Override
public DataContent build(boolean encrypt) {
if (encrypt && decryptionIv != null) {
throw new IllegalStateException("A caller-supplied IV is valid only for AES decryption");
}
final SecretKey key = resolveKey();
// finalize AesSpec from builder + header
@@ -358,11 +360,11 @@ public final class AesDataContentBuilder implements DataContentBuilder<DataConte
}
ctx.put(ConfluxKeys.aad(ALGORITHM_ID), aad);
}
if (iv != null && iv.length > 0) {
if (decryptionIv != null && decryptionIv.length > 0) {
if (ctx == null) {
ctx = Ctx.INSTANCE.getContext("aes-ctx-" + System.nanoTime());
}
ctx.put(ConfluxKeys.iv(ALGORITHM_ID), iv);
ctx.put(ConfluxKeys.iv(ALGORITHM_ID), decryptionIv.clone());
}
return encrypt ? new EncryptContent(key, aesSpec, ctx) : new DecryptContent(key, aesSpec, ctx);
@@ -526,8 +528,7 @@ public final class AesDataContentBuilder implements DataContentBuilder<DataConte
if (!(enc instanceof ContextAware)) {
throw new IllegalStateException("AES context is not ContextAware; cannot pass conflux Ctx");
}
((ContextAware) enc).setContext(ctx); // ctx may be null → header disabled; IV generated only if ctx
// present
((ContextAware) enc).setContext(ctx);
return enc.attach(upstream.getStream());
}
}

View File

@@ -95,9 +95,9 @@ import zeroecho.sdk.content.api.PlainContent;
* is supplied via {@link #withHeaderCodec(SymmetricHeaderCodec)}, a context is
* required; if none was provided, a temporary one is created internally. A
* default header codec is chosen based on the variant when no custom codec is
* set. The nonce is typically 12 bytes; if absent during encryption and a
* context is available, an implementation-specific nonce may be generated and
* stored in the context.
* set. Encryption generates a 12-byte nonce internally after claiming its
* single-use context. Headerless decryption may receive a nonce through the
* decryption-only configuration method.
*
* <h2>Usage examples</h2> <pre>{@code
* // 1) ChaCha20 stream encryption with generated key and header
@@ -106,10 +106,10 @@ import zeroecho.sdk.content.api.PlainContent;
* .withHeader()
* .build(true);
*
* // 2) ChaCha20-Poly1305 decryption with supplied key, nonce, and AAD
* // 2) Headerless ChaCha20-Poly1305 decryption with supplied key, nonce, and AAD
* DataContent dec = ChaChaDataContentBuilder.builder()
* .withKey(secretKey)
* .withNonce(nonce12) // 12 bytes
* .withDecryptionNonce(nonce12) // 12 bytes
* .withAad("meta".getBytes(StandardCharsets.UTF_8))
* .build(false);
*
@@ -150,7 +150,7 @@ public final class ChaChaDataContentBuilder implements DataContentBuilder<DataCo
private ChaChaKeyImportSpec importSpec;
private CtxInterface ctx;
private byte[] nonce;
private byte[] decryptionNonce;
private int initialCounter = 1;
private boolean initialCounterSet; // = false;
@@ -300,19 +300,20 @@ public final class ChaChaDataContentBuilder implements DataContentBuilder<DataCo
}
/**
* Sets an optional nonce value.
* Sets an out-of-band nonce for headerless decryption.
*
* <p>
* For ChaCha20 and ChaCha20-Poly1305 this is typically 12 bytes. If absent
* during encryption and a context is in use, an implementation-specific nonce
* may be generated and placed into the context.
* For ChaCha20 and ChaCha20-Poly1305 this must be 12 bytes. The value is
* rejected when building an encrypting pipeline because encryption always
* generates its nonce internally.
* </p>
*
* @param nonce the nonce bytes; may be null
* @param nonce the nonce bytes; must not be null
* @return {@code this} builder for chaining
* @throws NullPointerException if {@code nonce} is {@code null}
*/
public ChaChaDataContentBuilder withNonce(byte[] nonce) {
this.nonce = nonce;
public ChaChaDataContentBuilder withDecryptionNonce(byte[] nonce) {
this.decryptionNonce = Objects.requireNonNull(nonce, "nonce must not be null").clone();
return this;
}
@@ -415,8 +416,8 @@ public final class ChaChaDataContentBuilder implements DataContentBuilder<DataCo
*
* <p>
* If a header is requested and no context is provided, a temporary context is
* created. When encrypting with a context, if no nonce is supplied, the
* implementation may generate one and store it into the context.
* created. Encryption generates its nonce internally; a configured decryption
* nonce is rejected for encrypting pipelines.
* </p>
*
* <pre>{@code
@@ -435,20 +436,23 @@ public final class ChaChaDataContentBuilder implements DataContentBuilder<DataCo
*/
@Override
public DataContent build(boolean encrypt) { // NOPMD
if (encrypt && decryptionNonce != null) {
throw new IllegalStateException("A caller-supplied nonce is valid only for ChaCha decryption");
}
final Variant v = inferVariant();
final String algId = v == Variant.AEAD ? "CHACHA20-POLY1305" : "CHACHA20";
final SecretKey key = resolveKey(algId);
// header → ensure ctx exists
if ((headerRequested || headerCodec != null) && ctx == null) {
if ((headerRequested || headerCodec != null || decryptionNonce != null) && ctx == null) {
ctx = Ctx.INSTANCE
.getContext("chacha-" + (v == Variant.AEAD ? "aead" : "stream") + "-" + System.nanoTime());
}
// fill ctx with runtime params
if (ctx != null) {
if (nonce != null && nonce.length > 0) {
ctx.put(ConfluxKeys.iv(algId), nonce);
if (decryptionNonce != null && decryptionNonce.length > 0) {
ctx.put(ConfluxKeys.iv(algId), decryptionNonce.clone());
}
if (v == Variant.AEAD) {
if (aad != null) { // NOPMD

View File

@@ -61,8 +61,6 @@ import zeroecho.sdk.hybrid.kex.HybridKexExporter;
* </p>
* <ul>
* <li>{@code label + "/key"} for the secret key</li>
* <li>{@code label + "/iv"} for AES IV</li>
* <li>{@code label + "/nonce"} for ChaCha nonce</li>
* <li>{@code label + "/aad"} for AEAD AAD (optional, if derived)</li>
* </ul>
*
@@ -190,7 +188,7 @@ public final class HybridDerived {
}
/**
* Derives an AES key and applies it (and optional IV/AAD) to the provided AES
* Derives an AES key and applies it with optional AAD to the provided AES
* builder.
*
* <p>
@@ -200,15 +198,12 @@ public final class HybridDerived {
*
* @param aes AES builder to configure (must not be null)
* @param keyBits AES key size in bits (128/192/256)
* @param ivLenBytes if &gt; 0, derive IV of this length and inject it via
* {@code withIv(...)}; if 0, do not set IV (header/ctx may
* generate it)
* @return the provided builder instance
* @throws NullPointerException if aes is null
* @throws IllegalArgumentException if keyBits is invalid
* @since 1.0
*/
public AesDataContentBuilder applyToAesGcm(AesDataContentBuilder aes, int keyBits, int ivLenBytes) {
public AesDataContentBuilder applyToAesGcm(AesDataContentBuilder aes, int keyBits) {
Objects.requireNonNull(aes, "aes");
validateBase();
@@ -221,11 +216,6 @@ public final class HybridDerived {
Arrays.fill(keyRaw, (byte) 0);
}
if (ivLenBytes > 0) {
byte[] iv = exportBytes(label + "/iv", ivLenBytes);
aes.withIv(iv);
}
byte[] aad = resolveAad();
if (aad != null) {
aes.withAad(aad);
@@ -235,7 +225,7 @@ public final class HybridDerived {
}
/**
* Derives a ChaCha key and applies it (and optional nonce/AAD) to the provided
* Derives a ChaCha key and applies it with optional AAD to the provided
* ChaCha builder.
*
* <p>
@@ -245,15 +235,12 @@ public final class HybridDerived {
*
* @param chacha ChaCha builder to configure (must not be null)
* @param keyBits key size in bits (typically 256)
* @param nonceLenBytes if &gt; 0, derive nonce of this length and inject it via
* {@code withNonce(...)}; if 0, do not set nonce
* (header/ctx may generate it)
* @return the provided builder instance
* @throws NullPointerException if chacha is null
* @throws IllegalArgumentException if keyBits is invalid
* @since 1.0
*/
public ChaChaDataContentBuilder applyToChaChaAead(ChaChaDataContentBuilder chacha, int keyBits, int nonceLenBytes) {
public ChaChaDataContentBuilder applyToChaChaAead(ChaChaDataContentBuilder chacha, int keyBits) {
Objects.requireNonNull(chacha, "chacha");
validateBase();
@@ -266,11 +253,6 @@ public final class HybridDerived {
Arrays.fill(keyRaw, (byte) 0);
}
if (nonceLenBytes > 0) {
byte[] nonce = exportBytes(label + "/nonce", nonceLenBytes);
chacha.withNonce(nonce);
}
byte[] aad = resolveAad();
if (aad != null) {
chacha.withAad(aad);