security(lib,storage): enforce single-use encryption contexts, encrypt keyring and harden key import

This commit is contained in:
2026-07-29 23:18:22 +02:00
parent 9bbcab7522
commit 8b2f3df41f
64 changed files with 7473 additions and 1799 deletions

View File

@@ -47,7 +47,7 @@ import zeroecho.core.context.EncryptionContext;
import zeroecho.core.spec.VoidSpec;
import zeroecho.core.spi.SymmetricKeyGenerator;
import zeroecho.core.spi.SymmetricKeyImporter;
import zeroecho.sdk.util.RandomSupport;
import zeroecho.core.util.RandomSupport;
/**
* AES algorithm registration and capability wiring.

View File

@@ -56,7 +56,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;
import zeroecho.core.util.RandomSupport;
/**
* Streaming AES cipher context for GCM / CBC / CTR.

View File

@@ -54,7 +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;
import zeroecho.core.util.RandomSupport;
/**
* <h2>Abstract streaming cipher context for ChaCha algorithms</h2>

View File

@@ -34,7 +34,7 @@
package zeroecho.core.alg.chacha;
import zeroecho.core.SymmetricHeaderCodec;
import zeroecho.sdk.util.RandomSupport;
import zeroecho.core.util.RandomSupport;
/**
* <h2>ChaCha20-Poly1305 (AEAD) algorithm</h2>

View File

@@ -33,7 +33,7 @@
******************************************************************************/
package zeroecho.core.alg.chacha;
import zeroecho.sdk.util.RandomSupport;
import zeroecho.core.util.RandomSupport;
/**
* <h2>ChaCha20 (stream) algorithm</h2>
*

View File

@@ -0,0 +1,29 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core.spi;
import java.io.IOException;
import zeroecho.core.storage.KeyringPassword;
/**
* Supplies a fresh destroyable password for one keyring open operation.
*
* <p>Ownership of the returned object transfers to the receiver, which must
* destroy it in a {@code finally} block immediately after the keyring has been
* opened. Implementations must not source passwords from immutable strings,
* process arguments, system properties, environment fallbacks, persistent
* files, or global mutable state.</p>
*/
@FunctionalInterface
public interface KeyringUnlockProvider {
/**
* Supplies unlock material for one operation.
*
* @return a fresh password owner whose ownership transfers to the receiver
* @throws IOException if unlock material cannot be supplied
*/
KeyringPassword acquire() throws IOException;
}

View File

@@ -0,0 +1,58 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core.storage;
import java.io.IOException;
import java.util.Objects;
/**
* Redacted checked failure raised by encrypted keyring operations.
*
* <p>The public message contains only the stable error code. Filesystem paths,
* aliases, key material, ciphertext, and provider-controlled messages are
* deliberately excluded.</p>
*/
public final class KeyringException extends IOException {
private static final long serialVersionUID = 1L;
/**
* Stable keyring failure categories.
*/
public enum Code {
KEYRING_ALREADY_OPEN,
KEYRING_FILESYSTEM_UNSUPPORTED,
KEYRING_FORMAT_INVALID,
KEYRING_LIMIT_EXCEEDED,
KEYRING_UNLOCK_FAILED,
KEYRING_IO_FAILED,
KEYRING_DURABILITY_UNCONFIRMED,
KEYRING_CLOSED,
KEYRING_NON_EXPORTABLE_KEY,
KEYRING_IMPORT_MAPPING_INVALID,
KEYRING_IMPORT_METADATA_INVALID,
KEYRING_KEY_NOT_CANONICALIZABLE
}
private final Code code;
/**
* Creates a redacted keyring exception.
*
* @param code stable error code
*/
public KeyringException(Code code) {
super(Objects.requireNonNull(code, "code must not be null").name());
this.code = code;
}
/**
* Returns the stable error code.
*
* @return failure code
*/
public Code code() {
return code;
}
}

View File

@@ -0,0 +1,97 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core.storage;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.FileAttribute;
import java.nio.file.attribute.PosixFilePermission;
import java.util.Set;
/**
* Narrow package-local boundary for atomic keyring persistence operations.
*/
interface KeyringFileOperations {
KeyringFileOperations NIO = new NioKeyringFileOperations();
/** Atomic persistence destination. */
enum Target {
MAIN_IMAGE,
NONCE_RESERVATION
}
/** Creates one owner-only temporary file beside its destination. */
Path createTemporary(Target target, Path parent, String prefix, String suffix,
FileAttribute<Set<PosixFilePermission>> permissions) throws IOException;
/** Writes the complete encrypted image to its temporary file. */
void writeTemporary(Target target, Path temporary, byte[] image) throws IOException;
/** Forces temporary-file contents to durable storage. */
void forceTemporary(Target target, Path temporary) throws IOException;
/** Atomically replaces the destination with the temporary file. */
void atomicReplace(Target target, Path temporary, Path destination) throws IOException;
/** Forces the destination directory after atomic replacement. */
void forceDirectory(Target target, Path parent) throws IOException;
/** Deletes a temporary file that did not become authoritative. */
void deleteTemporary(Target target, Path temporary) throws IOException;
}
/** Production NIO implementation of the atomic persistence boundary. */
final class NioKeyringFileOperations implements KeyringFileOperations {
@Override
public Path createTemporary(Target target, Path parent, String prefix, String suffix,
FileAttribute<Set<PosixFilePermission>> permissions) throws IOException {
return Files.createTempFile(parent, prefix, suffix, permissions);
}
@Override
public void writeTemporary(Target target, Path temporary, byte[] image)
throws IOException {
try (FileChannel channel = FileChannel.open(temporary,
StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
ByteBuffer buffer = ByteBuffer.wrap(image);
while (buffer.hasRemaining()) {
channel.write(buffer);
}
}
}
@Override
public void forceTemporary(Target target, Path temporary) throws IOException {
try (FileChannel channel = FileChannel.open(temporary,
StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
channel.force(true);
}
}
@Override
public void atomicReplace(Target target, Path temporary, Path destination)
throws IOException {
Files.move(temporary, destination, StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
}
@Override
public void forceDirectory(Target target, Path parent) throws IOException {
try (FileChannel directory = FileChannel.open(parent, StandardOpenOption.READ)) {
directory.force(true);
}
}
@Override
public void deleteTemporary(Target target, Path temporary) throws IOException {
Files.deleteIfExists(temporary);
}
}

View File

@@ -0,0 +1,452 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core.storage;
import java.security.GeneralSecurityException;
import java.security.Key;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import javax.security.auth.DestroyFailedException;
import javax.security.auth.Destroyable;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.KeyOperation;
import zeroecho.core.KeyOperationInfo;
import zeroecho.core.alg.aes.AesKeyImportSpec;
import zeroecho.core.alg.bike.BikePrivateKeySpec;
import zeroecho.core.alg.bike.BikePublicKeySpec;
import zeroecho.core.alg.chacha.ChaChaKeyImportSpec;
import zeroecho.core.alg.cmce.CmcePrivateKeySpec;
import zeroecho.core.alg.cmce.CmcePublicKeySpec;
import zeroecho.core.alg.dh.DhPrivateKeySpec;
import zeroecho.core.alg.dh.DhPublicKeySpec;
import zeroecho.core.alg.ecdsa.EcdsaPrivateKeySpec;
import zeroecho.core.alg.ecdsa.EcdsaPublicKeySpec;
import zeroecho.core.alg.ed25519.Ed25519PrivateKeySpec;
import zeroecho.core.alg.ed25519.Ed25519PublicKeySpec;
import zeroecho.core.alg.ed448.Ed448PrivateKeySpec;
import zeroecho.core.alg.ed448.Ed448PublicKeySpec;
import zeroecho.core.alg.elgamal.ElgamalPrivateKeySpec;
import zeroecho.core.alg.elgamal.ElgamalPublicKeySpec;
import zeroecho.core.alg.frodo.FrodoPrivateKeySpec;
import zeroecho.core.alg.frodo.FrodoPublicKeySpec;
import zeroecho.core.alg.hmac.HmacKeyImportSpec;
import zeroecho.core.alg.hqc.HqcPrivateKeySpec;
import zeroecho.core.alg.hqc.HqcPublicKeySpec;
import zeroecho.core.alg.kyber.KyberPrivateKeySpec;
import zeroecho.core.alg.kyber.KyberPublicKeySpec;
import zeroecho.core.alg.mldsa.MldsaPrivateKeySpec;
import zeroecho.core.alg.mldsa.MldsaPublicKeySpec;
import zeroecho.core.alg.ntru.NtruPrivateKeySpec;
import zeroecho.core.alg.ntru.NtruPublicKeySpec;
import zeroecho.core.alg.ntruprime.NtrulPrimePrivateKeySpec;
import zeroecho.core.alg.ntruprime.NtrulPrimePublicKeySpec;
import zeroecho.core.alg.ntruprime.SntruPrimePrivateKeySpec;
import zeroecho.core.alg.ntruprime.SntruPrimePublicKeySpec;
import zeroecho.core.alg.rsa.RsaPrivateKeySpec;
import zeroecho.core.alg.rsa.RsaPublicKeySpec;
import zeroecho.core.alg.saber.SaberPrivateKeySpec;
import zeroecho.core.alg.saber.SaberPublicKeySpec;
import zeroecho.core.alg.slhdsa.SlhDsaPrivateKeySpec;
import zeroecho.core.alg.slhdsa.SlhDsaPublicKeySpec;
import zeroecho.core.alg.sphincsplus.SphincsPlusPrivateKeySpec;
import zeroecho.core.alg.sphincsplus.SphincsPlusPublicKeySpec;
import zeroecho.core.alg.xdh.XdhPrivateKeySpec;
import zeroecho.core.alg.xdh.XdhPublicKeySpec;
import zeroecho.core.spec.AlgorithmKeySpec;
import zeroecho.core.spi.PrivateKeyImporter;
import zeroecho.core.spi.PublicKeyImporter;
import zeroecho.core.spi.SymmetricKeyImporter;
/**
* Closed trusted mapping from persistent key identities to canonical registered
* import operations.
*
* <p>Provider identity is deliberately absent. Standard encoded key material
* is reconstructed by the current runtime's canonical ZeroEcho importer. The
* original JCA provider is neither persisted nor reproduced.</p>
*/
final class KeyringImportRegistry {
private static final String ALGORITHM_AES = "AES";
private static final String ALGORITHM_HMAC = "HMAC";
private static final String ALGORITHM_CHACHA20 = "CHACHA20";
private static final String ALGORITHM_CHACHA20_POLY1305 = "CHACHA20-POLY1305";
private static final Map<Class<? extends AlgorithmKeySpec>,
Function<byte[], ? extends AlgorithmKeySpec>> SPEC_FACTORIES =
createSpecFactories();
private static final Map<Tuple, PersistentMapping> MAPPINGS = createMappings();
private KeyringImportRegistry() {
}
/**
* Closed HMAC variants admitted by the persistent format.
*/
/* default */
enum HmacVariant {
NONE(0, null),
SHA256(1, "HmacSHA256"),
SHA384(2, "HmacSHA384"),
SHA512(3, "HmacSHA512");
private final int code;
private final String jcaName;
HmacVariant(int code, String jcaName) {
this.code = code;
this.jcaName = jcaName;
}
/* default */ int code() {
return code;
}
/* default */ String jcaName() {
return jcaName;
}
/* default */ static HmacVariant fromCode(int code) throws KeyringException {
for (HmacVariant value : values()) {
if (value.code == code) {
return value;
}
}
throw new KeyringException(KeyringException.Code.KEYRING_IMPORT_METADATA_INVALID);
}
/* default */ static HmacVariant forStoredKey(String algorithmId, Key key)
throws KeyringException {
if (!ALGORITHM_HMAC.equals(algorithmId)) {
return NONE;
}
String algorithm = key.getAlgorithm();
for (HmacVariant value : values()) {
if (value != NONE && value.jcaName.equals(algorithm)) {
return value;
}
}
throw new KeyringException(KeyringException.Code.KEYRING_IMPORT_METADATA_INVALID);
}
}
/**
* Immutable description used by the finite importer-matrix test.
*
* @param algorithmId canonical ZeroEcho algorithm identifier
* @param kind key kind
* @param encoding standard encoding
* @param hmacVariant closed HMAC variant, or {@link HmacVariant#NONE}
* @param specType exact registered importer specification type
*/
/* default */
record PersistentMapping(String algorithmId, KeyringStore.Kind kind,
KeyringStore.Encoding encoding, HmacVariant hmacVariant,
Class<? extends AlgorithmKeySpec> specType) {
}
@SuppressWarnings("PMD.AvoidCatchingGenericException")
/* default */ static Key importKey(String algorithmId, KeyringStore.Kind kind,
KeyringStore.Encoding encoding, HmacVariant hmacVariant, byte[] encoded)
throws GeneralSecurityException, KeyringException {
PersistentMapping mapping = requireMapping(algorithmId, kind, encoding, hmacVariant);
CryptoAlgorithm algorithm = requireAlgorithm(mapping.algorithmId);
AlgorithmKeySpec spec = createSpec(mapping, encoded);
Throwable primary = null;
try {
return invokeImporter(algorithm, kind, spec);
} catch (GeneralSecurityException | RuntimeException exception) {
primary = exception;
throw exception;
} finally {
KeyringStore.destroyTemporarySpec(spec, primary);
}
}
/* default */ static void validateMapping(String algorithmId, KeyringStore.Kind kind,
KeyringStore.Encoding encoding, HmacVariant hmacVariant)
throws KeyringException {
PersistentMapping mapping = requireMapping(algorithmId, kind, encoding, hmacVariant);
CryptoAlgorithm algorithm = requireAlgorithm(mapping.algorithmId);
List<KeyOperationInfo> operations = matchingOperations(algorithm, kind);
if (operations.size() != 1 || operations.get(0).specType() != mapping.specType) {
throw new KeyringException(KeyringException.Code.KEYRING_IMPORT_MAPPING_INVALID);
}
}
@SuppressWarnings({ "PMD.PreserveStackTrace", "PMD.AvoidCatchingGenericException" })
/* default */ static void validateCanonical(String algorithmId, KeyringStore.Kind kind,
KeyringStore.Encoding encoding, HmacVariant hmacVariant, Key source,
byte[] encoded)
throws KeyringException {
Key imported = null;
byte[] canonical = null;
try {
if (!matchesSourceAlgorithm(source, algorithmId, hmacVariant)) {
throw new KeyringException(
KeyringException.Code.KEYRING_KEY_NOT_CANONICALIZABLE);
}
imported = importKey(algorithmId, kind, encoding, hmacVariant, encoded);
canonical = imported.getEncoded();
if (canonical == null || !matchesFormat(imported.getFormat(), encoding)
|| !MessageDigest.isEqual(encoded, canonical)
|| !matchesAlgorithm(imported, algorithmId, hmacVariant)) {
throw new KeyringException(
KeyringException.Code.KEYRING_KEY_NOT_CANONICALIZABLE);
}
} catch (GeneralSecurityException | RuntimeException exception) {
throw new KeyringException(
KeyringException.Code.KEYRING_KEY_NOT_CANONICALIZABLE);
} finally {
if (canonical != null) {
Arrays.fill(canonical, (byte) 0);
}
destroyImported(imported);
}
}
/* default */ static List<PersistentMapping> mappings() {
return List.copyOf(MAPPINGS.values());
}
private static PersistentMapping requireMapping(String algorithmId,
KeyringStore.Kind kind, KeyringStore.Encoding encoding,
HmacVariant hmacVariant) throws KeyringException {
PersistentMapping mapping = MAPPINGS.get(
new Tuple(algorithmId, kind, encoding, hmacVariant));
if (mapping == null) {
throw new KeyringException(KeyringException.Code.KEYRING_IMPORT_MAPPING_INVALID);
}
return mapping;
}
@SuppressWarnings("PMD.PreserveStackTrace")
private static CryptoAlgorithm requireAlgorithm(String algorithmId)
throws KeyringException {
try {
return CryptoAlgorithms.require(algorithmId);
} catch (IllegalArgumentException exception) {
throw new KeyringException(KeyringException.Code.KEYRING_IMPORT_MAPPING_INVALID);
}
}
private static List<KeyOperationInfo> matchingOperations(CryptoAlgorithm algorithm,
KeyringStore.Kind kind) {
return algorithm.keyOperations().stream()
.filter(info -> info.operation() == operation(kind))
.toList();
}
private static KeyOperation operation(KeyringStore.Kind kind) {
return switch (kind) {
case PUBLIC_KEY -> KeyOperation.ASYMMETRIC_PUBLIC_IMPORT;
case PRIVATE_KEY -> KeyOperation.ASYMMETRIC_PRIVATE_IMPORT;
case SECRET_KEY -> KeyOperation.SYMMETRIC_IMPORT;
};
}
@SuppressWarnings({ "rawtypes", "unchecked" })
private static Key invokeImporter(CryptoAlgorithm algorithm, KeyringStore.Kind kind,
AlgorithmKeySpec spec) throws GeneralSecurityException {
return switch (kind) {
case PUBLIC_KEY -> ((PublicKeyImporter) algorithm.publicKeyImporter(spec.getClass()))
.importPublic(spec);
case PRIVATE_KEY -> ((PrivateKeyImporter) algorithm.privateKeyImporter(spec.getClass()))
.importPrivate(spec);
case SECRET_KEY -> ((SymmetricKeyImporter) algorithm.symmetricKeyImporter(spec.getClass()))
.importSecret(spec);
};
}
private static AlgorithmKeySpec createSpec(PersistentMapping mapping, byte[] encoded)
throws KeyringException {
if (mapping.specType == HmacKeyImportSpec.class) {
return new HmacKeyImportSpec(mapping.hmacVariant.jcaName, encoded);
}
Function<byte[], ? extends AlgorithmKeySpec> factory =
SPEC_FACTORIES.get(mapping.specType);
AlgorithmKeySpec result = factory == null ? null : factory.apply(encoded);
if (result == null) {
throw new KeyringException(KeyringException.Code.KEYRING_IMPORT_MAPPING_INVALID);
}
return result;
}
private static boolean matchesFormat(String actual, KeyringStore.Encoding encoding) {
return actual != null && switch (encoding) {
case X509 -> "X.509".equalsIgnoreCase(actual) || "X509".equalsIgnoreCase(actual);
case PKCS8 -> "PKCS#8".equalsIgnoreCase(actual) || "PKCS8".equalsIgnoreCase(actual);
case RAW -> "RAW".equalsIgnoreCase(actual);
};
}
private static boolean matchesAlgorithm(Key imported, String algorithmId,
HmacVariant hmacVariant) {
if (ALGORITHM_HMAC.equals(algorithmId)) {
return hmacVariant.jcaName.equals(imported.getAlgorithm());
}
if (ALGORITHM_AES.equals(algorithmId)) {
return ALGORITHM_AES.equals(imported.getAlgorithm());
}
if (ALGORITHM_CHACHA20.equals(algorithmId)
|| ALGORITHM_CHACHA20_POLY1305.equals(algorithmId)) {
return "ChaCha20".equals(imported.getAlgorithm());
}
return true;
}
private static boolean matchesSourceAlgorithm(Key source, String algorithmId,
HmacVariant hmacVariant) {
if (ALGORITHM_HMAC.equals(algorithmId)) {
return hmacVariant.jcaName.equals(source.getAlgorithm());
}
if (ALGORITHM_AES.equals(algorithmId)) {
return ALGORITHM_AES.equals(source.getAlgorithm());
}
if (ALGORITHM_CHACHA20.equals(algorithmId)
|| ALGORITHM_CHACHA20_POLY1305.equals(algorithmId)) {
return "ChaCha20".equals(source.getAlgorithm());
}
return true;
}
@SuppressWarnings("PMD.EmptyCatchBlock")
private static void destroyImported(Key imported) {
if (imported instanceof Destroyable destroyable) {
try {
destroyable.destroy();
} catch (DestroyFailedException exception) {
// Some JCA key implementations advertise but do not support destruction.
}
}
}
private static Map<Class<? extends AlgorithmKeySpec>,
Function<byte[], ? extends AlgorithmKeySpec>> createSpecFactories() {
return Map.ofEntries(
Map.entry(AesKeyImportSpec.class, AesKeyImportSpec::fromRaw),
Map.entry(ChaChaKeyImportSpec.class, ChaChaKeyImportSpec::fromRaw),
Map.entry(BikePublicKeySpec.class, BikePublicKeySpec::new),
Map.entry(BikePrivateKeySpec.class, BikePrivateKeySpec::new),
Map.entry(CmcePublicKeySpec.class, CmcePublicKeySpec::new),
Map.entry(CmcePrivateKeySpec.class, CmcePrivateKeySpec::new),
Map.entry(DhPublicKeySpec.class, DhPublicKeySpec::new),
Map.entry(DhPrivateKeySpec.class, DhPrivateKeySpec::new),
Map.entry(EcdsaPublicKeySpec.class, EcdsaPublicKeySpec::new),
Map.entry(EcdsaPrivateKeySpec.class, EcdsaPrivateKeySpec::new),
Map.entry(Ed25519PublicKeySpec.class, Ed25519PublicKeySpec::new),
Map.entry(Ed25519PrivateKeySpec.class, Ed25519PrivateKeySpec::new),
Map.entry(Ed448PublicKeySpec.class, Ed448PublicKeySpec::new),
Map.entry(Ed448PrivateKeySpec.class, Ed448PrivateKeySpec::new),
Map.entry(ElgamalPublicKeySpec.class, ElgamalPublicKeySpec::new),
Map.entry(ElgamalPrivateKeySpec.class, ElgamalPrivateKeySpec::new),
Map.entry(FrodoPublicKeySpec.class, FrodoPublicKeySpec::new),
Map.entry(FrodoPrivateKeySpec.class, FrodoPrivateKeySpec::new),
Map.entry(HqcPublicKeySpec.class, HqcPublicKeySpec::new),
Map.entry(HqcPrivateKeySpec.class, HqcPrivateKeySpec::new),
Map.entry(KyberPublicKeySpec.class, KyberPublicKeySpec::new),
Map.entry(KyberPrivateKeySpec.class, KyberPrivateKeySpec::new),
Map.entry(MldsaPublicKeySpec.class, MldsaPublicKeySpec::new),
Map.entry(MldsaPrivateKeySpec.class, MldsaPrivateKeySpec::new),
Map.entry(NtruPublicKeySpec.class, NtruPublicKeySpec::new),
Map.entry(NtruPrivateKeySpec.class, NtruPrivateKeySpec::new),
Map.entry(NtrulPrimePublicKeySpec.class, NtrulPrimePublicKeySpec::new),
Map.entry(NtrulPrimePrivateKeySpec.class, NtrulPrimePrivateKeySpec::new),
Map.entry(SntruPrimePublicKeySpec.class, SntruPrimePublicKeySpec::new),
Map.entry(SntruPrimePrivateKeySpec.class, SntruPrimePrivateKeySpec::new),
Map.entry(RsaPublicKeySpec.class, RsaPublicKeySpec::new),
Map.entry(RsaPrivateKeySpec.class, RsaPrivateKeySpec::new),
Map.entry(SaberPublicKeySpec.class, SaberPublicKeySpec::new),
Map.entry(SaberPrivateKeySpec.class, SaberPrivateKeySpec::new),
Map.entry(SlhDsaPublicKeySpec.class, SlhDsaPublicKeySpec::new),
Map.entry(SlhDsaPrivateKeySpec.class, SlhDsaPrivateKeySpec::new),
Map.entry(SphincsPlusPublicKeySpec.class, SphincsPlusPublicKeySpec::new),
Map.entry(SphincsPlusPrivateKeySpec.class, SphincsPlusPrivateKeySpec::new),
Map.entry(XdhPublicKeySpec.class, XdhPublicKeySpec::new),
Map.entry(XdhPrivateKeySpec.class, XdhPrivateKeySpec::new));
}
private static Map<Tuple, PersistentMapping> createMappings() {
Map<Tuple, PersistentMapping> mappings = new LinkedHashMap<>();
addAsymmetric(mappings, "BIKE", BikePublicKeySpec.class, BikePrivateKeySpec.class);
addAsymmetric(mappings, "CMCE", CmcePublicKeySpec.class, CmcePrivateKeySpec.class);
addAsymmetric(mappings, "DH", DhPublicKeySpec.class, DhPrivateKeySpec.class);
addAsymmetric(mappings, "ECDSA", EcdsaPublicKeySpec.class, EcdsaPrivateKeySpec.class);
addAsymmetric(mappings, "Ed25519", Ed25519PublicKeySpec.class,
Ed25519PrivateKeySpec.class);
addAsymmetric(mappings, "Ed448", Ed448PublicKeySpec.class,
Ed448PrivateKeySpec.class);
addAsymmetric(mappings, "ElGamal", ElgamalPublicKeySpec.class,
ElgamalPrivateKeySpec.class);
addAsymmetric(mappings, "Frodo", FrodoPublicKeySpec.class,
FrodoPrivateKeySpec.class);
addAsymmetric(mappings, "HQC", HqcPublicKeySpec.class, HqcPrivateKeySpec.class);
addAsymmetric(mappings, "ML-KEM", KyberPublicKeySpec.class,
KyberPrivateKeySpec.class);
addAsymmetric(mappings, "ML-DSA", MldsaPublicKeySpec.class,
MldsaPrivateKeySpec.class);
addAsymmetric(mappings, "NTRU", NtruPublicKeySpec.class, NtruPrivateKeySpec.class);
addAsymmetric(mappings, "NTRULPRime", NtrulPrimePublicKeySpec.class,
NtrulPrimePrivateKeySpec.class);
addAsymmetric(mappings, "SNTRUPrime", SntruPrimePublicKeySpec.class,
SntruPrimePrivateKeySpec.class);
addAsymmetric(mappings, "RSA", RsaPublicKeySpec.class, RsaPrivateKeySpec.class);
addAsymmetric(mappings, "SABER", SaberPublicKeySpec.class,
SaberPrivateKeySpec.class);
addAsymmetric(mappings, "SLH-DSA", SlhDsaPublicKeySpec.class,
SlhDsaPrivateKeySpec.class);
addAsymmetric(mappings, "SPHINCS+", SphincsPlusPublicKeySpec.class,
SphincsPlusPrivateKeySpec.class);
addAsymmetric(mappings, "Xdh", XdhPublicKeySpec.class, XdhPrivateKeySpec.class);
add(mappings, ALGORITHM_AES, KeyringStore.Kind.SECRET_KEY,
KeyringStore.Encoding.RAW,
HmacVariant.NONE, AesKeyImportSpec.class);
add(mappings, ALGORITHM_CHACHA20, KeyringStore.Kind.SECRET_KEY,
KeyringStore.Encoding.RAW,
HmacVariant.NONE, ChaChaKeyImportSpec.class);
add(mappings, ALGORITHM_CHACHA20_POLY1305, KeyringStore.Kind.SECRET_KEY,
KeyringStore.Encoding.RAW, HmacVariant.NONE, ChaChaKeyImportSpec.class);
add(mappings, ALGORITHM_HMAC, KeyringStore.Kind.SECRET_KEY,
KeyringStore.Encoding.RAW,
HmacVariant.SHA256, HmacKeyImportSpec.class);
add(mappings, ALGORITHM_HMAC, KeyringStore.Kind.SECRET_KEY,
KeyringStore.Encoding.RAW,
HmacVariant.SHA384, HmacKeyImportSpec.class);
add(mappings, ALGORITHM_HMAC, KeyringStore.Kind.SECRET_KEY,
KeyringStore.Encoding.RAW,
HmacVariant.SHA512, HmacKeyImportSpec.class);
return Collections.unmodifiableMap(mappings);
}
private static void addAsymmetric(Map<Tuple, PersistentMapping> mappings,
String algorithmId, Class<? extends AlgorithmKeySpec> publicSpec,
Class<? extends AlgorithmKeySpec> privateSpec) {
add(mappings, algorithmId, KeyringStore.Kind.PUBLIC_KEY,
KeyringStore.Encoding.X509, HmacVariant.NONE, publicSpec);
add(mappings, algorithmId, KeyringStore.Kind.PRIVATE_KEY,
KeyringStore.Encoding.PKCS8, HmacVariant.NONE, privateSpec);
}
private static void add(Map<Tuple, PersistentMapping> mappings, String algorithmId,
KeyringStore.Kind kind, KeyringStore.Encoding encoding,
HmacVariant hmacVariant, Class<? extends AlgorithmKeySpec> specType) {
Tuple tuple = new Tuple(algorithmId, kind, encoding, hmacVariant);
PersistentMapping mapping = new PersistentMapping(algorithmId, kind,
encoding, hmacVariant, specType);
if (mappings.put(tuple, mapping) != null) {
throw new IllegalStateException("Duplicate persistent key importer tuple");
}
}
private record Tuple(String algorithmId, KeyringStore.Kind kind,
KeyringStore.Encoding encoding, HmacVariant hmacVariant) {
}
}

View File

@@ -0,0 +1,76 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core.storage;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.util.Arrays;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* Fixed-purpose RFC 5869 derivation for nonce-reservation authentication.
*/
final class KeyringNonceReservationKdf {
private static final int MASTER_KEY_BYTES = 32;
private static final int STORE_ID_BYTES = 16;
private static final int OUTPUT_BYTES = 32;
private static final String HMAC_SHA256 = "HmacSHA256";
private static final String DOMAIN_LABEL =
"zeroecho:keyring:nonce-reservation-mac:v1";
private KeyringNonceReservationKdf() {
}
/**
* Derives the store-specific nonce-reservation MAC key.
*
* @param masterKey borrowed 256-bit store master key
* @param storeId borrowed canonical 128-bit binary store UUID
* @return newly owned 256-bit derived key
* @throws GeneralSecurityException if HMAC-SHA-256 is unavailable
*/
/* default */ static byte[] derive(byte[] masterKey, byte[] storeId)
throws GeneralSecurityException {
if (masterKey == null || masterKey.length != MASTER_KEY_BYTES
|| storeId == null || storeId.length != STORE_ID_BYTES) {
throw new IllegalArgumentException("Invalid keyring derivation input");
}
byte[] salt = storeId.clone();
byte[] info = DOMAIN_LABEL.getBytes(StandardCharsets.US_ASCII);
byte[] prk = null;
byte[] expandInput = null;
try {
prk = hmac(salt, masterKey);
expandInput = Arrays.copyOf(info, info.length + 1);
expandInput[expandInput.length - 1] = 1;
byte[] output = hmac(prk, expandInput);
if (output.length != OUTPUT_BYTES) {
Arrays.fill(output, (byte) 0);
throw new GeneralSecurityException("KEYRING_DERIVATION_FAILED");
}
return output;
} finally {
Arrays.fill(salt, (byte) 0);
Arrays.fill(info, (byte) 0);
wipe(prk);
wipe(expandInput);
}
}
private static byte[] hmac(byte[] key, byte[] input)
throws GeneralSecurityException {
Mac mac = Mac.getInstance(HMAC_SHA256);
mac.init(new SecretKeySpec(key, HMAC_SHA256));
return mac.doFinal(input);
}
private static void wipe(byte[] value) {
if (value != null) {
Arrays.fill(value, (byte) 0);
}
}
}

View File

@@ -0,0 +1,120 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core.storage;
import java.util.Arrays;
import java.util.Objects;
import java.util.concurrent.locks.ReentrantLock;
import javax.security.auth.DestroyFailedException;
import javax.security.auth.Destroyable;
/**
* Destroyable owner of a keyring password.
*
* <p>The constructor and {@link #copy()} use defensive copies. Callers retain
* ownership of the array supplied to the constructor and must clear it. The
* returned copy belongs to the receiver and must be cleared immediately after
* key derivation. This object never creates an immutable password
* {@link String}.</p>
*
* <p>Instances are thread-safe. Destruction is idempotent and makes subsequent
* access fail deterministically.</p>
*/
public final class KeyringPassword implements Destroyable, AutoCloseable {
private final ReentrantLock lifecycleLock = new ReentrantLock();
private final char[] password;
private boolean destroyed;
/**
* Creates a password owner.
*
* @param password password characters, which are defensively copied
* @throws NullPointerException if {@code password} is {@code null}
* @throws IllegalArgumentException if {@code password} is empty
*/
@SuppressWarnings("PMD.UseVarargs")
public KeyringPassword(char[] password) {
Objects.requireNonNull(password, "password must not be null");
if (password.length == 0) {
throw new IllegalArgumentException("password must not be empty");
}
this.password = password.clone();
}
/**
* Returns a receiver-owned password copy.
*
* @return a new mutable array that the receiver must clear
* @throws IllegalStateException if this object has been destroyed
*/
public char[] copy() {
lifecycleLock.lock();
try {
if (destroyed) {
throw new IllegalStateException("Keyring password is destroyed");
}
return password.clone();
} finally {
lifecycleLock.unlock();
}
}
/**
* Clears the owned password characters.
*
* @throws DestroyFailedException never thrown by this implementation
*/
@Override
public void destroy() throws DestroyFailedException {
lifecycleLock.lock();
try {
if (!destroyed) {
Arrays.fill(password, '\0');
destroyed = true;
}
} finally {
lifecycleLock.unlock();
}
}
/**
* Reports whether the password has been destroyed.
*
* @return {@code true} after destruction
*/
@Override
public boolean isDestroyed() {
lifecycleLock.lock();
try {
return destroyed;
} finally {
lifecycleLock.unlock();
}
}
/**
* Destroys the password.
*/
@Override
@SuppressWarnings("PMD.PreserveStackTrace")
public void close() {
try {
destroy();
} catch (DestroyFailedException impossible) {
throw new IllegalStateException("Keyring password destruction failed");
}
}
/**
* Returns a redacted representation.
*
* @return a constant redacted value
*/
@Override
public String toString() {
return "KeyringPassword[REDACTED]";
}
}

View File

@@ -0,0 +1,42 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core.storage;
/**
* Operational limits applied while opening an encrypted software keyring.
*
* @param operationalIterationMaximum maximum accepted PBKDF2 iteration count;
* it may restrict but never exceed the absolute decoded maximum
*/
public record KeyringProtection(int operationalIterationMaximum) {
/** Iterations used when a new keyring is created. */
public static final int CREATION_ITERATIONS = 600_000;
/** Largest operationally accepted PBKDF2 iteration count. */
public static final int MAX_OPERATIONAL_ITERATIONS = 1_000_000;
/** Absolute safety ceiling for decoded PBKDF2 iteration counts. */
public static final int MAX_DECODED_ITERATIONS = 10_000_000;
/**
* Validates the operational limit.
*
* @throws IllegalArgumentException if the limit is below the creation
* setting or above the absolute operational maximum
*/
public KeyringProtection {
if (operationalIterationMaximum < CREATION_ITERATIONS
|| operationalIterationMaximum > MAX_OPERATIONAL_ITERATIONS) {
throw new IllegalArgumentException("operationalIterationMaximum must be in [600000,1000000]");
}
}
/**
* Returns the standard protection policy.
*
* @return policy accepting up to one million iterations
*/
public static KeyringProtection standard() {
return new KeyringProtection(MAX_OPERATIONAL_ITERATIONS);
}
}

View File

@@ -0,0 +1,21 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core.storage;
/**
* Fills keyring randomness buffers.
*
* <p>This package-private seam supports deterministic format tests; production
* creation uses the authoritative shared secure random source.</p>
*/
@FunctionalInterface
interface KeyringRandomBytes {
/**
* Fills a destination buffer.
*
* @param destination buffer to fill completely
*/
void nextBytes(byte[] destination);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,25 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
@@ -32,94 +32,51 @@
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
/**
* Human-editable key storage persisted in a compact UTF-8 text format.
* Encrypted local software-keyring storage.
*
* <p>
* This package provides a lightweight keyring that applications can read and
* write without specialized tooling. The store keeps entries in insertion
* order, allows simple alias-based lookups, and materializes keys through the
* core catalog.
* {@link zeroecho.core.storage.KeyringStore} binds an unlocked store to one
* filesystem path and holds exclusive process-lifetime ownership until close.
* It wraps one random store master key with PBKDF2-HMAC-SHA-256 and
* AES-256-GCM, encrypts every entry independently, and authenticates the
* ordered entry manifest. Mutations use owner-only temporary files and atomic
* replacement.
* </p>
*
* <h2>Key elements</h2>
* <ul>
* <li>{@link KeyringStore} - in-memory map of aliases to immutable records with
* helpers to add, load, save, import, export, and resolve keys.</li>
* <li>{@link KeyringStore.Record} - value object describing one entry: alias,
* algorithm id, {@link KeyringStore.Record.Kind kind}, spec class, and a
* {@link zeroecho.core.marshal.PairSeq} payload.</li>
* <li><i>Resolution helpers</i> - {@link KeyringStore#getPublic(String)},
* {@link KeyringStore#getPrivate(String)}, and
* {@link KeyringStore#getSecret(String)} plus
* {@link KeyringStore.PublicWithId}, {@link KeyringStore.PrivateWithId},
* {@link KeyringStore.SecretWithId} to return the algorithm id together with
* the key.</li>
* </ul>
*
* <h2>File format</h2>
* <p>
* Files begin with a magic header followed by one or more <code>@entry</code>
* blocks. Keys that belong to the spec payload are prefixed with
* <code>s.</code> to avoid collisions with top-level fields; in-memory they are
* stored without the prefix. Lines beginning with <code>#</code> are comments.
* A blank line terminates an entry.
* Nonce-reservation authentication is cryptographically separated from AES
* entry and manifest encryption. A dedicated 256-bit MAC key is derived with
* HKDF-HMAC-SHA-256 from the store master key, canonical binary store UUID, and
* fixed nonce-reservation domain. It is never persisted, remains owned by the
* open store, and is destroyed on close. Only the current sidecar version is
* accepted.
* </p>
*
* <pre>{@code
* # KeyringStore v1
* &#64;entry
* alias=my-rsa
* algorithm=RSA
* kind=PUBLIC_KEY
* spec=zeroecho.core.alg.rsa.RsaPublicKeySpec
* s.x509B64=MIIBIjANBgkqh...
* }</pre>
*
* <h2>Spec marshaling contract</h2>
* <p>
* Each spec class named in the <code>spec</code> field must expose two public
* static methods discovered by reflection:
* Unlock passwords are destroyable, transfer ownership to the receiver, and
* are destroyed immediately after the master key is unwrapped. The unlocked
* store retains the master key, its domain-separated nonce-reservation MAC
* key, and encrypted entry records; closing the store clears this material.
* The store requires a POSIX filesystem on which owner-only permissions can be
* verified. A directory-force failure after atomic replacement makes the open
* instance unusable until close and authenticated reopen resolves which
* complete image is current. Non-exportable keys must remain behind an
* external provider reference.
* </p>
* <ul>
* <li><code>static PairSeq marshal(SpecType spec)</code></li>
* <li><code>static SpecType unmarshal(PairSeq pairs)</code></li>
* </ul>
*
* <p>
* {@link KeyringStore} validates each persisted specification type against the
* selected algorithm's registered import operation before invoking these
* methods.
* Only the current binary format is accepted. Earlier plaintext development
* formats are rejected without migration. Persisted input contains no Java
* class or provider names and cannot trigger runtime class loading. Import
* specifications are selected from a closed mapping of canonical ZeroEcho
* algorithm, key kind, standard encoding, and (only for HMAC) one of the
* explicit SHA-256, SHA-384, or SHA-512 variants. Standard encodings are
* reconstructed through the current runtime's canonical importer; the
* originating JCA provider identity is not persisted or guaranteed. Keys that
* are provider-bound, non-exportable, or not canonically reconstructable must
* remain behind an external provider reference.
* </p>
*
* <h2>Typical usage</h2> <pre>{@code
* // Create and persist a keyring.
* zeroecho.sdk.ZeroEchoSession session = new zeroecho.sdk.ZeroEchoSession();
* KeyringStore ks = new KeyringStore(session);
* ks.putPublic("site-signing", "Ed25519", myEd25519PublicSpec);
* ks.putPrivate("site-signing", "Ed25519", myEd25519PrivateSpec);
* ks.save(java.nio.file.Path.of("keyring.txt"));
*
* // Load and resolve a key later.
* KeyringStore reloaded = KeyringStore.load(session, java.nio.file.Path.of("keyring.txt"));
* java.security.PublicKey pub = reloaded.getPublic("site-signing");
* }</pre>
*
* <h2>Notes and recommendations</h2>
* <ul>
* <li>Persistence is plaintext. Treat files as sensitive, protect with OS
* permissions, and avoid committing to VCS.</li>
* <li>Resolution delegates to {@link zeroecho.core.CryptoAlgorithms}; the
* algorithm id must be one that the catalog recognizes.</li>
* <li>Records are immutable;
* {@link KeyringStore#putPublic(String, String, zeroecho.core.spec.AlgorithmKeySpec)},
* {@link KeyringStore#putPrivate(String, String, zeroecho.core.spec.AlgorithmKeySpec)},
* and
* {@link KeyringStore#putSecret(String, String, zeroecho.core.spec.AlgorithmKeySpec)}
* replace entries by alias.</li>
* <li>Lookups validate kind; for example,
* {@link KeyringStore#getPublic(String)} fails if the alias stores a private
* key.</li>
* </ul>
*
* @since 1.0
*/
package zeroecho.core.storage;

View File

@@ -0,0 +1,70 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core.util;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Provides the authoritative process-wide cryptographic random source.
*/
public final class RandomSupport {
private static final Logger LOG = Logger.getLogger(RandomSupport.class.getName());
private static final SecureRandom RANDOM = createRandom();
private RandomSupport() {
}
/**
* Returns the shared thread-safe random source.
*
* @return shared secure random generator
*/
public static SecureRandom getRandom() {
return RANDOM;
}
/**
* Allocates and fills a random byte array.
*
* @param size required array length
* @return random bytes
*/
public static byte[] generateRandom(int size) {
return generateRandom(new byte[size]);
}
/**
* Fills the supplied array with random bytes.
*
* @param buffer destination array
* @return {@code buffer}
*/
public static byte[] generateRandom(byte[] buffer) {
RANDOM.nextBytes(buffer);
return buffer;
}
/**
* Returns a uniform value below the bound.
*
* @param bound exclusive positive upper bound
* @return generated value
*/
public static int nextInt(int bound) {
return RANDOM.nextInt(bound);
}
private static SecureRandom createRandom() {
try {
return SecureRandom.getInstanceStrong();
} catch (NoSuchAlgorithmException exception) {
LOG.log(Level.WARNING, "Strong SecureRandom unavailable; using the platform default");
return new SecureRandom();
}
}
}

View File

@@ -52,7 +52,7 @@ import zeroecho.sdk.builders.alg.AesDataContentBuilder;
import zeroecho.sdk.builders.alg.ChaChaDataContentBuilder;
import zeroecho.sdk.content.api.DataContent;
import zeroecho.sdk.content.api.EncryptedContent;
import zeroecho.sdk.util.RandomSupport;
import zeroecho.core.util.RandomSupport;
/**
* Encrypting stage that emits the recipient table followed by the symmetric

View File

@@ -42,7 +42,7 @@ import java.util.Objects;
import zeroecho.core.context.KemContext;
import zeroecho.core.context.KemContext.KemResult;
import zeroecho.core.io.Util;
import zeroecho.sdk.util.RandomSupport;
import zeroecho.core.util.RandomSupport;
/**
* Recipient implementation that derives a key-encryption key (KEK) via a

View File

@@ -44,7 +44,7 @@ import javax.security.auth.Destroyable;
import zeroecho.core.io.Util;
import zeroecho.sdk.Pbkdf2Limits;
import zeroecho.sdk.util.RandomSupport;
import zeroecho.core.util.RandomSupport;
/**
* Password recipient that derives a KEK via PBKDF2(HMAC-SHA-256) and wraps the

View File

@@ -33,6 +33,8 @@
******************************************************************************/
package zeroecho.sdk.util;
import zeroecho.core.util.RandomSupport;
/**
* Utility class for generating random passwords and secure random byte arrays.

View File

@@ -1,115 +0,0 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.sdk.util;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Utility class providing support for secure random number generation.
* <p>
* This class provides cryptographically secure random data through one
* process-wide, thread-safe {@link SecureRandom}. It selects the platform's
* strong implementation when available and otherwise uses the platform default.
* </p>
*
* @author Leo Galambos
*/
public final class RandomSupport {
private static final Logger LOG = Logger.getLogger(RandomSupport.class.getName());
/** Shared {@link SecureRandom} instance. */
private static final SecureRandom RANDOM = createRandom();
private RandomSupport() {
// this is a utility class
}
/**
* Returns the shared {@link SecureRandom} instance.
*
* @return the shared random source
*/
public static SecureRandom getRandom() {
return RANDOM;
}
private static SecureRandom createRandom() {
try {
return SecureRandom.getInstanceStrong();
} catch (NoSuchAlgorithmException exception) {
LOG.log(Level.WARNING, "Strong SecureRandom unavailable; using the platform default");
return new SecureRandom();
}
}
/**
* Generates a secure random byte array of the specified size.
*
* @param size The size of the byte array to generate.
* @return A byte array filled with cryptographically secure random bytes.
*/
public static byte[] generateRandom(final int size) {
return generateRandom(new byte[size]);
}
/**
* Fills the given byte array with random bytes and returns it.
* <p>
* This method uses a thread-safe {@code SecureRandom} instance to generate
* cryptographically strong random values.
*
* @param buffer the byte array to fill with random bytes
* @return the same byte array, now containing random data
* @throws NullPointerException if {@code buffer} is {@code null}
*/
public static byte[] generateRandom(final byte[] buffer) {
RANDOM.nextBytes(buffer);
return buffer;
}
/**
* Returns a uniformly distributed value between zero (inclusive) and the
* specified bound (exclusive).
*
* @param bound exclusive upper bound; must be positive
* @return a uniformly distributed value
* @throws IllegalArgumentException if {@code bound} is not positive
*/
public static int nextInt(final int bound) {
return RANDOM.nextInt(bound);
}
}

View File

@@ -60,9 +60,6 @@
* stream.</li>
* <li>{@link Password} - helpers for generating random bytes and printable
* passwords.</li>
* <li>{@link RandomSupport} - shared or per-call
* {@link java.security.SecureRandom} access with thread-safe helpers for
* filling arrays.</li>
* <li>{@link X509Support} - minimal PEM-based load and print helpers for
* certificates, private keys, and certificate signing requests.</li>
* </ul>

View File

@@ -42,7 +42,7 @@ import zeroecho.core.context.EncryptionContext;
import zeroecho.core.spec.VoidSpec;
import zeroecho.core.spi.ContextAware;
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
import zeroecho.sdk.util.RandomSupport;
import zeroecho.core.util.RandomSupport;
class AesRandomSupportTest {
private static final SecretKey KEY = new SecretKeySpec(new byte[16], "AES");

View File

@@ -0,0 +1,413 @@
package zeroecho.core.storage;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.nio.file.Path;
import java.security.Key;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import javax.security.auth.Destroyable;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestFactory;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.KeyOperation;
import zeroecho.core.KeyOperationInfo;
import zeroecho.core.KeyUsage;
import zeroecho.core.context.AgreementContext;
import zeroecho.core.context.EncryptionContext;
import zeroecho.core.context.KemContext;
import zeroecho.core.context.SignatureContext;
import zeroecho.core.io.TailStrippingInputStream;
import zeroecho.core.spec.AlgorithmKeySpec;
import zeroecho.core.spi.AsymmetricKeyPairGenerator;
import zeroecho.sdk.ZeroEchoSession;
import zeroecho.sdk.util.BouncyCastleActivator;
/**
* Executes every approved persistent importer through the encrypted store and
* then proves that the reconstructed key remains operational.
*/
class KeyringAlgorithmCoverageTest {
private static final byte[] MESSAGE = "keyring-algorithm-matrix".getBytes(
java.nio.charset.StandardCharsets.UTF_8);
@TempDir
Path temporaryDirectory;
@BeforeAll
static void initializeProviders() {
BouncyCastleActivator.init();
}
@Test
void persistentImporterUniverseHasAcceptedCardinality() {
List<KeyringImportRegistry.PersistentMapping> mappings =
KeyringImportRegistry.mappings();
assertEquals(19, count(mappings, KeyringStore.Kind.PUBLIC_KEY));
assertEquals(19, count(mappings, KeyringStore.Kind.PRIVATE_KEY));
assertEquals(6, count(mappings, KeyringStore.Kind.SECRET_KEY));
}
@Test
void publicApiRequiresUnlockMaterialAndExposesNoPlaintextStorePath() {
List<Method> publicMethods = Arrays.stream(KeyringStore.class.getDeclaredMethods())
.filter(method -> Modifier.isPublic(method.getModifiers()))
.toList();
publicMethods.stream()
.filter(method -> "create".equals(method.getName())
|| "open".equals(method.getName()))
.forEach(method -> {
assertTrue(Arrays.asList(method.getParameterTypes())
.contains(KeyringPassword.class));
assertTrue(Arrays.stream(method.getParameterTypes())
.noneMatch(String.class::equals));
});
assertTrue(publicMethods.stream().noneMatch(method -> switch (method.getName()) {
case "exportText", "importText", "save", "load" -> true;
default -> false;
}));
assertTrue(Arrays.stream(KeyringStore.class.getFields())
.noneMatch(field -> field.getType() == byte[].class));
}
@TestFactory
Stream<DynamicTest> asymmetricEncryptedStoreRoundTrips() {
List<String> algorithms = KeyringImportRegistry.mappings().stream()
.filter(mapping -> mapping.kind() == KeyringStore.Kind.PUBLIC_KEY)
.map(KeyringImportRegistry.PersistentMapping::algorithmId)
.toList();
return algorithms.stream().map(algorithmId -> DynamicTest.dynamicTest(
algorithmId + " PUBLIC/X.509 + PRIVATE/PKCS#8",
() -> roundTripAsymmetric(algorithmId)));
}
@TestFactory
Stream<DynamicTest> secretEncryptedStoreRoundTrips() {
return KeyringImportRegistry.mappings().stream()
.filter(mapping -> mapping.kind() == KeyringStore.Kind.SECRET_KEY)
.map(mapping -> DynamicTest.dynamicTest(secretDisplayName(mapping),
() -> roundTripSecret(mapping)));
}
private void roundTripAsymmetric(String algorithmId) throws Exception {
KeyPair original = generatePair(algorithmId);
Path path = temporaryDirectory.resolve("asymmetric-" + safeName(algorithmId) + ".zek");
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.create(path, password)) {
store.putPublic("matrix", algorithmId, original.getPublic());
store.putPrivate("matrix", algorithmId, original.getPrivate());
}
PublicKey reconstructedPublic = null;
PrivateKey reconstructedPrivate = null;
byte[] originalPublic = null;
byte[] reconstructedPublicBytes = null;
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.open(path, password)) {
KeyringStore.PublicWithId publicWithId = store.getPublicWithId("matrix");
KeyringStore.PrivateWithId privateWithId = store.getPrivateWithId("matrix");
assertEquals(algorithmId, publicWithId.algorithm());
assertEquals(algorithmId, privateWithId.algorithm());
reconstructedPublic = publicWithId.key();
reconstructedPrivate = privateWithId.key();
originalPublic = original.getPublic().getEncoded();
reconstructedPublicBytes = reconstructedPublic.getEncoded();
assertArrayEquals(originalPublic, reconstructedPublicBytes);
proveAsymmetricOperation(algorithmId, reconstructedPublic,
reconstructedPrivate);
} finally {
wipe(originalPublic);
wipe(reconstructedPublicBytes);
destroy(reconstructedPublic);
destroy(reconstructedPrivate);
destroy(original.getPublic());
destroy(original.getPrivate());
}
}
private void roundTripSecret(KeyringImportRegistry.PersistentMapping mapping)
throws Exception {
byte[] material = new byte[32];
Arrays.fill(material, secretFill(mapping));
String jcaName = secretJcaName(mapping);
SecretKey original = new SecretKeySpec(material, jcaName);
Path path = temporaryDirectory.resolve("secret-" + safeName(secretDisplayName(mapping))
+ ".zek");
SecretKey reconstructed = null;
byte[] reconstructedBytes = null;
try {
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.create(path, password)) {
store.putSecret("matrix", mapping.algorithmId(), original);
}
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.open(path, password)) {
KeyringStore.SecretWithId withId = store.getSecretWithId("matrix");
assertEquals(mapping.algorithmId(), withId.algorithm());
reconstructed = withId.key();
reconstructedBytes = reconstructed.getEncoded();
assertEquals(material.length, reconstructedBytes.length);
assertArrayEquals(material, reconstructedBytes);
proveSecretOperation(mapping, reconstructed);
}
} finally {
wipe(material);
wipe(reconstructedBytes);
destroy(reconstructed);
destroy(original);
}
}
private static void proveAsymmetricOperation(String algorithmId,
PublicKey publicKey, PrivateKey privateKey) throws Exception {
CryptoAlgorithm algorithm = CryptoAlgorithms.require(algorithmId);
if (algorithm.roles().contains(KeyUsage.SIGN)
&& algorithm.roles().contains(KeyUsage.VERIFY)) {
proveSignature(algorithmId, publicKey, privateKey);
} else if (algorithm.roles().contains(KeyUsage.ENCAPSULATE)
&& algorithm.roles().contains(KeyUsage.DECAPSULATE)) {
proveKem(algorithmId, publicKey, privateKey);
} else if (algorithm.roles().contains(KeyUsage.AGREEMENT)) {
proveAgreement(algorithmId, publicKey, privateKey);
} else {
assertTrue(algorithm.roles().contains(KeyUsage.ENCRYPT)
&& algorithm.roles().contains(KeyUsage.DECRYPT));
proveEncryption(algorithmId, publicKey, privateKey);
}
}
private static void proveSignature(String algorithmId, PublicKey publicKey,
PrivateKey privateKey) throws Exception {
ZeroEchoSession session = new ZeroEchoSession();
AtomicReference<byte[]> signatureHolder = new AtomicReference<>();
byte[] signature = null;
try (SignatureContext signer = session.createContext(
algorithmId, KeyUsage.SIGN, privateKey);
InputStream signed = new TailStrippingInputStream(
signer.wrap(new ByteArrayInputStream(MESSAGE)),
signer.tagLength(), 512) {
@Override
protected void processTail(byte[] tail) {
signatureHolder.set(tail == null ? null : tail.clone());
}
}) {
assertArrayEquals(MESSAGE, signed.readAllBytes());
signature = signatureHolder.get();
}
try {
assertTrue(signature != null && signature.length > 0);
try (SignatureContext verifier = session.createContext(
algorithmId, KeyUsage.VERIFY, publicKey);
InputStream verified = verificationStream(verifier, signature)) {
assertArrayEquals(MESSAGE, verified.readAllBytes());
}
} finally {
wipe(signature);
}
}
private static InputStream verificationStream(SignatureContext verifier,
byte[] signature) throws IOException {
verifier.setVerificationApproach(verifier.getVerificationCore().getThrowOnMismatch());
verifier.setExpectedTag(signature);
return verifier.wrap(new ByteArrayInputStream(MESSAGE));
}
private static void proveKem(String algorithmId, PublicKey publicKey,
PrivateKey privateKey) throws Exception {
ZeroEchoSession session = new ZeroEchoSession();
byte[] encapsulated = null;
byte[] senderSecret = null;
byte[] recipientSecret = null;
try (KemContext sender = session.createContext(
algorithmId, KeyUsage.ENCAPSULATE, publicKey);
KemContext recipient = session.createContext(
algorithmId, KeyUsage.DECAPSULATE, privateKey)) {
KemContext.KemResult result = sender.encapsulate();
encapsulated = result.ciphertext();
senderSecret = result.sharedSecret();
recipientSecret = recipient.decapsulate(encapsulated);
assertArrayEquals(senderSecret, recipientSecret);
} finally {
wipe(encapsulated);
wipe(senderSecret);
wipe(recipientSecret);
}
}
private static void proveAgreement(String algorithmId, PublicKey publicKey,
PrivateKey privateKey) throws Exception {
KeyPair peer = generatePair(algorithmId);
ZeroEchoSession session = new ZeroEchoSession();
byte[] firstSecret = null;
byte[] secondSecret = null;
try (AgreementContext first = session.createContext(
algorithmId, KeyUsage.AGREEMENT, privateKey);
AgreementContext second = session.createContext(
algorithmId, KeyUsage.AGREEMENT, peer.getPrivate())) {
first.setPeerPublic(peer.getPublic());
second.setPeerPublic(publicKey);
firstSecret = first.deriveSecret();
secondSecret = second.deriveSecret();
assertArrayEquals(firstSecret, secondSecret);
} finally {
wipe(firstSecret);
wipe(secondSecret);
destroy(peer.getPublic());
destroy(peer.getPrivate());
}
}
private static void proveEncryption(String algorithmId, Key encryptionKey,
Key decryptionKey) throws Exception {
ZeroEchoSession session = new ZeroEchoSession();
conflux.CtxInterface operationContext =
conflux.Ctx.INSTANCE.getContext("keyring-matrix-" + algorithmId);
byte[] ciphertext = null;
byte[] plaintext = null;
try {
try (EncryptionContext encryption = session.createContext(
algorithmId, KeyUsage.ENCRYPT, encryptionKey)) {
if (encryption instanceof zeroecho.core.spi.ContextAware contextAware) {
contextAware.setContext(operationContext);
}
try (InputStream encrypted = encryption.attach(
new ByteArrayInputStream(MESSAGE))) {
ciphertext = encrypted.readAllBytes();
}
}
try (EncryptionContext decryption = session.createContext(
algorithmId, KeyUsage.DECRYPT, decryptionKey)) {
if (decryption instanceof zeroecho.core.spi.ContextAware contextAware) {
contextAware.setContext(operationContext);
}
try (InputStream decrypted = decryption.attach(
new ByteArrayInputStream(ciphertext))) {
plaintext = decrypted.readAllBytes();
}
}
assertArrayEquals(MESSAGE, plaintext);
} finally {
wipe(ciphertext);
wipe(plaintext);
}
}
private static void proveSecretOperation(
KeyringImportRegistry.PersistentMapping mapping, SecretKey key)
throws Exception {
if ("HMAC".equals(mapping.algorithmId())) {
proveMac(key);
} else {
proveEncryption(mapping.algorithmId(), key, key);
}
}
private static void proveMac(SecretKey key) throws Exception {
byte[] first = null;
byte[] second = null;
try {
Mac producer = Mac.getInstance(key.getAlgorithm());
producer.init(key);
first = producer.doFinal(MESSAGE);
Mac verifier = Mac.getInstance(key.getAlgorithm());
verifier.init(key);
second = verifier.doFinal(MESSAGE);
assertArrayEquals(first, second);
} finally {
wipe(first);
wipe(second);
}
}
@SuppressWarnings({ "rawtypes", "unchecked" })
private static KeyPair generatePair(String algorithmId) throws Exception {
CryptoAlgorithm algorithm = CryptoAlgorithms.require(algorithmId);
KeyOperationInfo generation = algorithm.keyOperations().stream()
.filter(info -> info.operation()
== KeyOperation.ASYMMETRIC_KEY_PAIR_GENERATE)
.filter(info -> info.defaultSpec() != null)
.findFirst()
.orElseThrow(() -> new AssertionError(
"No default key-pair generation mapping for " + algorithmId));
AsymmetricKeyPairGenerator generator =
algorithm.asymmetricKeyPairGenerator(generation.specType());
return generator.generateKeyPair((AlgorithmKeySpec) generation.defaultSpec());
}
private static long count(List<KeyringImportRegistry.PersistentMapping> mappings,
KeyringStore.Kind kind) {
return mappings.stream().filter(mapping -> mapping.kind() == kind).count();
}
private static String secretDisplayName(
KeyringImportRegistry.PersistentMapping mapping) {
return mapping.algorithmId() + "/" + mapping.hmacVariant().name()
+ " SECRET/RAW";
}
private static String secretJcaName(
KeyringImportRegistry.PersistentMapping mapping) {
return switch (mapping.algorithmId()) {
case "AES" -> "AES";
case "CHACHA20", "CHACHA20-POLY1305" -> "ChaCha20";
case "HMAC" -> mapping.hmacVariant().jcaName();
default -> throw new AssertionError("Unexpected secret mapping");
};
}
private static byte secretFill(
KeyringImportRegistry.PersistentMapping mapping) {
return (byte) (mapping.hmacVariant().code() + mapping.algorithmId().length() + 1);
}
private static String safeName(String value) {
return value.replaceAll("[^A-Za-z0-9]", "_");
}
private static KeyringPassword password() {
char[] value = "keyring-matrix-password".toCharArray();
try {
return new KeyringPassword(value);
} finally {
Arrays.fill(value, '\0');
}
}
private static void destroy(Key key) {
if (key instanceof Destroyable destroyable) {
try {
destroyable.destroy();
} catch (Exception exception) {
// Provider keys may advertise but not implement destruction.
}
}
}
private static void wipe(byte[] value) {
if (value != null) {
Arrays.fill(value, (byte) 0);
}
}
}

View File

@@ -0,0 +1,426 @@
package zeroecho.core.storage;
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 java.io.IOException;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileAttribute;
import java.nio.file.attribute.PosixFilePermission;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.core.storage.KeyringFileOperations.Target;
class KeyringAtomicPersistenceTest {
private static final char[] PASSWORD = { 'a', 't', 'o', 'm', 'i', 'c' };
private static final byte[] ENTRY_A = material((byte) 0x31);
private static final byte[] ENTRY_B = material((byte) 0x72);
private static final Set<PosixFilePermission> FILE_PERMISSIONS = Set.of(
PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE);
@TempDir
Path temporaryDirectory;
@Test
void mainImagePreCommitFailuresPreserveAuthoritativeState() throws Exception {
start("mainImagePreCommitFailuresPreserveAuthoritativeState");
for (FailureStage stage : List.of(FailureStage.CREATE_TEMP,
FailureStage.WRITE_TEMP, FailureStage.FORCE_TEMP,
FailureStage.ATOMIC_MOVE)) {
Path path = initialized("main-" + stage + ".zek");
byte[] before = Files.readAllBytes(path);
FailingFileOperations operations =
new FailingFileOperations(Target.MAIN_IMAGE, stage, false);
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.open(path, password,
KeyringProtection.standard(), deterministicRandom(50),
operations)) {
long highWater = longField(store, "nonceHighWater");
KeyringException failure = assertThrows(KeyringException.class,
() -> put(store, "B", ENTRY_B));
assertSafe(failure, KeyringException.Code.KEYRING_IO_FAILED);
assertTrue(store.contains("A"));
assertFalse(store.contains("B"));
assertArrayEquals(before, Files.readAllBytes(path));
assertEquals(highWater + 2, longField(store, "nonceHighWater"));
assertAlreadyOpen(path);
put(store, "B", ENTRY_B);
assertTrue(store.contains("B"));
} finally {
wipe(before);
}
assertReopened(path, true);
assertSentinelAbsent(path.getParent(), ENTRY_B);
}
ok();
}
@Test
void mainImageDirectoryForceFailurePoisonsUntilReopen() throws Exception {
start("mainImageDirectoryForceFailurePoisonsUntilReopen");
Path path = initialized("main-directory.zek");
FailingFileOperations operations = new FailingFileOperations(
Target.MAIN_IMAGE, FailureStage.FORCE_DIRECTORY, false);
KeyringStore store;
try (KeyringPassword password = password()) {
store = KeyringStore.open(path, password, KeyringProtection.standard(),
deterministicRandom(70), operations);
}
try {
KeyringException failure = assertThrows(KeyringException.class,
() -> put(store, "B", ENTRY_B));
assertSafe(failure, KeyringException.Code.KEYRING_DURABILITY_UNCONFIRMED);
assertTrue(store.isDestroyed());
assertThrows(IllegalStateException.class, () -> store.contains("A"));
assertThrows(KeyringException.class, () -> put(store, "C", ENTRY_B));
} finally {
store.close();
}
assertReopened(path, true);
assertSentinelAbsent(path.getParent(), ENTRY_B);
ok();
}
@Test
void sidecarPreCommitFailuresIssueNoUncommittedNonce() throws Exception {
start("sidecarPreCommitFailuresIssueNoUncommittedNonce");
for (FailureStage stage : List.of(FailureStage.CREATE_TEMP,
FailureStage.WRITE_TEMP, FailureStage.FORCE_TEMP,
FailureStage.ATOMIC_MOVE)) {
Path path = initialized("sidecar-" + stage + ".zek");
byte[] mainBefore = Files.readAllBytes(path);
byte[] sidecarBefore = Files.readAllBytes(sidecar(path));
FailingFileOperations operations =
new FailingFileOperations(Target.NONCE_RESERVATION, stage, false);
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.open(path, password,
KeyringProtection.standard(), deterministicRandom(90),
operations)) {
long highWater = longField(store, "nonceHighWater");
KeyringException failure = assertThrows(KeyringException.class,
() -> put(store, "B", ENTRY_B));
assertSafe(failure, KeyringException.Code.KEYRING_IO_FAILED);
assertEquals(highWater, longField(store, "nonceHighWater"));
assertFalse(store.contains("B"));
assertArrayEquals(mainBefore, Files.readAllBytes(path));
assertArrayEquals(sidecarBefore, Files.readAllBytes(sidecar(path)));
put(store, "B", ENTRY_B);
assertEquals(highWater + 2, longField(store, "nonceHighWater"));
} finally {
wipe(mainBefore);
wipe(sidecarBefore);
}
assertReopened(path, true);
assertSentinelAbsent(path.getParent(), ENTRY_B);
}
ok();
}
@Test
void sidecarDirectoryForceFailureIssuesNothingUntilReopen() throws Exception {
start("sidecarDirectoryForceFailureIssuesNothingUntilReopen");
Path path = initialized("sidecar-directory.zek");
long initialHighWater;
FailingFileOperations operations = new FailingFileOperations(
Target.NONCE_RESERVATION, FailureStage.FORCE_DIRECTORY, false);
KeyringStore store;
try (KeyringPassword password = password()) {
store = KeyringStore.open(path, password, KeyringProtection.standard(),
deterministicRandom(110), operations);
}
try {
initialHighWater = longField(store, "nonceHighWater");
KeyringException failure = assertThrows(KeyringException.class,
() -> put(store, "B", ENTRY_B));
assertSafe(failure, KeyringException.Code.KEYRING_DURABILITY_UNCONFIRMED);
assertFalse(store.isDestroyed());
assertThrows(IllegalStateException.class, () -> store.contains("A"));
assertAlreadyOpen(path);
} finally {
store.close();
}
try (KeyringPassword password = password();
KeyringStore reopened = KeyringStore.open(path, password,
KeyringProtection.standard(), deterministicRandom(130))) {
assertTrue(reopened.contains("A"));
assertFalse(reopened.contains("B"));
assertEquals(initialHighWater + 1, longField(reopened, "nonceHighWater"));
put(reopened, "B", ENTRY_B);
assertEquals(initialHighWater + 3, longField(reopened, "nonceHighWater"));
}
assertReopened(path, true);
ok();
}
@Test
void cleanupFailuresPreservePrimaryAndEncryptedResiduals() throws Exception {
start("cleanupFailuresPreservePrimaryAndEncryptedResiduals");
assertCleanupFailure(Target.MAIN_IMAGE, "cleanup-main.zek");
assertCleanupFailure(Target.NONCE_RESERVATION, "cleanup-sidecar.zek");
ok();
}
private void assertCleanupFailure(Target target, String file) throws Exception {
Path path = initialized(file);
FailingFileOperations operations = new FailingFileOperations(
target, FailureStage.WRITE_TEMP, true);
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.open(path, password,
KeyringProtection.standard(), deterministicRandom(150),
operations)) {
KeyringException failure = assertThrows(KeyringException.class,
() -> put(store, "B", ENTRY_B));
assertSafe(failure, KeyringException.Code.KEYRING_IO_FAILED);
assertEquals(1, failure.getSuppressed().length);
assertSafe((KeyringException) failure.getSuppressed()[0],
KeyringException.Code.KEYRING_IO_FAILED);
assertTrue(store.contains("A"));
assertFalse(store.contains("B"));
Path residual = operations.firstTemporary();
assertTrue(Files.isRegularFile(residual));
assertEquals(FILE_PERMISSIONS, Files.getPosixFilePermissions(residual));
assertSentinelAbsent(residual.getParent(), ENTRY_B);
put(store, "B", ENTRY_B);
assertNotEquals(residual, operations.lastTemporary());
assertTrue(store.contains("B"));
}
assertReopened(path, true);
}
private Path initialized(String file) throws Exception {
Path path = temporaryDirectory.resolve(file);
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.create(path, password,
KeyringProtection.standard(), deterministicRandom(1))) {
put(store, "A", ENTRY_A);
}
return path;
}
private static void put(KeyringStore store, String alias, byte[] material)
throws Exception {
store.putSecret(alias, "AES", new SecretKeySpec(material, "AES"));
}
private static void assertReopened(Path path, boolean hasB) throws Exception {
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.open(path, password)) {
assertTrue(store.contains("A"));
assertEquals(hasB, store.contains("B"));
assertSecretEquals(ENTRY_A, store.getSecret("A"));
if (hasB) {
assertSecretEquals(ENTRY_B, store.getSecret("B"));
}
}
}
private static void assertSecretEquals(byte[] expected, SecretKey key) {
byte[] encoded = key.getEncoded();
try {
assertArrayEquals(expected, encoded);
} finally {
wipe(encoded);
}
}
private static void assertAlreadyOpen(Path path) throws Exception {
try (KeyringPassword password = password()) {
KeyringException failure = assertThrows(KeyringException.class,
() -> KeyringStore.open(path, password));
assertSafe(failure, KeyringException.Code.KEYRING_ALREADY_OPEN);
}
}
private static void assertSafe(KeyringException failure,
KeyringException.Code expected) {
assertEquals(expected, failure.code());
assertEquals(expected.name(), failure.getMessage());
assertNull(failure.getCause());
}
private static void assertSentinelAbsent(Path directory, byte[] sentinel)
throws IOException {
try (java.util.stream.Stream<Path> paths = Files.list(directory)) {
for (Path current : paths.toList()) {
if (Files.isRegularFile(current)) {
byte[] image = Files.readAllBytes(current);
try {
assertEquals(-1, indexOf(image, sentinel));
} finally {
wipe(image);
}
}
}
}
}
private static int indexOf(byte[] haystack, byte[] needle) {
outer:
for (int index = 0; index <= haystack.length - needle.length; index++) {
for (int offset = 0; offset < needle.length; offset++) {
if (haystack[index + offset] != needle[offset]) {
continue outer;
}
}
return index;
}
return -1;
}
private static long longField(KeyringStore store, String name) throws Exception {
Field field = KeyringStore.class.getDeclaredField(name);
field.setAccessible(true);
return field.getLong(store);
}
private static Path sidecar(Path keyring) {
return keyring.resolveSibling(keyring.getFileName() + ".nonce");
}
private static KeyringPassword password() {
return new KeyringPassword(PASSWORD);
}
private static KeyringRandomBytes deterministicRandom(int initial) {
AtomicInteger value = new AtomicInteger(initial);
return destination -> {
int base = value.getAndIncrement();
for (int index = 0; index < destination.length; index++) {
destination[index] = (byte) (base + index);
}
};
}
private static byte[] material(byte value) {
byte[] material = new byte[32];
Arrays.fill(material, value);
return material;
}
private static void wipe(byte[] value) {
if (value != null) {
Arrays.fill(value, (byte) 0);
}
}
private static void start(String method) {
System.out.println(method);
}
private static void ok() {
System.out.println("...ok");
}
private enum FailureStage {
CREATE_TEMP,
WRITE_TEMP,
FORCE_TEMP,
ATOMIC_MOVE,
FORCE_DIRECTORY,
DELETE_TEMP
}
private static final class FailingFileOperations implements KeyringFileOperations {
private final Target target;
private final FailureStage primaryStage;
private final boolean failCleanup;
private final List<Path> temporaryPaths = new ArrayList<>();
private boolean primaryFailed;
private boolean cleanupFailed;
private FailingFileOperations(Target target, FailureStage primaryStage,
boolean failCleanup) {
this.target = target;
this.primaryStage = primaryStage;
this.failCleanup = failCleanup;
}
@Override
public Path createTemporary(Target actualTarget, Path parent, String prefix,
String suffix, FileAttribute<Set<PosixFilePermission>> permissions)
throws IOException {
failBefore(actualTarget, FailureStage.CREATE_TEMP);
Path temporary = NIO.createTemporary(actualTarget, parent, prefix, suffix,
permissions);
if (actualTarget == target) {
temporaryPaths.add(temporary);
}
return temporary;
}
@Override
public void writeTemporary(Target actualTarget, Path temporary, byte[] image)
throws IOException {
NIO.writeTemporary(actualTarget, temporary, image);
failAfter(actualTarget, FailureStage.WRITE_TEMP);
}
@Override
public void forceTemporary(Target actualTarget, Path temporary)
throws IOException {
NIO.forceTemporary(actualTarget, temporary);
failAfter(actualTarget, FailureStage.FORCE_TEMP);
}
@Override
public void atomicReplace(Target actualTarget, Path temporary, Path destination)
throws IOException {
failBefore(actualTarget, FailureStage.ATOMIC_MOVE);
NIO.atomicReplace(actualTarget, temporary, destination);
}
@Override
public void forceDirectory(Target actualTarget, Path parent) throws IOException {
failBefore(actualTarget, FailureStage.FORCE_DIRECTORY);
NIO.forceDirectory(actualTarget, parent);
}
@Override
public void deleteTemporary(Target actualTarget, Path temporary)
throws IOException {
if (actualTarget == target && failCleanup && !cleanupFailed) {
cleanupFailed = true;
throw new IOException("DELETE_TEMP_SENTINEL");
}
NIO.deleteTemporary(actualTarget, temporary);
}
private void failBefore(Target actualTarget, FailureStage stage)
throws IOException {
if (actualTarget == target && primaryStage == stage && !primaryFailed) {
primaryFailed = true;
throw new IOException(stage.name() + "_SENTINEL");
}
}
private void failAfter(Target actualTarget, FailureStage stage)
throws IOException {
failBefore(actualTarget, stage);
}
private Path firstTemporary() {
return temporaryPaths.get(0);
}
private Path lastTemporary() {
return temporaryPaths.get(temporaryPaths.size() - 1);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,865 @@
package zeroecho.core.storage;
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URI;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.UserPrincipal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.logging.LogManager;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class KeyringFilesystemSecurityTest {
private static final char[] PASSWORD = { 'f', 'i', 'l', 'e', 's', 'y', 's' };
private static final byte[] CHILD_PASSWORD = { 'f', 'i', 'l', 'e', 's', 'y', 's' };
private static final long TIMEOUT_SECONDS = 15L;
private static final Set<PosixFilePermission> DIRECTORY_PERMISSIONS = Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE);
private static final Set<PosixFilePermission> FILE_PERMISSIONS = Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE);
@TempDir
Path temporaryDirectory;
@Test
void secureCreationAndUnsafePermissionsMatrix() throws Exception {
start("secureCreationAndUnsafePermissionsMatrix");
Path parent = temporaryDirectory.resolve("secure");
Path path = parent.resolve("keys.zek");
createPopulated(path, 1);
assertEquals(DIRECTORY_PERMISSIONS, Files.getPosixFilePermissions(parent));
assertEquals(FILE_PERMISSIONS, Files.getPosixFilePermissions(path));
assertEquals(FILE_PERMISSIONS, Files.getPosixFilePermissions(lock(path)));
assertEquals(FILE_PERMISSIONS, Files.getPosixFilePermissions(sidecar(path)));
try (java.util.stream.Stream<Path> files = Files.list(parent)) {
assertTrue(files.noneMatch(value -> value.getFileName().toString().endsWith(".tmp")));
}
assertOpenSucceeds(path);
for (PosixFilePermission unsafe : List.of(
PosixFilePermission.GROUP_READ,
PosixFilePermission.GROUP_WRITE,
PosixFilePermission.OTHERS_READ,
PosixFilePermission.OTHERS_WRITE)) {
Path unsafeParent = temporaryDirectory.resolve("parent-" + unsafe.name());
Path unsafeStore = unsafeParent.resolve("keys.zek");
createPopulated(unsafeStore, unsafe.ordinal() + 10);
Set<PosixFilePermission> permissions = new java.util.HashSet<>(
DIRECTORY_PERMISSIONS);
permissions.add(unsafe);
Files.setPosixFilePermissions(unsafeParent, permissions);
assertRedactedFailure(unsafeStore,
KeyringException.Code.KEYRING_FILESYSTEM_UNSUPPORTED);
Files.setPosixFilePermissions(unsafeParent, DIRECTORY_PERMISSIONS);
}
assertUnsafeFilePermissions("main", Artifact.MAIN);
assertUnsafeFilePermissions("lock", Artifact.LOCK);
assertUnsafeFilePermissions("sidecar", Artifact.SIDECAR);
ok();
}
@Test
void nonPosixAndOwnershipMismatchFailClosed() throws Exception {
start("nonPosixAndOwnershipMismatchFailClosed");
Path archive = temporaryDirectory.resolve("non-posix.zip");
URI uri = URI.create("jar:" + archive.toUri());
try (FileSystem fileSystem = FileSystems.newFileSystem(uri, Map.of("create", "true"));
KeyringPassword password = password()) {
KeyringException failure = assertThrows(KeyringException.class,
() -> KeyringStore.create(fileSystem.getPath("/keys.zek"), password));
assertEquals(KeyringException.Code.KEYRING_FILESYSTEM_UNSUPPORTED, failure.code());
assertEquals(failure.code().name(), failure.getMessage());
assertNull(failure.getCause());
}
Path path = temporaryDirectory.resolve("owner-mismatch.zek");
createPopulated(path, 31);
Method validator = KeyringStore.class.getDeclaredMethod("validateExistingFile",
Path.class, UserPrincipal.class);
validator.setAccessible(true);
UserPrincipal other = () -> "controlled-other-owner";
InvocationTargetException failure = assertThrows(InvocationTargetException.class,
() -> validator.invoke(null, path, other));
assertTrue(failure.getCause() instanceof KeyringException);
assertEquals(KeyringException.Code.KEYRING_FILESYSTEM_UNSUPPORTED,
((KeyringException) failure.getCause()).code());
ok();
}
@Test
void symlinkAndHardLinkMatrixFailsClosed() throws Exception {
start("symlinkAndHardLinkMatrixFailsClosed");
assertKeyringSymlinkRejected();
assertArtifactSymlinkRejected(Artifact.LOCK);
assertArtifactSymlinkRejected(Artifact.SIDECAR);
assertParentSymlinkRejected();
assertArtifactHardLinkRejected(Artifact.MAIN);
assertArtifactHardLinkRejected(Artifact.LOCK);
assertArtifactHardLinkRejected(Artifact.SIDECAR);
assertPrecreatedTemporarySymlinkIgnored();
ok();
}
@Test
void sameJvmOwnershipAndIndependentStores() throws Exception {
start("sameJvmOwnershipAndIndependentStores");
Path path = temporaryDirectory.resolve("shared.zek");
byte[] material = material((byte) 0x41);
try (KeyringPassword password = password();
KeyringStore first = KeyringStore.create(path, password,
KeyringProtection.standard(), deterministicRandom(41));
KeyringPassword secondPassword = password()) {
first.putSecret("shared", "AES", new SecretKeySpec(material, "AES"));
KeyringException contention = assertThrows(KeyringException.class,
() -> KeyringStore.open(path, secondPassword));
assertEquals(KeyringException.Code.KEYRING_ALREADY_OPEN, contention.code());
assertEquals(KeyringException.Code.KEYRING_ALREADY_OPEN.name(),
contention.getMessage());
assertNull(contention.getCause());
assertArrayEquals(material, first.getSecret("shared").getEncoded());
} finally {
wipe(material);
}
assertOpenSucceeds(path);
Path firstPath = temporaryDirectory.resolve("independent-one.zek");
Path secondPath = temporaryDirectory.resolve("independent-two.zek");
try (KeyringPassword firstPassword = password();
KeyringPassword secondPassword = password();
KeyringStore first = KeyringStore.create(firstPath, firstPassword);
KeyringStore second = KeyringStore.create(secondPath, secondPassword)) {
assertTrue(first.aliases().isEmpty());
assertTrue(second.aliases().isEmpty());
}
ok();
}
@Test
void forkedJvmOwnershipReleasesGracefullyAndAfterTermination() throws Exception {
start("forkedJvmOwnershipReleasesGracefullyAndAfterTermination");
Path graceful = temporaryDirectory.resolve("fork-graceful.zek");
createPopulated(graceful, 51);
try (ChildOwner child = ChildOwner.start(graceful)) {
child.sendPassword(CHILD_PASSWORD);
child.expect("KEYRING_OPEN");
assertAlreadyOpen(graceful);
child.send("PING");
child.expect("KEYRING_USABLE");
child.send("CLOSE");
child.expect("KEYRING_CLOSED");
child.awaitExit(0);
assertFalse(child.outputContains("filesys"));
}
assertOpenSucceeds(graceful);
Path abrupt = temporaryDirectory.resolve("fork-abrupt.zek");
createPopulated(abrupt, 61);
try (ChildOwner child = ChildOwner.start(abrupt)) {
child.sendPassword(CHILD_PASSWORD);
child.expect("KEYRING_OPEN");
assertAlreadyOpen(abrupt);
child.destroyForcibly();
}
assertOpenSucceeds(abrupt);
ok();
}
@Test
void concurrentReadsAndSerializedMutationsRemainConsistent() throws Exception {
start("concurrentReadsAndSerializedMutationsRemainConsistent");
Path path = temporaryDirectory.resolve("concurrent.zek");
byte[] first = material((byte) 0x12);
byte[] second = material((byte) 0x34);
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.create(path, password,
KeyringProtection.standard(), deterministicRandom(71))) {
store.putSecret("one", "AES", new SecretKeySpec(first, "AES"));
store.putSecret("two", "AES", new SecretKeySpec(second, "AES"));
runConcurrentReaders(store, first, second);
runConcurrentPuts(store);
} finally {
wipe(first);
wipe(second);
}
assertOpenSucceeds(path);
ok();
}
@Test
void closeWaitsForAdmittedOperationsAndClearsKeysOnce() throws Exception {
start("closeWaitsForAdmittedOperationsAndClearsKeysOnce");
Path path = temporaryDirectory.resolve("close-coordination.zek");
KeyringStore store;
try (KeyringPassword password = password()) {
store = KeyringStore.create(path, password);
}
byte[] master = bytesField(store, "masterKey");
byte[] macKey = bytesField(store, "nonceReservationMacKey");
ReentrantReadWriteLock lock = lockField(store);
lock.readLock().lock();
CountDownLatch started = new CountDownLatch(2);
AtomicReference<Throwable> failure = new AtomicReference<>();
Thread first = closeThread(store, started, failure, "keyring-close-one");
Thread second = closeThread(store, started, failure, "keyring-close-two");
first.start();
second.start();
assertTrue(started.await(TIMEOUT_SECONDS, TimeUnit.SECONDS));
awaitQueued(lock, 2);
assertFalse(store.isDestroyed());
lock.readLock().unlock();
first.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
second.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
assertFalse(first.isAlive());
assertFalse(second.isAlive());
assertNull(failure.get());
assertTrue(store.isDestroyed());
assertTrue(allZero(master));
assertTrue(allZero(macKey));
assertThrows(IllegalStateException.class, store::aliases);
store.close();
assertOpenSucceeds(path);
ok();
}
@Test
void admittedMutationCompletesBeforeCloseAndLaterMutationFails() throws Exception {
start("admittedMutationCompletesBeforeCloseAndLaterMutationFails");
Path path = temporaryDirectory.resolve("mutation-close.zek");
BlockingRandom random = new BlockingRandom(81);
KeyringStore store;
try (KeyringPassword password = password()) {
store = KeyringStore.create(path, password,
KeyringProtection.standard(), random);
}
byte[] material = material((byte) 0x5c);
AtomicReference<Throwable> putFailure = new AtomicReference<>();
AtomicReference<Throwable> closeFailure = new AtomicReference<>();
random.arm();
Thread mutation = new Thread(() -> {
try {
store.putSecret("admitted", "AES",
new SecretKeySpec(material, "AES"));
} catch (Throwable throwable) {
putFailure.set(throwable);
}
}, "keyring-admitted-mutation");
mutation.start();
assertTrue(random.awaitEntered());
Thread closer = new Thread(() -> {
try {
store.close();
} catch (Throwable throwable) {
closeFailure.set(throwable);
}
}, "keyring-close-after-mutation");
closer.start();
awaitQueued(lockField(store), 1);
random.release();
mutation.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
closer.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
assertNull(putFailure.get());
assertNull(closeFailure.get());
assertTrue(store.isDestroyed());
assertThrows(KeyringException.class,
() -> store.putSecret("late", "AES",
new SecretKeySpec(material, "AES")));
try (KeyringPassword password = password();
KeyringStore reopened = KeyringStore.open(path, password)) {
assertArrayEquals(material, reopened.getSecret("admitted").getEncoded());
} finally {
wipe(material);
}
ok();
}
private void assertUnsafeFilePermissions(String name, Artifact artifact) throws Exception {
Path path = temporaryDirectory.resolve("unsafe-" + name + ".zek");
createPopulated(path, artifact.ordinal() + 20);
Path target = artifact.path(path);
Set<PosixFilePermission> permissions = new java.util.HashSet<>(FILE_PERMISSIONS);
permissions.add(PosixFilePermission.GROUP_READ);
Files.setPosixFilePermissions(target, permissions);
assertRedactedFailure(path, KeyringException.Code.KEYRING_FILESYSTEM_UNSUPPORTED);
Files.setPosixFilePermissions(target, FILE_PERMISSIONS);
}
private void assertKeyringSymlinkRejected() throws Exception {
Path target = temporaryDirectory.resolve("symlink-main-target.zek");
createPopulated(target, 91);
byte[] before = Files.readAllBytes(target);
Path link = temporaryDirectory.resolve("symlink-main.zek");
Files.createSymbolicLink(link, target.getFileName());
try {
assertRedactedFailure(link, KeyringException.Code.KEYRING_FILESYSTEM_UNSUPPORTED);
assertArrayEquals(before, Files.readAllBytes(target));
} finally {
wipe(before);
}
}
private void assertArtifactSymlinkRejected(Artifact artifact) throws Exception {
Path path = temporaryDirectory.resolve("symlink-" + artifact.name() + ".zek");
createPopulated(path, 101 + artifact.ordinal());
Path original = artifact.path(path);
Path saved = original.resolveSibling(original.getFileName() + ".saved");
Files.move(original, saved);
Path target = temporaryDirectory.resolve("symlink-target-" + artifact.name());
Files.write(target, new byte[] { 7 });
ownerOnly(target);
byte[] before = Files.readAllBytes(target);
Files.createSymbolicLink(original, target.getFileName());
try {
assertRedactedFailure(path, KeyringException.Code.KEYRING_FILESYSTEM_UNSUPPORTED);
assertArrayEquals(before, Files.readAllBytes(target));
} finally {
wipe(before);
}
}
private void assertParentSymlinkRejected() throws Exception {
Path real = temporaryDirectory.resolve("real-parent");
Path path = real.resolve("keys.zek");
createPopulated(path, 111);
Path link = temporaryDirectory.resolve("linked-parent");
Files.createSymbolicLink(link, real.getFileName());
assertRedactedFailure(link.resolve("keys.zek"),
KeyringException.Code.KEYRING_FILESYSTEM_UNSUPPORTED);
}
private void assertArtifactHardLinkRejected(Artifact artifact) throws Exception {
Path path = temporaryDirectory.resolve("hard-" + artifact.name() + ".zek");
createPopulated(path, 121 + artifact.ordinal());
Path target = artifact.path(path);
Path extra = target.resolveSibling(target.getFileName() + ".hard");
Files.createLink(extra, target);
try {
assertRedactedFailure(path, KeyringException.Code.KEYRING_FILESYSTEM_UNSUPPORTED);
} finally {
Files.delete(extra);
}
}
private void assertPrecreatedTemporarySymlinkIgnored() throws Exception {
Path path = temporaryDirectory.resolve("temp-race.zek");
createPopulated(path, 131);
Path target = temporaryDirectory.resolve("temp-target");
Files.write(target, new byte[] { 9, 8, 7 });
ownerOnly(target);
byte[] before = Files.readAllBytes(target);
Path malicious = temporaryDirectory.resolve(".temp-race.zek.precreated.tmp");
Files.createSymbolicLink(malicious, target.getFileName());
byte[] material = material((byte) 0x63);
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.open(path, password)) {
store.putSecret("safe", "AES", new SecretKeySpec(material, "AES"));
assertArrayEquals(before, Files.readAllBytes(target));
} finally {
wipe(before);
wipe(material);
}
}
private void runConcurrentReaders(KeyringStore store, byte[] first, byte[] second)
throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(8);
CountDownLatch start = new CountDownLatch(1);
List<Future<Boolean>> results = new ArrayList<>();
try {
for (int index = 0; index < 8; index++) {
int selected = index;
results.add(executor.submit(() -> {
start.await();
if (selected == 7) {
assertThrows(IllegalArgumentException.class,
() -> store.getSecret("missing"));
return true;
}
String alias = selected % 2 == 0 ? "one" : "two";
byte[] expected = selected % 2 == 0 ? first : second;
SecretKey key = store.getSecret(alias);
byte[] encoded = key.getEncoded();
try {
return Arrays.equals(expected, encoded);
} finally {
wipe(encoded);
}
}));
}
start.countDown();
for (Future<Boolean> result : results) {
assertTrue(result.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
}
} finally {
executor.shutdownNow();
assertTrue(executor.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS));
}
}
private void runConcurrentPuts(KeyringStore store) throws Exception {
byte[] third = material((byte) 0x71);
byte[] fourth = material((byte) 0x72);
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch start = new CountDownLatch(1);
try {
Future<?> one = executor.submit(() -> {
start.await();
store.putSecret("three", "AES", new SecretKeySpec(third, "AES"));
return null;
});
Future<?> two = executor.submit(() -> {
start.await();
store.putSecret("four", "AES", new SecretKeySpec(fourth, "AES"));
return null;
});
start.countDown();
one.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
two.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
assertArrayEquals(third, store.getSecret("three").getEncoded());
assertArrayEquals(fourth, store.getSecret("four").getEncoded());
} finally {
executor.shutdownNow();
assertTrue(executor.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS));
wipe(third);
wipe(fourth);
}
}
private void createPopulated(Path path, int seed) throws Exception {
byte[] material = material((byte) seed);
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.create(path, password,
KeyringProtection.standard(), deterministicRandom(seed))) {
store.putSecret("child", "AES", new SecretKeySpec(material, "AES"));
} finally {
wipe(material);
}
}
private static void assertOpenSucceeds(Path path) throws Exception {
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.open(path, password)) {
assertFalse(store.isDestroyed());
store.aliases();
}
}
private static void assertAlreadyOpen(Path path) throws Exception {
try (KeyringPassword password = password()) {
KeyringException failure = assertThrows(KeyringException.class,
() -> KeyringStore.open(path, password));
assertEquals(KeyringException.Code.KEYRING_ALREADY_OPEN, failure.code());
assertEquals(failure.code().name(), failure.getMessage());
assertNull(failure.getCause());
}
}
private static void assertRedactedFailure(Path path, KeyringException.Code code)
throws Exception {
try (KeyringPassword password = password()) {
KeyringException failure = assertThrows(KeyringException.class,
() -> KeyringStore.open(path, password));
assertEquals(code, failure.code());
assertEquals(code.name(), failure.getMessage());
assertNull(failure.getCause());
assertEquals(0, failure.getSuppressed().length);
assertFalse(failure.getMessage().contains(path.toString()));
}
}
private static Thread closeThread(KeyringStore store, CountDownLatch started,
AtomicReference<Throwable> failure, String name) {
return new Thread(() -> {
started.countDown();
try {
store.close();
} catch (Throwable throwable) {
failure.compareAndSet(null, throwable);
}
}, name);
}
private static void awaitQueued(ReentrantReadWriteLock lock, int minimum)
throws Exception {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(TIMEOUT_SECONDS);
while (lock.getQueueLength() < minimum && System.nanoTime() < deadline) {
Thread.onSpinWait();
}
assertTrue(lock.getQueueLength() >= minimum);
}
private static ReentrantReadWriteLock lockField(KeyringStore store) throws Exception {
Field field = KeyringStore.class.getDeclaredField("lifecycleLock");
field.setAccessible(true);
return (ReentrantReadWriteLock) field.get(store);
}
private static byte[] bytesField(KeyringStore store, String name) throws Exception {
Field field = KeyringStore.class.getDeclaredField(name);
field.setAccessible(true);
return (byte[]) field.get(store);
}
private static boolean allZero(byte[] value) {
for (byte current : value) {
if (current != 0) {
return false;
}
}
return true;
}
private static byte[] material(byte value) {
byte[] result = new byte[32];
Arrays.fill(result, value);
return result;
}
private static KeyringPassword password() {
return new KeyringPassword(PASSWORD);
}
private static KeyringRandomBytes deterministicRandom(int initial) {
AtomicInteger value = new AtomicInteger(initial);
return destination -> {
int base = value.getAndIncrement();
for (int index = 0; index < destination.length; index++) {
destination[index] = (byte) (base + index);
}
};
}
private static void ownerOnly(Path path) throws Exception {
Files.setPosixFilePermissions(path, FILE_PERMISSIONS);
}
private static Path lock(Path keyring) {
return keyring.resolveSibling(keyring.getFileName() + ".lock");
}
private static Path sidecar(Path keyring) {
return keyring.resolveSibling(keyring.getFileName() + ".nonce");
}
private static void wipe(byte[] value) {
if (value != null) {
Arrays.fill(value, (byte) 0);
}
}
private static void start(String method) {
System.out.println(method);
}
private static void ok() {
System.out.println("...ok");
}
private enum Artifact {
MAIN {
@Override
Path path(Path keyring) {
return keyring;
}
},
LOCK {
@Override
Path path(Path keyring) {
return lock(keyring);
}
},
SIDECAR {
@Override
Path path(Path keyring) {
return sidecar(keyring);
}
};
abstract Path path(Path keyring);
}
private static final class BlockingRandom implements KeyringRandomBytes {
private final AtomicInteger value;
private final AtomicBoolean armed = new AtomicBoolean();
private final AtomicBoolean blocked = new AtomicBoolean();
private final CountDownLatch entered = new CountDownLatch(1);
private final CountDownLatch release = new CountDownLatch(1);
private BlockingRandom(int initial) {
value = new AtomicInteger(initial);
}
private void arm() {
armed.set(true);
}
private boolean awaitEntered() throws InterruptedException {
return entered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS);
}
private void release() {
release.countDown();
}
@Override
public void nextBytes(byte[] destination) {
if (armed.get() && blocked.compareAndSet(false, true)) {
entered.countDown();
try {
if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
throw new IllegalStateException("controlled random release timed out");
}
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException("controlled random interrupted");
}
}
int base = value.getAndIncrement();
for (int index = 0; index < destination.length; index++) {
destination[index] = (byte) (base + index);
}
}
}
private static final class ChildOwner implements AutoCloseable {
private final Process process;
private final BufferedReader output;
private final OutputStream input;
private final ExecutorService reader = Executors.newSingleThreadExecutor();
private final StringBuilder transcript = new StringBuilder();
private ChildOwner(Process process) {
this.process = process;
output = new BufferedReader(new InputStreamReader(process.getInputStream(),
StandardCharsets.UTF_8));
input = process.getOutputStream();
}
private static ChildOwner start(Path path) throws Exception {
String executable = System.getProperty("os.name", "")
.toLowerCase(java.util.Locale.ROOT).contains("win")
? "java.exe" : "java";
Path java = Path.of(System.getProperty("java.home"), "bin", executable);
Process process = new ProcessBuilder(java.toString(), "-cp", childClasspath(),
KeyringStoreLockProcess.class.getName(), path.toString())
.redirectErrorStream(true).start();
return new ChildOwner(process);
}
private void sendPassword(byte[] password) throws IOException {
byte[] copy = password.clone();
try {
input.write(copy);
input.write('\n');
input.flush();
} finally {
wipe(copy);
}
}
private void send(String command) throws IOException {
byte[] encoded = command.getBytes(StandardCharsets.US_ASCII);
try {
input.write(encoded);
input.write('\n');
input.flush();
} finally {
wipe(encoded);
}
}
private void expect(String expected) throws Exception {
Future<String> future = reader.submit(output::readLine);
String line;
try {
line = future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (ExecutionException exception) {
throw new IOException("child protocol failed");
} catch (TimeoutException exception) {
future.cancel(true);
throw new IOException("child protocol timed out");
}
transcript.append(line);
assertEquals(expected, line);
}
private boolean outputContains(String value) {
return transcript.toString().contains(value);
}
private void awaitExit(int expected) throws Exception {
assertTrue(process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS));
assertEquals(expected, process.exitValue());
}
private void destroyForcibly() throws Exception {
process.destroyForcibly();
assertTrue(process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS));
}
@Override
public void close() throws Exception {
if (process.isAlive()) {
process.destroyForcibly();
process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
}
input.close();
output.close();
reader.shutdownNow();
assertTrue(reader.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS));
}
}
private static String childClasspath() throws Exception {
Set<String> entries = new LinkedHashSet<>();
String configured = System.getProperty("java.class.path", "");
if (!configured.isBlank()) {
entries.addAll(Arrays.asList(configured.split(java.io.File.pathSeparator)));
}
ClassLoader loader = KeyringFilesystemSecurityTest.class.getClassLoader();
while (loader != null) {
if (loader instanceof URLClassLoader urls) {
for (URL url : urls.getURLs()) {
if ("file".equals(url.getProtocol())) {
entries.add(Path.of(url.toURI()).toString());
}
}
}
loader = loader.getParent();
}
entries.add(codeSource(KeyringFilesystemSecurityTest.class));
entries.add(codeSource(KeyringStore.class));
return String.join(java.io.File.pathSeparator, entries);
}
private static String codeSource(Class<?> type) throws Exception {
return Path.of(type.getProtectionDomain().getCodeSource().getLocation().toURI())
.toString();
}
}
final class KeyringStoreLockProcess {
private KeyringStoreLockProcess() {
}
public static void main(String[] args) {
if (args.length != 1) {
System.out.println("SETUP_FAILED");
System.exit(2);
}
LogManager.getLogManager().reset();
byte[] passwordBytes = null;
char[] passwordChars = null;
try {
passwordBytes = readSecret(System.in, 64);
passwordChars = new char[passwordBytes.length];
for (int index = 0; index < passwordBytes.length; index++) {
passwordChars[index] = (char) Byte.toUnsignedInt(passwordBytes[index]);
}
try (KeyringPassword password = new KeyringPassword(passwordChars);
KeyringStore store = KeyringStore.open(Path.of(args[0]), password);
BufferedReader control = new BufferedReader(new InputStreamReader(
System.in, StandardCharsets.US_ASCII))) {
Arrays.fill(passwordChars, '\0');
Arrays.fill(passwordBytes, (byte) 0);
System.out.println("KEYRING_OPEN");
System.out.flush();
String command;
while ((command = control.readLine()) != null) {
if ("PING".equals(command)) {
store.aliases();
System.out.println("KEYRING_USABLE");
System.out.flush();
} else if ("CLOSE".equals(command)) {
store.close();
System.out.println("KEYRING_CLOSED");
System.out.flush();
return;
} else {
System.out.println("PROTOCOL_FAILED");
System.out.flush();
System.exit(3);
}
}
}
} catch (Exception exception) {
System.out.println("SETUP_FAILED");
System.exit(4);
} finally {
if (passwordChars != null) {
Arrays.fill(passwordChars, '\0');
}
wipe(passwordBytes);
}
}
private static byte[] readSecret(InputStream input, int maximum) throws IOException {
byte[] buffer = new byte[maximum];
int count = 0;
try {
int current;
while ((current = input.read()) >= 0 && current != '\n') {
if (count >= maximum) {
throw new IOException("secret input exceeded test protocol limit");
}
buffer[count++] = (byte) current;
}
if (current < 0) {
throw new IOException("secret input ended early");
}
return Arrays.copyOf(buffer, count);
} finally {
wipe(buffer);
}
}
private static void wipe(byte[] value) {
if (value != null) {
Arrays.fill(value, (byte) 0);
}
}
}

View File

@@ -0,0 +1,219 @@
package zeroecho.core.storage;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.security.Key;
import java.security.KeyPair;
import java.security.PublicKey;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import javax.security.auth.Destroyable;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.KeyOperation;
import zeroecho.core.KeyOperationInfo;
import zeroecho.core.spec.AlgorithmKeySpec;
import zeroecho.core.spi.AsymmetricKeyPairGenerator;
import zeroecho.sdk.util.BouncyCastleActivator;
class KeyringImportRegistryTest {
@BeforeAll
static void initializeProviders() {
BouncyCastleActivator.init();
}
@Test
void persistentImporterMatrixIsClosedUniqueAndExecutable() throws Exception {
start("persistentImporterMatrixIsClosedUniqueAndExecutable");
List<KeyringImportRegistry.PersistentMapping> mappings =
KeyringImportRegistry.mappings();
assertEquals(19, count(mappings, KeyringStore.Kind.PUBLIC_KEY));
assertEquals(19, count(mappings, KeyringStore.Kind.PRIVATE_KEY));
assertEquals(6, count(mappings, KeyringStore.Kind.SECRET_KEY));
assertEquals(mappings.size(), mappings.stream().distinct().count());
for (KeyringImportRegistry.PersistentMapping mapping : mappings) {
KeyringImportRegistry.validateMapping(mapping.algorithmId(), mapping.kind(),
mapping.encoding(), mapping.hmacVariant());
}
roundTripAsymmetricMappings(mappings);
roundTripSecretMappings(mappings);
assertEquals(KeyringException.Code.KEYRING_IMPORT_MAPPING_INVALID,
assertThrows(KeyringException.class,
() -> KeyringImportRegistry.validateMapping("RSA",
KeyringStore.Kind.SECRET_KEY, KeyringStore.Encoding.RAW,
KeyringImportRegistry.HmacVariant.NONE)).code());
System.out.println("...mapping-count=" + mappings.size());
ok();
}
@Test
void alternateProviderStandardEncodingUsesCanonicalImporter(
@TempDir Path temporaryDirectory) throws Exception {
start("alternateProviderStandardEncodingUsesCanonicalImporter");
java.security.KeyPairGenerator generator =
java.security.KeyPairGenerator.getInstance("RSA", "BC");
generator.initialize(2048);
KeyPair pair = generator.generateKeyPair();
byte[] encoded = pair.getPublic().getEncoded();
Key imported = null;
PublicKey reopened = null;
byte[] importedEncoding = null;
byte[] reopenedEncoding = null;
try {
imported = KeyringImportRegistry.importKey("RSA",
KeyringStore.Kind.PUBLIC_KEY, KeyringStore.Encoding.X509,
KeyringImportRegistry.HmacVariant.NONE, encoded);
importedEncoding = imported.getEncoded();
assertArrayEquals(encoded, importedEncoding);
assertEquals("RSA", imported.getAlgorithm());
assertNotEquals(pair.getPublic().getClass(), imported.getClass());
Path path = temporaryDirectory.resolve("alternate-provider.zek");
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.create(path, password)) {
store.putPublic("alternate", "RSA", pair.getPublic());
}
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.open(path, password)) {
reopened = store.getPublic("alternate");
reopenedEncoding = reopened.getEncoded();
assertArrayEquals(encoded, reopenedEncoding);
}
} finally {
wipe(encoded);
wipe(importedEncoding);
wipe(reopenedEncoding);
destroy(imported);
destroy(reopened);
}
ok();
}
private static long count(List<KeyringImportRegistry.PersistentMapping> mappings,
KeyringStore.Kind kind) {
return mappings.stream().filter(mapping -> mapping.kind() == kind).count();
}
private static void roundTripAsymmetricMappings(
List<KeyringImportRegistry.PersistentMapping> mappings) throws Exception {
List<String> algorithms = mappings.stream()
.filter(mapping -> mapping.kind() == KeyringStore.Kind.PUBLIC_KEY)
.map(KeyringImportRegistry.PersistentMapping::algorithmId)
.toList();
for (String algorithmId : algorithms) {
KeyPair pair = generatePair(algorithmId);
roundTrip(mapping(mappings, algorithmId, KeyringStore.Kind.PUBLIC_KEY),
pair.getPublic());
roundTrip(mapping(mappings, algorithmId, KeyringStore.Kind.PRIVATE_KEY),
pair.getPrivate());
}
}
private static void roundTripSecretMappings(
List<KeyringImportRegistry.PersistentMapping> mappings) throws Exception {
for (KeyringImportRegistry.PersistentMapping mapping : mappings) {
if (mapping.kind() != KeyringStore.Kind.SECRET_KEY) {
continue;
}
String jcaName = switch (mapping.algorithmId()) {
case "AES" -> "AES";
case "CHACHA20", "CHACHA20-POLY1305" -> "ChaCha20";
case "HMAC" -> mapping.hmacVariant().jcaName();
default -> throw new AssertionError("Unexpected secret mapping");
};
byte[] material = new byte[32];
Arrays.fill(material, (byte) (mapping.hmacVariant().code() + 1));
try {
roundTrip(mapping, new SecretKeySpec(material, jcaName));
} finally {
wipe(material);
}
}
}
@SuppressWarnings({ "rawtypes", "unchecked" })
private static KeyPair generatePair(String algorithmId) throws Exception {
CryptoAlgorithm algorithm = CryptoAlgorithms.require(algorithmId);
KeyOperationInfo generation = algorithm.keyOperations().stream()
.filter(info -> info.operation() == KeyOperation.ASYMMETRIC_KEY_PAIR_GENERATE)
.filter(info -> info.defaultSpec() != null)
.findFirst()
.orElseThrow(() -> new AssertionError(
"No default key-pair generation mapping for " + algorithmId));
AsymmetricKeyPairGenerator generator =
algorithm.asymmetricKeyPairGenerator(generation.specType());
return generator.generateKeyPair((AlgorithmKeySpec) generation.defaultSpec());
}
private static KeyringImportRegistry.PersistentMapping mapping(
List<KeyringImportRegistry.PersistentMapping> mappings, String algorithmId,
KeyringStore.Kind kind) {
return mappings.stream()
.filter(candidate -> candidate.algorithmId().equals(algorithmId)
&& candidate.kind() == kind)
.findFirst()
.orElseThrow();
}
private static void roundTrip(KeyringImportRegistry.PersistentMapping mapping,
Key source) throws Exception {
byte[] encoded = source.getEncoded();
Key imported = null;
byte[] reconstructed = null;
try {
imported = KeyringImportRegistry.importKey(mapping.algorithmId(), mapping.kind(),
mapping.encoding(), mapping.hmacVariant(), encoded);
reconstructed = imported.getEncoded();
assertArrayEquals(encoded, reconstructed);
} finally {
wipe(encoded);
wipe(reconstructed);
destroy(imported);
}
}
private static void destroy(Key key) {
if (key instanceof Destroyable destroyable) {
try {
destroyable.destroy();
} catch (Exception exception) {
// Provider keys may advertise but not implement destruction.
}
}
}
private static void wipe(byte[] value) {
if (value != null) {
Arrays.fill(value, (byte) 0);
}
}
private static KeyringPassword password() {
char[] value = "alternate-provider-test".toCharArray();
try {
return new KeyringPassword(value);
} finally {
Arrays.fill(value, '\0');
}
}
private static void start(String method) {
System.out.println(method);
}
private static void ok() {
System.out.println("...ok");
}
}

View File

@@ -0,0 +1,504 @@
package zeroecho.core.storage;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.lang.reflect.Field;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class KeyringNonceReservationTest {
private static final int MAGIC_BYTES = 8;
private static final int VERSION_OFFSET = MAGIC_BYTES;
private static final int HIGH_WATER_OFFSET =
MAGIC_BYTES + Integer.BYTES + KeyringStore.UUID_BYTES + Integer.BYTES;
private static final int TAG_BYTES = 32;
private static final int CURRENT_SIDECAR_VERSION = 2;
private static final char[] PASSWORD = { 'n', 'o', 'n', 'c', 'e' };
@TempDir
Path temporaryDirectory;
@Test
void hkdfUsesFixedRfc5869Contract() throws Exception {
start("hkdfUsesFixedRfc5869Contract");
byte[] masterKey = new byte[32];
byte[] storeId = new byte[16];
for (int index = 0; index < masterKey.length; index++) {
masterKey[index] = (byte) index;
}
for (int index = 0; index < storeId.length; index++) {
storeId[index] = (byte) (0xa0 + index);
}
byte[] expected = HexFormat.of().parseHex(
"54e0e054749745a3ef5e2cc5a5c16bafed6f39df9daa4ff412bac74d56bd27b9");
byte[] first = null;
byte[] second = null;
byte[] changedMaster = null;
byte[] changedStore = null;
try {
first = KeyringNonceReservationKdf.derive(masterKey, storeId);
second = KeyringNonceReservationKdf.derive(masterKey, storeId);
assertArrayEquals(expected, first);
assertArrayEquals(first, second);
assertEquals(32, first.length);
assertFalse(MessageDigest.isEqual(masterKey, first));
masterKey[0] ^= 1;
changedMaster = KeyringNonceReservationKdf.derive(masterKey, storeId);
assertFalse(MessageDigest.isEqual(first, changedMaster));
masterKey[0] ^= 1;
storeId[0] ^= 1;
changedStore = KeyringNonceReservationKdf.derive(masterKey, storeId);
assertFalse(MessageDigest.isEqual(first, changedStore));
} finally {
wipe(masterKey);
wipe(storeId);
wipe(expected);
wipe(first);
wipe(second);
wipe(changedMaster);
wipe(changedStore);
}
ok();
}
@Test
void currentSidecarSurvivesRestartAndDerivedKeyIsCleared() throws Exception {
start("currentSidecarSurvivesRestartAndDerivedKeyIsCleared");
Path path = temporaryDirectory.resolve("current.zek");
byte[] retainedMacKey;
byte[] macKeyCopy;
try (KeyringPassword password = password()) {
KeyringStore store = KeyringStore.create(path, password,
KeyringProtection.standard(), deterministicRandom());
retainedMacKey = field(store, "nonceReservationMacKey");
macKeyCopy = retainedMacKey.clone();
assertEquals(CURRENT_SIDECAR_VERSION,
ByteBuffer.wrap(Files.readAllBytes(sidecar(path)),
VERSION_OFFSET, Integer.BYTES).getInt());
store.close();
store.close();
assertTrue(allZero(retainedMacKey));
}
byte[] main = Files.readAllBytes(path);
byte[] reservation = Files.readAllBytes(sidecar(path));
try {
assertFalse(indexOf(main, macKeyCopy) >= 0);
assertFalse(indexOf(reservation, macKeyCopy) >= 0);
} finally {
wipe(main);
wipe(reservation);
wipe(macKeyCopy);
}
try (KeyringPassword password = password();
KeyringStore reopened = KeyringStore.open(path, password)) {
assertTrue(reopened.aliases().isEmpty());
}
ok();
}
@Test
void directMasterKeyMacAndSidecarTamperingFailClosed() throws Exception {
start("directMasterKeyMacAndSidecarTamperingFailClosed");
assertRejected(sidecarWithDirectMasterKeyMac("direct-master.zek"));
assertRejected(sidecarWithBitFlippedTag("tag-bit.zek"));
assertRejected(sidecarWithTamperedHighWater("high-water.zek"));
assertRejected(sidecarWithVersion("old-version.zek", 1, true));
assertRejected(sidecarWithVersion("future-version.zek", 3, false));
assertRejected(sidecarTruncated("truncated.zek"));
assertRejected(sidecarWithTrailingByte("trailing.zek"));
ok();
}
@Test
void sidecarCannotBeCopiedAcrossStores() throws Exception {
start("sidecarCannotBeCopiedAcrossStores");
Path first = createAndClose("first.zek", 1);
Path second = createAndClose("second.zek", 101);
byte[] copied = Files.readAllBytes(sidecar(first));
try {
Files.write(sidecar(second), copied);
} finally {
wipe(copied);
}
assertOpenRejectedWithoutMutation(second);
ok();
}
@Test
void restartAndAbandonedReservationAdvanceMonotonically() throws Exception {
start("restartAndAbandonedReservationAdvanceMonotonically");
Path path = temporaryDirectory.resolve("abandoned.zek");
Path saved = temporaryDirectory.resolve("abandoned.saved");
byte[] material = new byte[32];
Arrays.fill(material, (byte) 0x4a);
try {
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.create(path, password,
KeyringProtection.standard(), deterministicRandom())) {
assertEquals(1L, longField(store, "nonceHighWater"));
Files.move(path, saved);
Files.createDirectory(path);
assertThrows(IOException.class,
() -> store.putSecret("failed", "AES",
new SecretKeySpec(material, "AES")));
assertEquals(3L, sidecarHighWater(sidecar(path)));
assertEquals(3L, longField(store, "nonceHighWater"));
Files.delete(path);
Files.move(saved, path);
}
try (KeyringPassword password = password();
KeyringStore reopened = KeyringStore.open(path, password,
KeyringProtection.standard(), deterministicRandom(51))) {
assertEquals(3L, longField(reopened, "nonceHighWater"));
assertTrue(reopened.aliases().isEmpty());
reopened.putSecret("accepted", "AES",
new SecretKeySpec(material, "AES"));
assertEquals(5L, longField(reopened, "nonceHighWater"));
assertEquals(5L, sidecarHighWater(sidecar(path)));
}
try (KeyringPassword password = password();
KeyringStore reopened = KeyringStore.open(path, password)) {
assertEquals(5L, longField(reopened, "nonceHighWater"));
assertArrayEquals(material, reopened.getSecret("accepted").getEncoded());
}
} finally {
wipe(material);
}
ok();
}
@Test
void failedSidecarReservationDoesNotAdvanceOrEncrypt() throws Exception {
start("failedSidecarReservationDoesNotAdvanceOrEncrypt");
Path path = temporaryDirectory.resolve("sidecar-write-failure.zek");
Path reservation = sidecar(path);
Path saved = temporaryDirectory.resolve("sidecar.saved");
byte[] material = new byte[32];
Arrays.fill(material, (byte) 0x35);
RecordingRandom random = new RecordingRandom(1);
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.create(path, password,
KeyringProtection.standard(), random)) {
byte[] mainBefore = Files.readAllBytes(path);
try {
assertEquals(List.of(32, 32, 16, 11, 3), random.requests());
Files.move(reservation, saved);
Files.createDirectory(reservation);
assertThrows(IOException.class,
() -> store.putSecret("failed", "AES",
new SecretKeySpec(material, "AES")));
assertEquals(1L, longField(store, "nonceHighWater"));
assertArrayEquals(mainBefore, Files.readAllBytes(path));
assertEquals(List.of(32, 32, 16, 11, 3, 16), random.requests());
Files.delete(reservation);
Files.move(saved, reservation);
store.putSecret("accepted", "AES",
new SecretKeySpec(material, "AES"));
assertEquals(3L, longField(store, "nonceHighWater"));
assertEquals(List.of(32, 32, 16, 11, 3, 16, 16), random.requests());
} finally {
wipe(mainBefore);
}
} finally {
wipe(material);
}
ok();
}
@Test
void nonceAllocationUsesOneDurableCounterPerEncryption() throws Exception {
start("nonceAllocationUsesOneDurableCounterPerEncryption");
Path path = temporaryDirectory.resolve("allocation.zek");
byte[] first = new byte[32];
byte[] second = new byte[32];
Arrays.fill(first, (byte) 0x11);
Arrays.fill(second, (byte) 0x22);
RecordingRandom random = new RecordingRandom(7);
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.create(path, password,
KeyringProtection.standard(), random)) {
assertEquals(1L, longField(store, "nonceHighWater"));
assertEquals(List.of(32, 32, 16, 11, 3), random.requests());
store.putSecret("one", "AES", new SecretKeySpec(first, "AES"));
assertEquals(3L, longField(store, "nonceHighWater"));
store.putSecret("two", "AES", new SecretKeySpec(second, "AES"));
assertEquals(5L, longField(store, "nonceHighWater"));
store.putSecret("one", "AES", new SecretKeySpec(second, "AES"));
assertEquals(7L, longField(store, "nonceHighWater"));
assertEquals(List.of(32, 32, 16, 11, 3, 16, 16, 16), random.requests());
assertEquals(7L, sidecarHighWater(sidecar(path)));
} finally {
wipe(first);
wipe(second);
}
ok();
}
private Path sidecarWithDirectMasterKeyMac(String file) throws Exception {
Path path = temporaryDirectory.resolve(file);
byte[] image;
byte[] master;
try (KeyringPassword password = password()) {
KeyringStore store = KeyringStore.create(path, password,
KeyringProtection.standard(), deterministicRandom());
image = Files.readAllBytes(sidecar(path));
master = field(store, "masterKey").clone();
store.close();
}
byte[] tag = null;
try {
tag = hmac(master, authenticated(image));
replaceTag(image, tag);
Files.write(sidecar(path), image);
return path;
} finally {
wipe(image);
wipe(master);
wipe(tag);
}
}
private Path sidecarWithBitFlippedTag(String file) throws Exception {
Path path = createAndClose(file);
byte[] image = Files.readAllBytes(sidecar(path));
try {
image[image.length - 1] ^= 1;
Files.write(sidecar(path), image);
return path;
} finally {
wipe(image);
}
}
private Path sidecarWithTamperedHighWater(String file) throws Exception {
Path path = createAndClose(file);
byte[] image = Files.readAllBytes(sidecar(path));
try {
image[HIGH_WATER_OFFSET + Long.BYTES - 1] ^= 1;
Files.write(sidecar(path), image);
return path;
} finally {
wipe(image);
}
}
private Path sidecarWithVersion(String file, int version, boolean useMasterKey)
throws Exception {
Path path = temporaryDirectory.resolve(file);
byte[] image;
byte[] key;
try (KeyringPassword password = password()) {
KeyringStore store = KeyringStore.create(path, password,
KeyringProtection.standard(), deterministicRandom());
image = Files.readAllBytes(sidecar(path));
key = field(store, useMasterKey ? "masterKey" : "nonceReservationMacKey").clone();
store.close();
}
byte[] tag = null;
try {
ByteBuffer.wrap(image, VERSION_OFFSET, Integer.BYTES).putInt(version);
tag = hmac(key, authenticated(image));
replaceTag(image, tag);
Files.write(sidecar(path), image);
return path;
} finally {
wipe(image);
wipe(key);
wipe(tag);
}
}
private Path sidecarTruncated(String file) throws Exception {
Path path = createAndClose(file);
byte[] image = Files.readAllBytes(sidecar(path));
try {
Files.write(sidecar(path), Arrays.copyOf(image, image.length - 1));
return path;
} finally {
wipe(image);
}
}
private Path sidecarWithTrailingByte(String file) throws Exception {
Path path = createAndClose(file);
Files.write(sidecar(path), new byte[] { 0 },
java.nio.file.StandardOpenOption.APPEND);
return path;
}
private void assertRejected(Path path) throws Exception {
assertOpenRejectedWithoutMutation(path);
}
private void assertOpenRejectedWithoutMutation(Path path) throws Exception {
byte[] mainBefore = Files.readAllBytes(path);
byte[] sidecarBefore = Files.readAllBytes(sidecar(path));
try (KeyringPassword password = password()) {
KeyringException exception = assertThrows(KeyringException.class,
() -> KeyringStore.open(path, password));
assertEquals(KeyringException.Code.KEYRING_FORMAT_INVALID, exception.code());
assertEquals(KeyringException.Code.KEYRING_FORMAT_INVALID.name(),
exception.getMessage());
assertArrayEquals(mainBefore, Files.readAllBytes(path));
assertArrayEquals(sidecarBefore, Files.readAllBytes(sidecar(path)));
} finally {
wipe(mainBefore);
wipe(sidecarBefore);
}
}
private Path createAndClose(String file) throws Exception {
return createAndClose(file, 1);
}
private Path createAndClose(String file, int randomSeed) throws Exception {
Path path = temporaryDirectory.resolve(file);
try (KeyringPassword password = password();
KeyringStore ignored = KeyringStore.create(path, password,
KeyringProtection.standard(), deterministicRandom(randomSeed))) {
// Creation writes the initial durable reservation.
}
return path;
}
private static byte[] authenticated(byte[] image) {
return Arrays.copyOf(image, image.length - TAG_BYTES);
}
private static void replaceTag(byte[] image, byte[] tag) {
System.arraycopy(tag, 0, image, image.length - TAG_BYTES, TAG_BYTES);
}
private static byte[] hmac(byte[] key, byte[] input) throws Exception {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return mac.doFinal(input);
} finally {
wipe(input);
}
}
private static byte[] field(KeyringStore store, String name) throws Exception {
Field field = KeyringStore.class.getDeclaredField(name);
field.setAccessible(true);
return (byte[]) field.get(store);
}
private static long longField(KeyringStore store, String name) throws Exception {
Field field = KeyringStore.class.getDeclaredField(name);
field.setAccessible(true);
return field.getLong(store);
}
private static long sidecarHighWater(Path path) throws Exception {
byte[] image = Files.readAllBytes(path);
try {
return ByteBuffer.wrap(image, HIGH_WATER_OFFSET, Long.BYTES).getLong();
} finally {
wipe(image);
}
}
private static Path sidecar(Path keyring) {
return keyring.resolveSibling(keyring.getFileName() + ".nonce");
}
private static KeyringPassword password() {
return new KeyringPassword(PASSWORD);
}
private static KeyringRandomBytes deterministicRandom() {
return deterministicRandom(1);
}
private static KeyringRandomBytes deterministicRandom(int initialValue) {
AtomicInteger value = new AtomicInteger(initialValue);
return destination -> {
int base = value.getAndIncrement();
for (int index = 0; index < destination.length; index++) {
destination[index] = (byte) (base + index);
}
};
}
private static boolean allZero(byte[] value) {
for (byte current : value) {
if (current != 0) {
return false;
}
}
return true;
}
private static int indexOf(byte[] haystack, byte[] needle) {
outer:
for (int index = 0; index <= haystack.length - needle.length; index++) {
for (int offset = 0; offset < needle.length; offset++) {
if (haystack[index + offset] != needle[offset]) {
continue outer;
}
}
return index;
}
return -1;
}
private static void wipe(byte[] value) {
if (value != null) {
Arrays.fill(value, (byte) 0);
}
}
private static void start(String method) {
System.out.println(method);
}
private static void ok() {
System.out.println("...ok");
}
private static final class RecordingRandom implements KeyringRandomBytes {
private final AtomicInteger value;
private final List<Integer> requests = new ArrayList<>();
private RecordingRandom(int initialValue) {
value = new AtomicInteger(initialValue);
}
@Override
public void nextBytes(byte[] destination) {
requests.add(destination.length);
int base = value.getAndIncrement();
for (int index = 0; index < destination.length; index++) {
destination[index] = (byte) (base + index);
}
}
private List<Integer> requests() {
return List.copyOf(requests);
}
}
}

View File

@@ -1,392 +0,0 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.storage;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import javax.crypto.SecretKey;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.alg.rsa.RsaKeyGenSpec;
import zeroecho.core.alg.rsa.RsaPrivateKeySpec;
import zeroecho.core.alg.rsa.RsaPublicKeySpec;
import zeroecho.core.spec.AlgorithmKeySpec;
import zeroecho.core.KeyOperation;
import zeroecho.core.KeyOperationInfo;
import zeroecho.core.spi.AsymmetricKeyPairGenerator;
import zeroecho.core.spi.SymmetricKeyGenerator;
import zeroecho.sdk.util.BouncyCastleActivator;
public class KeyringStoreDynamicTest {
@BeforeAll
static void setupProviders() {
BouncyCastleActivator.init();
}
private static void logBegin(Object... params) {
String thisClass = KeyringStoreDynamicTest.class.getName();
String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
.walk(frames -> frames
.dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logBegin"))
.findFirst().map(StackWalker.StackFrame::getMethodName).orElse("<?>"));
System.out.println(method + "(" + Arrays.deepToString(params) + ")");
}
private static void logEnd() {
String thisClass = KeyringStoreDynamicTest.class.getName();
String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
.walk(frames -> frames
.dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logEnd"))
.findFirst().map(StackWalker.StackFrame::getMethodName).orElse("<?>"));
System.out.println(method + "...ok");
}
private static byte[] randomBytes(int len) {
byte[] b = new byte[len];
new SecureRandom().nextBytes(b);
return b;
}
private static String encLen(byte[] der) {
if (der == null) {
return "0";
}
String str = Base64.getEncoder().withoutPadding().encodeToString(der);
if (str.length() > 64) {
str = str.substring(0, 64) + "...";
}
return der.length + " / b64 " + str;
}
private static AlgorithmKeySpec makeImportSpec(Class<?> specType, byte[] material, String algId, Object defaultSpec)
throws Exception {
try {
Method m = specType.getMethod("fromRaw", byte[].class);
Object spec = m.invoke(null, material);
return (AlgorithmKeySpec) spec;
} catch (NoSuchMethodException ignored) {
}
try {
Method m = specType.getMethod("fromRaw", String.class, byte[].class);
String name = deriveVariantNameForImport(algId, defaultSpec);
Object spec = m.invoke(null, name, material);
return (AlgorithmKeySpec) spec;
} catch (NoSuchMethodException ignored) {
}
try {
Method m = specType.getMethod("of", byte[].class);
Object spec = m.invoke(null, material);
return (AlgorithmKeySpec) spec;
} catch (NoSuchMethodException ignored) {
}
try {
Constructor<?> c = specType.getConstructor(byte[].class);
Object spec = c.newInstance(new Object[] { material });
return (AlgorithmKeySpec) spec;
} catch (NoSuchMethodException ignored) {
}
try {
Constructor<?> c = specType.getConstructor(String.class);
Object spec = c.newInstance(Base64.getEncoder().encodeToString(material));
return (AlgorithmKeySpec) spec;
} catch (NoSuchMethodException ignored) {
}
throw new IllegalStateException("No usable import factory/ctor found for " + specType.getName());
}
private static String deriveVariantNameForImport(String algId, Object defaultSpec) {
if (defaultSpec != null) {
try {
Method m = defaultSpec.getClass().getMethod("macName");
Object v = m.invoke(defaultSpec);
if (v instanceof String) {
return (String) v;
}
} catch (Exception ignored) {
}
}
if ("HMAC".equalsIgnoreCase(algId)) {
return "HmacSHA256";
}
return algId;
}
private static boolean looksLikeImportSpecForPublic(Class<?> specType) {
String n = specType.getSimpleName();
return n.contains("Public") || n.endsWith("PublicKeySpec");
}
private static boolean looksLikeImportSpecForPrivate(Class<?> specType) {
String n = specType.getSimpleName();
return n.contains("Private") || n.endsWith("PrivateKeySpec");
}
private static boolean looksLikeImportSpecForSecret(Class<?> specType) {
String n = specType.getSimpleName();
return n.contains("Import") || n.endsWith("KeyImportSpec") || n.endsWith("SecretSpec");
}
@Test
void testExport(@TempDir Path tempDir) throws Exception {
logBegin();
Path keyringPath = tempDir.resolve("keyring-" + System.nanoTime() + ".txt");
KeyringStore store = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
zeroecho.sdk.ZeroEchoSession session = new zeroecho.sdk.ZeroEchoSession();
KeyPair kp = session.keyBuilders().asymmetric().generateKeyPair("RSA", RsaKeyGenSpec.rsa4096());
store.putPrivate("alice.priv", "RSA", new RsaPrivateKeySpec(kp.getPrivate().getEncoded()));
store.putPublic("alice.pub", "RSA", new RsaPublicKeySpec(kp.getPublic().getEncoded()));
kp = session.keyBuilders().asymmetric().generateKeyPair("RSA", RsaKeyGenSpec.rsa4096());
store.putPrivate("bob.priv", "RSA", new RsaPrivateKeySpec(kp.getPrivate().getEncoded()));
store.putPublic("bob.pub", "RSA", new RsaPublicKeySpec(kp.getPublic().getEncoded()));
store.save(keyringPath);
store = KeyringStore.load(new zeroecho.sdk.ZeroEchoSession(), keyringPath);
String s = store.exportText(Collections.singleton("alice.pub"));
assertTrue(s.contains("# KeyringStore v1\n"));
assertTrue(s.contains("\n@entry\n"));
assertTrue(s.contains("\nalias=alice.pub\n"));
assertTrue(s.contains("\nalgorithm=RSA\n"));
assertTrue(s.contains("\nkind=PUBLIC_KEY\n"));
assertTrue(s.contains("\nspec=zeroecho.core.alg.rsa.RsaPublicKeySpec\n"));
assertTrue(s.contains("\ns.type=RSA-PUB\n"));
assertTrue(s.contains("\ns.x509.b64="));
logEnd();
}
@Test
void keyring_dynamic_population_roundtrip_and_dump(@TempDir Path tempDir) throws Exception {
logBegin();
KeyringStore store = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
Set<String> ids = CryptoAlgorithms.available();
System.out.println("...algorithms discovered: " + ids);
int totalAdded = 0;
for (String id : ids) {
CryptoAlgorithm alg = CryptoAlgorithms.require(id);
System.out.println("\n-- " + id + " --");
if (alg.keyOperations().stream()
.anyMatch(info -> info.operation() == KeyOperation.ASYMMETRIC_KEY_PAIR_GENERATE)) {
int perAlg = 0;
for (KeyOperationInfo bi : alg.keyOperations()) {
if (bi.operation() != KeyOperation.ASYMMETRIC_KEY_PAIR_GENERATE || bi.defaultSpec() == null) {
continue;
}
try {
@SuppressWarnings("unchecked")
Class<AlgorithmKeySpec> genSpecType = (Class<AlgorithmKeySpec>) bi.specType();
AsymmetricKeyPairGenerator<AlgorithmKeySpec> b = alg.asymmetricKeyPairGenerator(genSpecType);
AlgorithmKeySpec genSpec = bi.defaultSpec();
KeyPair kp = b.generateKeyPair(genSpec);
PublicKey pub = kp.getPublic();
PrivateKey prv = kp.getPrivate();
Class<?> pubImpType = null;
Class<?> prvImpType = null;
for (KeyOperationInfo x : alg.keyOperations()) {
if (x.operation() == KeyOperation.ASYMMETRIC_PUBLIC_IMPORT) {
pubImpType = x.specType();
} else if (x.operation() == KeyOperation.ASYMMETRIC_PRIVATE_IMPORT) {
prvImpType = x.specType();
}
}
if (pubImpType != null) {
AlgorithmKeySpec pubSpec = makeImportSpec(pubImpType, pub.getEncoded(), id,
bi.defaultSpec());
String alias = id.toLowerCase() + "-pub-" + perAlg;
store.putPublic(alias, id, pubSpec);
System.out.println("..." + alias + " saved, len=" + encLen(pub.getEncoded()));
totalAdded++;
} else {
System.out.println("...*** SKIP *** no public import spec for " + id);
}
if (prvImpType != null) {
AlgorithmKeySpec prvSpec = makeImportSpec(prvImpType, prv.getEncoded(), id,
bi.defaultSpec());
String alias = id.toLowerCase() + "-prv-" + perAlg;
store.putPrivate(alias, id, prvSpec);
System.out.println("..." + alias + " saved, len=" + encLen(prv.getEncoded()));
totalAdded++;
} else {
System.out.println("...*** SKIP *** no private import spec for " + id);
}
perAlg++;
if (perAlg >= 3) {
break;
}
} catch (Throwable t) {
System.out.println("...*** SKIP asym for " + id + " *** " + t.getClass().getSimpleName() + ": "
+ t.getMessage());
}
}
}
if (alg.keyOperations().stream()
.anyMatch(info -> info.operation() == KeyOperation.SYMMETRIC_GENERATE)) {
int perAlg = 0;
for (KeyOperationInfo bi : alg.keyOperations()) {
if (bi.operation() != KeyOperation.SYMMETRIC_GENERATE || bi.defaultSpec() == null) {
continue;
}
try {
@SuppressWarnings("unchecked")
Class<AlgorithmKeySpec> genSpecType = (Class<AlgorithmKeySpec>) bi.specType();
SymmetricKeyGenerator<AlgorithmKeySpec> b = alg.symmetricKeyGenerator(genSpecType);
AlgorithmKeySpec genSpec = bi.defaultSpec();
SecretKey sk = b.generateSecret(genSpec);
Class<?> impType = null;
for (KeyOperationInfo x : alg.keyOperations()) {
if (x.operation() == KeyOperation.SYMMETRIC_IMPORT
&& looksLikeImportSpecForSecret(x.specType())) {
impType = x.specType();
}
}
if (impType != null) {
byte[] raw = sk.getEncoded();
if (raw == null) {
raw = randomBytes(32);
}
AlgorithmKeySpec imp = makeImportSpec(impType, raw, id, bi.defaultSpec());
String alias = id.toLowerCase() + "-sec-" + perAlg;
store.putSecret(alias, id, imp);
System.out.println("..." + alias + " saved, len=" + raw.length);
totalAdded++;
} else {
System.out.println("...*** SKIP *** no symmetric import spec for " + id);
}
perAlg++;
if (perAlg >= 3) {
break;
}
} catch (Throwable t) {
System.out.println("...*** SKIP sym for " + id + " *** " + t.getClass().getSimpleName() + ": "
+ t.getMessage());
}
}
}
}
// Persist using JUnit-managed temp directory
Path keyringPath = tempDir.resolve("keyring-" + System.nanoTime() + ".txt");
store.save(keyringPath);
System.out.println("\n...saved keyring: " + keyringPath.getFileName());
System.out.println("...entries stored: " + totalAdded);
KeyringStore loaded = KeyringStore.load(new zeroecho.sdk.ZeroEchoSession(), keyringPath);
assertTrue(loaded.aliases().size() >= Math.min(totalAdded, 1), "no entries reloaded");
int ok = 0;
for (String alias : loaded.aliases()) {
boolean success = false;
try {
PublicKey k = loaded.getPublic(alias);
if (k != null && k.getEncoded() != null) {
System.out.println("..." + alias + " OK public len=" + encLen(k.getEncoded()));
success = true;
}
} catch (Throwable ignore) {
}
if (!success) {
try {
PrivateKey k = loaded.getPrivate(alias);
if (k != null && k.getEncoded() != null) {
System.out.println("..." + alias + " OK private len=" + encLen(k.getEncoded()));
success = true;
}
} catch (Throwable ignore) {
}
}
if (!success) {
try {
SecretKey k = loaded.getSecret(alias);
if (k != null && k.getEncoded() != null) {
System.out.println("..." + alias + " OK secret len=" + encLen(k.getEncoded()));
success = true;
}
} catch (Throwable ignore) {
}
}
if (success) {
ok++;
} else {
System.out.println("...*** WARN *** could not reconstruct: " + alias);
}
}
assertTrue(ok > 0, "nothing reconstructed from keyring");
System.out.println("\n===== KEYRING DUMP BEGIN =====");
List<String> lines = Files.readAllLines(keyringPath, StandardCharsets.UTF_8);
for (String ln : lines) {
System.out.printf(ln.length() > 80 ? "%.77s...%n" : "%s%n", ln);
}
System.out.println("===== KEYRING DUMP END =====\n");
logEnd();
}
}

View File

@@ -1,138 +0,0 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core.storage;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.security.GeneralSecurityException;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.security.auth.DestroyFailedException;
import javax.security.auth.Destroyable;
import org.junit.jupiter.api.Test;
import zeroecho.core.spec.AlgorithmKeySpec;
import zeroecho.sdk.ZeroEchoSession;
class KeyringStoreSecurityTest {
private static final AtomicBoolean UNREGISTERED_INITIALIZED = new AtomicBoolean();
@Test
void rejectsUnregisteredPersistedSpecBeforeClassInitialization() throws Exception {
System.out.println("rejectsUnregisteredPersistedSpecBeforeClassInitialization");
KeyringStore store = new KeyringStore(new ZeroEchoSession());
String text = "# KeyringStore v1\n"
+ "@entry\n"
+ "alias=attacker.pub\n"
+ "algorithm=RSA\n"
+ "kind=PUBLIC_KEY\n"
+ "spec=zeroecho.core.storage.KeyringStoreSecurityTest$UnregisteredSpec\n\n";
store.importText(text, false);
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
() -> store.getPublic("attacker"));
assertFalse(UNREGISTERED_INITIALIZED.get());
assertTrue(failure.getMessage().contains("not registered"));
System.out.println("...classInitialized=false");
System.out.println("rejectsUnregisteredPersistedSpecBeforeClassInitialization...ok");
}
@Test
void rejectsSpecRegisteredForDifferentOperation() throws Exception {
System.out.println("rejectsSpecRegisteredForDifferentOperation");
KeyringStore store = new KeyringStore(new ZeroEchoSession());
String text = "# KeyringStore v1\n"
+ "@entry\n"
+ "alias=mismatch.pub\n"
+ "algorithm=RSA\n"
+ "kind=PUBLIC_KEY\n"
+ "spec=zeroecho.core.alg.rsa.RsaPrivateKeySpec\n\n";
store.importText(text, false);
assertThrows(IllegalArgumentException.class, () -> store.getPublic("mismatch"));
System.out.println("...mismatchedOperationRejected=true");
System.out.println("rejectsSpecRegisteredForDifferentOperation...ok");
}
@Test
void temporarySpecDestructionIsIdempotentAndObservable() throws Exception {
System.out.println("temporarySpecDestructionIsIdempotentAndObservable");
ControlledDestroyableSpec spec = new ControlledDestroyableSpec(false);
KeyringStore.destroyTemporarySpec(spec, null);
KeyringStore.destroyTemporarySpec(spec, null);
assertTrue(spec.isDestroyed());
assertEquals(1, spec.destroyCalls);
System.out.println("...destroyCalls=" + spec.destroyCalls);
System.out.println("temporarySpecDestructionIsIdempotentAndObservable...ok");
}
@Test
void destructionFailureIsSuppressedOnPrimaryFailure() throws Exception {
System.out.println("destructionFailureIsSuppressedOnPrimaryFailure");
ControlledDestroyableSpec spec = new ControlledDestroyableSpec(true);
IllegalStateException primary = new IllegalStateException("controlled primary");
KeyringStore.destroyTemporarySpec(spec, primary);
assertEquals(1, primary.getSuppressed().length);
assertTrue(primary.getSuppressed()[0] instanceof DestroyFailedException);
System.out.println("...suppressedFailures=1");
System.out.println("destructionFailureIsSuppressedOnPrimaryFailure...ok");
}
@Test
void destructionFailureWithoutPrimaryUsesSecurityExceptionFamily() {
System.out.println("destructionFailureWithoutPrimaryUsesSecurityExceptionFamily");
ControlledDestroyableSpec spec = new ControlledDestroyableSpec(true);
GeneralSecurityException failure = assertThrows(GeneralSecurityException.class,
() -> KeyringStore.destroyTemporarySpec(spec, null));
assertSame(DestroyFailedException.class, failure.getCause().getClass());
System.out.println("...failureType=" + failure.getClass().getSimpleName());
System.out.println("destructionFailureWithoutPrimaryUsesSecurityExceptionFamily...ok");
}
/**
* A deliberately unregistered type whose initialization must never occur.
*/
public static final class UnregisteredSpec implements AlgorithmKeySpec {
static {
UNREGISTERED_INITIALIZED.set(true);
}
}
private static final class ControlledDestroyableSpec implements AlgorithmKeySpec, Destroyable {
private final boolean fail;
private boolean destroyed;
private int destroyCalls;
private ControlledDestroyableSpec(boolean fail) {
this.fail = fail;
}
@Override
public void destroy() throws DestroyFailedException {
destroyCalls++;
if (fail) {
throw new DestroyFailedException("controlled destruction failure");
}
destroyed = true;
}
@Override
public boolean isDestroyed() {
return destroyed;
}
}
}

View File

@@ -0,0 +1,344 @@
package zeroecho.core.storage;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class KeyringStoreTest {
private static final char[] PASSWORD = new char[] { 'c', 'o', 'r', 'r', 'e', 'c', 't' };
@TempDir
Path temporaryDirectory;
@Test
void encryptedRoundTripAndPlaintextAbsence() throws Exception {
start("encryptedRoundTripAndPlaintextAbsence");
Path path = temporaryDirectory.resolve("keys.zek");
byte[] aesBytes = new byte[32];
Arrays.fill(aesBytes, (byte) 0x5a);
SecretKey aes = new SecretKeySpec(aesBytes, "AES");
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
KeyPair pair = generator.generateKeyPair();
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.create(path, password,
KeyringProtection.standard(), deterministicRandom())) {
store.putPublic("rsa", "RSA", pair.getPublic());
store.putPrivate("rsa", "RSA", pair.getPrivate());
store.putSecret("aes", "AES", aes);
assertArrayEquals(aesBytes, store.getSecret("aes").getEncoded());
assertArrayEquals(pair.getPublic().getEncoded(), store.getPublic("rsa").getEncoded());
assertArrayEquals(pair.getPrivate().getEncoded(), store.getPrivate("rsa").getEncoded());
}
byte[] persisted = Files.readAllBytes(path);
try {
assertFalse(indexOf(persisted, aesBytes) >= 0);
assertFalse(new String(persisted, StandardCharsets.ISO_8859_1).contains("java."));
System.out.println("...encrypted-bytes=" + persisted.length);
} finally {
Arrays.fill(persisted, (byte) 0);
Arrays.fill(aesBytes, (byte) 0);
}
ok();
}
@Test
void wrongPasswordAndCorruptionFailUniformly() throws Exception {
start("wrongPasswordAndCorruptionFailUniformly");
Path path = temporaryDirectory.resolve("wrong.zek");
try (KeyringPassword password = password();
KeyringStore ignored = KeyringStore.create(path, password)) {
// empty current-format store
}
try (KeyringPassword wrong = new KeyringPassword(new char[] { 'w', 'r', 'o', 'n', 'g' })) {
KeyringException exception = assertThrows(KeyringException.class,
() -> KeyringStore.open(path, wrong));
assertEquals(KeyringException.Code.KEYRING_UNLOCK_FAILED, exception.code());
}
byte[] bytes = Files.readAllBytes(path);
bytes[bytes.length - 1] ^= 1;
Files.write(path, bytes);
Arrays.fill(bytes, (byte) 0);
try (KeyringPassword password = password()) {
KeyringException exception = assertThrows(KeyringException.class,
() -> KeyringStore.open(path, password));
assertEquals(KeyringException.Code.KEYRING_FORMAT_INVALID, exception.code());
}
ok();
}
@Test
void plaintextV1AndDuplicateOwnerAreRejected() throws Exception {
start("plaintextV1AndDuplicateOwnerAreRejected");
Path old = temporaryDirectory.resolve("old.txt");
Files.writeString(old, "# KeyringStore v1\n", StandardCharsets.UTF_8);
Files.setPosixFilePermissions(old, java.util.Set.of(
java.nio.file.attribute.PosixFilePermission.OWNER_READ,
java.nio.file.attribute.PosixFilePermission.OWNER_WRITE));
try (KeyringPassword password = password()) {
assertEquals(KeyringException.Code.KEYRING_FORMAT_INVALID,
assertThrows(KeyringException.class,
() -> KeyringStore.open(old, password)).code());
}
Path path = temporaryDirectory.resolve("owned.zek");
try (KeyringPassword password = password();
KeyringStore first = KeyringStore.create(path, password);
KeyringPassword secondPassword = password()) {
assertEquals(KeyringException.Code.KEYRING_ALREADY_OPEN,
assertThrows(KeyringException.class,
() -> KeyringStore.open(path, secondPassword)).code());
assertTrue(first.aliases().isEmpty());
}
try (KeyringPassword password = password();
KeyringStore reopened = KeyringStore.open(path, password)) {
assertTrue(reopened.aliases().isEmpty());
}
ok();
}
@Test
void closeIsRepeatSafeAndRejectsUse() throws Exception {
start("closeIsRepeatSafeAndRejectsUse");
Path path = temporaryDirectory.resolve("closed.zek");
KeyringStore store;
try (KeyringPassword password = password()) {
store = KeyringStore.create(path, password);
}
store.close();
store.close();
assertTrue(store.isDestroyed());
assertThrows(IllegalStateException.class, store::aliases);
ok();
}
@Test
void trailingDataAndNonExportableKeysAreRejected() throws Exception {
start("trailingDataAndNonExportableKeysAreRejected");
Path path = temporaryDirectory.resolve("strict.zek");
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.create(path, password)) {
SecretKey nonExportable = new SecretKey() {
private static final long serialVersionUID = 1L;
@Override
public String getAlgorithm() {
return "AES";
}
@Override
public String getFormat() {
return "RAW";
}
@Override
public byte[] getEncoded() {
return null;
}
};
KeyringException exception = assertThrows(KeyringException.class,
() -> store.putSecret("opaque", "AES", nonExportable));
assertEquals(KeyringException.Code.KEYRING_NON_EXPORTABLE_KEY, exception.code());
assertTrue(store.aliases().isEmpty());
}
Files.write(path, new byte[] { 1 }, java.nio.file.StandardOpenOption.APPEND);
try (KeyringPassword password = password()) {
KeyringException exception = assertThrows(KeyringException.class,
() -> KeyringStore.open(path, password));
assertEquals(KeyringException.Code.KEYRING_FORMAT_INVALID, exception.code());
}
ok();
}
@Test
void hmacVariantsAreClosedAndRejectedBeforeMutation() throws Exception {
start("hmacVariantsAreClosedAndRejectedBeforeMutation");
Path path = temporaryDirectory.resolve("hmac.zek");
List<String> accepted = List.of("HmacSHA256", "HmacSHA384", "HmacSHA512");
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.create(path, password,
KeyringProtection.standard(), deterministicRandom())) {
for (String variant : accepted) {
byte[] material = new byte[64];
Arrays.fill(material, (byte) variant.length());
try {
String alias = variant + ".key";
store.putSecret(alias, "HMAC", new SecretKeySpec(material, variant));
assertArrayEquals(material, store.getSecret(alias).getEncoded());
} finally {
Arrays.fill(material, (byte) 0);
}
}
byte[] before = Files.readAllBytes(path);
try {
for (String rejected : List.of("HmacMD5", "HmacSHA1", "HmacSHA224",
"hmacsha256", "HmacSha384", " HmacSHA512", "HmacSHA512 ",
"BC:HmacSHA256", "", "X".repeat(4097))) {
SecretKey key = controlledSecret(rejected, new byte[32]);
KeyringException exception = assertThrows(KeyringException.class,
() -> store.putSecret("rejected", "HMAC", key));
assertEquals(KeyringException.Code.KEYRING_IMPORT_METADATA_INVALID,
exception.code());
assertEquals(KeyringException.Code.KEYRING_IMPORT_METADATA_INVALID.name(),
exception.getMessage());
assertArrayEquals(before, Files.readAllBytes(path));
assertFalse(store.contains("rejected"));
}
SecretKey mismatched = controlledSecret("HmacSHA256", new byte[32]);
assertEquals(KeyringException.Code.KEYRING_KEY_NOT_CANONICALIZABLE,
assertThrows(KeyringException.class,
() -> store.putSecret("wrong", "AES", mismatched)).code());
assertArrayEquals(before, Files.readAllBytes(path));
} finally {
Arrays.fill(before, (byte) 0);
}
}
ok();
}
@Test
void providerDraftAndUnknownHmacVariantAreRejectedStructurally() throws Exception {
start("providerDraftAndUnknownHmacVariantAreRejectedStructurally");
assertEquals(KeyringException.Code.KEYRING_IMPORT_METADATA_INVALID,
assertThrows(KeyringException.class,
() -> KeyringImportRegistry.HmacVariant.fromCode(99)).code());
byte[] staleDraft = staleProviderEntryPlaintext();
try {
java.lang.reflect.Method decoder = KeyringStore.class.getDeclaredMethod(
"decodeEntryPlaintext", byte[].class);
decoder.setAccessible(true);
java.lang.reflect.InvocationTargetException failure =
assertThrows(java.lang.reflect.InvocationTargetException.class,
() -> decoder.invoke(null, (Object) staleDraft));
assertTrue(failure.getCause() instanceof KeyringException);
assertEquals(KeyringException.Code.KEYRING_FORMAT_INVALID,
((KeyringException) failure.getCause()).code());
} finally {
Arrays.fill(staleDraft, (byte) 0);
}
ok();
}
@Test
void providerBoundOrNoncanonicalKeysFailBeforeMutation() throws Exception {
start("providerBoundOrNoncanonicalKeysFailBeforeMutation");
Path path = temporaryDirectory.resolve("provider-bound.zek");
try (KeyringPassword password = password();
KeyringStore store = KeyringStore.create(path, password)) {
byte[] before = Files.readAllBytes(path);
try {
SecretKey unsupported = controlledSecret("ProviderAES", new byte[32]);
KeyringException failure = assertThrows(KeyringException.class,
() -> store.putSecret("bad", "AES", unsupported));
assertEquals(KeyringException.Code.KEYRING_KEY_NOT_CANONICALIZABLE,
failure.code());
assertArrayEquals(before, Files.readAllBytes(path));
assertFalse(store.contains("bad"));
} finally {
Arrays.fill(before, (byte) 0);
}
}
ok();
}
private static KeyringPassword password() {
return new KeyringPassword(PASSWORD);
}
private static KeyringRandomBytes deterministicRandom() {
AtomicInteger value = new AtomicInteger(1);
return destination -> {
int base = value.getAndIncrement();
for (int index = 0; index < destination.length; index++) {
destination[index] = (byte) (base + index);
}
};
}
private static SecretKey controlledSecret(String algorithm, byte[] encoded) {
return new SecretKey() {
private static final long serialVersionUID = 1L;
@Override
public String getAlgorithm() {
return algorithm;
}
@Override
public String getFormat() {
return "RAW";
}
@Override
public byte[] getEncoded() {
return encoded.clone();
}
};
}
private static byte[] staleProviderEntryPlaintext() throws Exception {
java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream();
try (java.io.DataOutputStream out = new java.io.DataOutputStream(bytes)) {
out.writeInt(2);
writeString(out, "legacy.pub");
writeString(out, "ML-DSA");
out.writeByte(KeyringStore.Kind.PUBLIC_KEY.ordinal() + 1);
out.writeByte(KeyringStore.Encoding.X509.ordinal() + 1);
writeString(out, "BC");
out.writeInt(3);
out.write(new byte[] { 1, 2, 3 });
}
return bytes.toByteArray();
}
private static void writeString(java.io.DataOutputStream out, String value)
throws java.io.IOException {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
try {
out.writeInt(bytes.length);
out.write(bytes);
} finally {
Arrays.fill(bytes, (byte) 0);
}
}
private static int indexOf(byte[] haystack, byte[] needle) {
outer:
for (int index = 0; index <= haystack.length - needle.length; index++) {
for (int offset = 0; offset < needle.length; offset++) {
if (haystack[index + offset] != needle[offset]) {
continue outer;
}
}
return index;
}
return -1;
}
private static void start(String method) {
System.out.println(method);
}
private static void ok() {
System.out.println("...ok");
}
}

View File

@@ -19,6 +19,8 @@ import java.util.concurrent.Future;
import org.junit.jupiter.api.Test;
import zeroecho.core.util.RandomSupport;
class PasswordTest {
@Test
void canonicalRandomFacadeValidatesNullAndAcceptsEmptyArrays() {