security(lib,storage): enforce single-use encryption contexts, encrypt keyring and harden key import
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
344
lib/src/test/java/zeroecho/core/storage/KeyringStoreTest.java
Normal file
344
lib/src/test/java/zeroecho/core/storage/KeyringStoreTest.java
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user