diff --git a/app/src/main/java/zeroecho/Guard.java b/app/src/main/java/zeroecho/Guard.java
index aece07b..da52c65 100644
--- a/app/src/main/java/zeroecho/Guard.java
+++ b/app/src/main/java/zeroecho/Guard.java
@@ -56,6 +56,7 @@ import zeroecho.core.KeyUsage;
import zeroecho.core.context.EncryptionContext;
import zeroecho.core.context.KemContext;
import zeroecho.core.err.UnsupportedRoleException;
+import zeroecho.core.spi.KeyringUnlockProvider;
import zeroecho.core.storage.KeyringStore;
import zeroecho.sdk.Pbkdf2Limits;
import zeroecho.sdk.ZeroEchoSession;
@@ -66,7 +67,7 @@ import zeroecho.sdk.guard.MultiRecipientContent;
import zeroecho.sdk.guard.MultiRecipientDataSourceBuilder;
import zeroecho.sdk.guard.RecipientKekSizes;
import zeroecho.sdk.guard.UnlockMaterial;
-import zeroecho.sdk.util.RandomSupport;
+import zeroecho.core.util.RandomSupport;
/**
* Guard is a unified subcommand that encrypts and decrypts using a
@@ -111,6 +112,7 @@ import zeroecho.sdk.util.RandomSupport;
* --alg chacha-aead
* }
*/
+@SuppressWarnings("PMD.CyclomaticComplexity")
public final class Guard {
private Guard() {
@@ -128,7 +130,26 @@ public final class Guard {
* @throws IOException on I/O errors
* @throws GeneralSecurityException on cryptographic setup or keyring errors
*/
- public static int main(final String[] args, final Options options) // NOPMD
+ public static int main(final String[] args, final Options options)
+ throws ParseException, IOException, GeneralSecurityException {
+ return main(args, options, KeyringUnlocks.console());
+ }
+
+ /**
+ * Executes Guard with an explicit keyring unlock source.
+ *
+ * @param args command arguments
+ * @param options dispatcher options
+ * @param keyringUnlockProvider destroyable-password provider
+ * @return process exit code
+ * @throws ParseException if parsing fails
+ * @throws IOException if I/O fails
+ * @throws GeneralSecurityException if cryptographic processing fails
+ */
+ @SuppressWarnings({ "PMD.NcssCount", "PMD.CognitiveComplexity",
+ "PMD.CyclomaticComplexity", "PMD.NPathComplexity" })
+ public static int main(final String[] args, final Options options,
+ KeyringUnlockProvider keyringUnlockProvider)
throws ParseException, IOException, GeneralSecurityException {
// ---- operation selection
final Option OPT_ENCRYPT = Option.builder("e").longOpt("encrypt").hasArg().argName("in-file")
@@ -356,40 +377,43 @@ public final class Guard {
final int kekLen = RecipientKekSizes.requireSupported(
Integer.parseInt(cmd.getOptionValue(OPT_PSW_KEK, "32")));
- final KeyringStore ks = loadKeyringIfPresent(session, cmd, OPT_KEYRING);
- for (String alias : cmd.getOptionValues(OPT_TO_ALIAS) == null ? new String[0]
- : cmd.getOptionValues(OPT_TO_ALIAS)) {
- addRecipientFromAlias(session, env, ks, alias, kekLen, saltLen, false);
- }
- for (String psw : cmd.getOptionValues(OPT_TO_PSW) == null ? new String[0]
- : cmd.getOptionValues(OPT_TO_PSW)) {
- char[] passwordChars = psw.toCharArray();
- try {
- env.addPasswordRecipient(passwordChars, iter, saltLen, kekLen);
- } finally {
- Arrays.fill(passwordChars, '\0');
+ KeyringStore ks = loadKeyringIfPresent(cmd, OPT_KEYRING,
+ keyringUnlockProvider);
+ try (ks) {
+ for (String alias : cmd.getOptionValues(OPT_TO_ALIAS) == null ? new String[0]
+ : cmd.getOptionValues(OPT_TO_ALIAS)) {
+ addRecipientFromAlias(session, env, ks, alias, kekLen, saltLen, false);
}
- }
- for (String alias : cmd.getOptionValues(OPT_DECOY_ALIAS) == null ? new String[0]
- : cmd.getOptionValues(OPT_DECOY_ALIAS)) {
- addRecipientFromAlias(session, env, ks, alias, kekLen, saltLen, true);
- }
- for (String psw : cmd.getOptionValues(OPT_DECOY_PSW) == null ? new String[0]
- : cmd.getOptionValues(OPT_DECOY_PSW)) {
- char[] passwordChars = psw.toCharArray();
- try {
- env.addPasswordRecipientDecoy(passwordChars, iter, saltLen, kekLen);
- } finally {
- Arrays.fill(passwordChars, '\0');
+ for (String psw : cmd.getOptionValues(OPT_TO_PSW) == null ? new String[0]
+ : cmd.getOptionValues(OPT_TO_PSW)) {
+ char[] passwordChars = psw.toCharArray();
+ try {
+ env.addPasswordRecipient(passwordChars, iter, saltLen, kekLen);
+ } finally {
+ Arrays.fill(passwordChars, '\0');
+ }
}
- }
- final int rndCount = Integer.parseInt(cmd.getOptionValue(OPT_DECOY_PSW_RAND, "0"));
- for (int i = 0; i < rndCount; i++) {
- char[] passwordChars = randomPassword();
- try {
- env.addPasswordRecipientDecoy(passwordChars, iter, saltLen, kekLen);
- } finally {
- Arrays.fill(passwordChars, '\0');
+ for (String alias : cmd.getOptionValues(OPT_DECOY_ALIAS) == null ? new String[0]
+ : cmd.getOptionValues(OPT_DECOY_ALIAS)) {
+ addRecipientFromAlias(session, env, ks, alias, kekLen, saltLen, true);
+ }
+ for (String psw : cmd.getOptionValues(OPT_DECOY_PSW) == null ? new String[0]
+ : cmd.getOptionValues(OPT_DECOY_PSW)) {
+ char[] passwordChars = psw.toCharArray();
+ try {
+ env.addPasswordRecipientDecoy(passwordChars, iter, saltLen, kekLen);
+ } finally {
+ Arrays.fill(passwordChars, '\0');
+ }
+ }
+ final int rndCount = Integer.parseInt(cmd.getOptionValue(OPT_DECOY_PSW_RAND, "0"));
+ for (int i = 0; i < rndCount; i++) {
+ char[] passwordChars = randomPassword();
+ try {
+ env.addPasswordRecipientDecoy(passwordChars, iter, saltLen, kekLen);
+ } finally {
+ Arrays.fill(passwordChars, '\0');
+ }
}
}
} else {
@@ -400,9 +424,11 @@ public final class Guard {
throw new ParseException("Specify exactly one of --priv-alias or --password for decryption");
}
if (privAlias != null) {
- final KeyringStore ks = requireKeyring(session, cmd, OPT_KEYRING);
- final KeyringStore.PrivateWithId pr = ks.getPrivateWithId(privAlias);
- borrowedUnlockMaterial = new UnlockMaterial.Private(pr.key());
+ try (KeyringStore ks = requireKeyring(cmd, OPT_KEYRING,
+ keyringUnlockProvider)) {
+ final KeyringStore.PrivateWithId pr = ks.getPrivateWithId(privAlias);
+ borrowedUnlockMaterial = new UnlockMaterial.Private(pr.key());
+ }
} else {
char[] passwordChars = password.toCharArray();
try {
@@ -575,19 +601,21 @@ public final class Guard {
return out;
}
- private static KeyringStore loadKeyringIfPresent(ZeroEchoSession session, CommandLine cmd, Option optKs)
- throws IOException {
+ private static KeyringStore loadKeyringIfPresent(CommandLine cmd, Option optKs,
+ KeyringUnlockProvider unlockProvider)
+ throws IOException, GeneralSecurityException {
if (!cmd.hasOption(optKs)) {
- return new KeyringStore(session);
+ return null;
}
- return KeyringStore.load(session, Paths.get(cmd.getOptionValue(optKs)));
+ return KeyringUnlocks.open(Paths.get(cmd.getOptionValue(optKs)), unlockProvider);
}
- private static KeyringStore requireKeyring(ZeroEchoSession session, CommandLine cmd, Option optKs)
- throws IOException, ParseException {
+ private static KeyringStore requireKeyring(CommandLine cmd, Option optKs,
+ KeyringUnlockProvider unlockProvider)
+ throws IOException, ParseException, GeneralSecurityException {
if (!cmd.hasOption(optKs)) {
throw new ParseException("--keyring is required when aliases are used");
}
- return KeyringStore.load(session, Paths.get(cmd.getOptionValue(optKs)));
+ return KeyringUnlocks.open(Paths.get(cmd.getOptionValue(optKs)), unlockProvider);
}
}
diff --git a/app/src/main/java/zeroecho/Kem.java b/app/src/main/java/zeroecho/Kem.java
index bbc3629..8e8f96d 100644
--- a/app/src/main/java/zeroecho/Kem.java
+++ b/app/src/main/java/zeroecho/Kem.java
@@ -59,6 +59,7 @@ import org.apache.commons.cli.ParseException;
import zeroecho.core.AlgorithmFamily;
import zeroecho.core.CatalogSelector;
import zeroecho.core.KeyUsage;
+import zeroecho.core.spi.KeyringUnlockProvider;
import zeroecho.core.storage.KeyringStore;
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
import zeroecho.sdk.builders.alg.ChaChaDataContentBuilder;
@@ -203,7 +204,37 @@ public final class Kem { // NOPMD
// no instances
}
- public static int main(String[] args, Options opts) throws ParseException, IOException, GeneralSecurityException { // NOPMD
+ /**
+ * Executes the KEM command using interactive keyring unlocking.
+ *
+ * @param args command arguments
+ * @param opts command options
+ * @return process exit code
+ * @throws ParseException if arguments are invalid
+ * @throws IOException if I/O fails
+ * @throws GeneralSecurityException if cryptographic processing fails
+ */
+ public static int main(String[] args, Options opts)
+ throws ParseException, IOException, GeneralSecurityException {
+ return main(args, opts, KeyringUnlocks.console());
+ }
+
+ /**
+ * Executes the KEM command with an explicit keyring unlock source.
+ *
+ * @param args command arguments
+ * @param opts command options
+ * @param unlockProvider keyring password provider
+ * @return process exit code
+ * @throws ParseException if arguments are invalid
+ * @throws IOException if I/O fails
+ * @throws GeneralSecurityException if cryptographic processing fails
+ */
+ @SuppressWarnings({ "PMD.NcssCount", "PMD.CyclomaticComplexity",
+ "PMD.NPathComplexity" })
+ public static int main(String[] args, Options opts,
+ KeyringUnlockProvider unlockProvider)
+ throws ParseException, IOException, GeneralSecurityException {
ZeroEchoSession session = new ZeroEchoSession();
defineOptions(opts);
CommandLineParser parser = new DefaultParser();
@@ -242,7 +273,7 @@ public final class Kem { // NOPMD
final String kemId = cmd.getOptionValue(OPT_KEM.getLongOpt());
final Path keyringPath = Path.of(cmd.getOptionValue(OPT_KEYRING.getLongOpt()));
- final KeyringStore keyring = KeyringStore.load(session, keyringPath);
+ try (KeyringStore keyring = KeyringUnlocks.open(keyringPath, unlockProvider)) {
// Configure KEM envelope
KemDataContentBuilder kem = KemDataContentBuilder.builder(session).kem(kemId);
@@ -343,7 +374,8 @@ public final class Kem { // NOPMD
}
return 1;
}
- return 0;
+ return 0;
+ }
}
/**
diff --git a/app/src/main/java/zeroecho/KeyStoreManagement.java b/app/src/main/java/zeroecho/KeyStoreManagement.java
index 5e700b8..b63b36c 100644
--- a/app/src/main/java/zeroecho/KeyStoreManagement.java
+++ b/app/src/main/java/zeroecho/KeyStoreManagement.java
@@ -33,19 +33,13 @@
******************************************************************************/
package zeroecho;
-import java.io.BufferedReader;
import java.io.IOException;
-import java.io.InputStreamReader;
import java.io.PrintWriter;
-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.GeneralSecurityException;
import java.security.KeyPair;
-import java.util.ArrayList;
-import java.util.Base64;
import java.util.List;
import java.util.Locale;
import java.util.Set;
@@ -65,21 +59,19 @@ import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.KeyOperation;
import zeroecho.core.KeyOperationInfo;
import zeroecho.core.spec.AlgorithmKeySpec;
+import zeroecho.core.spi.KeyringUnlockProvider;
import zeroecho.core.storage.KeyringStore;
import zeroecho.sdk.ZeroEchoSession;
/**
- * Command-line utility for managing key material in a text-based keyring store.
+ * Command-line utility for managing an encrypted software keyring.
*
* Overview
The {@code KeyStoreManagement} subcommand provides
* lifecycle operations on key material stored in
* {@link zeroecho.core.storage.KeyringStore}. It supports listing algorithms
- * and aliases, generating key pairs or symmetric keys, exporting/importing
- * versioned text snippets, and overwriting existing entries.
- *
- * The keystore is a plain-text file with aliases mapped to keys. Import/export
- * uses line-oriented snippets with format-version headers, suitable for
- * exchanging public keys or migrating key material between systems.
+ * and aliases and generating key pairs or symmetric keys. Passwords are
+ * supplied through a destroyable unlock provider and are never accepted as
+ * command-line text.
*
* Usage
Invoked as: {@code
* ZeroEcho -K [options]
@@ -92,9 +84,6 @@ import zeroecho.sdk.ZeroEchoSession;
* {@code --list-aliases} - list aliases present in the keystore.
* {@code --generate} - generate a new key pair or secret and store under
* the given alias.
- * {@code --export} - export one or more aliases as a versioned
- * snippet.
- * {@code --import} - import a versioned snippet into the keystore.
*
*
* General options
@@ -116,38 +105,18 @@ import zeroecho.sdk.ZeroEchoSession;
* .prv).
*
*
- * Export options
- *
- * - {@code --aliases a,b,c} - comma-separated list of aliases to export
- * (default: all).
- * - {@code --out
- *
- *
- * Import options
- *
- * - {@code --in
- *
- {@code --overwrite} - allow replacing existing aliases when
- * importing.
- *
- *
* Examples
{@code
* # List available algorithms
* ZeroEcho -K --list-algorithms
*
* # Generate a new RSA key pair and store as alice.pub / alice.prv
- * ZeroEcho -K --generate --alg RSA --alias alice --keystore keys.txt
+ * ZeroEcho -K --generate --alg RSA --alias alice --keystore keys.zekr
*
* # Generate a new AES secret key and store as "backup-key"
- * ZeroEcho -K --generate --alg AES --alias backup-key --kind sym --keystore keys.txt
+ * ZeroEcho -K --generate --alg AES --alias backup-key --kind sym --keystore keys.zekr
*
* # List aliases in the keystore
- * ZeroEcho -K --list-aliases --keystore keys.txt
- *
- * # Export selected aliases to a file
- * ZeroEcho -K --export --aliases alice.pub,alice.prv --out alice-keys.txt --keystore keys.txt
- *
- * # Import aliases from stdin, overwriting if necessary
- * ZeroEcho -K --import --in - --overwrite --keystore keys.txt < alice-keys.txt
+ * ZeroEcho -K --list-aliases --keystore keys.zekr
* }
*
* Exit codes
@@ -159,9 +128,7 @@ import zeroecho.sdk.ZeroEchoSession;
*
* @since 1.0
*/
-public final class KeyStoreManagement { // NOPMD
-
- private final static String STD_IN_OUT = "-";
+public final class KeyStoreManagement {
// ---------------------------------------------------------------------
// Option constants (centralized for maintainability)
@@ -197,21 +164,6 @@ public final class KeyStoreManagement { // NOPMD
private static final Option OVERWRITE_OPTION = Option.builder().longOpt("overwrite")
.desc("Overwrite existing aliases on conflict").get();
- private static final Option EXPORT_OPTION = Option.builder().longOpt("export")
- .desc("Export selected aliases as a versioned text snippet").get();
-
- private static final Option IMPORT_OPTION = Option.builder().longOpt("import")
- .desc("Import a versioned text snippet into the keyring").get();
-
- private static final Option ALIASES_OPTION = Option.builder().longOpt("aliases").hasArg().argName("a,b,c")
- .desc("Comma-separated aliases to export; empty means all").get();
-
- private static final Option OUTFILE_OPTION = Option.builder().longOpt("out").hasArg().argName("file|-")
- .desc("Output file for export (default '-' for stdout)").get();
-
- private static final Option INFILE_OPTION = Option.builder().longOpt("in").hasArg().argName("file|-")
- .desc("Input file for import (default '-' for stdin)").get();
-
/** Prevents instantiation. */
private KeyStoreManagement() {
}
@@ -232,7 +184,25 @@ public final class KeyStoreManagement { // NOPMD
* @throws ParseException if the parser fails
* @throws IOException if I/O fails
*/
- public static int main(final String[] args, final Options dispatcherOptions) throws ParseException, IOException {
+ public static int main(final String[] args, final Options dispatcherOptions)
+ throws ParseException, IOException, GeneralSecurityException {
+ return main(args, dispatcherOptions, KeyringUnlocks.console());
+ }
+
+ /**
+ * Executes the command with an explicit unlock source.
+ *
+ * @param args arguments passed by the application dispatcher
+ * @param dispatcherOptions dispatcher options
+ * @param unlockProvider explicit destroyable-password provider
+ * @return process exit code
+ * @throws ParseException if parsing fails
+ * @throws IOException if keyring I/O fails
+ * @throws GeneralSecurityException if cryptographic processing fails
+ */
+ public static int main(final String[] args, final Options dispatcherOptions,
+ KeyringUnlockProvider unlockProvider)
+ throws ParseException, IOException, GeneralSecurityException {
ZeroEchoSession session = new ZeroEchoSession();
defineOptions(dispatcherOptions);
CommandLineParser parser = new DefaultParser();
@@ -248,26 +218,17 @@ public final class KeyStoreManagement { // NOPMD
}
Path keyringPath = Path.of(cmd.getOptionValue(KEYSTORE_OPTION.getLongOpt()));
- KeyringStore store = Files.exists(keyringPath) ? KeyringStore.load(session, keyringPath)
- : new KeyringStore(session);
-
- if (cmd.hasOption(LIST_ALIASES_OPTION.getLongOpt())) {
- listAliases(store);
- return 0;
- }
- if (cmd.hasOption(GENERATE_OPTION.getLongOpt())) {
- doGenerate(session, store, cmd);
- store.save(keyringPath);
- return 0;
- }
- if (cmd.hasOption(EXPORT_OPTION.getLongOpt())) {
- doExportSnippet(store, cmd);
- return 0;
- }
- if (cmd.hasOption(IMPORT_OPTION.getLongOpt())) {
- doImportSnippet(store, cmd);
- store.save(keyringPath);
- return 0;
+ try (KeyringStore store = Files.exists(keyringPath)
+ ? KeyringUnlocks.open(keyringPath, unlockProvider)
+ : KeyringUnlocks.create(keyringPath, unlockProvider)) {
+ if (cmd.hasOption(LIST_ALIASES_OPTION.getLongOpt())) {
+ listAliases(store);
+ return 0;
+ }
+ if (cmd.hasOption(GENERATE_OPTION.getLongOpt())) {
+ doGenerate(session, store, cmd);
+ return 0;
+ }
}
throw new IllegalArgumentException("No operation selected");
@@ -287,8 +248,6 @@ public final class KeyStoreManagement { // NOPMD
actions.addOption(LIST_ALGORITHMS_OPTION);
actions.addOption(LIST_ALIASES_OPTION);
actions.addOption(GENERATE_OPTION);
- actions.addOption(EXPORT_OPTION);
- actions.addOption(IMPORT_OPTION);
options.addOptionGroup(actions);
options.addOption(ALG_OPTION);
@@ -298,9 +257,6 @@ public final class KeyStoreManagement { // NOPMD
options.addOption(PRV_SUFFIX_OPTION);
options.addOption(OVERWRITE_OPTION);
- options.addOption(ALIASES_OPTION);
- options.addOption(OUTFILE_OPTION);
- options.addOption(INFILE_OPTION);
}
// ---------------------------------------------------------------------
@@ -355,7 +311,7 @@ public final class KeyStoreManagement { // NOPMD
* @param cmd parsed command line
*/
public static void doGenerate(final ZeroEchoSession session, final KeyringStore store,
- final CommandLine cmd) {
+ final CommandLine cmd) throws IOException, GeneralSecurityException {
String algId = required(cmd, ALG_OPTION, "--alg is required for --generate");
String aliasBase = required(cmd, ALIAS_OPTION, "--alias is required for --generate");
String kind = cmd.getOptionValue(KIND_OPTION.getLongOpt());
@@ -393,30 +349,21 @@ public final class KeyStoreManagement { // NOPMD
}
private static void generateAsymmetric(ZeroEchoSession session, KeyringStore store, CryptoAlgorithm algorithm,
- String algorithmId, String aliasBase, String publicSuffix, String privateSuffix, boolean overwrite) {
+ String algorithmId, String aliasBase, String publicSuffix, String privateSuffix, boolean overwrite)
+ throws IOException, GeneralSecurityException {
GeneratedKeyPair generated = firstGeneratedKeyPair(session, algorithm, algorithmId);
- Class> publicImport = findImportSpecClass(algorithm, KeyOperation.ASYMMETRIC_PUBLIC_IMPORT, true);
- Class> privateImport = findImportSpecClass(algorithm, KeyOperation.ASYMMETRIC_PRIVATE_IMPORT, false);
- if (publicImport == null && privateImport == null) {
- throw new IllegalStateException("No import spec class found for " + algorithmId + " (asymmetric)");
- }
KeyPair pair = generated.pair();
- byte[] publicEncoding = pair.getPublic() == null ? null : pair.getPublic().getEncoded();
- byte[] privateEncoding = pair.getPrivate() == null ? null : pair.getPrivate().getEncoded();
- AlgorithmKeySpec publicSpec = publicImport == null ? null
- : makeImportSpec(publicImport, publicEncoding, algorithmId, generated.info().defaultSpec());
- AlgorithmKeySpec privateSpec = privateImport == null ? null
- : makeImportSpec(privateImport, privateEncoding, algorithmId, generated.info().defaultSpec());
- requireImportSpec(publicImport, publicSpec, "public", algorithmId);
- requireImportSpec(privateImport, privateSpec, "private", algorithmId);
+ if (pair.getPublic() == null || pair.getPrivate() == null) {
+ throw new IllegalStateException("Generated key pair is incomplete");
+ }
String publicAlias = aliasBase + publicSuffix;
String privateAlias = aliasBase + privateSuffix;
ensureWritable(store, publicAlias, overwrite);
ensureWritable(store, privateAlias, overwrite);
- store.putPublic(publicAlias, algorithmId, publicSpec);
- store.putPrivate(privateAlias, algorithmId, privateSpec);
+ store.putPublic(publicAlias, algorithmId, pair.getPublic());
+ store.putPrivate(privateAlias, algorithmId, pair.getPrivate());
PrintWriter out = new PrintWriter(System.out, true, StandardCharsets.UTF_8); // NOPMD
out.printf("Generated %s -> %s, %s%n", algorithmId, publicAlias, privateAlias);
@@ -443,39 +390,12 @@ public final class KeyStoreManagement { // NOPMD
throw new IllegalStateException("No asymmetric builder with default spec worked for " + algorithmId);
}
- private static Class> findImportSpecClass(CryptoAlgorithm algorithm, KeyOperation operation,
- boolean publicImport) {
- for (KeyOperationInfo info : algorithm.keyOperations()) {
- boolean matchingName = publicImport ? looksLikeImportSpecForPublic(info.specType())
- : looksLikeImportSpecForPrivate(info.specType());
- if (info.operation() == operation && matchingName) {
- return info.specType();
- }
- }
- return null;
- }
-
- private static void requireImportSpec(Class> specType, AlgorithmKeySpec spec, String kind, String algorithmId) {
- if (specType != null && spec == null) {
- throw new IllegalStateException("Cannot construct " + kind + " import spec for " + algorithmId);
- }
- }
-
private static void generateSymmetric(ZeroEchoSession session, KeyringStore store, CryptoAlgorithm algorithm,
- String algorithmId, String alias, boolean overwrite) {
+ String algorithmId, String alias, boolean overwrite)
+ throws IOException, GeneralSecurityException {
GeneratedSecret generated = firstGeneratedSecret(session, algorithm, algorithmId);
- Class> importType = findSymmetricImportSpecClass(algorithm);
- if (importType == null) {
- throw new IllegalStateException("No symmetric import spec class for " + algorithmId);
- }
-
- byte[] encoding = generated.key().getEncoded();
- AlgorithmKeySpec spec = makeImportSpec(importType, encoding, algorithmId, generated.info().defaultSpec());
- if (spec == null) {
- throw new IllegalStateException("Cannot construct symmetric import spec for " + algorithmId);
- }
ensureWritable(store, alias, overwrite);
- store.putSecret(alias, algorithmId, spec);
+ store.putSecret(alias, algorithmId, generated.key());
PrintWriter out = new PrintWriter(System.out, true, StandardCharsets.UTF_8); // NOPMD
out.printf("Generated %s -> %s%n", algorithmId, alias);
@@ -515,163 +435,6 @@ public final class KeyStoreManagement { // NOPMD
NONE
}
- /**
- * Exports a versioned, line-oriented snippet to stdout or a file.
- *
- * @param store source keyring store
- * @param cmd parsed command line
- * @throws IOException if writing fails
- */
- public static void doExportSnippet(final KeyringStore store, final CommandLine cmd) throws IOException {
- List aliases = parseCsv(cmd.getOptionValue(ALIASES_OPTION.getLongOpt(), ""));
- if (aliases.isEmpty()) {
- aliases = store.aliases();
- }
-
- String text = store.exportText(aliases);
- String outFile = cmd.getOptionValue(OUTFILE_OPTION.getLongOpt(), STD_IN_OUT);
-
- if (STD_IN_OUT.equals(outFile)) {
- try (PrintWriter out = new PrintWriter(System.out, true, StandardCharsets.UTF_8)) {
- out.print(text);
- if (!text.endsWith("\n")) {
- out.println();
- }
- }
- } else {
- Files.writeString(Path.of(outFile), text, StandardCharsets.UTF_8);
- System.out.printf("Wrote snippet: %s%n", outFile);
- }
- }
-
- /**
- * Imports a versioned, line-oriented snippet from stdin or a file.
- *
- * @param store destination keyring store
- * @param cmd parsed command line
- * @throws IOException if reading fails or the snippet is invalid
- */
- public static void doImportSnippet(final KeyringStore store, final CommandLine cmd) throws IOException {
- boolean overwrite = cmd.hasOption(OVERWRITE_OPTION.getLongOpt());
- String inFile = cmd.getOptionValue(INFILE_OPTION.getLongOpt(), STD_IN_OUT);
-
- String text;
- if (STD_IN_OUT.equals(inFile)) {
- StringBuilder sb = new StringBuilder(1024);
- try (BufferedReader br = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))) {
- String line = br.readLine();
- while (line != null) {
- sb.append(line).append('\n');
- line = br.readLine();
- }
- }
- text = sb.toString();
- } else {
- text = Files.readString(Path.of(inFile), StandardCharsets.UTF_8);
- }
-
- store.importText(text, overwrite);
- PrintWriter out = new PrintWriter(System.out, true, StandardCharsets.UTF_8); // NOPMD
- out.println("Imported snippet.");
- }
-
- // ---------------------------------------------------------------------
- // Helpers - EXACTLY the dynamic-test strategy
- // ---------------------------------------------------------------------
-
- 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 Class> findSymmetricImportSpecClass(CryptoAlgorithm alg) {
- for (KeyOperationInfo x : alg.keyOperations()) {
- if (x.operation() == KeyOperation.SYMMETRIC_IMPORT) {
- return x.specType();
- }
- }
- return null;
- }
-
- /**
- * Constructs an AlgorithmKeySpec from encoded bytes using conventional
- * factories/ctors. Mirrors the makeImportSpec approach used in the dynamic
- * test.
- *
- * @param specType target spec class
- * @param material encoded bytes (SPKI, PKCS#8, or RAW)
- * @param algId algorithm id (used for variant name heuristics)
- * @param defaultSpec the default spec used by the builder (for optional variant
- * hints)
- * @return constructed AlgorithmKeySpec or null if none matched
- */
- private static AlgorithmKeySpec makeImportSpec(Class> specType, byte[] material, String algId,
- Object defaultSpec) {
- if (material == null) {
- return null;
- }
- try {
- Method m = specType.getMethod("fromRaw", byte[].class);
- Object spec = m.invoke(null, material);
- return (AlgorithmKeySpec) spec;
- } catch (NoSuchMethodException ignored) {
- } catch (Throwable t) { // NOPMD
- }
- 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) {
- } catch (Throwable t) { // NOPMD
- }
- try {
- Method m = specType.getMethod("of", byte[].class);
- Object spec = m.invoke(null, material);
- return (AlgorithmKeySpec) spec;
- } catch (NoSuchMethodException ignored) {
- } catch (Throwable t) { // NOPMD
- }
- try {
- Constructor> c = specType.getConstructor(byte[].class);
- Object spec = c.newInstance(material);
- return (AlgorithmKeySpec) spec;
- } catch (NoSuchMethodException ignored) {
- } catch (Throwable t) { // NOPMD
- }
- try {
- Constructor> c = specType.getConstructor(String.class);
- String b64 = Base64.getEncoder().encodeToString(material);
- Object spec = c.newInstance(b64);
- return (AlgorithmKeySpec) spec;
- } catch (NoSuchMethodException ignored) {
- } catch (Throwable t) { // NOPMD
- }
- return null;
- }
-
- 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 (Throwable ignored) { // NOPMD
- }
- }
- if ("HMAC".equalsIgnoreCase(algId)) { // NOPMD
- return "HmacSHA256";
- }
- return algId;
- }
-
private static String required(CommandLine cmd, Option opt, String message) {
if (!cmd.hasOption(opt.getLongOpt())) {
throw new IllegalArgumentException(message);
@@ -679,20 +442,6 @@ public final class KeyStoreManagement { // NOPMD
return cmd.getOptionValue(opt.getLongOpt());
}
- private static List parseCsv(String csv) {
- List list = new ArrayList<>();
- if (csv == null || csv.isBlank()) {
- return list;
- }
- String[] parts = csv.split("\\s*,\\s*");
- for (String part : parts) {
- if (!part.isBlank()) {
- list.add(part);
- }
- }
- return list;
- }
-
/**
* Ensures that an alias can be written under the current collision policy.
*
diff --git a/app/src/main/java/zeroecho/KeyringUnlocks.java b/app/src/main/java/zeroecho/KeyringUnlocks.java
new file mode 100644
index 0000000..9d2c01b
--- /dev/null
+++ b/app/src/main/java/zeroecho/KeyringUnlocks.java
@@ -0,0 +1,64 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ ******************************************************************************/
+package zeroecho;
+
+import java.io.Console;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.security.GeneralSecurityException;
+import java.util.Arrays;
+
+import zeroecho.core.spi.KeyringUnlockProvider;
+import zeroecho.core.storage.KeyringPassword;
+import zeroecho.core.storage.KeyringStore;
+
+/**
+ * Application boundary for acquiring and promptly destroying keyring passwords.
+ */
+final class KeyringUnlocks {
+ private KeyringUnlocks() {
+ }
+
+ /* default */ static KeyringUnlockProvider console() {
+ return () -> {
+ Console console = System.console();
+ if (console == null) {
+ throw new IOException("Interactive keyring password input is unavailable");
+ }
+ char[] input = console.readPassword("Keyring password: ");
+ if (input == null) {
+ throw new IOException("Keyring password input was cancelled");
+ }
+ try {
+ return new KeyringPassword(input);
+ } finally {
+ Arrays.fill(input, '\0');
+ }
+ };
+ }
+
+ /* default */ static KeyringStore open(Path path, KeyringUnlockProvider provider)
+ throws IOException, GeneralSecurityException {
+ try (KeyringPassword password = acquire(provider)) {
+ return KeyringStore.open(path, password);
+ }
+ }
+
+ /* default */ static KeyringStore create(Path path, KeyringUnlockProvider provider)
+ throws IOException, GeneralSecurityException {
+ try (KeyringPassword password = acquire(provider)) {
+ return KeyringStore.create(path, password);
+ }
+ }
+
+ private static KeyringPassword acquire(KeyringUnlockProvider provider)
+ throws IOException {
+ KeyringPassword password = provider.acquire();
+ if (password == null) {
+ throw new IOException("Keyring unlock provider returned no password");
+ }
+ return password;
+ }
+}
diff --git a/app/src/main/java/zeroecho/Tag.java b/app/src/main/java/zeroecho/Tag.java
index fb1be9e..b6c03ec 100644
--- a/app/src/main/java/zeroecho/Tag.java
+++ b/app/src/main/java/zeroecho/Tag.java
@@ -56,6 +56,7 @@ import zeroecho.core.alg.digest.DigestSpec;
import zeroecho.core.err.VerificationException;
import zeroecho.core.spec.ContextSpec;
import zeroecho.core.spec.VoidSpec;
+import zeroecho.core.spi.KeyringUnlockProvider;
import zeroecho.core.storage.KeyringStore;
import zeroecho.core.tag.TagEngineBuilder;
import zeroecho.sdk.builders.TagTrailerDataContentBuilder;
@@ -157,7 +158,25 @@ public final class Tag { // NOPMD
* @throws GeneralSecurityException if a cryptographic error occurs during
* signature or digest processing
*/
- public static int main(String[] args, Options root) throws ParseException, IOException, GeneralSecurityException {
+ public static int main(String[] args, Options root)
+ throws ParseException, IOException, GeneralSecurityException {
+ return main(args, root, KeyringUnlocks.console());
+ }
+
+ /**
+ * Executes the command with an explicit keyring unlock source.
+ *
+ * @param args command arguments
+ * @param root root options
+ * @param unlockProvider keyring password provider
+ * @return process exit code
+ * @throws ParseException if parsing fails
+ * @throws IOException if I/O fails
+ * @throws GeneralSecurityException if cryptographic processing fails
+ */
+ public static int main(String[] args, Options root,
+ KeyringUnlockProvider unlockProvider)
+ throws ParseException, IOException, GeneralSecurityException {
ZeroEchoSession session = new ZeroEchoSession();
Options opts = root;
opts.addOption(TYPE_OPT);
@@ -198,19 +217,19 @@ public final class Tag { // NOPMD
if (TYPE_SIGNATURE.equals(type)) {
String ksPath = require(cli, KS_OPT, "--ks is required for --type signature");
- KeyringStore keyring = KeyringStore.load(session, Path.of(ksPath));
- ContextSpec spec = VoidSpec.INSTANCE;
-
- if (produce) {
- String privAlias = require(cli, PRIV_OPT, "signature produce requires --priv ");
- PrivateKey priv = keyring.getPrivate(privAlias);
- tail = new TagTrailerDataContentBuilder<>(TagEngineBuilder.signature(session, alg, priv, spec))
- .build(true);
- } else {
- String pubAlias = require(cli, PUB_OPT, "signature verify requires --pub ");
- PublicKey pub = keyring.getPublic(pubAlias);
- tail = new TagTrailerDataContentBuilder<>(TagEngineBuilder.signature(session, alg, pub, spec))
- .build(false);
+ try (KeyringStore keyring = KeyringUnlocks.open(Path.of(ksPath), unlockProvider)) {
+ ContextSpec spec = VoidSpec.INSTANCE;
+ if (produce) {
+ String privAlias = require(cli, PRIV_OPT, "signature produce requires --priv ");
+ PrivateKey priv = keyring.getPrivate(privAlias);
+ tail = new TagTrailerDataContentBuilder<>(
+ TagEngineBuilder.signature(session, alg, priv, spec)).build(true);
+ } else {
+ String pubAlias = require(cli, PUB_OPT, "signature verify requires --pub ");
+ PublicKey pub = keyring.getPublic(pubAlias);
+ tail = new TagTrailerDataContentBuilder<>(
+ TagEngineBuilder.signature(session, alg, pub, spec)).build(false);
+ }
}
} else { // digest
DigestSpec spec = parseDigest(alg);
diff --git a/app/src/test/java/zeroecho/GuardTest.java b/app/src/test/java/zeroecho/GuardTest.java
index c5a2da2..8c08be4 100644
--- a/app/src/test/java/zeroecho/GuardTest.java
+++ b/app/src/test/java/zeroecho/GuardTest.java
@@ -127,7 +127,7 @@ public class GuardTest {
"--pbkdf2-max", TEST_PBKDF2_MAXIMUM, "--pbkdf2-hard-max", TEST_PBKDF2_MAXIMUM, "--alg",
"aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex", aadHex };
System.out.println("...encrypt: " + Arrays.toString(encArgs));
- int e = Guard.main(encArgs, new Options());
+ int e = Guard.main(encArgs, new Options(), TestKeyringUnlocks.provider());
assertEquals(0, e, "... encrypt expected exit code 0");
// Decrypt (using password)
@@ -135,7 +135,7 @@ public class GuardTest {
"--pbkdf2-max", TEST_PBKDF2_MAXIMUM, "--pbkdf2-hard-max", TEST_PBKDF2_MAXIMUM, "--alg",
"aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex", aadHex };
System.out.println("...decrypt: " + Arrays.toString(decArgs));
- int d = Guard.main(decArgs, new Options());
+ int d = Guard.main(decArgs, new Options(), TestKeyringUnlocks.provider());
assertEquals(0, d, "... decrypt expected exit code 0");
assertArrayEquals(Files.readAllBytes(in), Files.readAllBytes(dec), "AES-GCM password round-trip mismatch");
@@ -150,7 +150,7 @@ public class GuardTest {
String[] arguments = { "--encrypt", input.toString(), "--to-psw", "controlled", "--alg", "aes-gcm" };
ParseException failure = assertThrows(ParseException.class,
- () -> Guard.main(arguments, new Options()));
+ () -> Guard.main(arguments, new Options(), TestKeyringUnlocks.provider()));
assertTrue(failure.getMessage().contains("--pbkdf2-max"));
System.out.println("...rejected=missingLimits");
@@ -170,7 +170,7 @@ public class GuardTest {
"--alg", "aes-gcm" };
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
- () -> Guard.main(arguments, new Options()));
+ () -> Guard.main(arguments, new Options(), TestKeyringUnlocks.provider()));
assertTrue(failure.getMessage().contains("exactly 16 or 32"));
assertTrue(Files.notExists(output));
@@ -215,14 +215,14 @@ public class GuardTest {
"--to-alias", rsa.pub, "--alg", "aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex",
aadAes };
System.out.println("...AES encrypt: " + Arrays.toString(encArgs));
- int e = Guard.main(encArgs, new Options());
+ int e = Guard.main(encArgs, new Options(), TestKeyringUnlocks.provider());
assertEquals(0, e, "... AES encrypt rc");
String[] decArgs = { "--decrypt", enc.toString(), "--output", dec.toString(), "--keyring", ring.toString(),
"--priv-alias", rsa.prv, "--alg", "aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex",
aadAes };
System.out.println("...AES decrypt: " + Arrays.toString(decArgs));
- int d = Guard.main(decArgs, new Options());
+ int d = Guard.main(decArgs, new Options(), TestKeyringUnlocks.provider());
assertEquals(0, d, "... AES decrypt rc");
assertArrayEquals(Files.readAllBytes(in), Files.readAllBytes(dec), "RSA AES-GCM round-trip mismatch");
@@ -238,13 +238,13 @@ public class GuardTest {
String[] encArgs = { "--encrypt", in.toString(), "--output", enc.toString(), "--keyring", ring.toString(),
"--to-alias", rsa.pub, "--alg", "chacha-aead", "--aad-hex", aadCha };
System.out.println("...ChaCha encrypt: " + Arrays.toString(encArgs));
- int e = Guard.main(encArgs, new Options());
+ int e = Guard.main(encArgs, new Options(), TestKeyringUnlocks.provider());
assertEquals(0, e, "... ChaCha encrypt rc");
String[] decArgs = { "--decrypt", enc.toString(), "--output", dec.toString(), "--keyring", ring.toString(),
"--priv-alias", rsa.prv, "--alg", "chacha-aead", "--aad-hex", aadCha };
System.out.println("...ChaCha decrypt: " + Arrays.toString(decArgs));
- int d = Guard.main(decArgs, new Options());
+ int d = Guard.main(decArgs, new Options(), TestKeyringUnlocks.provider());
assertEquals(0, d, "... ChaCha decrypt rc");
assertArrayEquals(Files.readAllBytes(in), Files.readAllBytes(dec),
@@ -293,7 +293,7 @@ public class GuardTest {
"--pbkdf2-max", TEST_PBKDF2_MAXIMUM, "--pbkdf2-hard-max", TEST_PBKDF2_MAXIMUM, "--alg",
"aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex", aad };
System.out.println("...encrypt: " + Arrays.toString(encArgs));
- int e = Guard.main(encArgs, new Options());
+ int e = Guard.main(encArgs, new Options(), TestKeyringUnlocks.provider());
assertEquals(0, e, "... encrypt rc");
// Decrypt via private RSA key
@@ -301,7 +301,7 @@ public class GuardTest {
"--priv-alias", rsa.prv, "--alg", "aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex",
aad };
System.out.println("...decrypt(private): " + Arrays.toString(decPriv));
- int d1 = Guard.main(decPriv, new Options());
+ int d1 = Guard.main(decPriv, new Options(), TestKeyringUnlocks.provider());
assertEquals(0, d1, "... decrypt(private) rc");
assertArrayEquals(Files.readAllBytes(in), Files.readAllBytes(dec1),
"mixed recipients decrypt(private) mismatch");
@@ -311,7 +311,7 @@ public class GuardTest {
"--pbkdf2-max", TEST_PBKDF2_MAXIMUM, "--pbkdf2-hard-max", TEST_PBKDF2_MAXIMUM, "--alg",
"aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex", aad };
System.out.println("...decrypt(password): " + Arrays.toString(decPwd));
- int d2 = Guard.main(decPwd, new Options());
+ int d2 = Guard.main(decPwd, new Options(), TestKeyringUnlocks.provider());
assertEquals(0, d2, "... decrypt(password) rc");
assertArrayEquals(Files.readAllBytes(in), Files.readAllBytes(dec2),
"mixed recipients decrypt(password) mismatch");
@@ -337,14 +337,14 @@ public class GuardTest {
String[] encArgs = { "--encrypt", in.toString(), "--output", enc.toString(), "--to-psw", pwd,
"--pbkdf2-max", TEST_PBKDF2_MAXIMUM, "--pbkdf2-hard-max", TEST_PBKDF2_MAXIMUM, "--alg",
"aes-gcm", "--tag-bits", "128" };
- int e = Guard.main(encArgs, new Options());
+ int e = Guard.main(encArgs, new Options(), TestKeyringUnlocks.provider());
assertEquals(0, e, "... encrypt rc");
// Supply both options on purpose
Exception ex = assertThrows(Exception.class, () -> {
String[] bad = { "--decrypt", enc.toString(), "--output", tmp.resolve("out-neg.bin").toString(),
"--password", pwd, "--priv-alias", "whatever", "--alg", "aes-gcm" };
- Guard.main(bad, new Options());
+ Guard.main(bad, new Options(), TestKeyringUnlocks.provider());
});
System.out.println("...got expected exception: " + ex);
System.out.println("...ok");
@@ -377,7 +377,7 @@ public class GuardTest {
String[] genArgs = { "--keystore", ring.toString(), "--generate", "--alg", algId, "--alias", baseAlias,
"--kind", "asym" };
System.out.println("...KeyStoreManagement generate: " + Arrays.toString(genArgs));
- int rc = KeyStoreManagement.main(genArgs, new Options());
+ int rc = KeyStoreManagement.main(genArgs, new Options(), TestKeyringUnlocks.provider());
if (rc != 0) {
throw new GeneralSecurityException("KeyStoreManagement failed with rc=" + rc + " for " + algId);
}
diff --git a/app/src/test/java/zeroecho/KemTest.java b/app/src/test/java/zeroecho/KemTest.java
index 208489d..e7c022b 100644
--- a/app/src/test/java/zeroecho/KemTest.java
+++ b/app/src/test/java/zeroecho/KemTest.java
@@ -165,9 +165,12 @@ public class KemTest {
KeyAliases aliases = generateKemIntoKeyStore(ring, kemId, "alias-" + shortId(kemId));
// Sanity: re-open to ensure the file is valid
- KeyringStore ks = KeyringStore.load(new zeroecho.sdk.ZeroEchoSession(), ring);
- if (!(ks.contains(aliases.pub) && ks.contains(aliases.prv))) {
- throw new IllegalStateException("Keyring does not contain expected aliases for " + kemId);
+ try (zeroecho.core.storage.KeyringPassword password =
+ TestKeyringUnlocks.provider().acquire();
+ KeyringStore ks = KeyringStore.open(ring, password)) {
+ if (!(ks.contains(aliases.pub) && ks.contains(aliases.prv))) {
+ throw new IllegalStateException("Keyring does not contain expected aliases for " + kemId);
+ }
}
// AES-GCM round-trip
@@ -181,7 +184,7 @@ public class KemTest {
int e = Kem.main(new String[] { "--encrypt", plain.toString(), "--output", enc.toString(),
"--keyring", ring.toString(), "--pub", aliases.pub, "--kem", kemId, "--aes", "--aes-cipher",
"gcm", "--aes-tag-bits", Integer.toString(gcmTagBits), "--header", "--aad", aadAes },
- new Options());
+ new Options(), TestKeyringUnlocks.provider());
if (e != 0) {
throw new IllegalStateException("AES encrypt rc=" + e);
}
@@ -189,7 +192,7 @@ public class KemTest {
int d = Kem.main(new String[] { "--decrypt", enc.toString(), "--output", dec.toString(),
"--keyring", ring.toString(), "--priv", aliases.prv, "--kem", kemId, "--aes",
"--aes-cipher", "gcm", "--aes-tag-bits", Integer.toString(gcmTagBits), "--header", "--aad",
- aadAes }, new Options());
+ aadAes }, new Options(), TestKeyringUnlocks.provider());
if (d != 0) {
throw new IllegalStateException("AES decrypt rc=" + d);
}
@@ -208,14 +211,14 @@ public class KemTest {
System.out.println("...[" + kemId + "] ChaCha encrypt");
int e = Kem.main(new String[] { "--encrypt", plain.toString(), "--output", enc.toString(),
"--keyring", ring.toString(), "--pub", aliases.pub, "--kem", kemId, "--chacha",
- "--aad", aadChaCha, "--header" }, new Options());
+ "--aad", aadChaCha, "--header" }, new Options(), TestKeyringUnlocks.provider());
if (e != 0) {
throw new IllegalStateException("ChaCha encrypt rc=" + e);
}
System.out.println("...[" + kemId + "] ChaCha decrypt");
int d = Kem.main(new String[] { "--decrypt", enc.toString(), "--output", dec.toString(),
"--keyring", ring.toString(), "--priv", aliases.prv, "--kem", kemId, "--chacha",
- "--aad", aadChaCha, "--header" }, new Options());
+ "--aad", aadChaCha, "--header" }, new Options(), TestKeyringUnlocks.provider());
if (d != 0) {
throw new IllegalStateException("ChaCha decrypt rc=" + d);
}
@@ -256,7 +259,7 @@ public class KemTest {
ByteArrayOutputStream sink = new ByteArrayOutputStream();
System.setOut(new PrintStream(sink, true, StandardCharsets.UTF_8));
try {
- int rc = Kem.main(new String[] { "--list-kems" }, new Options());
+ int rc = Kem.main(new String[] { "--list-kems" }, new Options(), TestKeyringUnlocks.provider());
if (rc != 0) {
throw new IllegalStateException("--list-kems rc=" + rc);
}
@@ -284,7 +287,7 @@ public class KemTest {
String[] genArgs = { "--keystore", ring.toString(), "--generate", "--alg", kemId, "--alias", baseAlias,
"--kind", "asym" };
System.out.println("...KeyStoreManagement generate: " + Arrays.toString(genArgs));
- int rc = KeyStoreManagement.main(genArgs, new Options());
+ int rc = KeyStoreManagement.main(genArgs, new Options(), TestKeyringUnlocks.provider());
if (rc != 0) {
throw new GeneralSecurityException("KeyStoreManagement failed with rc=" + rc + " for " + kemId);
}
diff --git a/app/src/test/java/zeroecho/KeyStoreManagementTest.java b/app/src/test/java/zeroecho/KeyStoreManagementTest.java
index ebf540a..54a0aed 100644
--- a/app/src/test/java/zeroecho/KeyStoreManagementTest.java
+++ b/app/src/test/java/zeroecho/KeyStoreManagementTest.java
@@ -109,7 +109,7 @@ public class KeyStoreManagementTest {
String[] argv = new String[] { "--keystore", ring.toString(), "--generate", "--alg", id, "--alias",
alias, "--kind", "asym" };
try {
- int rc = KeyStoreManagement.main(argv, dispatcher);
+ int rc = KeyStoreManagement.main(argv, dispatcher, TestKeyringUnlocks.provider());
System.out.println(" rc=" + rc);
assertTrue(rc == 0, "asymmetric generation failed for " + id);
attempted++;
@@ -125,7 +125,7 @@ public class KeyStoreManagementTest {
String[] argv = new String[] { "--keystore", ring.toString(), "--generate", "--alg", id, "--alias",
alias, "--kind", "sym" };
try {
- int rc = KeyStoreManagement.main(argv, dispatcher);
+ int rc = KeyStoreManagement.main(argv, dispatcher, TestKeyringUnlocks.provider());
System.out.println(" rc=" + rc);
assertTrue(rc == 0, "symmetric generation failed for " + id);
attempted++;
@@ -139,7 +139,14 @@ public class KeyStoreManagementTest {
assertTrue(attempted > 0, "No generation attempts were successful");
// Verify by reloading and materializing.
- KeyringStore store = KeyringStore.load(session, ring);
+ zeroecho.core.storage.KeyringPassword password =
+ TestKeyringUnlocks.provider().acquire();
+ KeyringStore store;
+ try {
+ store = KeyringStore.open(ring, password);
+ } finally {
+ password.close();
+ }
List aliases = store.aliases();
System.out.println("Reloaded aliases (" + aliases.size() + "): " + aliases);
@@ -184,6 +191,7 @@ public class KeyStoreManagementTest {
}
}
assertTrue(ok > 0, "No entries could be materialized back");
+ store.close();
}
// ---- helpers ----
diff --git a/app/src/test/java/zeroecho/TagTest.java b/app/src/test/java/zeroecho/TagTest.java
index 6e93edb..aa4fd77 100644
--- a/app/src/test/java/zeroecho/TagTest.java
+++ b/app/src/test/java/zeroecho/TagTest.java
@@ -127,8 +127,11 @@ public class TagTest {
Path ring = tmp.resolve("ring-ed25519.txt");
KeyAliases ed = generateIntoKeyStore(ring, "Ed25519", "ed");
// sanity
- KeyringStore ks = KeyringStore.load(new zeroecho.sdk.ZeroEchoSession(), ring);
- assertTrue(ks.contains(ed.pub) && ks.contains(ed.prv), "missing expected aliases");
+ try (zeroecho.core.storage.KeyringPassword password =
+ TestKeyringUnlocks.provider().acquire();
+ KeyringStore ks = KeyringStore.open(ring, password)) {
+ assertTrue(ks.contains(ed.pub) && ks.contains(ed.prv), "missing expected aliases");
+ }
byte[] pt = randomBytes(4096);
Path plain = tmp.resolve("plain.bin");
@@ -139,12 +142,12 @@ public class TagTest {
// produce
String[] produce = { "--type", "signature", "--mode", "produce", "--alg", "Ed25519", "--ks", ring.toString(),
"--priv", ed.prv, "--in", plain.toString(), "--out", signed.toString() };
- assertEquals(0, Tag.main(produce, new Options()), "produce rc");
+ assertEquals(0, Tag.main(produce, new Options(), TestKeyringUnlocks.provider()), "produce rc");
// verify (match)
String[] verify = { "--type", "signature", "--mode", "verify", "--alg", "Ed25519", "--ks", ring.toString(),
"--pub", ed.pub, "--in", signed.toString(), "--out", recovered.toString() };
- assertEquals(0, Tag.main(verify, new Options()), "verify rc");
+ assertEquals(0, Tag.main(verify, new Options(), TestKeyringUnlocks.provider()), "verify rc");
assertArrayEquals(pt, Files.readAllBytes(recovered), "round-trip mismatch");
@@ -168,7 +171,7 @@ public class TagTest {
assertEquals(0,
Tag.main(new String[] { "--type", "signature", "--mode", "produce", "--alg", "Ed25519", "--ks",
ring.toString(), "--priv", ed.prv, "--in", plain.toString(), "--out", signed.toString() },
- new Options()));
+ new Options(), TestKeyringUnlocks.provider()));
// corrupt last byte -> break signature
flipLastByte(signed);
@@ -178,7 +181,7 @@ public class TagTest {
Tag.main(
new String[] { "--type", "signature", "--mode", "verify", "--alg", "Ed25519", "--ks",
ring.toString(), "--pub", ed.pub, "--in", signed.toString(), "--out", out.toString() },
- new Options()));
+ new Options(), TestKeyringUnlocks.provider()));
assertTrue(Files.notExists(out, LinkOption.NOFOLLOW_LINKS));
@@ -198,11 +201,11 @@ public class TagTest {
// produce
assertEquals(0, Tag.main(new String[] { "--type", "digest", "--mode", "produce", "--alg", "SHA-256", "--in",
- plain.toString(), "--out", tagged.toString() }, new Options()));
+ plain.toString(), "--out", tagged.toString() }, new Options(), TestKeyringUnlocks.provider()));
// verify (match)
assertEquals(0, Tag.main(new String[] { "--type", "digest", "--mode", "verify", "--alg", "SHA-256", "--in",
- tagged.toString(), "--out", recovered.toString() }, new Options()));
+ tagged.toString(), "--out", recovered.toString() }, new Options(), TestKeyringUnlocks.provider()));
assertArrayEquals(pt, Files.readAllBytes(recovered), "digest round-trip mismatch");
@@ -221,14 +224,14 @@ public class TagTest {
// produce
assertEquals(0, Tag.main(new String[] { "--type", "digest", "--mode", "produce", "--alg", "SHA-256", "--in",
- plain.toString(), "--out", tagged.toString() }, new Options()));
+ plain.toString(), "--out", tagged.toString() }, new Options(), TestKeyringUnlocks.provider()));
// corrupt last byte -> break digest
flipLastByte(tagged);
// verify (mismatch): expect throw + default marker ("digest invalid")
assertEquals(1, Tag.main(new String[] { "--type", "digest", "--mode", "verify", "--alg", "SHA-256", "--in",
- tagged.toString(), "--out", out.toString() }, new Options()));
+ tagged.toString(), "--out", out.toString() }, new Options(), TestKeyringUnlocks.provider()));
assertTrue(Files.notExists(out, LinkOption.NOFOLLOW_LINKS));
@@ -252,7 +255,7 @@ public class TagTest {
assertEquals(0, Tag.main(
new String[] { "--type", "digest", "--mode", "produce", "--alg", "SHA-256", "--in", "-", "--out", "-" },
- new Options()));
+ new Options(), TestKeyringUnlocks.provider()));
// save produced bytes
Path tagged = tmp.resolve("stdio-tagged.bin");
@@ -264,7 +267,7 @@ public class TagTest {
System.setOut(new PrintStream(verifiedSink, true, StandardCharsets.UTF_8));
assertEquals(0, Tag.main(new String[] { "--type", "digest", "--mode", "verify", "--alg", "SHA-256", "--in",
- tagged.toString(), "--out", "-" }, new Options()));
+ tagged.toString(), "--out", "-" }, new Options(), TestKeyringUnlocks.provider()));
assertArrayEquals(pt, verifiedSink.toByteArray(), "stdio round-trip mismatch");
@@ -279,7 +282,7 @@ public class TagTest {
private static KeyAliases generateIntoKeyStore(Path ring, String algId, String baseAlias) throws Exception {
String[] genArgs = { "--keystore", ring.toString(), "--generate", "--alg", algId, "--alias", baseAlias,
"--kind", "asym" };
- int rc = KeyStoreManagement.main(genArgs, new Options());
+ int rc = KeyStoreManagement.main(genArgs, new Options(), TestKeyringUnlocks.provider());
if (rc != 0) {
throw new GeneralSecurityException("KeyStoreManagement failed with rc=" + rc + " for " + algId);
}
diff --git a/app/src/test/java/zeroecho/TestKeyringUnlocks.java b/app/src/test/java/zeroecho/TestKeyringUnlocks.java
new file mode 100644
index 0000000..bce4419
--- /dev/null
+++ b/app/src/test/java/zeroecho/TestKeyringUnlocks.java
@@ -0,0 +1,14 @@
+package zeroecho;
+
+import zeroecho.core.spi.KeyringUnlockProvider;
+import zeroecho.core.storage.KeyringPassword;
+
+final class TestKeyringUnlocks {
+ private TestKeyringUnlocks() {
+ }
+
+ static KeyringUnlockProvider provider() {
+ return () -> new KeyringPassword(
+ new char[] { 't', 'e', 's', 't', '-', 'k', 'e', 'y', 'r', 'i', 'n', 'g' });
+ }
+}
diff --git a/ext/src/main/java/zeroecho/ext/integrations/covert/TextualCodec.java b/ext/src/main/java/zeroecho/ext/integrations/covert/TextualCodec.java
index ea3959b..66a3e0a 100644
--- a/ext/src/main/java/zeroecho/ext/integrations/covert/TextualCodec.java
+++ b/ext/src/main/java/zeroecho/ext/integrations/covert/TextualCodec.java
@@ -39,7 +39,7 @@ import java.util.NavigableMap;
import java.util.Queue;
import java.util.TreeMap;
-import zeroecho.sdk.util.RandomSupport;
+import zeroecho.core.util.RandomSupport;
/**
* A utility class for generating pseudo-random textual content based on
diff --git a/lib/src/main/java/zeroecho/core/alg/aes/AesAlgorithm.java b/lib/src/main/java/zeroecho/core/alg/aes/AesAlgorithm.java
index c26ffe2..cb0d48b 100644
--- a/lib/src/main/java/zeroecho/core/alg/aes/AesAlgorithm.java
+++ b/lib/src/main/java/zeroecho/core/alg/aes/AesAlgorithm.java
@@ -47,7 +47,7 @@ import zeroecho.core.context.EncryptionContext;
import zeroecho.core.spec.VoidSpec;
import zeroecho.core.spi.SymmetricKeyGenerator;
import zeroecho.core.spi.SymmetricKeyImporter;
-import zeroecho.sdk.util.RandomSupport;
+import zeroecho.core.util.RandomSupport;
/**
* AES algorithm registration and capability wiring.
diff --git a/lib/src/main/java/zeroecho/core/alg/aes/AesCipherContext.java b/lib/src/main/java/zeroecho/core/alg/aes/AesCipherContext.java
index 3ee602d..ad9fd1d 100644
--- a/lib/src/main/java/zeroecho/core/alg/aes/AesCipherContext.java
+++ b/lib/src/main/java/zeroecho/core/alg/aes/AesCipherContext.java
@@ -56,7 +56,7 @@ import zeroecho.core.context.EncryptionContext;
import zeroecho.core.err.ProviderFailureException;
import zeroecho.core.io.CipherTransformInputStreamBuilder;
import zeroecho.core.spi.ContextAware;
-import zeroecho.sdk.util.RandomSupport;
+import zeroecho.core.util.RandomSupport;
/**
* Streaming AES cipher context for GCM / CBC / CTR.
diff --git a/lib/src/main/java/zeroecho/core/alg/chacha/AbstractChaChaCipherContext.java b/lib/src/main/java/zeroecho/core/alg/chacha/AbstractChaChaCipherContext.java
index d3ed6af..9bda202 100644
--- a/lib/src/main/java/zeroecho/core/alg/chacha/AbstractChaChaCipherContext.java
+++ b/lib/src/main/java/zeroecho/core/alg/chacha/AbstractChaChaCipherContext.java
@@ -54,7 +54,7 @@ import zeroecho.core.context.EncryptionContext;
import zeroecho.core.err.ProviderFailureException;
import zeroecho.core.io.CipherTransformInputStreamBuilder;
import zeroecho.core.spi.ContextAware;
-import zeroecho.sdk.util.RandomSupport;
+import zeroecho.core.util.RandomSupport;
/**
* Abstract streaming cipher context for ChaCha algorithms
diff --git a/lib/src/main/java/zeroecho/core/alg/chacha/ChaCha20Poly1305Algorithm.java b/lib/src/main/java/zeroecho/core/alg/chacha/ChaCha20Poly1305Algorithm.java
index ba1259c..292e46b 100644
--- a/lib/src/main/java/zeroecho/core/alg/chacha/ChaCha20Poly1305Algorithm.java
+++ b/lib/src/main/java/zeroecho/core/alg/chacha/ChaCha20Poly1305Algorithm.java
@@ -34,7 +34,7 @@
package zeroecho.core.alg.chacha;
import zeroecho.core.SymmetricHeaderCodec;
-import zeroecho.sdk.util.RandomSupport;
+import zeroecho.core.util.RandomSupport;
/**
* ChaCha20-Poly1305 (AEAD) algorithm
diff --git a/lib/src/main/java/zeroecho/core/alg/chacha/ChaChaAlgorithm.java b/lib/src/main/java/zeroecho/core/alg/chacha/ChaChaAlgorithm.java
index 6c6c84b..6dd61ad 100644
--- a/lib/src/main/java/zeroecho/core/alg/chacha/ChaChaAlgorithm.java
+++ b/lib/src/main/java/zeroecho/core/alg/chacha/ChaChaAlgorithm.java
@@ -33,7 +33,7 @@
******************************************************************************/
package zeroecho.core.alg.chacha;
-import zeroecho.sdk.util.RandomSupport;
+import zeroecho.core.util.RandomSupport;
/**
* ChaCha20 (stream) algorithm
*
diff --git a/lib/src/main/java/zeroecho/core/spi/KeyringUnlockProvider.java b/lib/src/main/java/zeroecho/core/spi/KeyringUnlockProvider.java
new file mode 100644
index 0000000..46b96b4
--- /dev/null
+++ b/lib/src/main/java/zeroecho/core/spi/KeyringUnlockProvider.java
@@ -0,0 +1,29 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ ******************************************************************************/
+package zeroecho.core.spi;
+
+import java.io.IOException;
+
+import zeroecho.core.storage.KeyringPassword;
+
+/**
+ * Supplies a fresh destroyable password for one keyring open operation.
+ *
+ * Ownership of the returned object transfers to the receiver, which must
+ * destroy it in a {@code finally} block immediately after the keyring has been
+ * opened. Implementations must not source passwords from immutable strings,
+ * process arguments, system properties, environment fallbacks, persistent
+ * files, or global mutable state.
+ */
+@FunctionalInterface
+public interface KeyringUnlockProvider {
+ /**
+ * Supplies unlock material for one operation.
+ *
+ * @return a fresh password owner whose ownership transfers to the receiver
+ * @throws IOException if unlock material cannot be supplied
+ */
+ KeyringPassword acquire() throws IOException;
+}
diff --git a/lib/src/main/java/zeroecho/core/storage/KeyringException.java b/lib/src/main/java/zeroecho/core/storage/KeyringException.java
new file mode 100644
index 0000000..a8c4133
--- /dev/null
+++ b/lib/src/main/java/zeroecho/core/storage/KeyringException.java
@@ -0,0 +1,58 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ ******************************************************************************/
+package zeroecho.core.storage;
+
+import java.io.IOException;
+import java.util.Objects;
+
+/**
+ * Redacted checked failure raised by encrypted keyring operations.
+ *
+ * The public message contains only the stable error code. Filesystem paths,
+ * aliases, key material, ciphertext, and provider-controlled messages are
+ * deliberately excluded.
+ */
+public final class KeyringException extends IOException {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Stable keyring failure categories.
+ */
+ public enum Code {
+ KEYRING_ALREADY_OPEN,
+ KEYRING_FILESYSTEM_UNSUPPORTED,
+ KEYRING_FORMAT_INVALID,
+ KEYRING_LIMIT_EXCEEDED,
+ KEYRING_UNLOCK_FAILED,
+ KEYRING_IO_FAILED,
+ KEYRING_DURABILITY_UNCONFIRMED,
+ KEYRING_CLOSED,
+ KEYRING_NON_EXPORTABLE_KEY,
+ KEYRING_IMPORT_MAPPING_INVALID,
+ KEYRING_IMPORT_METADATA_INVALID,
+ KEYRING_KEY_NOT_CANONICALIZABLE
+ }
+
+ private final Code code;
+
+ /**
+ * Creates a redacted keyring exception.
+ *
+ * @param code stable error code
+ */
+ public KeyringException(Code code) {
+ super(Objects.requireNonNull(code, "code must not be null").name());
+ this.code = code;
+ }
+
+ /**
+ * Returns the stable error code.
+ *
+ * @return failure code
+ */
+ public Code code() {
+ return code;
+ }
+}
diff --git a/lib/src/main/java/zeroecho/core/storage/KeyringFileOperations.java b/lib/src/main/java/zeroecho/core/storage/KeyringFileOperations.java
new file mode 100644
index 0000000..957185e
--- /dev/null
+++ b/lib/src/main/java/zeroecho/core/storage/KeyringFileOperations.java
@@ -0,0 +1,97 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ ******************************************************************************/
+package zeroecho.core.storage;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.FileAttribute;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.Set;
+
+/**
+ * Narrow package-local boundary for atomic keyring persistence operations.
+ */
+interface KeyringFileOperations {
+ KeyringFileOperations NIO = new NioKeyringFileOperations();
+
+ /** Atomic persistence destination. */
+ enum Target {
+ MAIN_IMAGE,
+ NONCE_RESERVATION
+ }
+
+ /** Creates one owner-only temporary file beside its destination. */
+ Path createTemporary(Target target, Path parent, String prefix, String suffix,
+ FileAttribute> permissions) throws IOException;
+
+ /** Writes the complete encrypted image to its temporary file. */
+ void writeTemporary(Target target, Path temporary, byte[] image) throws IOException;
+
+ /** Forces temporary-file contents to durable storage. */
+ void forceTemporary(Target target, Path temporary) throws IOException;
+
+ /** Atomically replaces the destination with the temporary file. */
+ void atomicReplace(Target target, Path temporary, Path destination) throws IOException;
+
+ /** Forces the destination directory after atomic replacement. */
+ void forceDirectory(Target target, Path parent) throws IOException;
+
+ /** Deletes a temporary file that did not become authoritative. */
+ void deleteTemporary(Target target, Path temporary) throws IOException;
+}
+
+/** Production NIO implementation of the atomic persistence boundary. */
+final class NioKeyringFileOperations implements KeyringFileOperations {
+ @Override
+ public Path createTemporary(Target target, Path parent, String prefix, String suffix,
+ FileAttribute> permissions) throws IOException {
+ return Files.createTempFile(parent, prefix, suffix, permissions);
+ }
+
+ @Override
+ public void writeTemporary(Target target, Path temporary, byte[] image)
+ throws IOException {
+ try (FileChannel channel = FileChannel.open(temporary,
+ StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
+ ByteBuffer buffer = ByteBuffer.wrap(image);
+ while (buffer.hasRemaining()) {
+ channel.write(buffer);
+ }
+ }
+ }
+
+ @Override
+ public void forceTemporary(Target target, Path temporary) throws IOException {
+ try (FileChannel channel = FileChannel.open(temporary,
+ StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
+ channel.force(true);
+ }
+ }
+
+ @Override
+ public void atomicReplace(Target target, Path temporary, Path destination)
+ throws IOException {
+ Files.move(temporary, destination, StandardCopyOption.ATOMIC_MOVE,
+ StandardCopyOption.REPLACE_EXISTING);
+ }
+
+ @Override
+ public void forceDirectory(Target target, Path parent) throws IOException {
+ try (FileChannel directory = FileChannel.open(parent, StandardOpenOption.READ)) {
+ directory.force(true);
+ }
+ }
+
+ @Override
+ public void deleteTemporary(Target target, Path temporary) throws IOException {
+ Files.deleteIfExists(temporary);
+ }
+}
diff --git a/lib/src/main/java/zeroecho/core/storage/KeyringImportRegistry.java b/lib/src/main/java/zeroecho/core/storage/KeyringImportRegistry.java
new file mode 100644
index 0000000..552a313
--- /dev/null
+++ b/lib/src/main/java/zeroecho/core/storage/KeyringImportRegistry.java
@@ -0,0 +1,452 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ ******************************************************************************/
+package zeroecho.core.storage;
+
+import java.security.GeneralSecurityException;
+import java.security.Key;
+import java.security.MessageDigest;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+import javax.security.auth.DestroyFailedException;
+import javax.security.auth.Destroyable;
+
+import zeroecho.core.CryptoAlgorithm;
+import zeroecho.core.CryptoAlgorithms;
+import zeroecho.core.KeyOperation;
+import zeroecho.core.KeyOperationInfo;
+import zeroecho.core.alg.aes.AesKeyImportSpec;
+import zeroecho.core.alg.bike.BikePrivateKeySpec;
+import zeroecho.core.alg.bike.BikePublicKeySpec;
+import zeroecho.core.alg.chacha.ChaChaKeyImportSpec;
+import zeroecho.core.alg.cmce.CmcePrivateKeySpec;
+import zeroecho.core.alg.cmce.CmcePublicKeySpec;
+import zeroecho.core.alg.dh.DhPrivateKeySpec;
+import zeroecho.core.alg.dh.DhPublicKeySpec;
+import zeroecho.core.alg.ecdsa.EcdsaPrivateKeySpec;
+import zeroecho.core.alg.ecdsa.EcdsaPublicKeySpec;
+import zeroecho.core.alg.ed25519.Ed25519PrivateKeySpec;
+import zeroecho.core.alg.ed25519.Ed25519PublicKeySpec;
+import zeroecho.core.alg.ed448.Ed448PrivateKeySpec;
+import zeroecho.core.alg.ed448.Ed448PublicKeySpec;
+import zeroecho.core.alg.elgamal.ElgamalPrivateKeySpec;
+import zeroecho.core.alg.elgamal.ElgamalPublicKeySpec;
+import zeroecho.core.alg.frodo.FrodoPrivateKeySpec;
+import zeroecho.core.alg.frodo.FrodoPublicKeySpec;
+import zeroecho.core.alg.hmac.HmacKeyImportSpec;
+import zeroecho.core.alg.hqc.HqcPrivateKeySpec;
+import zeroecho.core.alg.hqc.HqcPublicKeySpec;
+import zeroecho.core.alg.kyber.KyberPrivateKeySpec;
+import zeroecho.core.alg.kyber.KyberPublicKeySpec;
+import zeroecho.core.alg.mldsa.MldsaPrivateKeySpec;
+import zeroecho.core.alg.mldsa.MldsaPublicKeySpec;
+import zeroecho.core.alg.ntru.NtruPrivateKeySpec;
+import zeroecho.core.alg.ntru.NtruPublicKeySpec;
+import zeroecho.core.alg.ntruprime.NtrulPrimePrivateKeySpec;
+import zeroecho.core.alg.ntruprime.NtrulPrimePublicKeySpec;
+import zeroecho.core.alg.ntruprime.SntruPrimePrivateKeySpec;
+import zeroecho.core.alg.ntruprime.SntruPrimePublicKeySpec;
+import zeroecho.core.alg.rsa.RsaPrivateKeySpec;
+import zeroecho.core.alg.rsa.RsaPublicKeySpec;
+import zeroecho.core.alg.saber.SaberPrivateKeySpec;
+import zeroecho.core.alg.saber.SaberPublicKeySpec;
+import zeroecho.core.alg.slhdsa.SlhDsaPrivateKeySpec;
+import zeroecho.core.alg.slhdsa.SlhDsaPublicKeySpec;
+import zeroecho.core.alg.sphincsplus.SphincsPlusPrivateKeySpec;
+import zeroecho.core.alg.sphincsplus.SphincsPlusPublicKeySpec;
+import zeroecho.core.alg.xdh.XdhPrivateKeySpec;
+import zeroecho.core.alg.xdh.XdhPublicKeySpec;
+import zeroecho.core.spec.AlgorithmKeySpec;
+import zeroecho.core.spi.PrivateKeyImporter;
+import zeroecho.core.spi.PublicKeyImporter;
+import zeroecho.core.spi.SymmetricKeyImporter;
+
+/**
+ * Closed trusted mapping from persistent key identities to canonical registered
+ * import operations.
+ *
+ * Provider identity is deliberately absent. Standard encoded key material
+ * is reconstructed by the current runtime's canonical ZeroEcho importer. The
+ * original JCA provider is neither persisted nor reproduced.
+ */
+final class KeyringImportRegistry {
+ private static final String ALGORITHM_AES = "AES";
+ private static final String ALGORITHM_HMAC = "HMAC";
+ private static final String ALGORITHM_CHACHA20 = "CHACHA20";
+ private static final String ALGORITHM_CHACHA20_POLY1305 = "CHACHA20-POLY1305";
+ private static final Map,
+ Function> SPEC_FACTORIES =
+ createSpecFactories();
+ private static final Map MAPPINGS = createMappings();
+
+ private KeyringImportRegistry() {
+ }
+
+ /**
+ * Closed HMAC variants admitted by the persistent format.
+ */
+ /* default */
+ enum HmacVariant {
+ NONE(0, null),
+ SHA256(1, "HmacSHA256"),
+ SHA384(2, "HmacSHA384"),
+ SHA512(3, "HmacSHA512");
+
+ private final int code;
+ private final String jcaName;
+
+ HmacVariant(int code, String jcaName) {
+ this.code = code;
+ this.jcaName = jcaName;
+ }
+
+ /* default */ int code() {
+ return code;
+ }
+
+ /* default */ String jcaName() {
+ return jcaName;
+ }
+
+ /* default */ static HmacVariant fromCode(int code) throws KeyringException {
+ for (HmacVariant value : values()) {
+ if (value.code == code) {
+ return value;
+ }
+ }
+ throw new KeyringException(KeyringException.Code.KEYRING_IMPORT_METADATA_INVALID);
+ }
+
+ /* default */ static HmacVariant forStoredKey(String algorithmId, Key key)
+ throws KeyringException {
+ if (!ALGORITHM_HMAC.equals(algorithmId)) {
+ return NONE;
+ }
+ String algorithm = key.getAlgorithm();
+ for (HmacVariant value : values()) {
+ if (value != NONE && value.jcaName.equals(algorithm)) {
+ return value;
+ }
+ }
+ throw new KeyringException(KeyringException.Code.KEYRING_IMPORT_METADATA_INVALID);
+ }
+ }
+
+ /**
+ * Immutable description used by the finite importer-matrix test.
+ *
+ * @param algorithmId canonical ZeroEcho algorithm identifier
+ * @param kind key kind
+ * @param encoding standard encoding
+ * @param hmacVariant closed HMAC variant, or {@link HmacVariant#NONE}
+ * @param specType exact registered importer specification type
+ */
+ /* default */
+ record PersistentMapping(String algorithmId, KeyringStore.Kind kind,
+ KeyringStore.Encoding encoding, HmacVariant hmacVariant,
+ Class extends AlgorithmKeySpec> specType) {
+ }
+
+ @SuppressWarnings("PMD.AvoidCatchingGenericException")
+ /* default */ static Key importKey(String algorithmId, KeyringStore.Kind kind,
+ KeyringStore.Encoding encoding, HmacVariant hmacVariant, byte[] encoded)
+ throws GeneralSecurityException, KeyringException {
+ PersistentMapping mapping = requireMapping(algorithmId, kind, encoding, hmacVariant);
+ CryptoAlgorithm algorithm = requireAlgorithm(mapping.algorithmId);
+ AlgorithmKeySpec spec = createSpec(mapping, encoded);
+ Throwable primary = null;
+ try {
+ return invokeImporter(algorithm, kind, spec);
+ } catch (GeneralSecurityException | RuntimeException exception) {
+ primary = exception;
+ throw exception;
+ } finally {
+ KeyringStore.destroyTemporarySpec(spec, primary);
+ }
+ }
+
+ /* default */ static void validateMapping(String algorithmId, KeyringStore.Kind kind,
+ KeyringStore.Encoding encoding, HmacVariant hmacVariant)
+ throws KeyringException {
+ PersistentMapping mapping = requireMapping(algorithmId, kind, encoding, hmacVariant);
+ CryptoAlgorithm algorithm = requireAlgorithm(mapping.algorithmId);
+ List operations = matchingOperations(algorithm, kind);
+ if (operations.size() != 1 || operations.get(0).specType() != mapping.specType) {
+ throw new KeyringException(KeyringException.Code.KEYRING_IMPORT_MAPPING_INVALID);
+ }
+ }
+
+ @SuppressWarnings({ "PMD.PreserveStackTrace", "PMD.AvoidCatchingGenericException" })
+ /* default */ static void validateCanonical(String algorithmId, KeyringStore.Kind kind,
+ KeyringStore.Encoding encoding, HmacVariant hmacVariant, Key source,
+ byte[] encoded)
+ throws KeyringException {
+ Key imported = null;
+ byte[] canonical = null;
+ try {
+ if (!matchesSourceAlgorithm(source, algorithmId, hmacVariant)) {
+ throw new KeyringException(
+ KeyringException.Code.KEYRING_KEY_NOT_CANONICALIZABLE);
+ }
+ imported = importKey(algorithmId, kind, encoding, hmacVariant, encoded);
+ canonical = imported.getEncoded();
+ if (canonical == null || !matchesFormat(imported.getFormat(), encoding)
+ || !MessageDigest.isEqual(encoded, canonical)
+ || !matchesAlgorithm(imported, algorithmId, hmacVariant)) {
+ throw new KeyringException(
+ KeyringException.Code.KEYRING_KEY_NOT_CANONICALIZABLE);
+ }
+ } catch (GeneralSecurityException | RuntimeException exception) {
+ throw new KeyringException(
+ KeyringException.Code.KEYRING_KEY_NOT_CANONICALIZABLE);
+ } finally {
+ if (canonical != null) {
+ Arrays.fill(canonical, (byte) 0);
+ }
+ destroyImported(imported);
+ }
+ }
+
+ /* default */ static List mappings() {
+ return List.copyOf(MAPPINGS.values());
+ }
+
+ private static PersistentMapping requireMapping(String algorithmId,
+ KeyringStore.Kind kind, KeyringStore.Encoding encoding,
+ HmacVariant hmacVariant) throws KeyringException {
+ PersistentMapping mapping = MAPPINGS.get(
+ new Tuple(algorithmId, kind, encoding, hmacVariant));
+ if (mapping == null) {
+ throw new KeyringException(KeyringException.Code.KEYRING_IMPORT_MAPPING_INVALID);
+ }
+ return mapping;
+ }
+
+ @SuppressWarnings("PMD.PreserveStackTrace")
+ private static CryptoAlgorithm requireAlgorithm(String algorithmId)
+ throws KeyringException {
+ try {
+ return CryptoAlgorithms.require(algorithmId);
+ } catch (IllegalArgumentException exception) {
+ throw new KeyringException(KeyringException.Code.KEYRING_IMPORT_MAPPING_INVALID);
+ }
+ }
+
+ private static List matchingOperations(CryptoAlgorithm algorithm,
+ KeyringStore.Kind kind) {
+ return algorithm.keyOperations().stream()
+ .filter(info -> info.operation() == operation(kind))
+ .toList();
+ }
+
+ private static KeyOperation operation(KeyringStore.Kind kind) {
+ return switch (kind) {
+ case PUBLIC_KEY -> KeyOperation.ASYMMETRIC_PUBLIC_IMPORT;
+ case PRIVATE_KEY -> KeyOperation.ASYMMETRIC_PRIVATE_IMPORT;
+ case SECRET_KEY -> KeyOperation.SYMMETRIC_IMPORT;
+ };
+ }
+
+ @SuppressWarnings({ "rawtypes", "unchecked" })
+ private static Key invokeImporter(CryptoAlgorithm algorithm, KeyringStore.Kind kind,
+ AlgorithmKeySpec spec) throws GeneralSecurityException {
+ return switch (kind) {
+ case PUBLIC_KEY -> ((PublicKeyImporter) algorithm.publicKeyImporter(spec.getClass()))
+ .importPublic(spec);
+ case PRIVATE_KEY -> ((PrivateKeyImporter) algorithm.privateKeyImporter(spec.getClass()))
+ .importPrivate(spec);
+ case SECRET_KEY -> ((SymmetricKeyImporter) algorithm.symmetricKeyImporter(spec.getClass()))
+ .importSecret(spec);
+ };
+ }
+
+ private static AlgorithmKeySpec createSpec(PersistentMapping mapping, byte[] encoded)
+ throws KeyringException {
+ if (mapping.specType == HmacKeyImportSpec.class) {
+ return new HmacKeyImportSpec(mapping.hmacVariant.jcaName, encoded);
+ }
+ Function factory =
+ SPEC_FACTORIES.get(mapping.specType);
+ AlgorithmKeySpec result = factory == null ? null : factory.apply(encoded);
+ if (result == null) {
+ throw new KeyringException(KeyringException.Code.KEYRING_IMPORT_MAPPING_INVALID);
+ }
+ return result;
+ }
+
+ private static boolean matchesFormat(String actual, KeyringStore.Encoding encoding) {
+ return actual != null && switch (encoding) {
+ case X509 -> "X.509".equalsIgnoreCase(actual) || "X509".equalsIgnoreCase(actual);
+ case PKCS8 -> "PKCS#8".equalsIgnoreCase(actual) || "PKCS8".equalsIgnoreCase(actual);
+ case RAW -> "RAW".equalsIgnoreCase(actual);
+ };
+ }
+
+ private static boolean matchesAlgorithm(Key imported, String algorithmId,
+ HmacVariant hmacVariant) {
+ if (ALGORITHM_HMAC.equals(algorithmId)) {
+ return hmacVariant.jcaName.equals(imported.getAlgorithm());
+ }
+ if (ALGORITHM_AES.equals(algorithmId)) {
+ return ALGORITHM_AES.equals(imported.getAlgorithm());
+ }
+ if (ALGORITHM_CHACHA20.equals(algorithmId)
+ || ALGORITHM_CHACHA20_POLY1305.equals(algorithmId)) {
+ return "ChaCha20".equals(imported.getAlgorithm());
+ }
+ return true;
+ }
+
+ private static boolean matchesSourceAlgorithm(Key source, String algorithmId,
+ HmacVariant hmacVariant) {
+ if (ALGORITHM_HMAC.equals(algorithmId)) {
+ return hmacVariant.jcaName.equals(source.getAlgorithm());
+ }
+ if (ALGORITHM_AES.equals(algorithmId)) {
+ return ALGORITHM_AES.equals(source.getAlgorithm());
+ }
+ if (ALGORITHM_CHACHA20.equals(algorithmId)
+ || ALGORITHM_CHACHA20_POLY1305.equals(algorithmId)) {
+ return "ChaCha20".equals(source.getAlgorithm());
+ }
+ return true;
+ }
+
+ @SuppressWarnings("PMD.EmptyCatchBlock")
+ private static void destroyImported(Key imported) {
+ if (imported instanceof Destroyable destroyable) {
+ try {
+ destroyable.destroy();
+ } catch (DestroyFailedException exception) {
+ // Some JCA key implementations advertise but do not support destruction.
+ }
+ }
+ }
+
+ private static Map,
+ Function> createSpecFactories() {
+ return Map.ofEntries(
+ Map.entry(AesKeyImportSpec.class, AesKeyImportSpec::fromRaw),
+ Map.entry(ChaChaKeyImportSpec.class, ChaChaKeyImportSpec::fromRaw),
+ Map.entry(BikePublicKeySpec.class, BikePublicKeySpec::new),
+ Map.entry(BikePrivateKeySpec.class, BikePrivateKeySpec::new),
+ Map.entry(CmcePublicKeySpec.class, CmcePublicKeySpec::new),
+ Map.entry(CmcePrivateKeySpec.class, CmcePrivateKeySpec::new),
+ Map.entry(DhPublicKeySpec.class, DhPublicKeySpec::new),
+ Map.entry(DhPrivateKeySpec.class, DhPrivateKeySpec::new),
+ Map.entry(EcdsaPublicKeySpec.class, EcdsaPublicKeySpec::new),
+ Map.entry(EcdsaPrivateKeySpec.class, EcdsaPrivateKeySpec::new),
+ Map.entry(Ed25519PublicKeySpec.class, Ed25519PublicKeySpec::new),
+ Map.entry(Ed25519PrivateKeySpec.class, Ed25519PrivateKeySpec::new),
+ Map.entry(Ed448PublicKeySpec.class, Ed448PublicKeySpec::new),
+ Map.entry(Ed448PrivateKeySpec.class, Ed448PrivateKeySpec::new),
+ Map.entry(ElgamalPublicKeySpec.class, ElgamalPublicKeySpec::new),
+ Map.entry(ElgamalPrivateKeySpec.class, ElgamalPrivateKeySpec::new),
+ Map.entry(FrodoPublicKeySpec.class, FrodoPublicKeySpec::new),
+ Map.entry(FrodoPrivateKeySpec.class, FrodoPrivateKeySpec::new),
+ Map.entry(HqcPublicKeySpec.class, HqcPublicKeySpec::new),
+ Map.entry(HqcPrivateKeySpec.class, HqcPrivateKeySpec::new),
+ Map.entry(KyberPublicKeySpec.class, KyberPublicKeySpec::new),
+ Map.entry(KyberPrivateKeySpec.class, KyberPrivateKeySpec::new),
+ Map.entry(MldsaPublicKeySpec.class, MldsaPublicKeySpec::new),
+ Map.entry(MldsaPrivateKeySpec.class, MldsaPrivateKeySpec::new),
+ Map.entry(NtruPublicKeySpec.class, NtruPublicKeySpec::new),
+ Map.entry(NtruPrivateKeySpec.class, NtruPrivateKeySpec::new),
+ Map.entry(NtrulPrimePublicKeySpec.class, NtrulPrimePublicKeySpec::new),
+ Map.entry(NtrulPrimePrivateKeySpec.class, NtrulPrimePrivateKeySpec::new),
+ Map.entry(SntruPrimePublicKeySpec.class, SntruPrimePublicKeySpec::new),
+ Map.entry(SntruPrimePrivateKeySpec.class, SntruPrimePrivateKeySpec::new),
+ Map.entry(RsaPublicKeySpec.class, RsaPublicKeySpec::new),
+ Map.entry(RsaPrivateKeySpec.class, RsaPrivateKeySpec::new),
+ Map.entry(SaberPublicKeySpec.class, SaberPublicKeySpec::new),
+ Map.entry(SaberPrivateKeySpec.class, SaberPrivateKeySpec::new),
+ Map.entry(SlhDsaPublicKeySpec.class, SlhDsaPublicKeySpec::new),
+ Map.entry(SlhDsaPrivateKeySpec.class, SlhDsaPrivateKeySpec::new),
+ Map.entry(SphincsPlusPublicKeySpec.class, SphincsPlusPublicKeySpec::new),
+ Map.entry(SphincsPlusPrivateKeySpec.class, SphincsPlusPrivateKeySpec::new),
+ Map.entry(XdhPublicKeySpec.class, XdhPublicKeySpec::new),
+ Map.entry(XdhPrivateKeySpec.class, XdhPrivateKeySpec::new));
+ }
+
+ private static Map createMappings() {
+ Map mappings = new LinkedHashMap<>();
+ addAsymmetric(mappings, "BIKE", BikePublicKeySpec.class, BikePrivateKeySpec.class);
+ addAsymmetric(mappings, "CMCE", CmcePublicKeySpec.class, CmcePrivateKeySpec.class);
+ addAsymmetric(mappings, "DH", DhPublicKeySpec.class, DhPrivateKeySpec.class);
+ addAsymmetric(mappings, "ECDSA", EcdsaPublicKeySpec.class, EcdsaPrivateKeySpec.class);
+ addAsymmetric(mappings, "Ed25519", Ed25519PublicKeySpec.class,
+ Ed25519PrivateKeySpec.class);
+ addAsymmetric(mappings, "Ed448", Ed448PublicKeySpec.class,
+ Ed448PrivateKeySpec.class);
+ addAsymmetric(mappings, "ElGamal", ElgamalPublicKeySpec.class,
+ ElgamalPrivateKeySpec.class);
+ addAsymmetric(mappings, "Frodo", FrodoPublicKeySpec.class,
+ FrodoPrivateKeySpec.class);
+ addAsymmetric(mappings, "HQC", HqcPublicKeySpec.class, HqcPrivateKeySpec.class);
+ addAsymmetric(mappings, "ML-KEM", KyberPublicKeySpec.class,
+ KyberPrivateKeySpec.class);
+ addAsymmetric(mappings, "ML-DSA", MldsaPublicKeySpec.class,
+ MldsaPrivateKeySpec.class);
+ addAsymmetric(mappings, "NTRU", NtruPublicKeySpec.class, NtruPrivateKeySpec.class);
+ addAsymmetric(mappings, "NTRULPRime", NtrulPrimePublicKeySpec.class,
+ NtrulPrimePrivateKeySpec.class);
+ addAsymmetric(mappings, "SNTRUPrime", SntruPrimePublicKeySpec.class,
+ SntruPrimePrivateKeySpec.class);
+ addAsymmetric(mappings, "RSA", RsaPublicKeySpec.class, RsaPrivateKeySpec.class);
+ addAsymmetric(mappings, "SABER", SaberPublicKeySpec.class,
+ SaberPrivateKeySpec.class);
+ addAsymmetric(mappings, "SLH-DSA", SlhDsaPublicKeySpec.class,
+ SlhDsaPrivateKeySpec.class);
+ addAsymmetric(mappings, "SPHINCS+", SphincsPlusPublicKeySpec.class,
+ SphincsPlusPrivateKeySpec.class);
+ addAsymmetric(mappings, "Xdh", XdhPublicKeySpec.class, XdhPrivateKeySpec.class);
+ add(mappings, ALGORITHM_AES, KeyringStore.Kind.SECRET_KEY,
+ KeyringStore.Encoding.RAW,
+ HmacVariant.NONE, AesKeyImportSpec.class);
+ add(mappings, ALGORITHM_CHACHA20, KeyringStore.Kind.SECRET_KEY,
+ KeyringStore.Encoding.RAW,
+ HmacVariant.NONE, ChaChaKeyImportSpec.class);
+ add(mappings, ALGORITHM_CHACHA20_POLY1305, KeyringStore.Kind.SECRET_KEY,
+ KeyringStore.Encoding.RAW, HmacVariant.NONE, ChaChaKeyImportSpec.class);
+ add(mappings, ALGORITHM_HMAC, KeyringStore.Kind.SECRET_KEY,
+ KeyringStore.Encoding.RAW,
+ HmacVariant.SHA256, HmacKeyImportSpec.class);
+ add(mappings, ALGORITHM_HMAC, KeyringStore.Kind.SECRET_KEY,
+ KeyringStore.Encoding.RAW,
+ HmacVariant.SHA384, HmacKeyImportSpec.class);
+ add(mappings, ALGORITHM_HMAC, KeyringStore.Kind.SECRET_KEY,
+ KeyringStore.Encoding.RAW,
+ HmacVariant.SHA512, HmacKeyImportSpec.class);
+ return Collections.unmodifiableMap(mappings);
+ }
+
+ private static void addAsymmetric(Map mappings,
+ String algorithmId, Class extends AlgorithmKeySpec> publicSpec,
+ Class extends AlgorithmKeySpec> privateSpec) {
+ add(mappings, algorithmId, KeyringStore.Kind.PUBLIC_KEY,
+ KeyringStore.Encoding.X509, HmacVariant.NONE, publicSpec);
+ add(mappings, algorithmId, KeyringStore.Kind.PRIVATE_KEY,
+ KeyringStore.Encoding.PKCS8, HmacVariant.NONE, privateSpec);
+ }
+
+ private static void add(Map mappings, String algorithmId,
+ KeyringStore.Kind kind, KeyringStore.Encoding encoding,
+ HmacVariant hmacVariant, Class extends AlgorithmKeySpec> specType) {
+ Tuple tuple = new Tuple(algorithmId, kind, encoding, hmacVariant);
+ PersistentMapping mapping = new PersistentMapping(algorithmId, kind,
+ encoding, hmacVariant, specType);
+ if (mappings.put(tuple, mapping) != null) {
+ throw new IllegalStateException("Duplicate persistent key importer tuple");
+ }
+ }
+
+ private record Tuple(String algorithmId, KeyringStore.Kind kind,
+ KeyringStore.Encoding encoding, HmacVariant hmacVariant) {
+ }
+}
diff --git a/lib/src/main/java/zeroecho/core/storage/KeyringNonceReservationKdf.java b/lib/src/main/java/zeroecho/core/storage/KeyringNonceReservationKdf.java
new file mode 100644
index 0000000..ff0872b
--- /dev/null
+++ b/lib/src/main/java/zeroecho/core/storage/KeyringNonceReservationKdf.java
@@ -0,0 +1,76 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ ******************************************************************************/
+package zeroecho.core.storage;
+
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.util.Arrays;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * Fixed-purpose RFC 5869 derivation for nonce-reservation authentication.
+ */
+final class KeyringNonceReservationKdf {
+ private static final int MASTER_KEY_BYTES = 32;
+ private static final int STORE_ID_BYTES = 16;
+ private static final int OUTPUT_BYTES = 32;
+ private static final String HMAC_SHA256 = "HmacSHA256";
+ private static final String DOMAIN_LABEL =
+ "zeroecho:keyring:nonce-reservation-mac:v1";
+
+ private KeyringNonceReservationKdf() {
+ }
+
+ /**
+ * Derives the store-specific nonce-reservation MAC key.
+ *
+ * @param masterKey borrowed 256-bit store master key
+ * @param storeId borrowed canonical 128-bit binary store UUID
+ * @return newly owned 256-bit derived key
+ * @throws GeneralSecurityException if HMAC-SHA-256 is unavailable
+ */
+ /* default */ static byte[] derive(byte[] masterKey, byte[] storeId)
+ throws GeneralSecurityException {
+ if (masterKey == null || masterKey.length != MASTER_KEY_BYTES
+ || storeId == null || storeId.length != STORE_ID_BYTES) {
+ throw new IllegalArgumentException("Invalid keyring derivation input");
+ }
+ byte[] salt = storeId.clone();
+ byte[] info = DOMAIN_LABEL.getBytes(StandardCharsets.US_ASCII);
+ byte[] prk = null;
+ byte[] expandInput = null;
+ try {
+ prk = hmac(salt, masterKey);
+ expandInput = Arrays.copyOf(info, info.length + 1);
+ expandInput[expandInput.length - 1] = 1;
+ byte[] output = hmac(prk, expandInput);
+ if (output.length != OUTPUT_BYTES) {
+ Arrays.fill(output, (byte) 0);
+ throw new GeneralSecurityException("KEYRING_DERIVATION_FAILED");
+ }
+ return output;
+ } finally {
+ Arrays.fill(salt, (byte) 0);
+ Arrays.fill(info, (byte) 0);
+ wipe(prk);
+ wipe(expandInput);
+ }
+ }
+
+ private static byte[] hmac(byte[] key, byte[] input)
+ throws GeneralSecurityException {
+ Mac mac = Mac.getInstance(HMAC_SHA256);
+ mac.init(new SecretKeySpec(key, HMAC_SHA256));
+ return mac.doFinal(input);
+ }
+
+ private static void wipe(byte[] value) {
+ if (value != null) {
+ Arrays.fill(value, (byte) 0);
+ }
+ }
+}
diff --git a/lib/src/main/java/zeroecho/core/storage/KeyringPassword.java b/lib/src/main/java/zeroecho/core/storage/KeyringPassword.java
new file mode 100644
index 0000000..2eb7049
--- /dev/null
+++ b/lib/src/main/java/zeroecho/core/storage/KeyringPassword.java
@@ -0,0 +1,120 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ ******************************************************************************/
+package zeroecho.core.storage;
+
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.concurrent.locks.ReentrantLock;
+
+import javax.security.auth.DestroyFailedException;
+import javax.security.auth.Destroyable;
+
+/**
+ * Destroyable owner of a keyring password.
+ *
+ * The constructor and {@link #copy()} use defensive copies. Callers retain
+ * ownership of the array supplied to the constructor and must clear it. The
+ * returned copy belongs to the receiver and must be cleared immediately after
+ * key derivation. This object never creates an immutable password
+ * {@link String}.
+ *
+ * Instances are thread-safe. Destruction is idempotent and makes subsequent
+ * access fail deterministically.
+ */
+public final class KeyringPassword implements Destroyable, AutoCloseable {
+ private final ReentrantLock lifecycleLock = new ReentrantLock();
+ private final char[] password;
+ private boolean destroyed;
+
+ /**
+ * Creates a password owner.
+ *
+ * @param password password characters, which are defensively copied
+ * @throws NullPointerException if {@code password} is {@code null}
+ * @throws IllegalArgumentException if {@code password} is empty
+ */
+ @SuppressWarnings("PMD.UseVarargs")
+ public KeyringPassword(char[] password) {
+ Objects.requireNonNull(password, "password must not be null");
+ if (password.length == 0) {
+ throw new IllegalArgumentException("password must not be empty");
+ }
+ this.password = password.clone();
+ }
+
+ /**
+ * Returns a receiver-owned password copy.
+ *
+ * @return a new mutable array that the receiver must clear
+ * @throws IllegalStateException if this object has been destroyed
+ */
+ public char[] copy() {
+ lifecycleLock.lock();
+ try {
+ if (destroyed) {
+ throw new IllegalStateException("Keyring password is destroyed");
+ }
+ return password.clone();
+ } finally {
+ lifecycleLock.unlock();
+ }
+ }
+
+ /**
+ * Clears the owned password characters.
+ *
+ * @throws DestroyFailedException never thrown by this implementation
+ */
+ @Override
+ public void destroy() throws DestroyFailedException {
+ lifecycleLock.lock();
+ try {
+ if (!destroyed) {
+ Arrays.fill(password, '\0');
+ destroyed = true;
+ }
+ } finally {
+ lifecycleLock.unlock();
+ }
+ }
+
+ /**
+ * Reports whether the password has been destroyed.
+ *
+ * @return {@code true} after destruction
+ */
+ @Override
+ public boolean isDestroyed() {
+ lifecycleLock.lock();
+ try {
+ return destroyed;
+ } finally {
+ lifecycleLock.unlock();
+ }
+ }
+
+ /**
+ * Destroys the password.
+ */
+ @Override
+ @SuppressWarnings("PMD.PreserveStackTrace")
+ public void close() {
+ try {
+ destroy();
+ } catch (DestroyFailedException impossible) {
+ throw new IllegalStateException("Keyring password destruction failed");
+ }
+ }
+
+ /**
+ * Returns a redacted representation.
+ *
+ * @return a constant redacted value
+ */
+ @Override
+ public String toString() {
+ return "KeyringPassword[REDACTED]";
+ }
+}
diff --git a/lib/src/main/java/zeroecho/core/storage/KeyringProtection.java b/lib/src/main/java/zeroecho/core/storage/KeyringProtection.java
new file mode 100644
index 0000000..bfea301
--- /dev/null
+++ b/lib/src/main/java/zeroecho/core/storage/KeyringProtection.java
@@ -0,0 +1,42 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ ******************************************************************************/
+package zeroecho.core.storage;
+
+/**
+ * Operational limits applied while opening an encrypted software keyring.
+ *
+ * @param operationalIterationMaximum maximum accepted PBKDF2 iteration count;
+ * it may restrict but never exceed the absolute decoded maximum
+ */
+public record KeyringProtection(int operationalIterationMaximum) {
+ /** Iterations used when a new keyring is created. */
+ public static final int CREATION_ITERATIONS = 600_000;
+ /** Largest operationally accepted PBKDF2 iteration count. */
+ public static final int MAX_OPERATIONAL_ITERATIONS = 1_000_000;
+ /** Absolute safety ceiling for decoded PBKDF2 iteration counts. */
+ public static final int MAX_DECODED_ITERATIONS = 10_000_000;
+
+ /**
+ * Validates the operational limit.
+ *
+ * @throws IllegalArgumentException if the limit is below the creation
+ * setting or above the absolute operational maximum
+ */
+ public KeyringProtection {
+ if (operationalIterationMaximum < CREATION_ITERATIONS
+ || operationalIterationMaximum > MAX_OPERATIONAL_ITERATIONS) {
+ throw new IllegalArgumentException("operationalIterationMaximum must be in [600000,1000000]");
+ }
+ }
+
+ /**
+ * Returns the standard protection policy.
+ *
+ * @return policy accepting up to one million iterations
+ */
+ public static KeyringProtection standard() {
+ return new KeyringProtection(MAX_OPERATIONAL_ITERATIONS);
+ }
+}
diff --git a/lib/src/main/java/zeroecho/core/storage/KeyringRandomBytes.java b/lib/src/main/java/zeroecho/core/storage/KeyringRandomBytes.java
new file mode 100644
index 0000000..7223bc3
--- /dev/null
+++ b/lib/src/main/java/zeroecho/core/storage/KeyringRandomBytes.java
@@ -0,0 +1,21 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ ******************************************************************************/
+package zeroecho.core.storage;
+
+/**
+ * Fills keyring randomness buffers.
+ *
+ * This package-private seam supports deterministic format tests; production
+ * creation uses the authoritative shared secure random source.
+ */
+@FunctionalInterface
+interface KeyringRandomBytes {
+ /**
+ * Fills a destination buffer.
+ *
+ * @param destination buffer to fill completely
+ */
+ void nextBytes(byte[] destination);
+}
diff --git a/lib/src/main/java/zeroecho/core/storage/KeyringStore.java b/lib/src/main/java/zeroecho/core/storage/KeyringStore.java
index cadd102..d92baae 100644
--- a/lib/src/main/java/zeroecho/core/storage/KeyringStore.java
+++ b/lib/src/main/java/zeroecho/core/storage/KeyringStore.java
@@ -1,427 +1,1432 @@
/*******************************************************************************
* 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 java.io.BufferedReader;
-import java.io.BufferedWriter;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.EOFException;
import java.io.IOException;
-import java.io.StringReader;
-import java.io.StringWriter;
-import java.io.Writer;
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
+import java.nio.channels.FileChannel;
+import java.nio.channels.FileLock;
+import java.nio.channels.OverlappingFileLockException;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.FileStore;
import java.nio.file.Files;
+import java.nio.file.LinkOption;
import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.FileAttribute;
+import java.nio.file.attribute.PosixFileAttributeView;
+import java.nio.file.attribute.PosixFileAttributes;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.nio.file.attribute.UserPrincipal;
import java.security.GeneralSecurityException;
+import java.security.Key;
+import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.ArrayList;
-import java.util.Collection;
+import java.util.Arrays;
+import java.util.EnumSet;
+import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+import javax.crypto.AEADBadTagException;
+import javax.crypto.Cipher;
+import javax.crypto.Mac;
import javax.crypto.SecretKey;
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.PBEKeySpec;
+import javax.crypto.spec.SecretKeySpec;
import javax.security.auth.DestroyFailedException;
import javax.security.auth.Destroyable;
-import zeroecho.core.CryptoAlgorithm;
-import zeroecho.core.KeyOperation;
-import zeroecho.core.KeyOperationInfo;
-import zeroecho.core.marshal.PairSeq;
-import zeroecho.core.spec.AlgorithmKeySpec;
-import zeroecho.sdk.ZeroEchoSession;
+import zeroecho.core.storage.KeyringException.Code;
+import zeroecho.core.storage.KeyringFileOperations.Target;
+import zeroecho.core.util.RandomSupport;
/**
- * Human-editable keyring persisted in a simple UTF-8 text format.
+ * Path-bound encrypted local software keystore.
*
- * File format
Each file starts with a magic header followed by one or
- * more entries. Lines starting with the hash character are comments. A blank
- * line terminates the current entry. Keys belonging to the key-spec payload are
- * prefixed with "s." to avoid collisions with top-level metadata.
+ * A keyring uses PBKDF2-HMAC-SHA-256 to unwrap one random 256-bit store
+ * master key. Every entry and the ordered manifest are independently protected
+ * by AES-256-GCM. Java class names are neither persisted nor resolved. Import
+ * mappings come exclusively from a closed mapping to the immutable algorithm
+ * registry. Provider names are not persisted: standard key encodings are
+ * reconstructed by the current runtime's canonical importer. HMAC persistence
+ * admits only HmacSHA256, HmacSHA384, and HmacSHA512.
*
- * {@code
- * # KeyringStore v1
- * @entry
- * alias=my-rsa
- * algorithm=RSA
- * kind=public|private|secret
- * spec=zeroecho.core.alg.rsa.RsaPublicKeySpec
- * s.x509B64=MIIBIjANBgkqh...
- * s.note=optional
+ * The keyring exclusively owns its filesystem path from successful
+ * {@link #create(Path, KeyringPassword)} or
+ * {@link #open(Path, KeyringPassword)} until {@link #close()}. Only POSIX
+ * filesystems on which owner-only permissions and ownership can be verified are
+ * supported. Mutations replace a complete encrypted image atomically.
*
- * @entry
- * ...
- * }
+ * The nonce-reservation sidecar is authenticated independently from AES
+ * entry and manifest encryption. Its 256-bit MAC key is derived from the store
+ * master key and binary store UUID with HKDF-HMAC-SHA-256 and the fixed domain
+ * label {@code zeroecho:keyring:nonce-reservation-mac:v1}. The derived key is
+ * retained only while the store is open and is cleared on close. Previous
+ * sidecar versions are rejected.
*
- * Reading and writing
Use {@link #save(java.nio.file.Path)} to write
- * the keyring to disk and {@link #load(ZeroEchoSession, java.nio.file.Path)} to
- * read it back.
- * The loader tolerates the presence of the header and comment lines but
- * requires the magic header for the v1 format.
- *
- * Spec marshaling
A record holds a fully qualified spec class name and
- * a key-value payload. The store relies on a pair of static utility methods
- * that must be implemented by each spec type:
- *
- * - {@code static PairSeq marshal(SpecType spec)}
- * - {@code static SpecType unmarshal(PairSeq pairs)}
- *
- * These are discovered and invoked via reflection. See
- * {@link #marshalSpec(AlgorithmKeySpec)} and
- * {@link #unmarshalSpec(Class, PairSeq)} for details.
- *
- * Basic usage
{@code
- * ZeroEchoSession session = new ZeroEchoSession();
- * KeyringStore ks = new KeyringStore(session);
- * ks.putPublic("site-signing", "Ed25519", myEd25519PublicSpec);
- * ks.putPrivate("site-signing", "Ed25519", myEd25519PrivateSpec);
- * ks.save(Path.of("keyring.txt"));
- *
- * KeyringStore reloaded = KeyringStore.load(session, Path.of("keyring.txt"));
- * PublicKey pub = reloaded.getPublic("site-signing");
- * }
+ * Instances are thread-safe. Reads may proceed concurrently; mutations and
+ * close are exclusive. Closing clears the master and sidecar MAC keys and makes
+ * all subsequent operations fail. The pre-release plaintext format is rejected
+ * and is not migrated.
*/
-public final class KeyringStore { // NOPMD
+// The store intentionally centralizes its closed format, crypto, and lifecycle types.
+@SuppressWarnings("PMD.CouplingBetweenObjects")
+public final class KeyringStore implements AutoCloseable, Destroyable { // NOPMD
+ /* default */ static final byte[] MAGIC = { 'Z', 'E', 'K', 'R', 'I', 'N', 'G', '2' };
+ /* default */ static final int FORMAT_VERSION = 2;
+ /* default */ static final int ENTRY_FORMAT_VERSION = 1;
+ /* default */ static final int MANIFEST_FORMAT_VERSION = 1;
+ /* default */ static final int MAX_FILE_BYTES = 128 * 1024 * 1024;
+ /* default */ static final int MAX_ENTRY_COUNT = 16_384;
+ /* default */ static final int MAX_ENTRY_CIPHERTEXT_BYTES = 16 * 1024 * 1024;
+ /* default */ static final int MAX_ALIAS_BYTES = 512;
+ /* default */ static final int MAX_METADATA_BYTES = 4_096;
+ /* default */ static final int SALT_BYTES = 32;
+ /* default */ static final int KEK_BYTES = 32;
+ /* default */ static final int MASTER_KEY_BYTES = 32;
+ /* default */ static final int NONCE_BYTES = 12;
+ /* default */ static final int UUID_BYTES = 16;
+ /* default */ static final int GCM_TAG_BITS = 128;
+ /* default */ static final int GCM_TAG_BYTES = GCM_TAG_BITS / 8;
+ /* default */ static final int SHA256_BYTES = 32;
+ /* default */ static final byte KDF_PBKDF2_SHA256 = 1;
+ /* default */ static final byte AEAD_AES_256_GCM = 1;
+ private static final byte[] NONCE_RESERVATION_MAGIC =
+ { 'Z', 'E', 'K', 'N', 'O', 'N', 'C', '2' };
+ private static final int NONCE_RESERVATION_VERSION = 2;
+ private static final int NONCE_RESERVATION_TAG_BYTES = 32;
+ private static final byte MASTER_WRAP_NONCE_DOMAIN = 1;
+ private static final byte ENTRY_NONCE_DOMAIN = 2;
- private static final String MAGIC_HEADER = "# KeyringStore v1";
- private static final String ENTRY_MARKER = "@entry";
- private static final String PREFIX_SPEC = "s."; // marks spec payload keys
-
- /** Suffix used for persisted public key aliases. */
private static final String SUFFIX_PUBLIC = ".pub";
-
- /** Suffix used for persisted private key aliases. */
private static final String SUFFIX_PRIVATE = ".priv";
-
- private final Map byAlias = new LinkedHashMap<>();
- private final ZeroEchoSession session;
+ private static final long MIN_NONCE_HIGH_WATER = 1L;
+ private static final Set DIRECTORY_PERMISSIONS =
+ EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE,
+ PosixFilePermission.OWNER_EXECUTE);
+ private static final Set FILE_PERMISSIONS =
+ EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE);
+ private static final FileAttribute> DIRECTORY_ATTRIBUTE =
+ PosixFilePermissions.asFileAttribute(DIRECTORY_PERMISSIONS);
+ private static final FileAttribute> FILE_ATTRIBUTE =
+ PosixFilePermissions.asFileAttribute(FILE_PERMISSIONS);
+ private static final KeyringRandomBytes SYSTEM_RANDOM = destination ->
+ RandomSupport.getRandom().nextBytes(destination);
+ private final Path path;
+ private final Path nonceReservationPath;
+ private final Ownership ownership;
+ private final KeyringRandomBytes random;
+ private final KeyringFileOperations fileOperations;
+ private final ReentrantReadWriteLock lifecycleLock = new ReentrantReadWriteLock();
+ private final AtomicBoolean destroyed = new AtomicBoolean();
+ private final byte[] storeId;
+ private final byte[] salt;
+ private final byte[] masterWrapNonce;
+ private final byte[] wrappedMasterKey;
+ private final byte[] masterKey;
+ private final byte[] nonceReservationMacKey;
+ private final int iterations;
+ private final int noncePrefix;
+ private long nonceHighWater;
+ private Map entries;
+ private boolean poisoned;
/**
- * Creates an empty keyring bound to a runtime session.
- *
- * @param session explicit runtime configuration
- * @throws NullPointerException if {@code session} is {@code null}
+ * Standard encoded key forms supported by the encrypted store.
*/
- public KeyringStore(ZeroEchoSession session) {
- this.session = Objects.requireNonNull(session, "session must not be null");
- }
+ public enum Encoding {
+ X509(1),
+ PKCS8(2),
+ RAW(3);
- /**
- * Immutable entry in a {@link KeyringStore}.
- *
- *
- * Each record represents one alias-bound key with its algorithm metadata and
- * serialized specification payload. Records are immutable value objects and
- * provide structural equality and stable hashing.
- *
- *
- * @param alias alias that identifies the entry. It must be unique within
- * the store.
- * @param algorithm algorithm identifier such as {@code "RSA"},
- * {@code "Ed25519"}, {@code "HMAC"}, or {@code "AES"}. The
- * value is passed to the crypto catalog when materializing
- * keys.
- * @param kind type of key stored in this record
- * @param specClass fully qualified class name of the key-spec used to
- * materialize the key
- * @param specPayload specification payload represented as alternating key and
- * value pairs. Keys appear in the file with the {@code "s."}
- * prefix but are stored here without the prefix.
- */
- public record Record(String alias, String algorithm, Kind kind, String specClass, PairSeq specPayload) {
+ private final int code;
- /**
- * Enumeration of supported key kinds.
- *
- *
- * - {@link #PUBLIC_KEY} - public key material
- * - {@link #PRIVATE_KEY} - private key material
- * - {@link #SECRET_KEY} - symmetric key material
- *
- */
- public enum Kind {
- PUBLIC_KEY, PRIVATE_KEY, SECRET_KEY
+ Encoding(int code) {
+ this.code = code;
+ }
+
+ /* default */ static Encoding fromCode(int code) throws KeyringException {
+ for (Encoding value : values()) {
+ if (value.code == code) {
+ return value;
+ }
+ }
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
}
}
/**
- * Adds or replaces a public-key entry under the given alias.
- *
- * @param alias the unique alias to store under; must not be null or blank
- * @param algorithmId algorithm identifier understood by the crypto catalog
- * @param importSpec the algorithm-specific spec carrying public key material
- * @throws IllegalArgumentException if any argument is invalid
+ * Key classifications supported by the encrypted store.
*/
- public void putPublic(String alias, String algorithmId, AlgorithmKeySpec importSpec) {
- put(withPublicSuffix(alias), algorithmId, Record.Kind.PUBLIC_KEY, importSpec);
+ public enum Kind {
+ PUBLIC_KEY(1),
+ PRIVATE_KEY(2),
+ SECRET_KEY(3);
+
+ private final int code;
+
+ Kind(int code) {
+ this.code = code;
+ }
+
+ /* default */ static Kind fromCode(int code) throws KeyringException {
+ for (Kind value : values()) {
+ if (value.code == code) {
+ return value;
+ }
+ }
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ }
+
+ private KeyringStore(Path path, Ownership ownership,
+ KeyringRandomBytes random, KeyringFileOperations fileOperations,
+ Header header, byte[] masterKey,
+ byte[] nonceReservationMacKey, Manifest manifest,
+ Map entries) {
+ this.path = path;
+ this.nonceReservationPath = nonceReservationPath(path);
+ this.ownership = ownership;
+ this.random = random;
+ this.fileOperations = fileOperations;
+ this.storeId = header.storeId.clone();
+ this.salt = header.salt.clone();
+ this.masterWrapNonce = header.masterWrapNonce.clone();
+ this.wrappedMasterKey = header.wrappedMasterKey.clone();
+ this.masterKey = masterKey;
+ this.nonceReservationMacKey = nonceReservationMacKey.clone();
+ this.iterations = header.iterations;
+ this.noncePrefix = manifest.noncePrefix;
+ this.nonceHighWater = manifest.highWater;
+ this.entries = entries;
}
/**
- * Adds or replaces a private-key entry under the given alias.
+ * Creates a new encrypted keyring with the standard protection policy.
*
- * @param alias the unique alias to store under; must not be null or blank
- * @param algorithmId algorithm identifier understood by the crypto catalog
- * @param importSpec the algorithm-specific spec carrying private key material
- * @throws IllegalArgumentException if any argument is invalid
+ * @param path new keyring file
+ * @param password borrowed unlock password; the caller remains responsible
+ * for destroying it
+ * @return open encrypted keyring
+ * @throws IOException if secure creation or durable persistence fails
+ * @throws GeneralSecurityException if cryptographic initialization fails
*/
- public void putPrivate(String alias, String algorithmId, AlgorithmKeySpec importSpec) {
- put(withPrivateSuffix(alias), algorithmId, Record.Kind.PRIVATE_KEY, importSpec);
+ public static KeyringStore create(Path path, KeyringPassword password)
+ throws IOException, GeneralSecurityException {
+ return create(path, password, KeyringProtection.standard());
}
/**
- * Adds or replaces a secret-key entry under the given alias.
+ * Creates a new encrypted keyring.
*
- * @param alias the unique alias to store under; must not be null or blank
- * @param algorithmId algorithm identifier understood by the crypto catalog
- * @param importSpec the algorithm-specific spec carrying secret key material
- * @throws IllegalArgumentException if any argument is invalid
+ * @param path new keyring file
+ * @param password borrowed unlock password
+ * @param protection operational protection policy
+ * @return open encrypted keyring
+ * @throws IOException if secure creation or durable persistence fails
+ * @throws GeneralSecurityException if cryptographic initialization fails
*/
- public void putSecret(String alias, String algorithmId, AlgorithmKeySpec importSpec) {
- put(alias, algorithmId, Record.Kind.SECRET_KEY, importSpec);
+ public static KeyringStore create(Path path, KeyringPassword password,
+ KeyringProtection protection) throws IOException, GeneralSecurityException {
+ return create(path, password, protection, SYSTEM_RANDOM);
+ }
+
+ /* default */ static KeyringStore create(Path path, KeyringPassword password,
+ KeyringProtection protection, KeyringRandomBytes random)
+ throws IOException, GeneralSecurityException {
+ return create(path, password, protection, random, KeyringFileOperations.NIO);
+ }
+
+ @SuppressWarnings({ "PMD.CloseResource", "PMD.UseTryWithResources" })
+ /* default */ static KeyringStore create(Path path, KeyringPassword password,
+ KeyringProtection protection, KeyringRandomBytes random,
+ KeyringFileOperations fileOperations)
+ throws IOException, GeneralSecurityException {
+ Objects.requireNonNull(password, "password must not be null");
+ Objects.requireNonNull(protection, "protection must not be null");
+ Objects.requireNonNull(random, "random must not be null");
+ Objects.requireNonNull(fileOperations, "fileOperations must not be null");
+ Path normalized = securePath(path, true);
+ Ownership ownership = Ownership.acquire(normalized);
+ byte[] masterKey = new byte[MASTER_KEY_BYTES];
+ byte[] salt = new byte[SALT_BYTES];
+ byte[] storeId = new byte[UUID_BYTES];
+ byte[] wrapNonce = new byte[NONCE_BYTES];
+ byte[] kek = null;
+ byte[] aad = null;
+ byte[] wrapped = null;
+ byte[] nonceReservationMacKey = null;
+ KeyringStore store = null;
+ boolean success = false;
+ try {
+ if (Files.exists(normalized, LinkOption.NOFOLLOW_LINKS)) {
+ throw new KeyringException(Code.KEYRING_IO_FAILED);
+ }
+ random.nextBytes(masterKey);
+ random.nextBytes(salt);
+ random.nextBytes(storeId);
+ nonceReservationMacKey =
+ KeyringNonceReservationKdf.derive(masterKey, storeId);
+ fillMasterWrapNonce(random, wrapNonce);
+ int prefix = randomPrefix(random);
+ Header provisional = new Header(storeId, KeyringProtection.CREATION_ITERATIONS,
+ salt, wrapNonce, new byte[0]);
+ aad = masterWrapAad(provisional);
+ kek = deriveKek(password, salt, provisional.iterations);
+ wrapped = crypt(Cipher.ENCRYPT_MODE, kek, wrapNonce, aad, masterKey);
+ Header header = new Header(storeId, provisional.iterations, salt, wrapNonce, wrapped);
+ Manifest initial = new Manifest(prefix, 0, List.of());
+ store = new KeyringStore(normalized, ownership, random, fileOperations, header,
+ masterKey, nonceReservationMacKey, initial, new LinkedHashMap<>());
+ store.persistSnapshot(new LinkedHashMap<>());
+ success = true;
+ return store;
+ } finally {
+ wipe(kek);
+ wipe(aad);
+ wipe(wrapped);
+ wipe(salt);
+ wipe(storeId);
+ wipe(wrapNonce);
+ wipe(nonceReservationMacKey);
+ if (!success) {
+ wipe(masterKey);
+ if (store != null) {
+ store.close();
+ } else {
+ ownership.close();
+ }
+ }
+ }
}
/**
- * Checks whether an entry with the given alias exists.
+ * Opens an encrypted keyring with the standard protection policy.
*
- * @param alias the alias to test
- * @return true if an entry exists for the alias, false otherwise
+ * @param path keyring file
+ * @param password borrowed unlock password
+ * @return open encrypted keyring
+ * @throws IOException if the format, filesystem, or authentication check
+ * fails
+ * @throws GeneralSecurityException if cryptographic initialization fails
+ */
+ public static KeyringStore open(Path path, KeyringPassword password)
+ throws IOException, GeneralSecurityException {
+ return open(path, password, KeyringProtection.standard());
+ }
+
+ /**
+ * Opens an encrypted keyring.
+ *
+ * @param path keyring file
+ * @param password borrowed unlock password
+ * @param protection operational protection policy
+ * @return open encrypted keyring
+ * @throws IOException if the format, filesystem, or authentication check
+ * fails
+ * @throws GeneralSecurityException if cryptographic initialization fails
+ */
+ public static KeyringStore open(Path path, KeyringPassword password,
+ KeyringProtection protection) throws IOException, GeneralSecurityException {
+ return open(path, password, protection, SYSTEM_RANDOM);
+ }
+
+ /* default */ static KeyringStore open(Path path, KeyringPassword password,
+ KeyringProtection protection, KeyringRandomBytes random)
+ throws IOException, GeneralSecurityException {
+ return open(path, password, protection, random, KeyringFileOperations.NIO);
+ }
+
+ @SuppressWarnings({ "PMD.CloseResource", "PMD.UseTryWithResources" })
+ /* default */ static KeyringStore open(Path path, KeyringPassword password,
+ KeyringProtection protection, KeyringRandomBytes random,
+ KeyringFileOperations fileOperations)
+ throws IOException, GeneralSecurityException {
+ Objects.requireNonNull(password, "password must not be null");
+ Objects.requireNonNull(protection, "protection must not be null");
+ Objects.requireNonNull(random, "random must not be null");
+ Objects.requireNonNull(fileOperations, "fileOperations must not be null");
+ Path normalized = securePath(path, false);
+ Ownership ownership = Ownership.acquire(normalized);
+ byte[] image = null;
+ byte[] masterKey = null;
+ byte[] nonceReservationMacKey = null;
+ boolean success = false;
+ try {
+ validateExistingFile(normalized, ownership.owner());
+ image = readStoreImage(normalized);
+ Decoded decoded = decodeImage(image, password, protection);
+ masterKey = decoded.masterKey;
+ nonceReservationMacKey = KeyringNonceReservationKdf.derive(
+ masterKey, decoded.header.storeId);
+ long reservedHighWater = readNonceReservation(normalized, ownership.owner(),
+ decoded.header.storeId, decoded.manifest.noncePrefix,
+ nonceReservationMacKey);
+ if (reservedHighWater < decoded.manifest.highWater) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ Manifest effectiveManifest = new Manifest(decoded.manifest.noncePrefix,
+ reservedHighWater, decoded.manifest.entries);
+ KeyringStore store = new KeyringStore(normalized, ownership,
+ random, fileOperations, decoded.header, masterKey, nonceReservationMacKey,
+ effectiveManifest, decoded.entries);
+ success = true;
+ return store;
+ } finally {
+ wipe(image);
+ wipe(nonceReservationMacKey);
+ if (!success) {
+ wipe(masterKey);
+ ownership.close();
+ }
+ }
+ }
+
+ /**
+ * Adds or replaces an encoded public key and persists the mutation.
+ *
+ * @param alias logical alias
+ * @param algorithmId canonical registered algorithm identifier
+ * @param key exportable public key
+ * @throws IOException if validation or durable persistence fails
+ * @throws GeneralSecurityException if entry encryption fails
+ */
+ public void putPublic(String alias, String algorithmId, PublicKey key)
+ throws IOException, GeneralSecurityException {
+ put(withPublicSuffix(alias), algorithmId, Kind.PUBLIC_KEY, Encoding.X509, key,
+ KeyringImportRegistry.HmacVariant.NONE);
+ }
+
+ /**
+ * Adds or replaces an encoded private key and persists the mutation.
+ *
+ * @param alias logical alias
+ * @param algorithmId canonical registered algorithm identifier
+ * @param key exportable private key
+ * @throws IOException if validation or durable persistence fails
+ * @throws GeneralSecurityException if entry encryption fails
+ */
+ public void putPrivate(String alias, String algorithmId, PrivateKey key)
+ throws IOException, GeneralSecurityException {
+ put(withPrivateSuffix(alias), algorithmId, Kind.PRIVATE_KEY, Encoding.PKCS8, key,
+ KeyringImportRegistry.HmacVariant.NONE);
+ }
+
+ /**
+ * Adds or replaces an encoded secret key and persists the mutation.
+ *
+ * @param alias logical alias
+ * @param algorithmId canonical registered algorithm identifier
+ * @param key exportable secret key
+ * @throws IOException if validation or durable persistence fails
+ * @throws GeneralSecurityException if entry encryption fails
+ */
+ public void putSecret(String alias, String algorithmId, SecretKey key)
+ throws IOException, GeneralSecurityException {
+ Objects.requireNonNull(key, "key must not be null");
+ KeyringImportRegistry.HmacVariant hmacVariant =
+ KeyringImportRegistry.HmacVariant.forStoredKey(algorithmId, key);
+ put(alias, algorithmId, Kind.SECRET_KEY, Encoding.RAW, key, hmacVariant);
+ }
+
+ /**
+ * Reports whether an alias is present.
+ *
+ * @param alias logical or suffixed alias
+ * @return {@code true} if a matching entry exists
+ * @throws IllegalStateException after close
*/
public boolean contains(String alias) {
- if (alias == null) {
- return false;
+ lifecycleLock.readLock().lock();
+ try {
+ ensureOpenUnchecked();
+ if (alias == null) {
+ return false;
+ }
+ return entries.containsKey(alias) || entries.containsKey(withPublicSuffix(alias))
+ || entries.containsKey(withPrivateSuffix(alias));
+ } finally {
+ lifecycleLock.readLock().unlock();
}
- if (byAlias.containsKey(alias)) {
- return true;
- }
- return byAlias.containsKey(withPublicSuffix(alias)) || byAlias.containsKey(withPrivateSuffix(alias));
}
/**
- * Returns all aliases currently present in insertion order.
+ * Returns aliases in their persisted order.
*
- * @return a new list of aliases; the returned list is mutable but independent
+ * @return independent alias list
+ * @throws IllegalStateException after close
*/
public List aliases() {
- List out = new ArrayList<>(byAlias.size());
- for (String a : byAlias.keySet()) {
- out.add(stripKnownSuffix(a));
+ lifecycleLock.readLock().lock();
+ try {
+ ensureOpenUnchecked();
+ List result = new ArrayList<>(entries.size());
+ for (String alias : entries.keySet()) {
+ result.add(stripKnownSuffix(alias));
+ }
+ return result;
+ } finally {
+ lifecycleLock.readLock().unlock();
}
- return out;
}
/**
- * PublicWithId pairs the algorithm identifier with a resolved public key.
+ * Resolves a public key and its canonical algorithm identifier.
*
- * Usage
{@code
- * KeyringStore ks = KeyringStore.load(session, path);
- * KeyringStore.PublicWithId r = ks.getPublicWithId("alice");
- * String algId = r.algorithm();
- * PublicKey pub = r.key();
- * }
+ * @param alias logical alias
+ * @return imported key and algorithm
+ * @throws IOException if the entry cannot be authenticated
+ * @throws GeneralSecurityException if decryption or import fails
+ */
+ public PublicWithId getPublicWithId(String alias) throws IOException, GeneralSecurityException {
+ Imported imported = importEntry(withPublicSuffix(alias), Kind.PUBLIC_KEY);
+ return new PublicWithId(imported.algorithm, (PublicKey) imported.key);
+ }
+
+ /**
+ * Resolves a private key and its canonical algorithm identifier.
+ *
+ * @param alias logical alias
+ * @return imported key and algorithm
+ * @throws IOException if the entry cannot be authenticated
+ * @throws GeneralSecurityException if decryption or import fails
+ */
+ public PrivateWithId getPrivateWithId(String alias) throws IOException, GeneralSecurityException {
+ Imported imported = importEntry(withPrivateSuffix(alias), Kind.PRIVATE_KEY);
+ return new PrivateWithId(imported.algorithm, (PrivateKey) imported.key);
+ }
+
+ /**
+ * Resolves a secret key and its canonical algorithm identifier.
+ *
+ * @param alias logical alias
+ * @return imported key and algorithm
+ * @throws IOException if the entry cannot be authenticated
+ * @throws GeneralSecurityException if decryption or import fails
+ */
+ public SecretWithId getSecretWithId(String alias) throws IOException, GeneralSecurityException {
+ Imported imported = importEntry(alias, Kind.SECRET_KEY);
+ return new SecretWithId(imported.algorithm, (SecretKey) imported.key);
+ }
+
+ /**
+ * Resolves a public key.
+ *
+ * @param alias logical alias
+ * @return imported public key
+ * @throws IOException if the entry cannot be authenticated
+ * @throws GeneralSecurityException if decryption or import fails
+ */
+ public PublicKey getPublic(String alias) throws IOException, GeneralSecurityException {
+ return getPublicWithId(alias).key();
+ }
+
+ /**
+ * Resolves a private key.
+ *
+ * @param alias logical alias
+ * @return imported private key
+ * @throws IOException if the entry cannot be authenticated
+ * @throws GeneralSecurityException if decryption or import fails
+ */
+ public PrivateKey getPrivate(String alias) throws IOException, GeneralSecurityException {
+ return getPrivateWithId(alias).key();
+ }
+
+ /**
+ * Resolves a secret key.
+ *
+ * @param alias logical alias
+ * @return imported secret key
+ * @throws IOException if the entry cannot be authenticated
+ * @throws GeneralSecurityException if decryption or import fails
+ */
+ public SecretKey getSecret(String alias) throws IOException, GeneralSecurityException {
+ return getSecretWithId(alias).key();
+ }
+
+ /**
+ * Algorithm identifier paired with an imported public key.
+ *
+ * @param algorithm canonical algorithm identifier
+ * @param key imported key
*/
public record PublicWithId(String algorithm, PublicKey key) {
}
/**
- * PrivateWithId pairs the algorithm identifier with a resolved private key.
+ * Algorithm identifier paired with an imported private key.
*
- * Usage
{@code
- * KeyringStore ks = KeyringStore.load(session, path);
- * KeyringStore.PrivateWithId r = ks.getPrivateWithId("alice");
- * String algId = r.algorithm();
- * PrivateKey prv = r.key();
- * }
+ * @param algorithm canonical algorithm identifier
+ * @param key imported key
*/
public record PrivateWithId(String algorithm, PrivateKey key) {
}
/**
- * SecretWithId pairs the algorithm identifier with a resolved secret key.
+ * Algorithm identifier paired with an imported secret key.
*
- * Usage
{@code
- * KeyringStore ks = KeyringStore.load(session, path);
- * KeyringStore.SecretWithId r = ks.getSecretWithId("hmac-key");
- * String algId = r.algorithm();
- * SecretKey sk = r.key();
- * }
+ * @param algorithm canonical algorithm identifier
+ * @param key imported key
*/
public record SecretWithId(String algorithm, SecretKey key) {
}
/**
- * Resolves a public key together with its algorithm identifier.
+ * Clears the master and nonce-reservation MAC keys and releases filesystem
+ * ownership.
*
- *
- * The method mirrors {@link #getPublic(String)} but returns the algorithm id
- * stored in the record along with the materialized key so that callers can
- * route to algorithm-specific processing without asking the user to repeat it.
- *
+ * Concurrent calls are safe and cleanup occurs exactly once.
*
- * @param alias the alias of a {@link Record.Kind#PUBLIC_KEY} entry
- * @return a pair containing the algorithm id and the resolved public key
- * @throws GeneralSecurityException if spec unmarshaling or key construction
- * fails
- * @throws IllegalArgumentException if alias is missing or not a public key
+ * @throws DestroyFailedException if resource release fails
*/
- public PublicWithId getPublicWithId(String alias) throws GeneralSecurityException {
- Record r = require(withPublicSuffix(alias), Record.Kind.PUBLIC_KEY);
- AlgorithmKeySpec spec = unmarshalRecord(r);
- PublicKey key = session.keyBuilders().asymmetric().importPublic(r.algorithm, spec);
- return new PublicWithId(r.algorithm, key);
+ @Override
+ @SuppressWarnings("PMD.PreserveStackTrace")
+ public void destroy() throws DestroyFailedException {
+ lifecycleLock.writeLock().lock();
+ try {
+ if (!destroyed.compareAndSet(false, true)) {
+ return;
+ }
+ wipe(masterKey);
+ wipe(nonceReservationMacKey);
+ entries.clear();
+ entries = new LinkedHashMap<>();
+ try {
+ ownership.close();
+ } catch (IOException exception) {
+ throw new DestroyFailedException("KEYRING_IO_FAILED");
+ }
+ } finally {
+ lifecycleLock.writeLock().unlock();
+ }
}
/**
- * Resolves a private key together with its algorithm identifier.
+ * Reports whether the keyring has been destroyed.
*
- * @param alias the alias of a {@link Record.Kind#PRIVATE_KEY} entry
- * @return a pair containing the algorithm id and the resolved private key
- * @throws GeneralSecurityException if spec unmarshaling or key construction
- * fails
- * @throws IllegalArgumentException if alias is missing or not a private key
+ * @return {@code true} after close or destruction
*/
- public PrivateWithId getPrivateWithId(String alias) throws GeneralSecurityException {
- Record r = require(withPrivateSuffix(alias), Record.Kind.PRIVATE_KEY);
- AlgorithmKeySpec spec = unmarshalRecord(r);
- Throwable failure = null;
+ @Override
+ public boolean isDestroyed() {
+ return destroyed.get();
+ }
+
+ /**
+ * Closes the keyring, clearing its master key.
+ */
+ @Override
+ @SuppressWarnings("PMD.PreserveStackTrace")
+ public void close() {
try {
- PrivateKey key = session.keyBuilders().asymmetric().importPrivate(r.algorithm, spec);
- return new PrivateWithId(r.algorithm, key);
- } catch (GeneralSecurityException | RuntimeException | Error exception) { // NOPMD - retain primary failure
- failure = exception;
+ destroy();
+ } catch (DestroyFailedException exception) {
+ throw new IllegalStateException("KEYRING_IO_FAILED");
+ }
+ }
+
+ private void put(String alias, String algorithmId, Kind kind, Encoding encoding,
+ Key key, KeyringImportRegistry.HmacVariant hmacVariant)
+ throws IOException, GeneralSecurityException {
+ Objects.requireNonNull(key, "key must not be null");
+ validateString(alias, MAX_ALIAS_BYTES);
+ validateString(algorithmId, MAX_METADATA_BYTES);
+ KeyringImportRegistry.validateMapping(algorithmId, kind, encoding, hmacVariant);
+ String format = key.getFormat();
+ if (format == null || !matchesFormat(format, encoding)) {
+ throw new KeyringException(Code.KEYRING_IMPORT_MAPPING_INVALID);
+ }
+ byte[] encoded = key.getEncoded();
+ if (encoded == null) {
+ throw new KeyringException(Code.KEYRING_NON_EXPORTABLE_KEY);
+ }
+ try {
+ KeyringImportRegistry.validateCanonical(algorithmId, kind, encoding,
+ hmacVariant, key, encoded);
+ lifecycleLock.writeLock().lock();
+ try {
+ ensureOpen();
+ if (entries.size() >= MAX_ENTRY_COUNT && !entries.containsKey(alias)) {
+ throw new KeyringException(Code.KEYRING_LIMIT_EXCEEDED);
+ }
+ Map candidate = new LinkedHashMap<>(entries);
+ int position = positionFor(candidate, alias);
+ byte[] entryId = new byte[UUID_BYTES];
+ random.nextBytes(entryId);
+ byte[] nonce = nextNonce();
+ byte[] plaintext = null;
+ byte[] aad = null;
+ byte[] ciphertext = null;
+ try {
+ plaintext = encodeEntryPlaintext(alias, algorithmId, kind, encoding,
+ hmacVariant, encoded);
+ aad = entryAad(entryId, position);
+ ciphertext = crypt(Cipher.ENCRYPT_MODE, masterKey, nonce, aad, plaintext);
+ if (ciphertext.length > MAX_ENTRY_CIPHERTEXT_BYTES) {
+ throw new KeyringException(Code.KEYRING_LIMIT_EXCEEDED);
+ }
+ EncryptedEntry entry = new EncryptedEntry(entryId, position, alias,
+ algorithmId, kind, encoding, hmacVariant, nonce, ciphertext,
+ digest(ciphertext));
+ candidate.put(alias, entry);
+ persistSnapshot(candidate);
+ entries = candidate;
+ } finally {
+ wipe(entryId);
+ wipe(nonce);
+ wipe(plaintext);
+ wipe(aad);
+ wipe(ciphertext);
+ }
+ } finally {
+ lifecycleLock.writeLock().unlock();
+ }
+ } finally {
+ wipe(encoded);
+ }
+ }
+
+ @SuppressWarnings("PMD.PreserveStackTrace")
+ private Imported importEntry(String alias, Kind expected)
+ throws IOException, GeneralSecurityException {
+ lifecycleLock.readLock().lock();
+ try {
+ ensureOpen();
+ EncryptedEntry entry = entries.get(alias);
+ if (entry == null || entry.kind != expected) {
+ throw new IllegalArgumentException("Requested key entry is unavailable");
+ }
+ byte[] aad = null;
+ byte[] plaintext = null;
+ EntryPlain decoded = null;
+ try {
+ aad = entryAad(entry.entryId, entry.position);
+ plaintext = crypt(Cipher.DECRYPT_MODE, masterKey, entry.nonce, aad, entry.ciphertext);
+ decoded = decodeEntryPlaintext(plaintext);
+ validateEntryBinding(entry, decoded);
+ Key key = KeyringImportRegistry.importKey(decoded.algorithm, decoded.kind,
+ decoded.encoding, decoded.hmacVariant, decoded.encoded);
+ return new Imported(decoded.algorithm, key);
+ } catch (AEADBadTagException exception) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ } finally {
+ if (decoded != null) {
+ wipe(decoded.encoded);
+ }
+ wipe(plaintext);
+ wipe(aad);
+ }
+ } finally {
+ lifecycleLock.readLock().unlock();
+ }
+ }
+
+ private void persistSnapshot(Map candidate)
+ throws IOException, GeneralSecurityException {
+ byte[] manifestNonce = null;
+ byte[] manifestPlain = null;
+ byte[] manifestAad = null;
+ byte[] manifestCipher = null;
+ byte[] image = null;
+ try {
+ manifestNonce = nextNonce();
+ manifestPlain = encodeManifest(candidate);
+ manifestAad = manifestAad(candidate.size());
+ manifestCipher = crypt(Cipher.ENCRYPT_MODE, masterKey, manifestNonce,
+ manifestAad, manifestPlain);
+ image = encodeImage(candidate, manifestNonce, manifestCipher);
+ writeAtomically(image);
+ } catch (IOException | GeneralSecurityException exception) {
+ if (poisoned) {
+ destroyPoisonedStore(exception);
+ }
throw exception;
} finally {
- destroyTemporarySpec(spec, failure);
+ wipe(manifestNonce);
+ wipe(manifestPlain);
+ wipe(manifestAad);
+ wipe(manifestCipher);
+ wipe(image);
}
}
- /**
- * Resolves a secret key together with its algorithm identifier.
- *
- * @param alias the alias of a {@link Record.Kind#SECRET_KEY} entry
- * @return a pair containing the algorithm id and the resolved secret key
- * @throws GeneralSecurityException if spec unmarshaling or key construction
- * fails
- * @throws IllegalArgumentException if alias is missing or not a secret key
- */
- public SecretWithId getSecretWithId(String alias) throws GeneralSecurityException {
- Record r = require(alias, Record.Kind.SECRET_KEY);
- AlgorithmKeySpec spec = unmarshalRecord(r);
- Throwable failure = null;
+ private void writeAtomically(byte[] image) throws IOException {
+ if (image.length > MAX_FILE_BYTES) {
+ throw new KeyringException(Code.KEYRING_LIMIT_EXCEEDED);
+ }
+ writeAtomically(Target.MAIN_IMAGE, path, image);
+ }
+
+ @SuppressWarnings({ "PMD.PreserveStackTrace", "PMD.ExceptionAsFlowControl" })
+ private void writeAtomically(Target target, Path destination, byte[] image)
+ throws IOException {
+ Path parent = destination.getParent();
+ Path temporary = null;
+ boolean moved = false;
+ IOException primary = null;
try {
- SecretKey key = session.keyBuilders().symmetric().importKey(r.algorithm, spec);
- return new SecretWithId(r.algorithm, key);
- } catch (GeneralSecurityException | RuntimeException | Error exception) { // NOPMD - retain primary failure
- failure = exception;
- throw exception;
- } finally {
- destroyTemporarySpec(spec, failure);
+ temporary = fileOperations.createTemporary(target, parent,
+ "." + destination.getFileName() + ".", ".tmp", FILE_ATTRIBUTE);
+ validateOwnerOnly(temporary, false);
+ fileOperations.writeTemporary(target, temporary, image);
+ fileOperations.forceTemporary(target, temporary);
+ try {
+ fileOperations.atomicReplace(target, temporary, destination);
+ } catch (AtomicMoveNotSupportedException exception) {
+ throw new KeyringException(Code.KEYRING_FILESYSTEM_UNSUPPORTED);
+ }
+ moved = true;
+ validateExistingFile(destination, ownership.owner());
+ fileOperations.forceDirectory(target, parent);
+ } catch (UnsupportedOperationException exception) {
+ primary = persistenceFailure(moved);
+ } catch (IOException exception) {
+ primary = moved ? persistenceFailure(true) : sanitizePersistenceFailure(exception);
+ }
+ if (!moved && temporary != null) {
+ try {
+ fileOperations.deleteTemporary(target, temporary);
+ } catch (IOException cleanupFailure) {
+ primary.addSuppressed(new KeyringException(Code.KEYRING_IO_FAILED));
+ }
+ }
+ if (primary != null) {
+ throw primary;
}
}
- /**
- * Resolves the public key bound to the alias.
- *
- *
- * The stored spec class is loaded and unmarshaled via
- * {@link #unmarshalSpec(Class, PairSeq)}, and the key is materialized via the
- * crypto catalog.
- *
- *
- * @param alias the alias of a {@link Record.Kind#PUBLIC_KEY} entry
- * @return a public key instance created from the stored spec
- * @throws GeneralSecurityException if spec unmarshaling or key construction
- * fails
- * @throws IllegalArgumentException if the alias is missing or the entry is not
- * a public key
- */
- public PublicKey getPublic(String alias) throws GeneralSecurityException {
- Record r = require(withPublicSuffix(alias), Record.Kind.PUBLIC_KEY);
- AlgorithmKeySpec spec = unmarshalRecord(r);
- return session.keyBuilders().asymmetric().importPublic(r.algorithm, spec);
+ private IOException persistenceFailure(boolean moved) {
+ if (moved) {
+ poisoned = true;
+ return new KeyringException(Code.KEYRING_DURABILITY_UNCONFIRMED);
+ }
+ return new KeyringException(Code.KEYRING_IO_FAILED);
}
- /**
- * Resolves the private key bound to the alias.
- *
- * @param alias the alias of a {@link Record.Kind#PRIVATE_KEY} entry
- * @return a private key instance created from the stored spec
- * @throws GeneralSecurityException if spec unmarshaling or key construction
- * fails
- * @throws IllegalArgumentException if the alias is missing or the entry is not
- * a private key
- */
- public PrivateKey getPrivate(String alias) throws GeneralSecurityException {
- Record r = require(withPrivateSuffix(alias), Record.Kind.PRIVATE_KEY);
- AlgorithmKeySpec spec = unmarshalRecord(r);
- Throwable failure = null;
+ private static IOException sanitizePersistenceFailure(IOException failure) {
+ if (failure instanceof KeyringException) {
+ return failure;
+ }
+ return new KeyringException(Code.KEYRING_IO_FAILED);
+ }
+
+ private void destroyPoisonedStore(Throwable primaryFailure) {
+ wipe(masterKey);
+ wipe(nonceReservationMacKey);
+ entries.clear();
+ destroyed.set(true);
try {
- return session.keyBuilders().asymmetric().importPrivate(r.algorithm, spec);
- } catch (GeneralSecurityException | RuntimeException | Error exception) { // NOPMD - retain primary failure
- failure = exception;
- throw exception;
+ ownership.close();
+ } catch (IOException exception) {
+ primaryFailure.addSuppressed(new KeyringException(Code.KEYRING_IO_FAILED));
+ }
+ }
+
+ private void writeNonceReservation(long highWater)
+ throws IOException, GeneralSecurityException {
+ byte[] authenticated = encodeNonceReservation(storeId, noncePrefix, highWater);
+ byte[] tag = null;
+ byte[] image = null;
+ try {
+ tag = hmac(nonceReservationMacKey, authenticated);
+ image = ByteBuffer.allocate(authenticated.length + tag.length)
+ .put(authenticated).put(tag).array();
+ writeSidecarAtomically(nonceReservationPath, image);
} finally {
- destroyTemporarySpec(spec, failure);
+ wipe(authenticated);
+ wipe(tag);
+ wipe(image);
}
}
- private static String withPublicSuffix(String baseAlias) {
- if (baseAlias == null || baseAlias.isBlank()) {
- throw new IllegalArgumentException("alias");
+ private static long readNonceReservation(Path keyring, UserPrincipal owner,
+ byte[] expectedStoreId, int expectedPrefix,
+ byte[] nonceReservationMacKey)
+ throws IOException, GeneralSecurityException {
+ Path reservation = nonceReservationPath(keyring);
+ validateExistingFile(reservation, owner);
+ byte[] image = readFixedImage(reservation,
+ NONCE_RESERVATION_MAGIC.length + Integer.BYTES + UUID_BYTES
+ + Integer.BYTES + Long.BYTES + NONCE_RESERVATION_TAG_BYTES);
+ byte[] authenticated = Arrays.copyOf(image,
+ image.length - NONCE_RESERVATION_TAG_BYTES);
+ byte[] actualTag = Arrays.copyOfRange(image,
+ authenticated.length, image.length);
+ byte[] expectedTag = null;
+ try {
+ expectedTag = hmac(nonceReservationMacKey, authenticated);
+ if (!MessageDigest.isEqual(actualTag, expectedTag)) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ ByteBuffer buffer = ByteBuffer.wrap(authenticated);
+ byte[] magic = new byte[NONCE_RESERVATION_MAGIC.length];
+ buffer.get(magic);
+ byte[] storeId = new byte[UUID_BYTES];
+ buffer.position(NONCE_RESERVATION_MAGIC.length + Integer.BYTES);
+ buffer.get(storeId);
+ int prefix = buffer.getInt();
+ long highWater = buffer.getLong();
+ if (!MessageDigest.isEqual(magic, NONCE_RESERVATION_MAGIC)
+ || ByteBuffer.wrap(authenticated,
+ NONCE_RESERVATION_MAGIC.length, Integer.BYTES).getInt()
+ != NONCE_RESERVATION_VERSION
+ || !MessageDigest.isEqual(storeId, expectedStoreId)
+ || prefix != expectedPrefix || highWater < MIN_NONCE_HIGH_WATER) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ return highWater;
+ } finally {
+ wipe(image);
+ wipe(authenticated);
+ wipe(actualTag);
+ wipe(expectedTag);
}
- if (baseAlias.endsWith(SUFFIX_PUBLIC)) {
- return baseAlias;
- }
- return baseAlias + SUFFIX_PUBLIC;
}
- private static String withPrivateSuffix(String baseAlias) {
- if (baseAlias == null || baseAlias.isBlank()) {
- throw new IllegalArgumentException("alias");
+ private static byte[] encodeNonceReservation(byte[] storeId, int prefix,
+ long highWater) {
+ return ByteBuffer.allocate(NONCE_RESERVATION_MAGIC.length + Integer.BYTES
+ + UUID_BYTES + Integer.BYTES + Long.BYTES)
+ .put(NONCE_RESERVATION_MAGIC)
+ .putInt(NONCE_RESERVATION_VERSION)
+ .put(storeId)
+ .putInt(prefix)
+ .putLong(highWater)
+ .array();
+ }
+
+ private static byte[] hmac(byte[] key, byte[] input)
+ throws GeneralSecurityException {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(key, "HmacSHA256"));
+ return mac.doFinal(input);
+ }
+
+ private void writeSidecarAtomically(Path target, byte[] image) throws IOException {
+ writeAtomically(Target.NONCE_RESERVATION, target, image);
+ }
+
+ private byte[] encodeImage(Map candidate,
+ byte[] manifestNonce, byte[] manifestCipher) throws IOException, KeyringException {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (DataOutputStream out = new DataOutputStream(bytes)) {
+ out.write(MAGIC);
+ out.writeInt(FORMAT_VERSION);
+ out.write(storeId);
+ out.writeByte(KDF_PBKDF2_SHA256);
+ out.writeInt(iterations);
+ writeFixed(out, salt, SALT_BYTES);
+ out.writeInt(KEK_BYTES);
+ out.writeByte(AEAD_AES_256_GCM);
+ writeFixed(out, masterWrapNonce, NONCE_BYTES);
+ writeBoundedBytes(out, wrappedMasterKey, MASTER_KEY_BYTES + GCM_TAG_BYTES);
+ out.writeInt(candidate.size());
+ for (EncryptedEntry entry : candidate.values()) {
+ out.write(entry.entryId);
+ out.write(entry.nonce);
+ writeBoundedBytes(out, entry.ciphertext, MAX_ENTRY_CIPHERTEXT_BYTES);
+ if (bytes.size() > MAX_FILE_BYTES) {
+ throw new KeyringException(Code.KEYRING_LIMIT_EXCEEDED);
+ }
+ }
+ out.write(manifestNonce);
+ writeBoundedBytes(out, manifestCipher, MAX_FILE_BYTES);
+ out.flush();
}
- if (baseAlias.endsWith(SUFFIX_PRIVATE)) {
- return baseAlias;
+ byte[] result = bytes.toByteArray();
+ if (result.length > MAX_FILE_BYTES) {
+ wipe(result);
+ throw new KeyringException(Code.KEYRING_LIMIT_EXCEEDED);
}
- return baseAlias + SUFFIX_PRIVATE;
+ return result;
+ }
+
+ private byte[] encodeManifest(Map candidate)
+ throws IOException {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (DataOutputStream out = new DataOutputStream(bytes)) {
+ out.writeInt(MANIFEST_FORMAT_VERSION);
+ out.writeInt(noncePrefix);
+ out.writeLong(nonceHighWater);
+ out.writeInt(candidate.size());
+ for (EncryptedEntry entry : candidate.values()) {
+ out.write(entry.entryId);
+ out.writeInt(entry.position);
+ writeString(out, entry.alias, MAX_ALIAS_BYTES);
+ writeString(out, entry.algorithm, MAX_METADATA_BYTES);
+ out.writeByte(entry.kind.code);
+ out.writeByte(entry.encoding.code);
+ out.writeByte(entry.hmacVariant.code());
+ out.write(entry.nonce);
+ out.writeInt(entry.ciphertext.length);
+ out.write(entry.digest);
+ }
+ }
+ return bytes.toByteArray();
+ }
+
+ @SuppressWarnings({ "PMD.NcssCount", "PMD.CyclomaticComplexity",
+ "PMD.PreserveStackTrace", "PMD.ExceptionAsFlowControl",
+ "PMD.AvoidCatchingGenericException" })
+ private static Decoded decodeImage(byte[] image, KeyringPassword password,
+ KeyringProtection protection) throws IOException, GeneralSecurityException {
+ try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(image))) {
+ byte[] magic = readFixed(in, MAGIC.length);
+ if (!MessageDigest.isEqual(MAGIC, magic)) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ int version = in.readInt();
+ if (version != FORMAT_VERSION) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ byte[] storeId = readFixed(in, UUID_BYTES);
+ requireCode(in.readUnsignedByte(), KDF_PBKDF2_SHA256);
+ int iterations = in.readInt();
+ validateIterations(iterations, protection);
+ byte[] salt = readFixed(in, SALT_BYTES);
+ if (in.readInt() != KEK_BYTES) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ requireCode(in.readUnsignedByte(), AEAD_AES_256_GCM);
+ byte[] wrapNonce = readFixed(in, NONCE_BYTES);
+ if (wrapNonce[0] != MASTER_WRAP_NONCE_DOMAIN) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ byte[] wrapped = readBoundedBytes(in, MASTER_KEY_BYTES + GCM_TAG_BYTES,
+ MASTER_KEY_BYTES + GCM_TAG_BYTES);
+ if (wrapped.length != MASTER_KEY_BYTES + GCM_TAG_BYTES) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ Header header = new Header(storeId, iterations, salt, wrapNonce, wrapped);
+ byte[] kek = null;
+ byte[] wrapAad = null;
+ byte[] masterKey = null;
+ try {
+ kek = deriveKek(password, salt, iterations);
+ wrapAad = masterWrapAad(header);
+ try {
+ masterKey = crypt(Cipher.DECRYPT_MODE, kek, wrapNonce, wrapAad, wrapped);
+ } catch (AEADBadTagException exception) {
+ throw new KeyringException(Code.KEYRING_UNLOCK_FAILED);
+ }
+ if (masterKey.length != MASTER_KEY_BYTES) {
+ throw new KeyringException(Code.KEYRING_UNLOCK_FAILED);
+ }
+ } finally {
+ wipe(kek);
+ wipe(wrapAad);
+ }
+ try {
+ int count = in.readInt();
+ if (count < 0 || count > MAX_ENTRY_COUNT) {
+ throw new KeyringException(Code.KEYRING_LIMIT_EXCEEDED);
+ }
+ List wireEntries = new ArrayList<>(count);
+ for (int index = 0; index < count; index++) {
+ byte[] entryId = readFixed(in, UUID_BYTES);
+ byte[] nonce = readFixed(in, NONCE_BYTES);
+ byte[] ciphertext = readBoundedBytes(in, MAX_ENTRY_CIPHERTEXT_BYTES,
+ MAX_ENTRY_CIPHERTEXT_BYTES);
+ wireEntries.add(new EncryptedWireEntry(entryId, nonce, ciphertext));
+ }
+ byte[] manifestNonce = readFixed(in, NONCE_BYTES);
+ byte[] manifestCipher = readBoundedBytes(in, MAX_FILE_BYTES, MAX_FILE_BYTES);
+ if (in.read() != -1) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ byte[] aad = null;
+ byte[] manifestPlain = null;
+ try {
+ aad = manifestAad(storeId, count);
+ try {
+ manifestPlain = crypt(Cipher.DECRYPT_MODE, masterKey,
+ manifestNonce, aad, manifestCipher);
+ } catch (AEADBadTagException exception) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ Manifest manifest = decodeManifest(manifestPlain, count);
+ Map entries =
+ bindManifest(manifest, wireEntries, manifestNonce);
+ return new Decoded(header, masterKey, manifest, entries);
+ } finally {
+ wipe(aad);
+ wipe(manifestPlain);
+ wipe(manifestNonce);
+ wipe(manifestCipher);
+ }
+ } catch (IOException | GeneralSecurityException | RuntimeException failure) {
+ wipe(masterKey);
+ throw failure;
+ }
+ } catch (EOFException exception) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ }
+
+ @SuppressWarnings("PMD.PreserveStackTrace")
+ private static Manifest decodeManifest(byte[] plaintext, int expectedCount)
+ throws IOException {
+ try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(plaintext))) {
+ if (in.readInt() != MANIFEST_FORMAT_VERSION) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ int prefix = in.readInt();
+ long highWater = in.readLong();
+ if (highWater < MIN_NONCE_HIGH_WATER) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ int count = in.readInt();
+ if (count != expectedCount) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ List descriptors = new ArrayList<>(count);
+ for (int index = 0; index < count; index++) {
+ byte[] entryId = readFixed(in, UUID_BYTES);
+ int position = in.readInt();
+ String alias = readString(in, MAX_ALIAS_BYTES);
+ String algorithm = readString(in, MAX_METADATA_BYTES);
+ Kind kind = Kind.fromCode(in.readUnsignedByte());
+ Encoding encoding = Encoding.fromCode(in.readUnsignedByte());
+ KeyringImportRegistry.HmacVariant hmacVariant =
+ KeyringImportRegistry.HmacVariant.fromCode(in.readUnsignedByte());
+ byte[] nonce = readFixed(in, NONCE_BYTES);
+ int length = in.readInt();
+ if (length < GCM_TAG_BYTES || length > MAX_ENTRY_CIPHERTEXT_BYTES) {
+ throw new KeyringException(Code.KEYRING_LIMIT_EXCEEDED);
+ }
+ byte[] digest = readFixed(in, SHA256_BYTES);
+ descriptors.add(new ManifestEntry(entryId, position, alias, algorithm,
+ kind, encoding, hmacVariant, nonce, length, digest));
+ }
+ if (in.read() != -1) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ return new Manifest(prefix, highWater, descriptors);
+ } catch (EOFException exception) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ }
+
+ @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
+ private static Map bindManifest(Manifest manifest,
+ List wireEntries, byte[] manifestNonce)
+ throws KeyringException {
+ if (manifest.entries.size() != wireEntries.size()) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ Set aliases = new HashSet<>();
+ Set ids = new HashSet<>();
+ Set nonces = new HashSet<>();
+ validateNonce(manifestNonce, manifest.noncePrefix, manifest.highWater);
+ nonces.add(new Nonce(manifestNonce));
+ Map result = new LinkedHashMap<>();
+ for (int index = 0; index < wireEntries.size(); index++) {
+ ManifestEntry descriptor = manifest.entries.get(index);
+ EncryptedWireEntry wire = wireEntries.get(index);
+ if (descriptor.position != index
+ || !MessageDigest.isEqual(descriptor.entryId, wire.entryId)
+ || !MessageDigest.isEqual(descriptor.nonce, wire.nonce)
+ || descriptor.ciphertextLength != wire.ciphertext.length
+ || !MessageDigest.isEqual(descriptor.digest, digest(wire.ciphertext))) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ validateNonce(wire.nonce, manifest.noncePrefix, manifest.highWater);
+ if (!aliases.add(descriptor.alias)
+ || !ids.add(uuid(descriptor.entryId))
+ || !nonces.add(new Nonce(wire.nonce))) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ KeyringImportRegistry.validateMapping(descriptor.algorithm,
+ descriptor.kind, descriptor.encoding, descriptor.hmacVariant);
+ result.put(descriptor.alias, new EncryptedEntry(wire.entryId, index,
+ descriptor.alias, descriptor.algorithm, descriptor.kind,
+ descriptor.encoding, descriptor.hmacVariant, wire.nonce,
+ wire.ciphertext, descriptor.digest));
+ }
+ return result;
+ }
+
+ @SuppressWarnings("PMD.PreserveStackTrace")
+ private static byte[] encodeEntryPlaintext(String alias, String algorithm,
+ Kind kind, Encoding encoding, KeyringImportRegistry.HmacVariant hmacVariant,
+ byte[] encoded)
+ throws KeyringException {
+ if (encoded.length <= 0 || encoded.length > MAX_ENTRY_CIPHERTEXT_BYTES - GCM_TAG_BYTES) {
+ throw new KeyringException(Code.KEYRING_LIMIT_EXCEEDED);
+ }
+ byte[] aliasBytes = alias.getBytes(StandardCharsets.UTF_8);
+ byte[] algorithmBytes = algorithm.getBytes(StandardCharsets.UTF_8);
+ try {
+ int size = Math.addExact(Integer.BYTES + 3,
+ Math.addExact(lengthPrefixedSize(aliasBytes),
+ Math.addExact(lengthPrefixedSize(algorithmBytes),
+ lengthPrefixedSize(encoded))));
+ if (size > MAX_ENTRY_CIPHERTEXT_BYTES - GCM_TAG_BYTES) {
+ throw new KeyringException(Code.KEYRING_LIMIT_EXCEEDED);
+ }
+ ByteBuffer buffer = ByteBuffer.allocate(size);
+ buffer.putInt(ENTRY_FORMAT_VERSION);
+ putLengthPrefixed(buffer, aliasBytes);
+ putLengthPrefixed(buffer, algorithmBytes);
+ buffer.put((byte) kind.code);
+ buffer.put((byte) encoding.code);
+ buffer.put((byte) hmacVariant.code());
+ putLengthPrefixed(buffer, encoded);
+ return buffer.array();
+ } catch (ArithmeticException exception) {
+ throw new KeyringException(Code.KEYRING_LIMIT_EXCEEDED);
+ } finally {
+ wipe(aliasBytes);
+ wipe(algorithmBytes);
+ }
+ }
+
+ private static int lengthPrefixedSize(byte[] value) {
+ return Math.addExact(Integer.BYTES, value.length);
+ }
+
+ private static void putLengthPrefixed(ByteBuffer buffer, byte[] value) {
+ buffer.putInt(value.length);
+ buffer.put(value);
+ }
+
+ @SuppressWarnings("PMD.PreserveStackTrace")
+ private static EntryPlain decodeEntryPlaintext(byte[] plaintext) throws IOException {
+ try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(plaintext))) {
+ if (in.readInt() != ENTRY_FORMAT_VERSION) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ String alias = readString(in, MAX_ALIAS_BYTES);
+ String algorithm = readString(in, MAX_METADATA_BYTES);
+ Kind kind = Kind.fromCode(in.readUnsignedByte());
+ Encoding encoding = Encoding.fromCode(in.readUnsignedByte());
+ KeyringImportRegistry.HmacVariant hmacVariant =
+ KeyringImportRegistry.HmacVariant.fromCode(in.readUnsignedByte());
+ byte[] encoded = readBoundedBytes(in,
+ MAX_ENTRY_CIPHERTEXT_BYTES - GCM_TAG_BYTES,
+ MAX_ENTRY_CIPHERTEXT_BYTES - GCM_TAG_BYTES);
+ if (in.read() != -1) {
+ wipe(encoded);
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ return new EntryPlain(alias, algorithm, kind, encoding, hmacVariant, encoded);
+ } catch (EOFException exception) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ }
+
+ private static void validateEntryBinding(EncryptedEntry entry, EntryPlain decoded)
+ throws KeyringException {
+ if (!entry.alias.equals(decoded.alias) || !entry.algorithm.equals(decoded.algorithm)
+ || entry.kind != decoded.kind || entry.encoding != decoded.encoding
+ || entry.hmacVariant != decoded.hmacVariant) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ }
+
+ private static byte[] masterWrapAad(Header header) throws IOException {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (DataOutputStream out = new DataOutputStream(bytes)) {
+ out.write(MAGIC);
+ out.writeInt(FORMAT_VERSION);
+ out.write(header.storeId);
+ out.writeByte(KDF_PBKDF2_SHA256);
+ out.writeInt(header.iterations);
+ out.write(header.salt);
+ out.writeInt(KEK_BYTES);
+ out.writeByte(AEAD_AES_256_GCM);
+ }
+ return bytes.toByteArray();
+ }
+
+ private byte[] entryAad(byte[] entryId, int position) throws IOException {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (DataOutputStream out = new DataOutputStream(bytes)) {
+ out.write(MAGIC);
+ out.writeInt(FORMAT_VERSION);
+ out.write(storeId);
+ out.write(entryId);
+ out.writeInt(position);
+ out.writeInt(ENTRY_FORMAT_VERSION);
+ }
+ return bytes.toByteArray();
+ }
+
+ private byte[] manifestAad(int count) throws IOException {
+ return manifestAad(storeId, count);
+ }
+
+ private static byte[] manifestAad(byte[] storeId, int count) throws IOException {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (DataOutputStream out = new DataOutputStream(bytes)) {
+ out.write(MAGIC);
+ out.writeInt(FORMAT_VERSION);
+ out.write(storeId);
+ out.writeInt(MANIFEST_FORMAT_VERSION);
+ out.writeInt(count);
+ }
+ return bytes.toByteArray();
+ }
+
+ private byte[] nextNonce() throws IOException, GeneralSecurityException {
+ if (nonceHighWater == Long.MAX_VALUE) {
+ poisoned = true;
+ throw new KeyringException(Code.KEYRING_LIMIT_EXCEEDED);
+ }
+ long reserved = nonceHighWater + 1;
+ writeNonceReservation(reserved);
+ nonceHighWater = reserved;
+ ByteBuffer buffer = ByteBuffer.allocate(NONCE_BYTES);
+ buffer.put(ENTRY_NONCE_DOMAIN);
+ putThreeBytePrefix(buffer, noncePrefix);
+ buffer.putLong(nonceHighWater);
+ return buffer.array();
+ }
+
+ private static void validateNonce(byte[] nonce, int prefix, long highWater)
+ throws KeyringException {
+ if (nonce.length != NONCE_BYTES) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ ByteBuffer buffer = ByteBuffer.wrap(nonce);
+ if (buffer.get() != ENTRY_NONCE_DOMAIN) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ int actualPrefix = readThreeBytePrefix(buffer);
+ long counter = buffer.getLong();
+ if (actualPrefix != prefix || counter <= 0 || counter > highWater) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ }
+
+ private static int randomPrefix(KeyringRandomBytes random) {
+ byte[] prefix = new byte[3];
+ try {
+ random.nextBytes(prefix);
+ return (Byte.toUnsignedInt(prefix[0]) << 16)
+ | (Byte.toUnsignedInt(prefix[1]) << 8)
+ | Byte.toUnsignedInt(prefix[2]);
+ } finally {
+ wipe(prefix);
+ }
+ }
+
+ private static void fillMasterWrapNonce(KeyringRandomBytes random, byte[] nonce) {
+ byte[] randomPart = new byte[NONCE_BYTES - 1];
+ try {
+ random.nextBytes(randomPart);
+ nonce[0] = MASTER_WRAP_NONCE_DOMAIN;
+ System.arraycopy(randomPart, 0, nonce, 1, randomPart.length);
+ } finally {
+ wipe(randomPart);
+ }
+ }
+
+ private static void putThreeBytePrefix(ByteBuffer buffer, int prefix) {
+ buffer.put((byte) (prefix >>> 16));
+ buffer.put((byte) (prefix >>> 8));
+ buffer.put((byte) prefix);
+ }
+
+ private static int readThreeBytePrefix(ByteBuffer buffer) {
+ return (Byte.toUnsignedInt(buffer.get()) << 16)
+ | (Byte.toUnsignedInt(buffer.get()) << 8)
+ | Byte.toUnsignedInt(buffer.get());
+ }
+
+ private static byte[] deriveKek(KeyringPassword password, byte[] salt, int iterations)
+ throws GeneralSecurityException {
+ char[] chars = password.copy();
+ PBEKeySpec spec = new PBEKeySpec(chars, salt, iterations, KEK_BYTES * Byte.SIZE);
+ Arrays.fill(chars, '\0');
+ try {
+ return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
+ .generateSecret(spec).getEncoded();
+ } finally {
+ spec.clearPassword();
+ }
+ }
+
+ private static byte[] crypt(int mode, byte[] key, byte[] nonce, byte[] aad,
+ byte[] input) throws GeneralSecurityException {
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(mode, new SecretKeySpec(key, "AES"),
+ new GCMParameterSpec(GCM_TAG_BITS, nonce));
+ cipher.updateAAD(aad);
+ return cipher.doFinal(input);
+ }
+
+ @SuppressWarnings("PMD.PreserveStackTrace")
+ private static byte[] digest(byte[] value) throws KeyringException {
+ try {
+ return MessageDigest.getInstance("SHA-256").digest(value);
+ } catch (GeneralSecurityException exception) {
+ throw new KeyringException(Code.KEYRING_IO_FAILED);
+ }
+ }
+
+ private static void validateIterations(int iterations, KeyringProtection protection)
+ throws KeyringException {
+ if (iterations < KeyringProtection.CREATION_ITERATIONS
+ || iterations > KeyringProtection.MAX_DECODED_ITERATIONS
+ || iterations > protection.operationalIterationMaximum()) {
+ throw new KeyringException(Code.KEYRING_LIMIT_EXCEEDED);
+ }
+ }
+
+ private static boolean matchesFormat(String actual, Encoding encoding) {
+ return switch (encoding) {
+ case X509 -> "X.509".equalsIgnoreCase(actual);
+ case PKCS8 -> "PKCS#8".equalsIgnoreCase(actual);
+ case RAW -> "RAW".equalsIgnoreCase(actual);
+ };
+ }
+
+ private static int positionFor(Map values,
+ String alias) {
+ int index = 0;
+ for (String current : values.keySet()) {
+ if (current.equals(alias)) {
+ return index;
+ }
+ index++;
+ }
+ return values.size();
+ }
+
+ private static String withPublicSuffix(String alias) {
+ requireAlias(alias);
+ return alias.endsWith(SUFFIX_PUBLIC) ? alias : alias + SUFFIX_PUBLIC;
+ }
+
+ private static String withPrivateSuffix(String alias) {
+ requireAlias(alias);
+ return alias.endsWith(SUFFIX_PRIVATE) ? alias : alias + SUFFIX_PRIVATE;
}
private static String stripKnownSuffix(String alias) {
- if (alias == null) {
- return null;
- }
if (alias.endsWith(SUFFIX_PUBLIC)) {
return alias.substring(0, alias.length() - SUFFIX_PUBLIC.length());
}
@@ -431,330 +1436,214 @@ public final class KeyringStore { // NOPMD
return alias;
}
- /**
- * Resolves the secret key bound to the alias.
- *
- * @param alias the alias of a {@link Record.Kind#SECRET_KEY} entry
- * @return a secret key instance created from the stored spec
- * @throws GeneralSecurityException
- * @throws IllegalArgumentException if the alias is missing or the entry is not
- * a secret key
- * @throws GeneralSecurityException if spec unmarshaling or key construction
- * fails
- */
- public SecretKey getSecret(String alias) throws GeneralSecurityException {
- Record r = require(alias, Record.Kind.SECRET_KEY);
- AlgorithmKeySpec spec = unmarshalRecord(r);
- Throwable failure = null;
- try {
- return session.keyBuilders().symmetric().importKey(r.algorithm, spec);
- } catch (GeneralSecurityException | RuntimeException | Error exception) { // NOPMD - retain primary failure
- failure = exception;
- throw exception;
- } finally {
- destroyTemporarySpec(spec, failure);
- }
- }
-
- /**
- * Saves the entire keyring to a UTF-8 text file.
- *
- * @param path destination path
- * @throws IOException if writing fails
- */
- public void save(Path path) throws IOException {
- try (BufferedWriter w = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
- writeAll(w, byAlias.values());
- }
- }
-
- /**
- * Loads a keyring from a UTF-8 text file.
- *
- * @param session explicit runtime configuration
- * @param path source path
- * @return a new store populated with entries from the file
- * @throws IOException if reading fails or the format is not supported
- */
- public static KeyringStore load(ZeroEchoSession session, Path path) throws IOException {
- try (BufferedReader r = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
- List recs = readAll(r, /* requireHeader */ true);
- KeyringStore store = new KeyringStore(session);
- recs.forEach(rec -> store.byAlias.put(rec.alias, rec));
- return store;
- }
- }
-
- /**
- * Exports the specified aliases as a versioned, line-oriented text snippet.
- *
- * @param aliases aliases to export; each alias must exist in this store
- * @return UTF-8 text beginning with the magic header
- * @throws IllegalArgumentException if an alias is not present
- */
- public String exportText(Collection aliases) {
- StringWriter out = new StringWriter(256);
- List recs = new ArrayList<>(aliases.size());
- for (String a : aliases) {
- Record r = byAlias.get(a);
- if (r == null) {
- throw new IllegalArgumentException("Missing alias: " + a);
- }
- recs.add(r);
- }
- try {
- writeAll(out, recs);
- } catch (IOException impossible) {
- throw new AssertionError(impossible);
- }
- return out.toString();
- }
-
- /**
- * Imports a versioned, line-oriented snippet into this store.
- *
- * @param text snippet text
- * @param overwrite whether collisions are allowed
- * @throws IOException if the header is missing/unsupported or the
- * body is malformed
- * @throws IllegalArgumentException if {@code overwrite} is false and a
- * collision occurs
- */
- public void importText(String text, boolean overwrite) throws IOException {
- if (text == null) {
- throw new IOException("Snippet is null");
- }
- try (BufferedReader r = new BufferedReader(new StringReader(text))) {
- List recs = readAll(r, /* requireHeader */ true);
- for (Record rec : recs) {
- if (!overwrite && byAlias.containsKey(rec.alias)) {
- throw new IllegalArgumentException("Alias already exists: " + rec.alias);
- }
- byAlias.put(rec.alias, rec);
- }
- }
- }
-
- // ---------------------------------------------------------------------
- // Shared IO helpers (used by save/load and export/import)
- // ---------------------------------------------------------------------
-
- /**
- * Writes the magic header and all records to {@code w}.
- */
- private static void writeAll(Writer w, Collection records) throws IOException {
- w.write(MAGIC_HEADER);
- w.write('\n');
- for (Record r : records) {
- writeRecord(w, r);
- }
- }
-
- /**
- * Writes a single record in the canonical line-oriented format.
- */
- private static void writeRecord(Writer w, Record r) throws IOException {
- w.write(ENTRY_MARKER);
- w.write('\n');
- w.write("alias=");
- w.write(r.alias);
- w.write('\n');
- w.write("algorithm=");
- w.write(r.algorithm);
- w.write('\n');
- w.write("kind=");
- w.write(r.kind.name());
- w.write('\n');
- w.write("spec=");
- w.write(r.specClass);
- w.write('\n');
-
- PairSeq.Cursor c = r.specPayload.cursor();
- while (c.next()) {
- w.write(PREFIX_SPEC);
- w.write(c.key());
- w.write('=');
- w.write(c.value());
- w.write('\n');
- }
- w.write('\n'); // end of entry
- }
-
- /**
- * Reads and returns all records from {@code r}. When {@code requireHeader} is
- * true the first non-empty line must equal the magic header.
- */
- private static List readAll(BufferedReader r, boolean requireHeader) throws IOException { // NOPMD
- List recs = new ArrayList<>();
- String line;
-
- // header
- while ((line = r.readLine()) != null && line.isBlank()) { // NOPMD
- /* skip leading blanks */
- }
- if (requireHeader && line == null || !MAGIC_HEADER.equals(line.trim())) {
- throw new IOException("Unsupported keyring header. Expected: " + MAGIC_HEADER);
- }
-
- String alias;
- String algorithm;
- Record.Kind kind;
- String spec;
- List payload;
-
- alias = algorithm = spec = null;
- kind = null;
- payload = new ArrayList<>();
-
- while ((line = r.readLine()) != null) {
- if (line.isEmpty()) {
- if (alias != null) {
- recs.add(new Record(alias, algorithm, kind, spec, PairSeq.of(payload.toArray(String[]::new))));
- }
- alias = algorithm = spec = null;
- kind = null;
- payload = new ArrayList<>(); // NOPMD
- continue;
- }
- if (line.startsWith("#")) {
- continue;
- }
-
- if (ENTRY_MARKER.equals(line)) {
- if (alias != null) {
- recs.add(new Record(alias, algorithm, kind, spec, PairSeq.of(payload.toArray(String[]::new))));
- }
- alias = algorithm = spec = null;
- kind = null;
- payload = new ArrayList<>(); // NOPMD
- continue;
- }
-
- int eq = line.indexOf('=');
- if (eq <= 0) {
- throw new IOException("Malformed line: " + line);
- }
- String k = line.substring(0, eq);
- String v = line.substring(eq + 1);
-
- switch (k) {
- case "alias" -> alias = v;
- case "algorithm" -> algorithm = v;
- case "kind" -> kind = Record.Kind.valueOf(v);
- case "spec" -> spec = v;
- default -> {
- if (k.startsWith(PREFIX_SPEC)) {
- String sk = k.substring(PREFIX_SPEC.length());
- payload.add(sk);
- payload.add(v);
- }
- }
- }
- }
-
- if (alias != null) {
- recs.add(new Record(alias, algorithm, kind, spec, PairSeq.of(payload.toArray(String[]::new))));
- }
- return recs;
- }
-
- private void put(String alias, String algorithmId, Record.Kind kind, AlgorithmKeySpec importSpec) {
+ private static void requireAlias(String alias) {
if (alias == null || alias.isBlank()) {
- throw new IllegalArgumentException("alias");
+ throw new IllegalArgumentException("alias must not be blank");
}
- if (algorithmId == null || algorithmId.isBlank()) {
- throw new IllegalArgumentException("algorithmId");
- }
- if (importSpec == null) {
- throw new IllegalArgumentException("importSpec");
- }
- registeredSpecClass(algorithmId, kind, importSpec.getClass().getName());
-
- PairSeq payload = marshalSpec(importSpec);
-
- Record r = new Record(alias, algorithmId, kind, importSpec.getClass().getName(), payload);
-
- byAlias.put(alias, r);
}
- private Record require(String alias, Record.Kind kind) {
- Record r = byAlias.get(alias);
- if (r == null) {
- throw new IllegalArgumentException("No entry: " + alias);
+ private static void validateString(String value, int maximumBytes)
+ throws KeyringException {
+ if (value == null || value.isBlank()
+ || value.getBytes(StandardCharsets.UTF_8).length > maximumBytes) {
+ throw new KeyringException(Code.KEYRING_LIMIT_EXCEEDED);
}
- if (r.kind != kind) {
- throw new IllegalArgumentException("Alias '" + alias + "' is not a " + kind);
- }
- return r;
}
- /**
- * Calls a static {@code marshal(SpecType)} method on the spec class.
- *
- * @param spec the spec instance to marshal
- * @return a pair sequence representing the spec payload
- * @throws IllegalStateException if the spec class does not expose a compatible
- * static method
- */
- private static PairSeq marshalSpec(AlgorithmKeySpec spec) {
+ private void ensureOpen() throws KeyringException {
+ if (destroyed.get() || poisoned) {
+ throw new KeyringException(Code.KEYRING_CLOSED);
+ }
+ }
+
+ private void ensureOpenUnchecked() {
+ if (destroyed.get() || poisoned) {
+ throw new IllegalStateException(Code.KEYRING_CLOSED.name());
+ }
+ }
+
+ private static void writeFixed(DataOutputStream out, byte[] value, int expected)
+ throws IOException, KeyringException {
+ if (value.length != expected) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ out.write(value);
+ }
+
+ private static void writeBoundedBytes(DataOutputStream out, byte[] value, int maximum)
+ throws IOException, KeyringException {
+ if (value.length < 0 || value.length > maximum) {
+ throw new KeyringException(Code.KEYRING_LIMIT_EXCEEDED);
+ }
+ out.writeInt(value.length);
+ out.write(value);
+ }
+
+ private static byte[] readFixed(DataInputStream in, int length) throws IOException {
+ byte[] value = new byte[length];
+ in.readFully(value);
+ return value;
+ }
+
+ private static byte[] readBoundedBytes(DataInputStream in, int maximum,
+ int exactOrMaximum) throws IOException {
+ int length = in.readInt();
+ if (length < 0 || length > maximum
+ || exactOrMaximum < maximum && length != exactOrMaximum) {
+ throw new KeyringException(Code.KEYRING_LIMIT_EXCEEDED);
+ }
+ byte[] value = new byte[length];
+ in.readFully(value);
+ return value;
+ }
+
+ private static void writeString(DataOutputStream out, String value, int maximum)
+ throws IOException, KeyringException {
+ byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
try {
- Method m = spec.getClass().getMethod("marshal", spec.getClass());
- Object out = m.invoke(null, spec);
- return (PairSeq) out;
- } catch (NoSuchMethodException e) {
- throw new IllegalStateException("Spec class lacks static marshal(Spec): " + spec.getClass().getName(), e);
- } catch (IllegalAccessException | InvocationTargetException | SecurityException e) {
- throw new IllegalStateException("Spec marshal failed: " + spec.getClass().getName(), e);
+ writeBoundedBytes(out, encoded, maximum);
+ } finally {
+ wipe(encoded);
}
}
- /**
- * Calls a static {@code unmarshal(PairSeq)} method on the spec class.
- *
- * @param spec type
- * @param specClass registered specification class
- * @param p the pair sequence to unmarshal
- * @return the reconstructed spec instance
- * @throws IllegalStateException if reflection fails or the method is absent
- */
- @SuppressWarnings("unchecked")
- private static S unmarshalSpec(Class specClass, PairSeq p) {
+ @SuppressWarnings("PMD.PreserveStackTrace")
+ private static String readString(DataInputStream in, int maximum) throws IOException {
+ byte[] encoded = readBoundedBytes(in, maximum, maximum);
try {
- Method m = specClass.getMethod("unmarshal", PairSeq.class);
- Object out = m.invoke(null, p);
- return (S) out;
- } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException | SecurityException e) {
- throw new IllegalStateException("Spec unmarshal failed for " + specClass.getName(), e);
+ try {
+ return StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(encoded)).toString();
+ } catch (CharacterCodingException exception) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ } finally {
+ wipe(encoded);
}
}
- private AlgorithmKeySpec unmarshalRecord(Record record) {
- Class extends AlgorithmKeySpec> specClass = registeredSpecClass(record.algorithm, record.kind,
- record.specClass);
- return unmarshalSpec(specClass, record.specPayload);
- }
-
- private Class extends AlgorithmKeySpec> registeredSpecClass(String algorithmId, Record.Kind kind,
- String persistedClassName) {
- if (persistedClassName == null || persistedClassName.isBlank()) {
- throw new IllegalArgumentException("Missing key specification class");
+ private static void requireCode(int actual, int expected) throws KeyringException {
+ if (actual != expected) {
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
}
- CryptoAlgorithm algorithm = session.require(algorithmId);
- KeyOperation expectedOperation = switch (kind) {
- case PUBLIC_KEY -> KeyOperation.ASYMMETRIC_PUBLIC_IMPORT;
- case PRIVATE_KEY -> KeyOperation.ASYMMETRIC_PRIVATE_IMPORT;
- case SECRET_KEY -> KeyOperation.SYMMETRIC_IMPORT;
- };
- return algorithm.keyOperations().stream()
- .filter(info -> info.operation() == expectedOperation)
- .map(KeyOperationInfo::specType)
- .filter(type -> type.getName().equals(persistedClassName))
- .findFirst()
- .orElseThrow(() -> new IllegalArgumentException(
- "Specification class is not registered for " + algorithmId + " " + kind));
}
- /* default */ static void destroyTemporarySpec(AlgorithmKeySpec spec, Throwable primary)
+ private static UUID uuid(byte[] bytes) {
+ ByteBuffer buffer = ByteBuffer.wrap(bytes);
+ return new UUID(buffer.getLong(), buffer.getLong());
+ }
+
+ private static Path nonceReservationPath(Path keyring) {
+ return keyring.resolveSibling(keyring.getFileName() + ".nonce");
+ }
+
+ @SuppressWarnings("PMD.PreserveStackTrace")
+ private static byte[] readStoreImage(Path source) throws IOException {
+ try (FileChannel channel = FileChannel.open(source,
+ StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
+ long size = channel.size();
+ if (size <= 0 || size > MAX_FILE_BYTES) {
+ throw new KeyringException(Code.KEYRING_LIMIT_EXCEEDED);
+ }
+ byte[] image = new byte[Math.toIntExact(size)];
+ ByteBuffer target = ByteBuffer.wrap(image);
+ while (target.hasRemaining()) {
+ if (channel.read(target) < 0) {
+ wipe(image);
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ }
+ ByteBuffer trailing = ByteBuffer.allocate(1);
+ if (channel.read(trailing) >= 0) {
+ wipe(image);
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ return image;
+ } catch (ArithmeticException exception) {
+ throw new KeyringException(Code.KEYRING_LIMIT_EXCEEDED);
+ }
+ }
+
+ private static byte[] readFixedImage(Path source, int expected) throws IOException {
+ byte[] image = readStoreImage(source);
+ if (image.length != expected) {
+ wipe(image);
+ throw new KeyringException(Code.KEYRING_FORMAT_INVALID);
+ }
+ return image;
+ }
+
+ @SuppressWarnings("PMD.PreserveStackTrace")
+ private static Path securePath(Path requested, boolean createParent)
+ throws IOException {
+ Objects.requireNonNull(requested, "path must not be null");
+ Path absolute = requested.toAbsolutePath().normalize();
+ Path parent = absolute.getParent();
+ if (parent == null) {
+ throw new KeyringException(Code.KEYRING_FILESYSTEM_UNSUPPORTED);
+ }
+ if (!Files.exists(parent, LinkOption.NOFOLLOW_LINKS)) {
+ if (!createParent) {
+ throw new KeyringException(Code.KEYRING_IO_FAILED);
+ }
+ try {
+ Files.createDirectories(parent, DIRECTORY_ATTRIBUTE);
+ } catch (UnsupportedOperationException exception) {
+ throw new KeyringException(Code.KEYRING_FILESYSTEM_UNSUPPORTED);
+ }
+ }
+ if (Files.isSymbolicLink(parent)) {
+ throw new KeyringException(Code.KEYRING_FILESYSTEM_UNSUPPORTED);
+ }
+ FileStore store = Files.getFileStore(parent);
+ if (!store.supportsFileAttributeView(PosixFileAttributeView.class)) {
+ throw new KeyringException(Code.KEYRING_FILESYSTEM_UNSUPPORTED);
+ }
+ validateOwnerOnly(parent, true);
+ if (Files.isSymbolicLink(absolute)) {
+ throw new KeyringException(Code.KEYRING_FILESYSTEM_UNSUPPORTED);
+ }
+ return absolute;
+ }
+
+ @SuppressWarnings("PMD.PreserveStackTrace")
+ private static void validateExistingFile(Path file, UserPrincipal expectedOwner)
+ throws IOException {
+ if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)
+ || Files.isSymbolicLink(file)) {
+ throw new KeyringException(Code.KEYRING_FILESYSTEM_UNSUPPORTED);
+ }
+ validateOwnerOnly(file, false);
+ PosixFileAttributes attributes = Files.readAttributes(file,
+ PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
+ if (!attributes.owner().equals(expectedOwner)) {
+ throw new KeyringException(Code.KEYRING_FILESYSTEM_UNSUPPORTED);
+ }
+ try {
+ Object links = Files.getAttribute(file, "unix:nlink", LinkOption.NOFOLLOW_LINKS);
+ if (!(links instanceof Number number) || number.longValue() != 1L) {
+ throw new KeyringException(Code.KEYRING_FILESYSTEM_UNSUPPORTED);
+ }
+ } catch (UnsupportedOperationException exception) {
+ throw new KeyringException(Code.KEYRING_FILESYSTEM_UNSUPPORTED);
+ }
+ }
+
+ private static void validateOwnerOnly(Path path, boolean directory)
+ throws IOException {
+ PosixFileAttributes attributes = Files.readAttributes(path,
+ PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
+ Set expected = directory ? DIRECTORY_PERMISSIONS : FILE_PERMISSIONS;
+ if (!attributes.permissions().equals(expected)) {
+ throw new KeyringException(Code.KEYRING_FILESYSTEM_UNSUPPORTED);
+ }
+ }
+
+ @SuppressWarnings("PMD.PreserveStackTrace")
+ /* default */ static void destroyTemporarySpec(
+ zeroecho.core.spec.AlgorithmKeySpec spec, Throwable primary)
throws GeneralSecurityException {
if (!(spec instanceof Destroyable destroyable)) {
return;
@@ -766,15 +1655,179 @@ public final class KeyringStore { // NOPMD
} catch (DestroyFailedException failure) {
if (primary != null) {
primary.addSuppressed(failure);
+ } else {
+ throw new GeneralSecurityException("Temporary key specification destruction failed");
+ }
+ }
+ }
+
+ private static void wipe(byte[] value) {
+ if (value != null) {
+ Arrays.fill(value, (byte) 0);
+ }
+ }
+
+ private record Header(byte[] storeId, int iterations, byte[] salt,
+ byte[] masterWrapNonce, byte[] wrappedMasterKey) {
+ }
+
+ private record Manifest(int noncePrefix, long highWater,
+ List entries) {
+ }
+
+ private record ManifestEntry(byte[] entryId, int position, String alias,
+ String algorithm, Kind kind, Encoding encoding,
+ KeyringImportRegistry.HmacVariant hmacVariant,
+ byte[] nonce, int ciphertextLength, byte[] digest) {
+ }
+
+ private record EncryptedWireEntry(byte[] entryId, byte[] nonce,
+ byte[] ciphertext) {
+ }
+
+ /** Immutable encrypted entry retained while the store is open. */
+ private static final class EncryptedEntry {
+ private final byte[] entryId;
+ private final int position;
+ private final String alias;
+ private final String algorithm;
+ private final Kind kind;
+ private final Encoding encoding;
+ private final KeyringImportRegistry.HmacVariant hmacVariant;
+ private final byte[] nonce;
+ private final byte[] ciphertext;
+ private final byte[] digest;
+
+ private EncryptedEntry(byte[] entryId, int position, String alias,
+ String algorithm, Kind kind, Encoding encoding,
+ KeyringImportRegistry.HmacVariant hmacVariant,
+ byte[] nonce, byte[] ciphertext, byte[] digest) {
+ this.entryId = entryId.clone();
+ this.position = position;
+ this.alias = alias;
+ this.algorithm = algorithm;
+ this.kind = kind;
+ this.encoding = encoding;
+ this.hmacVariant = hmacVariant;
+ this.nonce = nonce.clone();
+ this.ciphertext = ciphertext.clone();
+ this.digest = digest.clone();
+ }
+ }
+
+ private record EntryPlain(String alias, String algorithm, Kind kind,
+ Encoding encoding, KeyringImportRegistry.HmacVariant hmacVariant,
+ byte[] encoded) {
+ }
+
+ private record Imported(String algorithm, Key key) {
+ }
+
+ private record Decoded(Header header, byte[] masterKey, Manifest manifest,
+ Map entries) {
+ }
+
+ /** Value-semantic nonce used only for duplicate detection. */
+ private static final class Nonce {
+ private final byte[] bytes;
+
+ private Nonce(byte[] bytes) {
+ this.bytes = bytes.clone();
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return other instanceof Nonce nonce && Arrays.equals(bytes, nonce.bytes);
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(bytes);
+ }
+ }
+
+ /** Process-lifetime ownership of the sibling lock file. */
+ private static final class Ownership implements AutoCloseable {
+ private final FileChannel channel;
+ private final FileLock lock;
+ private final UserPrincipal owner;
+ private final AtomicBoolean closed = new AtomicBoolean();
+
+ private Ownership(FileChannel channel, FileLock lock, UserPrincipal owner) {
+ this.channel = channel;
+ this.lock = lock;
+ this.owner = owner;
+ }
+
+ @SuppressWarnings({ "PMD.PreserveStackTrace", "PMD.ExceptionAsFlowControl",
+ "PMD.AvoidCatchingGenericException" })
+ /* default */ static Ownership acquire(Path keyring) throws IOException {
+ Path lockPath = keyring.resolveSibling(keyring.getFileName() + ".lock");
+ try {
+ UserPrincipal expectedOwner = Files.readAttributes(lockPath.getParent(),
+ PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS).owner();
+ if (!Files.exists(lockPath, LinkOption.NOFOLLOW_LINKS)) {
+ try {
+ Files.createFile(lockPath, FILE_ATTRIBUTE);
+ } catch (java.nio.file.FileAlreadyExistsException ignored) {
+ // A concurrent creator won; validate the existing file below.
+ }
+ }
+ validateExistingFile(lockPath, expectedOwner);
+ FileChannel channel = FileChannel.open(lockPath,
+ StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
+ try {
+ FileLock lock;
+ try {
+ lock = channel.tryLock();
+ } catch (OverlappingFileLockException exception) {
+ lock = null;
+ }
+ if (lock == null) {
+ channel.close();
+ throw new KeyringException(Code.KEYRING_ALREADY_OPEN);
+ }
+ return new Ownership(channel, lock, expectedOwner);
+ } catch (IOException | RuntimeException exception) {
+ if (channel.isOpen()) {
+ channel.close();
+ }
+ throw exception;
+ }
+ } catch (KeyringException exception) {
+ throw exception;
+ } catch (IOException exception) {
+ throw new KeyringException(Code.KEYRING_IO_FAILED);
+ }
+ }
+
+ private UserPrincipal owner() {
+ return owner;
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (!closed.compareAndSet(false, true)) {
return;
}
- throw new GeneralSecurityException("Temporary key specification destruction failed", failure);
- } catch (RuntimeException failure) { // NOPMD - preserve cleanup failure and primary failure
- if (primary != null) {
- primary.addSuppressed(failure);
- return;
+ IOException failure = null;
+ try {
+ lock.release();
+ } catch (IOException exception) {
+ failure = exception;
+ }
+ try {
+ channel.close();
+ } catch (IOException exception) {
+ if (failure == null) {
+ failure = exception;
+ } else {
+ failure.addSuppressed(exception);
+ }
+ }
+ if (failure != null) {
+ throw new KeyringException(Code.KEYRING_IO_FAILED);
}
- throw failure;
}
}
}
diff --git a/lib/src/main/java/zeroecho/core/storage/package-info.java b/lib/src/main/java/zeroecho/core/storage/package-info.java
index 8797532..dc0c316 100644
--- a/lib/src/main/java/zeroecho/core/storage/package-info.java
+++ b/lib/src/main/java/zeroecho/core/storage/package-info.java
@@ -1,25 +1,25 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
- *
+ *
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
- *
+ *
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
- *
+ *
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
- *
+ *
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
- *
+ *
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
- *
+ *
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
@@ -32,94 +32,51 @@
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
/**
- * Human-editable key storage persisted in a compact UTF-8 text format.
+ * Encrypted local software-keyring storage.
*
*
- * This package provides a lightweight keyring that applications can read and
- * write without specialized tooling. The store keeps entries in insertion
- * order, allows simple alias-based lookups, and materializes keys through the
- * core catalog.
+ * {@link zeroecho.core.storage.KeyringStore} binds an unlocked store to one
+ * filesystem path and holds exclusive process-lifetime ownership until close.
+ * It wraps one random store master key with PBKDF2-HMAC-SHA-256 and
+ * AES-256-GCM, encrypts every entry independently, and authenticates the
+ * ordered entry manifest. Mutations use owner-only temporary files and atomic
+ * replacement.
*
*
- * Key elements
- *
- * - {@link KeyringStore} - in-memory map of aliases to immutable records with
- * helpers to add, load, save, import, export, and resolve keys.
- * - {@link KeyringStore.Record} - value object describing one entry: alias,
- * algorithm id, {@link KeyringStore.Record.Kind kind}, spec class, and a
- * {@link zeroecho.core.marshal.PairSeq} payload.
- * - Resolution helpers - {@link KeyringStore#getPublic(String)},
- * {@link KeyringStore#getPrivate(String)}, and
- * {@link KeyringStore#getSecret(String)} plus
- * {@link KeyringStore.PublicWithId}, {@link KeyringStore.PrivateWithId},
- * {@link KeyringStore.SecretWithId} to return the algorithm id together with
- * the key.
- *
- *
- * File format
*
- * Files begin with a magic header followed by one or more @entry
- * blocks. Keys that belong to the spec payload are prefixed with
- * s. to avoid collisions with top-level fields; in-memory they are
- * stored without the prefix. Lines beginning with # are comments.
- * A blank line terminates an entry.
+ * Nonce-reservation authentication is cryptographically separated from AES
+ * entry and manifest encryption. A dedicated 256-bit MAC key is derived with
+ * HKDF-HMAC-SHA-256 from the store master key, canonical binary store UUID, and
+ * fixed nonce-reservation domain. It is never persisted, remains owned by the
+ * open store, and is destroyed on close. Only the current sidecar version is
+ * accepted.
*
*
- * {@code
- * # KeyringStore v1
- * @entry
- * alias=my-rsa
- * algorithm=RSA
- * kind=PUBLIC_KEY
- * spec=zeroecho.core.alg.rsa.RsaPublicKeySpec
- * s.x509B64=MIIBIjANBgkqh...
- * }
- *
- * Spec marshaling contract
*
- * Each spec class named in the spec field must expose two public
- * static methods discovered by reflection:
+ * Unlock passwords are destroyable, transfer ownership to the receiver, and
+ * are destroyed immediately after the master key is unwrapped. The unlocked
+ * store retains the master key, its domain-separated nonce-reservation MAC
+ * key, and encrypted entry records; closing the store clears this material.
+ * The store requires a POSIX filesystem on which owner-only permissions can be
+ * verified. A directory-force failure after atomic replacement makes the open
+ * instance unusable until close and authenticated reopen resolves which
+ * complete image is current. Non-exportable keys must remain behind an
+ * external provider reference.
*
- *
- * static PairSeq marshal(SpecType spec)
- * static SpecType unmarshal(PairSeq pairs)
- *
+ *
*
- * {@link KeyringStore} validates each persisted specification type against the
- * selected algorithm's registered import operation before invoking these
- * methods.
+ * Only the current binary format is accepted. Earlier plaintext development
+ * formats are rejected without migration. Persisted input contains no Java
+ * class or provider names and cannot trigger runtime class loading. Import
+ * specifications are selected from a closed mapping of canonical ZeroEcho
+ * algorithm, key kind, standard encoding, and (only for HMAC) one of the
+ * explicit SHA-256, SHA-384, or SHA-512 variants. Standard encodings are
+ * reconstructed through the current runtime's canonical importer; the
+ * originating JCA provider identity is not persisted or guaranteed. Keys that
+ * are provider-bound, non-exportable, or not canonically reconstructable must
+ * remain behind an external provider reference.
*
*
- * Typical usage
{@code
- * // Create and persist a keyring.
- * zeroecho.sdk.ZeroEchoSession session = new zeroecho.sdk.ZeroEchoSession();
- * KeyringStore ks = new KeyringStore(session);
- * ks.putPublic("site-signing", "Ed25519", myEd25519PublicSpec);
- * ks.putPrivate("site-signing", "Ed25519", myEd25519PrivateSpec);
- * ks.save(java.nio.file.Path.of("keyring.txt"));
- *
- * // Load and resolve a key later.
- * KeyringStore reloaded = KeyringStore.load(session, java.nio.file.Path.of("keyring.txt"));
- * java.security.PublicKey pub = reloaded.getPublic("site-signing");
- * }
- *
- * Notes and recommendations
- *
- * - Persistence is plaintext. Treat files as sensitive, protect with OS
- * permissions, and avoid committing to VCS.
- * - Resolution delegates to {@link zeroecho.core.CryptoAlgorithms}; the
- * algorithm id must be one that the catalog recognizes.
- * - Records are immutable;
- * {@link KeyringStore#putPublic(String, String, zeroecho.core.spec.AlgorithmKeySpec)},
- * {@link KeyringStore#putPrivate(String, String, zeroecho.core.spec.AlgorithmKeySpec)},
- * and
- * {@link KeyringStore#putSecret(String, String, zeroecho.core.spec.AlgorithmKeySpec)}
- * replace entries by alias.
- * - Lookups validate kind; for example,
- * {@link KeyringStore#getPublic(String)} fails if the alias stores a private
- * key.
- *
- *
* @since 1.0
*/
package zeroecho.core.storage;
diff --git a/lib/src/main/java/zeroecho/core/util/RandomSupport.java b/lib/src/main/java/zeroecho/core/util/RandomSupport.java
new file mode 100644
index 0000000..4843b9c
--- /dev/null
+++ b/lib/src/main/java/zeroecho/core/util/RandomSupport.java
@@ -0,0 +1,70 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ ******************************************************************************/
+package zeroecho.core.util;
+
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Provides the authoritative process-wide cryptographic random source.
+ */
+public final class RandomSupport {
+ private static final Logger LOG = Logger.getLogger(RandomSupport.class.getName());
+ private static final SecureRandom RANDOM = createRandom();
+
+ private RandomSupport() {
+ }
+
+ /**
+ * Returns the shared thread-safe random source.
+ *
+ * @return shared secure random generator
+ */
+ public static SecureRandom getRandom() {
+ return RANDOM;
+ }
+
+ /**
+ * Allocates and fills a random byte array.
+ *
+ * @param size required array length
+ * @return random bytes
+ */
+ public static byte[] generateRandom(int size) {
+ return generateRandom(new byte[size]);
+ }
+
+ /**
+ * Fills the supplied array with random bytes.
+ *
+ * @param buffer destination array
+ * @return {@code buffer}
+ */
+ public static byte[] generateRandom(byte[] buffer) {
+ RANDOM.nextBytes(buffer);
+ return buffer;
+ }
+
+ /**
+ * Returns a uniform value below the bound.
+ *
+ * @param bound exclusive positive upper bound
+ * @return generated value
+ */
+ public static int nextInt(int bound) {
+ return RANDOM.nextInt(bound);
+ }
+
+ private static SecureRandom createRandom() {
+ try {
+ return SecureRandom.getInstanceStrong();
+ } catch (NoSuchAlgorithmException exception) {
+ LOG.log(Level.WARNING, "Strong SecureRandom unavailable; using the platform default");
+ return new SecureRandom();
+ }
+ }
+}
diff --git a/lib/src/main/java/zeroecho/sdk/guard/Encryptor.java b/lib/src/main/java/zeroecho/sdk/guard/Encryptor.java
index f17ab2b..0a4c7fb 100644
--- a/lib/src/main/java/zeroecho/sdk/guard/Encryptor.java
+++ b/lib/src/main/java/zeroecho/sdk/guard/Encryptor.java
@@ -52,7 +52,7 @@ import zeroecho.sdk.builders.alg.AesDataContentBuilder;
import zeroecho.sdk.builders.alg.ChaChaDataContentBuilder;
import zeroecho.sdk.content.api.DataContent;
import zeroecho.sdk.content.api.EncryptedContent;
-import zeroecho.sdk.util.RandomSupport;
+import zeroecho.core.util.RandomSupport;
/**
* Encrypting stage that emits the recipient table followed by the symmetric
diff --git a/lib/src/main/java/zeroecho/sdk/guard/KemCtxRecipient.java b/lib/src/main/java/zeroecho/sdk/guard/KemCtxRecipient.java
index 2d37645..348c13e 100644
--- a/lib/src/main/java/zeroecho/sdk/guard/KemCtxRecipient.java
+++ b/lib/src/main/java/zeroecho/sdk/guard/KemCtxRecipient.java
@@ -42,7 +42,7 @@ import java.util.Objects;
import zeroecho.core.context.KemContext;
import zeroecho.core.context.KemContext.KemResult;
import zeroecho.core.io.Util;
-import zeroecho.sdk.util.RandomSupport;
+import zeroecho.core.util.RandomSupport;
/**
* Recipient implementation that derives a key-encryption key (KEK) via a
diff --git a/lib/src/main/java/zeroecho/sdk/guard/PasswordRecipient.java b/lib/src/main/java/zeroecho/sdk/guard/PasswordRecipient.java
index dd061ef..95d1a20 100644
--- a/lib/src/main/java/zeroecho/sdk/guard/PasswordRecipient.java
+++ b/lib/src/main/java/zeroecho/sdk/guard/PasswordRecipient.java
@@ -44,7 +44,7 @@ import javax.security.auth.Destroyable;
import zeroecho.core.io.Util;
import zeroecho.sdk.Pbkdf2Limits;
-import zeroecho.sdk.util.RandomSupport;
+import zeroecho.core.util.RandomSupport;
/**
* Password recipient that derives a KEK via PBKDF2(HMAC-SHA-256) and wraps the
diff --git a/lib/src/main/java/zeroecho/sdk/util/Password.java b/lib/src/main/java/zeroecho/sdk/util/Password.java
index 3ab6479..8a397e7 100644
--- a/lib/src/main/java/zeroecho/sdk/util/Password.java
+++ b/lib/src/main/java/zeroecho/sdk/util/Password.java
@@ -33,6 +33,8 @@
******************************************************************************/
package zeroecho.sdk.util;
+import zeroecho.core.util.RandomSupport;
+
/**
* Utility class for generating random passwords and secure random byte arrays.
diff --git a/lib/src/main/java/zeroecho/sdk/util/RandomSupport.java b/lib/src/main/java/zeroecho/sdk/util/RandomSupport.java
deleted file mode 100644
index a41955b..0000000
--- a/lib/src/main/java/zeroecho/sdk/util/RandomSupport.java
+++ /dev/null
@@ -1,115 +0,0 @@
-/*******************************************************************************
- * Copyright (C) 2026, Leo Galambos
- * All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without modification,
- * are permitted provided that the following conditions are met:
- *
- * 1. Redistributions of source code must retain the above copyright notice, this
- * list of conditions and the following disclaimer.
- *
- * 2. Redistributions in binary form must reproduce the above copyright notice,
- * this list of conditions and the following disclaimer in the documentation
- * and/or other materials provided with the distribution.
- *
- * 3. All advertising materials mentioning features or use of this software must
- * display the following acknowledgement:
- * This product includes software developed by the Egothor project.
- *
- * 4. Neither the name of the copyright holder nor the names of its contributors
- * may be used to endorse or promote products derived from this software without
- * specific prior written permission.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
- * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
- * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
- * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
- * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
- * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
- * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
- * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
- * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
- * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- ******************************************************************************/
-package zeroecho.sdk.util;
-
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-
-/**
- * Utility class providing support for secure random number generation.
- *
- * This class provides cryptographically secure random data through one
- * process-wide, thread-safe {@link SecureRandom}. It selects the platform's
- * strong implementation when available and otherwise uses the platform default.
- *
- *
- * @author Leo Galambos
- */
-public final class RandomSupport {
- private static final Logger LOG = Logger.getLogger(RandomSupport.class.getName());
-
- /** Shared {@link SecureRandom} instance. */
- private static final SecureRandom RANDOM = createRandom();
-
- private RandomSupport() {
- // this is a utility class
- }
-
- /**
- * Returns the shared {@link SecureRandom} instance.
- *
- * @return the shared random source
- */
- public static SecureRandom getRandom() {
- return RANDOM;
- }
-
- private static SecureRandom createRandom() {
- try {
- return SecureRandom.getInstanceStrong();
- } catch (NoSuchAlgorithmException exception) {
- LOG.log(Level.WARNING, "Strong SecureRandom unavailable; using the platform default");
- return new SecureRandom();
- }
- }
-
- /**
- * Generates a secure random byte array of the specified size.
- *
- * @param size The size of the byte array to generate.
- * @return A byte array filled with cryptographically secure random bytes.
- */
- public static byte[] generateRandom(final int size) {
- return generateRandom(new byte[size]);
- }
-
- /**
- * Fills the given byte array with random bytes and returns it.
- *
- * This method uses a thread-safe {@code SecureRandom} instance to generate
- * cryptographically strong random values.
- *
- * @param buffer the byte array to fill with random bytes
- * @return the same byte array, now containing random data
- * @throws NullPointerException if {@code buffer} is {@code null}
- */
- public static byte[] generateRandom(final byte[] buffer) {
- RANDOM.nextBytes(buffer);
- return buffer;
- }
-
- /**
- * Returns a uniformly distributed value between zero (inclusive) and the
- * specified bound (exclusive).
- *
- * @param bound exclusive upper bound; must be positive
- * @return a uniformly distributed value
- * @throws IllegalArgumentException if {@code bound} is not positive
- */
- public static int nextInt(final int bound) {
- return RANDOM.nextInt(bound);
- }
-}
diff --git a/lib/src/main/java/zeroecho/sdk/util/package-info.java b/lib/src/main/java/zeroecho/sdk/util/package-info.java
index e9f2b2c..353fee2 100644
--- a/lib/src/main/java/zeroecho/sdk/util/package-info.java
+++ b/lib/src/main/java/zeroecho/sdk/util/package-info.java
@@ -60,9 +60,6 @@
* stream.
*
{@link Password} - helpers for generating random bytes and printable
* passwords.
- * {@link RandomSupport} - shared or per-call
- * {@link java.security.SecureRandom} access with thread-safe helpers for
- * filling arrays.
* {@link X509Support} - minimal PEM-based load and print helpers for
* certificates, private keys, and certificate signing requests.
*
diff --git a/lib/src/test/java/zeroecho/core/alg/aes/AesRandomSupportTest.java b/lib/src/test/java/zeroecho/core/alg/aes/AesRandomSupportTest.java
index 0c5e34d..7d0bff8 100644
--- a/lib/src/test/java/zeroecho/core/alg/aes/AesRandomSupportTest.java
+++ b/lib/src/test/java/zeroecho/core/alg/aes/AesRandomSupportTest.java
@@ -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");
diff --git a/lib/src/test/java/zeroecho/core/storage/KeyringAlgorithmCoverageTest.java b/lib/src/test/java/zeroecho/core/storage/KeyringAlgorithmCoverageTest.java
new file mode 100644
index 0000000..57e8c3f
--- /dev/null
+++ b/lib/src/test/java/zeroecho/core/storage/KeyringAlgorithmCoverageTest.java
@@ -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 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 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 asymmetricEncryptedStoreRoundTrips() {
+ List 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 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 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 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);
+ }
+ }
+}
diff --git a/lib/src/test/java/zeroecho/core/storage/KeyringAtomicPersistenceTest.java b/lib/src/test/java/zeroecho/core/storage/KeyringAtomicPersistenceTest.java
new file mode 100644
index 0000000..affbdde
--- /dev/null
+++ b/lib/src/test/java/zeroecho/core/storage/KeyringAtomicPersistenceTest.java
@@ -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 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 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 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> 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);
+ }
+ }
+}
diff --git a/lib/src/test/java/zeroecho/core/storage/KeyringCryptographicFormatTest.java b/lib/src/test/java/zeroecho/core/storage/KeyringCryptographicFormatTest.java
new file mode 100644
index 0000000..49bf04b
--- /dev/null
+++ b/lib/src/test/java/zeroecho/core/storage/KeyringCryptographicFormatTest.java
@@ -0,0 +1,1084 @@
+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.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.logging.Handler;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+import java.util.stream.Stream;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class KeyringCryptographicFormatTest {
+ private static final char[] PASSWORD = { 'f', 'o', 'r', 'm', 'a', 't' };
+ private static final int VERSION_OFFSET = 8;
+ private static final int STORE_ID_OFFSET = 12;
+ private static final int KDF_OFFSET = 28;
+ private static final int ITERATIONS_OFFSET = 29;
+ private static final int SALT_OFFSET = 33;
+ private static final int KEK_LENGTH_OFFSET = 65;
+ private static final int AEAD_OFFSET = 69;
+ private static final int WRAP_NONCE_OFFSET = 70;
+ private static final int WRAPPED_LENGTH_OFFSET = 82;
+ private static final int WRAPPED_OFFSET = 86;
+ private static final int ENTRY_COUNT_OFFSET = 134;
+
+ @TempDir
+ Path temporaryDirectory;
+
+ @Test
+ void masterWrapAuthenticationAndHeaderMatrixFailsClosed() throws Exception {
+ start("masterWrapAuthenticationAndHeaderMatrixFailsClosed");
+ Path source = createEmpty("master-source.zek", 1);
+ byte[] image = Files.readAllBytes(source);
+ try {
+ assertUniformUnlockFailure(source,
+ new char[] { 'w', 'r', 'o', 'n', 'g' });
+
+ assertUniformUnlockFailure(copyWithMutation(source, image, "wrapped-cipher",
+ value -> value[WRAPPED_OFFSET] ^= 1), PASSWORD);
+ assertUniformUnlockFailure(copyWithMutation(source, image, "wrapped-tag",
+ value -> value[WRAPPED_OFFSET + 47] ^= 1), PASSWORD);
+ assertUniformUnlockFailure(copyWithMutation(source, image, "salt",
+ value -> value[SALT_OFFSET] ^= 1), PASSWORD);
+ assertUniformUnlockFailure(copyWithMutation(source, image, "iterations-auth",
+ value -> putInt(value, ITERATIONS_OFFSET, 600_001)), PASSWORD);
+ assertUniformUnlockFailure(copyWithMutation(source, image, "wrap-nonce-auth",
+ value -> value[WRAP_NONCE_OFFSET + 1] ^= 1), PASSWORD);
+
+ assertRejected(copyWithMutation(source, image, "iterations-low",
+ value -> putInt(value, ITERATIONS_OFFSET, 599_999)),
+ KeyringException.Code.KEYRING_LIMIT_EXCEEDED);
+ assertRejected(copyWithMutation(source, image, "iterations-operational",
+ value -> putInt(value, ITERATIONS_OFFSET, 1_000_001)),
+ KeyringException.Code.KEYRING_LIMIT_EXCEEDED);
+ assertRejected(copyWithMutation(source, image, "iterations-absolute",
+ value -> putInt(value, ITERATIONS_OFFSET, 10_000_001)),
+ KeyringException.Code.KEYRING_LIMIT_EXCEEDED);
+ assertRejected(copyWithMutation(source, image, "unknown-kdf",
+ value -> value[KDF_OFFSET] = 99),
+ KeyringException.Code.KEYRING_FORMAT_INVALID);
+ assertRejected(copyWithMutation(source, image, "unknown-aead",
+ value -> value[AEAD_OFFSET] = 99),
+ KeyringException.Code.KEYRING_FORMAT_INVALID);
+ assertRejected(copyWithMutation(source, image, "kek-length",
+ value -> putInt(value, KEK_LENGTH_OFFSET, 31)),
+ KeyringException.Code.KEYRING_FORMAT_INVALID);
+ assertRejected(copyWithMutation(source, image, "wrap-domain",
+ value -> value[WRAP_NONCE_OFFSET] = 2),
+ KeyringException.Code.KEYRING_FORMAT_INVALID);
+ for (int wrappedLength : new int[] { -1, 47, 49 }) {
+ assertRejected(copyWithMutation(source, image,
+ "wrapped-length-" + wrappedLength,
+ value -> putInt(value, WRAPPED_LENGTH_OFFSET, wrappedLength)),
+ wrappedLength == 47
+ ? KeyringException.Code.KEYRING_FORMAT_INVALID
+ : KeyringException.Code.KEYRING_LIMIT_EXCEEDED);
+ }
+
+ int[] boundaries = { 1, VERSION_OFFSET, STORE_ID_OFFSET, KDF_OFFSET,
+ ITERATIONS_OFFSET, SALT_OFFSET, KEK_LENGTH_OFFSET, AEAD_OFFSET,
+ WRAP_NONCE_OFFSET, WRAPPED_LENGTH_OFFSET, WRAPPED_OFFSET,
+ WRAPPED_OFFSET + 47, ENTRY_COUNT_OFFSET };
+ for (int boundary : boundaries) {
+ Path truncated = copyImage(source, Arrays.copyOf(image, boundary),
+ "truncated-" + boundary);
+ assertSafeRejected(truncated);
+ }
+ byte[] trailing = Arrays.copyOf(image, image.length + 1);
+ assertRejected(copyImage(source, trailing, "trailing"),
+ KeyringException.Code.KEYRING_FORMAT_INVALID);
+ } finally {
+ wipe(image);
+ }
+ ok();
+ }
+
+ @Test
+ void entryAndManifestOuterTamperMatrixFailsBeforeExposure() throws Exception {
+ start("entryAndManifestOuterTamperMatrixFailsBeforeExposure");
+ Path source = createTwoEntries("outer-source.zek", 7);
+ byte[] image = Files.readAllBytes(source);
+ ImageLayout layout = layout(image);
+ try {
+ assertRejected(copyWithMutation(source, image, "entry-cipher",
+ value -> value[layout.entries.get(0).ciphertextOffset] ^= 1),
+ KeyringException.Code.KEYRING_FORMAT_INVALID);
+ assertRejected(copyWithMutation(source, image, "entry-tag",
+ value -> value[layout.entries.get(0).endOffset - 1] ^= 1),
+ KeyringException.Code.KEYRING_FORMAT_INVALID);
+ assertRejected(copyWithMutation(source, image, "entry-nonce",
+ value -> value[layout.entries.get(0).nonceOffset] ^= 1),
+ KeyringException.Code.KEYRING_FORMAT_INVALID);
+ assertRejected(copyWithMutation(source, image, "entry-id",
+ value -> value[layout.entries.get(0).entryIdOffset] ^= 1),
+ KeyringException.Code.KEYRING_FORMAT_INVALID);
+ assertRejected(copyWithMutation(source, image, "manifest-nonce",
+ value -> value[layout.manifestNonceOffset] ^= 1),
+ KeyringException.Code.KEYRING_FORMAT_INVALID);
+ assertRejected(copyWithMutation(source, image, "manifest-cipher",
+ value -> value[layout.manifestCipherOffset] ^= 1),
+ KeyringException.Code.KEYRING_FORMAT_INVALID);
+ assertRejected(copyWithMutation(source, image, "manifest-tag",
+ value -> value[image.length - 1] ^= 1),
+ KeyringException.Code.KEYRING_FORMAT_INVALID);
+
+ assertRejected(copyImage(source, reorderEntries(image, layout),
+ "entry-reorder"), KeyringException.Code.KEYRING_FORMAT_INVALID);
+ assertRejected(copyImage(source, duplicateFirstEntry(image, layout),
+ "entry-duplicate"), KeyringException.Code.KEYRING_FORMAT_INVALID);
+ assertRejected(copyImage(source, deleteFirstEntry(image, layout),
+ "entry-delete"), KeyringException.Code.KEYRING_FORMAT_INVALID);
+ assertRejected(copyImage(source, insertUnauthenticatedEntry(image, layout),
+ "entry-insert"), KeyringException.Code.KEYRING_FORMAT_INVALID);
+
+ Path other = createTwoEntries("outer-other.zek", 107);
+ byte[] otherImage = Files.readAllBytes(other);
+ try {
+ ImageLayout otherLayout = layout(otherImage);
+ byte[] copied = replaceEntry(otherImage, otherLayout.entries.get(0),
+ slice(image, layout.entries.get(0).entryIdOffset,
+ layout.entries.get(0).endOffset));
+ assertRejected(copyImage(other, copied, "entry-cross-store"),
+ KeyringException.Code.KEYRING_FORMAT_INVALID);
+ wipe(copied);
+ } finally {
+ wipe(otherImage);
+ }
+
+ assertFalse(openAliases(source).isEmpty());
+ } finally {
+ wipe(image);
+ }
+ ok();
+ }
+
+ @Test
+ void authenticatedManifestAndEntryBindingTamperMatrixFailsClosed() throws Exception {
+ start("authenticatedManifestAndEntryBindingTamperMatrixFailsClosed");
+ StoreFixture fixture = createFixture("authenticated-source.zek", 17);
+ byte[] image = Files.readAllBytes(fixture.path);
+ byte[] masterKey = fixture.masterKey;
+ try {
+ assertManifestMutationRejected(fixture.path, image, masterKey, "position",
+ value -> putInt(value, descriptor(value, 0).positionOffset, 1));
+ assertManifestMutationRejected(fixture.path, image, masterKey, "descriptor-id",
+ value -> value[descriptor(value, 0).entryIdOffset] ^= 1);
+ assertManifestMutationRejected(fixture.path, image, masterKey, "descriptor-nonce",
+ value -> value[descriptor(value, 0).nonceOffset] ^= 1);
+ assertManifestMutationRejected(fixture.path, image, masterKey, "cipher-length",
+ value -> putInt(value, descriptor(value, 0).ciphertextLengthOffset,
+ getInt(value,
+ descriptor(value, 0).ciphertextLengthOffset) + 1));
+ assertManifestMutationRejected(fixture.path, image, masterKey, "cipher-digest",
+ value -> value[descriptor(value, 0).digestOffset] ^= 1);
+ assertManifestMutationRejected(fixture.path, image, masterKey, "duplicate-id",
+ value -> copyField(value, descriptor(value, 0).entryIdOffset,
+ descriptor(value, 1).entryIdOffset, KeyringStore.UUID_BYTES));
+ assertManifestMutationRejected(fixture.path, image, masterKey, "duplicate-nonce",
+ value -> copyField(value, descriptor(value, 0).nonceOffset,
+ descriptor(value, 1).nonceOffset, KeyringStore.NONCE_BYTES));
+ assertManifestMutationRejected(fixture.path, image, masterKey, "duplicate-alias",
+ value -> copyField(value, descriptor(value, 0).aliasOffset,
+ descriptor(value, 1).aliasOffset,
+ descriptor(value, 0).aliasLength));
+ assertManifestMutationRejected(fixture.path, image, masterKey, "unknown-algorithm",
+ value -> overwriteAscii(value, descriptor(value, 0).algorithmOffset, "BAD"),
+ KeyringException.Code.KEYRING_IMPORT_MAPPING_INVALID);
+ assertManifestMutationRejected(fixture.path, image, masterKey, "unknown-kind",
+ value -> value[descriptor(value, 0).kindOffset] = 99);
+ assertManifestMutationRejected(fixture.path, image, masterKey, "unknown-encoding",
+ value -> value[descriptor(value, 0).encodingOffset] = 99);
+ assertManifestMutationRejected(fixture.path, image, masterKey, "unknown-hmac",
+ value -> value[descriptor(value, 0).hmacOffset] = 99,
+ KeyringException.Code.KEYRING_IMPORT_METADATA_INVALID);
+ assertManifestMutationRejected(fixture.path, image, masterKey, "manifest-version",
+ value -> putInt(value, 0, 2));
+ assertManifestMutationRejected(fixture.path, image, masterKey, "manifest-count",
+ value -> putInt(value, 16, 1));
+ assertManifestMutationRejected(fixture.path, image, masterKey, "manifest-reorder",
+ KeyringCryptographicFormatTest::swapManifestDescriptors);
+ assertManifestReplacementRejected(fixture.path, image, masterKey,
+ "manifest-truncated", value -> Arrays.copyOf(value, value.length - 1));
+ assertManifestReplacementRejected(fixture.path, image, masterKey,
+ "manifest-trailing", value -> Arrays.copyOf(value, value.length + 1));
+
+ assertEntryMutationRejected(fixture.path, image, masterKey, "entry-cipher-auth",
+ value -> value[0] ^= 1, false);
+ assertEntryMutationRejected(fixture.path, image, masterKey, "entry-tag-auth",
+ value -> value[value.length - 1] ^= 1, false);
+ assertEntryMutationRejected(fixture.path, image, masterKey, "entry-version",
+ value -> putInt(value, 0, 2), true);
+ assertEntryMutationRejected(fixture.path, image, masterKey, "alias-substitution",
+ value -> overwriteAscii(value, Integer.BYTES * 2, "xxx"), true);
+ assertEntryMutationRejected(fixture.path, image, masterKey,
+ "algorithm-substitution",
+ value -> overwriteAscii(value, Integer.BYTES * 3 + 3, "BAD"), true);
+ assertEntryMutationRejected(fixture.path, image, masterKey, "kind-substitution",
+ value -> value[Integer.BYTES * 4 + 2] = 1, true);
+ assertEntryMutationRejected(fixture.path, image, masterKey,
+ "encoding-substitution",
+ value -> value[Integer.BYTES * 4 + 3] = 1, true);
+ assertEntryMutationRejected(fixture.path, image, masterKey, "hmac-substitution",
+ value -> value[Integer.BYTES * 4 + 4] = 1, true);
+ assertCoherentIdentityMutationRejected(fixture.path, image, masterKey,
+ "coherent-entry-id", true);
+ assertCoherentIdentityMutationRejected(fixture.path, image, masterKey,
+ "coherent-entry-nonce", false);
+ } finally {
+ wipe(image);
+ wipe(masterKey);
+ }
+ ok();
+ }
+
+ @Test
+ void decodedEntryAndManifestSchemasRejectTypeConfusionAndBounds() throws Exception {
+ start("decodedEntryAndManifestSchemasRejectTypeConfusionAndBounds");
+ byte[] validEntry = entryPlaintext(1, "a", "AES", 3, 3, 0,
+ new byte[32], false);
+ try {
+ invokeDecodeEntry(validEntry);
+ assertDecodeEntryRejected(entryPlaintext(2, "a", "AES", 3, 3, 0,
+ new byte[32], false));
+ assertDecodeEntryRejected(entryPlaintext(1, "a", "AES", 99, 3, 0,
+ new byte[32], false));
+ assertDecodeEntryRejected(entryPlaintext(1, "a", "AES", 3, 99, 0,
+ new byte[32], false));
+ assertDecodeEntryRejected(entryPlaintext(1, "a", "AES", 3, 3, 99,
+ new byte[32], false));
+ assertDecodeEntryRejected(entryPlaintext(1, "a", "AES", 3, 3, 0,
+ new byte[32], true));
+ assertDecodeEntryRejected(lengthOnlyEntry(-1, "AES"));
+ assertDecodeEntryRejected(lengthOnlyEntry(KeyringStore.MAX_ALIAS_BYTES + 1,
+ "AES"));
+ assertDecodeEntryRejected(metadataLengthEntry(-1));
+ assertDecodeEntryRejected(metadataLengthEntry(
+ KeyringStore.MAX_METADATA_BYTES + 1));
+ assertDecodeEntryRejected(encodedLengthEntry(-1));
+ assertDecodeEntryRejected(encodedLengthEntry(
+ KeyringStore.MAX_ENTRY_CIPHERTEXT_BYTES));
+
+ byte[] maximumAlias = entryPlaintext(1,
+ "a".repeat(KeyringStore.MAX_ALIAS_BYTES), "AES", 3, 3, 0,
+ new byte[1], false);
+ byte[] maximumMetadata = entryPlaintext(1, "a",
+ "A".repeat(KeyringStore.MAX_METADATA_BYTES), 3, 3, 0,
+ new byte[1], false);
+ try {
+ invokeDecodeEntry(maximumAlias);
+ invokeDecodeEntry(maximumMetadata);
+ } finally {
+ wipe(maximumAlias);
+ wipe(maximumMetadata);
+ }
+ } finally {
+ wipe(validEntry);
+ }
+
+ byte[] manifest = emptyManifest(1, 1, 0, false);
+ try {
+ invokeDecodeManifest(manifest, 0);
+ assertDecodeManifestRejected(emptyManifest(2, 1, 0, false), 0);
+ assertDecodeManifestRejected(emptyManifest(1, 1, 0, true), 0);
+ assertDecodeManifestRejected(Arrays.copyOf(manifest, manifest.length - 1), 0);
+ assertDecodeManifestRejected(emptyManifest(1, 1, 1, false), 0);
+ assertDecodeManifestRejected(emptyManifest(1, 0, 0, false), 0);
+ } finally {
+ wipe(manifest);
+ }
+ ok();
+ }
+
+ @Test
+ void hardLimitsOldFormatsAndPlaintextSentinelsFailClosed() throws Exception {
+ start("hardLimitsOldFormatsAndPlaintextSentinelsFailClosed");
+ Path source = createEmpty("bounds-source.zek", 31);
+ byte[] image = Files.readAllBytes(source);
+ try {
+ assertRejected(copyWithMutation(source, image, "negative-count",
+ value -> putInt(value, ENTRY_COUNT_OFFSET, -1)),
+ KeyringException.Code.KEYRING_LIMIT_EXCEEDED);
+ assertRejected(copyWithMutation(source, image, "oversized-count",
+ value -> putInt(value, ENTRY_COUNT_OFFSET,
+ KeyringStore.MAX_ENTRY_COUNT + 1)),
+ KeyringException.Code.KEYRING_LIMIT_EXCEEDED);
+ assertRejected(copyWithMutation(source, image, "old-main-version",
+ value -> putInt(value, VERSION_OFFSET, 1)),
+ KeyringException.Code.KEYRING_FORMAT_INVALID);
+ assertRejected(copyWithMutation(source, image, "draft-main-version",
+ value -> putInt(value, VERSION_OFFSET, 3)),
+ KeyringException.Code.KEYRING_FORMAT_INVALID);
+ } finally {
+ wipe(image);
+ }
+
+ Path oversized = temporaryDirectory.resolve("oversized.zek");
+ try (java.nio.channels.FileChannel channel = java.nio.channels.FileChannel.open(
+ oversized, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
+ channel.position(KeyringStore.MAX_FILE_BYTES);
+ channel.write(ByteBuffer.wrap(new byte[] { 0 }));
+ }
+ ownerOnly(oversized);
+ try (KeyringPassword password = password()) {
+ KeyringException failure = assertThrows(KeyringException.class,
+ () -> KeyringStore.open(oversized, password));
+ assertEquals(KeyringException.Code.KEYRING_LIMIT_EXCEEDED, failure.code());
+ }
+
+ Path plaintext = temporaryDirectory.resolve("plaintext-v1.zek");
+ Files.writeString(plaintext, "# KeyringStore v1\njava.io.File\nHmacSHA1\n",
+ StandardCharsets.UTF_8);
+ ownerOnly(plaintext);
+ assertRejected(plaintext, KeyringException.Code.KEYRING_FORMAT_INVALID);
+
+ byte[] sentinel = "CONTROLLED-SECRET-SENTINEL-00001".getBytes(StandardCharsets.US_ASCII);
+ Path protectedStore = temporaryDirectory.resolve("sentinel.zek");
+ CapturingHandler handler = new CapturingHandler();
+ Logger root = Logger.getLogger("");
+ root.addHandler(handler);
+ try {
+ try (KeyringPassword password = password();
+ KeyringStore store = KeyringStore.create(protectedStore, password,
+ KeyringProtection.standard(), deterministicRandom(61))) {
+ store.putSecret("sentinel", "AES",
+ new SecretKeySpec(Arrays.copyOf(sentinel, 32), "AES"));
+ }
+ assertAbsent(Files.readAllBytes(protectedStore), sentinel);
+ assertAbsent(Files.readAllBytes(sidecar(protectedStore)), sentinel);
+ try (Stream files = Files.list(temporaryDirectory)) {
+ assertTrue(files.noneMatch(path -> path.getFileName().toString().endsWith(".tmp")));
+ }
+ byte[] corrupt = Files.readAllBytes(protectedStore);
+ try {
+ corrupt[corrupt.length - 1] ^= 1;
+ Files.write(protectedStore, corrupt);
+ } finally {
+ wipe(corrupt);
+ }
+ try (KeyringPassword password = password()) {
+ KeyringException failure = assertThrows(KeyringException.class,
+ () -> KeyringStore.open(protectedStore, password));
+ assertFalse(failure.getMessage().contains("CONTROLLED"));
+ assertNull(failure.getCause());
+ }
+ assertFalse(handler.output().contains("CONTROLLED"));
+ } finally {
+ root.removeHandler(handler);
+ wipe(sentinel);
+ }
+ ok();
+ }
+
+ private Path createEmpty(String name, int seed) throws Exception {
+ Path path = temporaryDirectory.resolve(name);
+ try (KeyringPassword password = password();
+ KeyringStore ignored = KeyringStore.create(path, password,
+ KeyringProtection.standard(), deterministicRandom(seed))) {
+ // Current empty image and reservation sidecar are durable.
+ }
+ return path;
+ }
+
+ private Path createTwoEntries(String name, int seed) throws Exception {
+ StoreFixture fixture = createFixture(name, seed);
+ wipe(fixture.masterKey);
+ return fixture.path;
+ }
+
+ private StoreFixture createFixture(String name, int seed) throws Exception {
+ Path path = temporaryDirectory.resolve(name);
+ byte[] first = new byte[32];
+ byte[] second = new byte[32];
+ byte[] masterKey;
+ Arrays.fill(first, (byte) 0x31);
+ Arrays.fill(second, (byte) 0x42);
+ try (KeyringPassword password = password();
+ KeyringStore store = KeyringStore.create(path, password,
+ KeyringProtection.standard(), deterministicRandom(seed))) {
+ store.putSecret("one", "AES", new SecretKeySpec(first, "AES"));
+ store.putSecret("two", "AES", new SecretKeySpec(second, "AES"));
+ masterKey = bytesField(store, "masterKey").clone();
+ } finally {
+ wipe(first);
+ wipe(second);
+ }
+ return new StoreFixture(path, masterKey);
+ }
+
+ private void assertManifestMutationRejected(Path source, byte[] image, byte[] masterKey,
+ String name, Mutation mutation) throws Exception {
+ assertManifestMutationRejected(source, image, masterKey, name, mutation,
+ KeyringException.Code.KEYRING_FORMAT_INVALID);
+ }
+
+ private void assertManifestMutationRejected(Path source, byte[] image, byte[] masterKey,
+ String name, Mutation mutation, KeyringException.Code code) throws Exception {
+ assertManifestReplacementRejected(source, image, masterKey, name, value -> {
+ mutation.apply(value);
+ return value;
+ }, code);
+ }
+
+ private void assertManifestReplacementRejected(Path source, byte[] image,
+ byte[] masterKey, String name, Replacement mutation) throws Exception {
+ assertManifestReplacementRejected(source, image, masterKey, name, mutation,
+ KeyringException.Code.KEYRING_FORMAT_INVALID);
+ }
+
+ private void assertManifestReplacementRejected(Path source, byte[] image,
+ byte[] masterKey, String name, Replacement mutation,
+ KeyringException.Code code) throws Exception {
+ byte[] plaintext = decryptManifest(image, masterKey);
+ byte[] replacement = null;
+ byte[] rewritten = null;
+ try {
+ replacement = mutation.apply(plaintext);
+ rewritten = replaceManifest(image, masterKey, replacement);
+ assertRejected(copyImage(source, rewritten, name), code);
+ } finally {
+ if (replacement != plaintext) {
+ wipe(replacement);
+ }
+ wipe(plaintext);
+ wipe(rewritten);
+ }
+ }
+
+ private void assertEntryMutationRejected(Path source, byte[] image, byte[] masterKey,
+ String name, Mutation mutation, boolean plaintextMutation) throws Exception {
+ ImageLayout imageLayout = layout(image);
+ WireEntry entry = imageLayout.entries.get(0);
+ byte[] ciphertext = slice(image, entry.ciphertextOffset, entry.endOffset);
+ byte[] changed = null;
+ byte[] rewritten = null;
+ try {
+ if (plaintextMutation) {
+ byte[] nonce = slice(image, entry.nonceOffset,
+ entry.nonceOffset + KeyringStore.NONCE_BYTES);
+ byte[] aad = entryAad(image, entry, 0);
+ byte[] plaintext = null;
+ try {
+ plaintext = crypt(Cipher.DECRYPT_MODE, masterKey, nonce, aad, ciphertext);
+ mutation.apply(plaintext);
+ changed = crypt(Cipher.ENCRYPT_MODE, masterKey, nonce, aad, plaintext);
+ } finally {
+ wipe(nonce);
+ wipe(aad);
+ wipe(plaintext);
+ }
+ } else {
+ changed = ciphertext.clone();
+ mutation.apply(changed);
+ }
+ rewritten = installFirstCiphertext(image, masterKey, changed);
+ Path path = copyImage(source, rewritten, name);
+ try (KeyringPassword password = password();
+ KeyringStore store = KeyringStore.open(path, password)) {
+ KeyringException failure = assertThrows(KeyringException.class,
+ () -> store.getSecret("one"));
+ assertEquals(KeyringException.Code.KEYRING_FORMAT_INVALID, failure.code());
+ assertEquals(KeyringException.Code.KEYRING_FORMAT_INVALID.name(),
+ failure.getMessage());
+ }
+ } finally {
+ wipe(ciphertext);
+ wipe(changed);
+ wipe(rewritten);
+ }
+ }
+
+ private void assertCoherentIdentityMutationRejected(Path source, byte[] image,
+ byte[] masterKey, String name, boolean entryId) throws Exception {
+ byte[] rewritten = image.clone();
+ byte[] manifest = decryptManifest(image, masterKey);
+ try {
+ ImageLayout imageLayout = layout(rewritten);
+ WireEntry wire = imageLayout.entries.get(0);
+ ManifestDescriptor descriptor = descriptor(manifest, 0);
+ if (entryId) {
+ rewritten[wire.entryIdOffset] ^= 1;
+ manifest[descriptor.entryIdOffset] ^= 1;
+ } else {
+ rewritten[wire.nonceOffset + KeyringStore.NONCE_BYTES - 1] = 1;
+ manifest[descriptor.nonceOffset + KeyringStore.NONCE_BYTES - 1] = 1;
+ }
+ byte[] withManifest = replaceManifest(rewritten, masterKey, manifest);
+ wipe(rewritten);
+ rewritten = withManifest;
+ Path path = copyImage(source, rewritten, name);
+ try (KeyringPassword password = password();
+ KeyringStore store = KeyringStore.open(path, password)) {
+ KeyringException failure = assertThrows(KeyringException.class,
+ () -> store.getSecret("one"));
+ assertEquals(KeyringException.Code.KEYRING_FORMAT_INVALID, failure.code());
+ }
+ } finally {
+ wipe(rewritten);
+ wipe(manifest);
+ }
+ }
+
+ private static byte[] installFirstCiphertext(byte[] image, byte[] masterKey,
+ byte[] ciphertext) throws Exception {
+ byte[] result = image.clone();
+ ImageLayout imageLayout = layout(result);
+ WireEntry entry = imageLayout.entries.get(0);
+ if (ciphertext.length != entry.endOffset - entry.ciphertextOffset) {
+ throw new IllegalArgumentException("ciphertext length changed");
+ }
+ System.arraycopy(ciphertext, 0, result, entry.ciphertextOffset, ciphertext.length);
+ byte[] manifest = decryptManifest(result, masterKey);
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(ciphertext);
+ try {
+ ManifestDescriptor descriptor = descriptor(manifest, 0);
+ System.arraycopy(digest, 0, manifest, descriptor.digestOffset, digest.length);
+ byte[] rewritten = replaceManifest(result, masterKey, manifest);
+ wipe(result);
+ return rewritten;
+ } finally {
+ wipe(manifest);
+ wipe(digest);
+ }
+ }
+
+ private static byte[] decryptManifest(byte[] image, byte[] masterKey) throws Exception {
+ ImageLayout imageLayout = layout(image);
+ byte[] nonce = slice(image, imageLayout.manifestNonceOffset,
+ imageLayout.manifestNonceOffset + KeyringStore.NONCE_BYTES);
+ byte[] ciphertext = slice(image, imageLayout.manifestCipherOffset, image.length);
+ byte[] aad = manifestAad(image);
+ try {
+ return crypt(Cipher.DECRYPT_MODE, masterKey, nonce, aad, ciphertext);
+ } finally {
+ wipe(nonce);
+ wipe(ciphertext);
+ wipe(aad);
+ }
+ }
+
+ private static byte[] replaceManifest(byte[] image, byte[] masterKey,
+ byte[] plaintext) throws Exception {
+ ImageLayout imageLayout = layout(image);
+ byte[] nonce = slice(image, imageLayout.manifestNonceOffset,
+ imageLayout.manifestNonceOffset + KeyringStore.NONCE_BYTES);
+ byte[] aad = manifestAad(image);
+ byte[] ciphertext = null;
+ try {
+ ciphertext = crypt(Cipher.ENCRYPT_MODE, masterKey, nonce, aad, plaintext);
+ int lengthOffset = imageLayout.manifestCipherOffset - Integer.BYTES;
+ byte[] result = Arrays.copyOf(image, imageLayout.manifestCipherOffset
+ + ciphertext.length);
+ putInt(result, lengthOffset, ciphertext.length);
+ System.arraycopy(ciphertext, 0, result, imageLayout.manifestCipherOffset,
+ ciphertext.length);
+ return result;
+ } finally {
+ wipe(nonce);
+ wipe(aad);
+ wipe(ciphertext);
+ }
+ }
+
+ private static byte[] manifestAad(byte[] image) {
+ ByteBuffer buffer = ByteBuffer.allocate(8 + Integer.BYTES
+ + KeyringStore.UUID_BYTES + Integer.BYTES * 2);
+ buffer.put(KeyringStore.MAGIC);
+ buffer.putInt(KeyringStore.FORMAT_VERSION);
+ buffer.put(image, STORE_ID_OFFSET, KeyringStore.UUID_BYTES);
+ buffer.putInt(KeyringStore.MANIFEST_FORMAT_VERSION);
+ buffer.putInt(getInt(image, ENTRY_COUNT_OFFSET));
+ return buffer.array();
+ }
+
+ private static byte[] entryAad(byte[] image, WireEntry entry, int position) {
+ ByteBuffer buffer = ByteBuffer.allocate(8 + Integer.BYTES
+ + KeyringStore.UUID_BYTES * 2 + Integer.BYTES * 2);
+ buffer.put(KeyringStore.MAGIC);
+ buffer.putInt(KeyringStore.FORMAT_VERSION);
+ buffer.put(image, STORE_ID_OFFSET, KeyringStore.UUID_BYTES);
+ buffer.put(image, entry.entryIdOffset, KeyringStore.UUID_BYTES);
+ buffer.putInt(position);
+ buffer.putInt(KeyringStore.ENTRY_FORMAT_VERSION);
+ return buffer.array();
+ }
+
+ private static byte[] crypt(int mode, byte[] key, byte[] nonce, byte[] aad,
+ byte[] input) throws Exception {
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(mode, new SecretKeySpec(key, "AES"),
+ new GCMParameterSpec(KeyringStore.GCM_TAG_BITS, nonce));
+ cipher.updateAAD(aad);
+ return cipher.doFinal(input);
+ }
+
+ private static ManifestDescriptor descriptor(byte[] manifest, int target) {
+ ByteBuffer buffer = ByteBuffer.wrap(manifest);
+ buffer.position(Integer.BYTES * 2 + Long.BYTES + Integer.BYTES);
+ for (int index = 0; index <= target; index++) {
+ int start = buffer.position();
+ int entryId = start;
+ buffer.position(entryId + KeyringStore.UUID_BYTES);
+ int position = buffer.position();
+ buffer.getInt();
+ int aliasLength = buffer.getInt();
+ int alias = buffer.position();
+ buffer.position(alias + aliasLength);
+ int algorithmLength = buffer.getInt();
+ int algorithm = buffer.position();
+ buffer.position(algorithm + algorithmLength);
+ int kind = buffer.position();
+ buffer.get();
+ int encoding = buffer.position();
+ buffer.get();
+ int hmac = buffer.position();
+ buffer.get();
+ int nonce = buffer.position();
+ buffer.position(nonce + KeyringStore.NONCE_BYTES);
+ int ciphertextLength = buffer.position();
+ buffer.getInt();
+ int digest = buffer.position();
+ buffer.position(digest + KeyringStore.SHA256_BYTES);
+ if (index == target) {
+ return new ManifestDescriptor(start, buffer.position(), entryId, position,
+ alias, aliasLength, algorithm, kind, encoding,
+ hmac, nonce, ciphertextLength, digest);
+ }
+ }
+ throw new IllegalArgumentException("descriptor index");
+ }
+
+ private static byte[] swapManifestDescriptors(byte[] manifest) {
+ ManifestDescriptor first = descriptor(manifest, 0);
+ ManifestDescriptor second = descriptor(manifest, 1);
+ byte[] firstBytes = slice(manifest, first.startOffset, first.endOffset);
+ byte[] secondBytes = slice(manifest, second.startOffset, second.endOffset);
+ try {
+ System.arraycopy(secondBytes, 0, manifest, first.startOffset, secondBytes.length);
+ System.arraycopy(firstBytes, 0, manifest, second.startOffset, firstBytes.length);
+ return manifest;
+ } finally {
+ wipe(firstBytes);
+ wipe(secondBytes);
+ }
+ }
+
+ private static void copyField(byte[] value, int source, int target, int length) {
+ System.arraycopy(value, source, value, target, length);
+ }
+
+ private static void overwriteAscii(byte[] value, int offset, String replacement) {
+ byte[] encoded = replacement.getBytes(StandardCharsets.US_ASCII);
+ try {
+ System.arraycopy(encoded, 0, value, offset, encoded.length);
+ } finally {
+ wipe(encoded);
+ }
+ }
+
+ private static int getInt(byte[] value, int offset) {
+ return ByteBuffer.wrap(value, offset, Integer.BYTES).getInt();
+ }
+
+ private static byte[] bytesField(KeyringStore store, String name) throws Exception {
+ java.lang.reflect.Field field = KeyringStore.class.getDeclaredField(name);
+ field.setAccessible(true);
+ return (byte[]) field.get(store);
+ }
+
+ private void assertUniformUnlockFailure(Path source, char[] candidate) throws Exception {
+ try (KeyringPassword password = new KeyringPassword(candidate)) {
+ KeyringException failure = assertThrows(KeyringException.class,
+ () -> KeyringStore.open(source, password));
+ assertEquals(KeyringException.Code.KEYRING_UNLOCK_FAILED, failure.code());
+ assertEquals(KeyringException.Code.KEYRING_UNLOCK_FAILED.name(),
+ failure.getMessage());
+ assertNull(failure.getCause());
+ assertEquals(0, failure.getSuppressed().length);
+ }
+ }
+
+ private void assertRejected(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());
+ }
+ }
+
+ private void assertSafeRejected(Path path) throws Exception {
+ try (KeyringPassword password = password()) {
+ KeyringException failure = assertThrows(KeyringException.class,
+ () -> KeyringStore.open(path, password));
+ assertTrue(Set.of(KeyringException.Code.KEYRING_FORMAT_INVALID,
+ KeyringException.Code.KEYRING_LIMIT_EXCEEDED,
+ KeyringException.Code.KEYRING_UNLOCK_FAILED).contains(failure.code()));
+ assertEquals(failure.code().name(), failure.getMessage());
+ assertNull(failure.getCause());
+ }
+ }
+
+ private Path copyWithMutation(Path source, byte[] image, String name,
+ Mutation mutation) throws Exception {
+ byte[] copy = image.clone();
+ try {
+ mutation.apply(copy);
+ return copyImage(source, copy, name);
+ } finally {
+ wipe(copy);
+ }
+ }
+
+ private Path copyImage(Path source, byte[] image, String name) throws Exception {
+ Path target = temporaryDirectory.resolve(name + ".zek");
+ Files.write(target, image, StandardOpenOption.CREATE_NEW);
+ ownerOnly(target);
+ Files.copy(sidecar(source), sidecar(target));
+ ownerOnly(sidecar(target));
+ return target;
+ }
+
+ private static List openAliases(Path path) throws Exception {
+ try (KeyringPassword password = password();
+ KeyringStore store = KeyringStore.open(path, password)) {
+ return store.aliases();
+ }
+ }
+
+ private static ImageLayout layout(byte[] image) {
+ ByteBuffer buffer = ByteBuffer.wrap(image);
+ buffer.position(ENTRY_COUNT_OFFSET);
+ int count = buffer.getInt();
+ List entries = new ArrayList<>();
+ for (int index = 0; index < count; index++) {
+ int entryId = buffer.position();
+ buffer.position(entryId + KeyringStore.UUID_BYTES);
+ int nonce = buffer.position();
+ buffer.position(nonce + KeyringStore.NONCE_BYTES);
+ int length = buffer.getInt();
+ int ciphertext = buffer.position();
+ buffer.position(ciphertext + length);
+ entries.add(new WireEntry(entryId, nonce, ciphertext,
+ buffer.position()));
+ }
+ int manifestNonce = buffer.position();
+ buffer.position(manifestNonce + KeyringStore.NONCE_BYTES);
+ buffer.getInt();
+ return new ImageLayout(entries, manifestNonce, buffer.position());
+ }
+
+ private static byte[] reorderEntries(byte[] image, ImageLayout layout) {
+ WireEntry first = layout.entries.get(0);
+ WireEntry second = layout.entries.get(1);
+ byte[] result = image.clone();
+ byte[] firstBytes = slice(image, first.entryIdOffset, first.endOffset);
+ byte[] secondBytes = slice(image, second.entryIdOffset, second.endOffset);
+ try {
+ System.arraycopy(secondBytes, 0, result, first.entryIdOffset, secondBytes.length);
+ System.arraycopy(firstBytes, 0, result, second.entryIdOffset, firstBytes.length);
+ return result;
+ } finally {
+ wipe(firstBytes);
+ wipe(secondBytes);
+ }
+ }
+
+ private static byte[] duplicateFirstEntry(byte[] image, ImageLayout layout) {
+ WireEntry first = layout.entries.get(0);
+ WireEntry second = layout.entries.get(1);
+ return replaceEntry(image, second,
+ slice(image, first.entryIdOffset, first.endOffset));
+ }
+
+ private static byte[] deleteFirstEntry(byte[] image, ImageLayout layout) {
+ WireEntry first = layout.entries.get(0);
+ byte[] result = new byte[image.length - (first.endOffset - first.entryIdOffset)];
+ System.arraycopy(image, 0, result, 0, ENTRY_COUNT_OFFSET);
+ putInt(result, ENTRY_COUNT_OFFSET, 1);
+ System.arraycopy(image, first.endOffset, result, ENTRY_COUNT_OFFSET + Integer.BYTES,
+ image.length - first.endOffset);
+ return result;
+ }
+
+ private static byte[] insertUnauthenticatedEntry(byte[] image, ImageLayout layout) {
+ WireEntry first = layout.entries.get(0);
+ byte[] encoded = slice(image, first.entryIdOffset, first.endOffset);
+ byte[] result = new byte[image.length + encoded.length];
+ System.arraycopy(image, 0, result, 0, layout.manifestNonceOffset);
+ putInt(result, ENTRY_COUNT_OFFSET, 3);
+ System.arraycopy(encoded, 0, result, layout.manifestNonceOffset, encoded.length);
+ System.arraycopy(image, layout.manifestNonceOffset, result,
+ layout.manifestNonceOffset + encoded.length,
+ image.length - layout.manifestNonceOffset);
+ wipe(encoded);
+ return result;
+ }
+
+ private static byte[] replaceEntry(byte[] image, WireEntry target, byte[] replacement) {
+ int currentLength = target.endOffset - target.entryIdOffset;
+ byte[] result = new byte[image.length - currentLength + replacement.length];
+ System.arraycopy(image, 0, result, 0, target.entryIdOffset);
+ System.arraycopy(replacement, 0, result, target.entryIdOffset, replacement.length);
+ System.arraycopy(image, target.endOffset, result,
+ target.entryIdOffset + replacement.length, image.length - target.endOffset);
+ wipe(replacement);
+ return result;
+ }
+
+ private static byte[] entryPlaintext(int version, String alias, String algorithm,
+ int kind, int encoding, int hmac, byte[] key, boolean trailing) throws Exception {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (DataOutputStream out = new DataOutputStream(bytes)) {
+ out.writeInt(version);
+ writeString(out, alias);
+ writeString(out, algorithm);
+ out.writeByte(kind);
+ out.writeByte(encoding);
+ out.writeByte(hmac);
+ out.writeInt(key.length);
+ out.write(key);
+ if (trailing) {
+ out.writeByte(0);
+ }
+ }
+ return bytes.toByteArray();
+ }
+
+ private static byte[] lengthOnlyEntry(int aliasLength, String algorithm)
+ throws Exception {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (DataOutputStream out = new DataOutputStream(bytes)) {
+ out.writeInt(1);
+ out.writeInt(aliasLength);
+ writeString(out, algorithm);
+ }
+ return bytes.toByteArray();
+ }
+
+ private static byte[] metadataLengthEntry(int length) throws Exception {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (DataOutputStream out = new DataOutputStream(bytes)) {
+ out.writeInt(1);
+ writeString(out, "a");
+ out.writeInt(length);
+ }
+ return bytes.toByteArray();
+ }
+
+ private static byte[] encodedLengthEntry(int length) throws Exception {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (DataOutputStream out = new DataOutputStream(bytes)) {
+ out.writeInt(1);
+ writeString(out, "a");
+ writeString(out, "AES");
+ out.writeByte(3);
+ out.writeByte(3);
+ out.writeByte(0);
+ out.writeInt(length);
+ }
+ return bytes.toByteArray();
+ }
+
+ private static byte[] emptyManifest(int version, long highWater, int count,
+ boolean trailing) {
+ ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES * 3 + Long.BYTES
+ + (trailing ? 1 : 0));
+ buffer.putInt(version).putInt(0x010203).putLong(highWater).putInt(count);
+ if (trailing) {
+ buffer.put((byte) 0);
+ }
+ return buffer.array();
+ }
+
+ private static void invokeDecodeEntry(byte[] input) throws Exception {
+ Method method = KeyringStore.class.getDeclaredMethod("decodeEntryPlaintext",
+ byte[].class);
+ method.setAccessible(true);
+ method.invoke(null, (Object) input);
+ }
+
+ private static void assertDecodeEntryRejected(byte[] input) throws Exception {
+ try {
+ InvocationTargetException failure = assertThrows(InvocationTargetException.class,
+ () -> invokeDecodeEntry(input));
+ assertTrue(failure.getCause() instanceof KeyringException);
+ } finally {
+ wipe(input);
+ }
+ }
+
+ private static void invokeDecodeManifest(byte[] input, int expectedCount)
+ throws Exception {
+ Method method = KeyringStore.class.getDeclaredMethod("decodeManifest",
+ byte[].class, int.class);
+ method.setAccessible(true);
+ method.invoke(null, input, expectedCount);
+ }
+
+ private static void assertDecodeManifestRejected(byte[] input, int expectedCount)
+ throws Exception {
+ try {
+ InvocationTargetException failure = assertThrows(InvocationTargetException.class,
+ () -> invokeDecodeManifest(input, expectedCount));
+ assertTrue(failure.getCause() instanceof KeyringException);
+ } finally {
+ wipe(input);
+ }
+ }
+
+ private static void writeString(DataOutputStream out, String value) throws Exception {
+ byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
+ try {
+ out.writeInt(encoded.length);
+ out.write(encoded);
+ } finally {
+ wipe(encoded);
+ }
+ }
+
+ private static void assertAbsent(byte[] haystack, byte[] needle) {
+ try {
+ assertEquals(-1, indexOf(haystack, needle));
+ } finally {
+ wipe(haystack);
+ }
+ }
+
+ 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 byte[] slice(byte[] input, int start, int end) {
+ return Arrays.copyOfRange(input, start, end);
+ }
+
+ private static void putInt(byte[] value, int offset, int replacement) {
+ ByteBuffer.wrap(value, offset, Integer.BYTES).putInt(replacement);
+ }
+
+ private static void ownerOnly(Path path) throws Exception {
+ Files.setPosixFilePermissions(path, Set.of(
+ java.nio.file.attribute.PosixFilePermission.OWNER_READ,
+ java.nio.file.attribute.PosixFilePermission.OWNER_WRITE));
+ }
+
+ 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 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 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");
+ }
+
+ @FunctionalInterface
+ private interface Mutation {
+ void apply(byte[] value);
+ }
+
+ @FunctionalInterface
+ private interface Replacement {
+ byte[] apply(byte[] value);
+ }
+
+ private record WireEntry(int entryIdOffset, int nonceOffset,
+ int ciphertextOffset, int endOffset) {
+ }
+
+ private record ImageLayout(List entries, int manifestNonceOffset,
+ int manifestCipherOffset) {
+ }
+
+ private record ManifestDescriptor(int startOffset, int endOffset,
+ int entryIdOffset, int positionOffset, int aliasOffset, int aliasLength,
+ int algorithmOffset, int kindOffset, int encodingOffset, int hmacOffset, int nonceOffset,
+ int ciphertextLengthOffset, int digestOffset) {
+ }
+
+ private record StoreFixture(Path path, byte[] masterKey) {
+ }
+
+ private static final class CapturingHandler extends Handler {
+ private final StringBuilder output = new StringBuilder();
+
+ @Override
+ public void publish(LogRecord record) {
+ if (record != null && record.getMessage() != null) {
+ output.append(record.getMessage());
+ }
+ }
+
+ @Override
+ public void flush() {
+ // No buffered external resource.
+ }
+
+ @Override
+ public void close() {
+ // No external resource.
+ }
+
+ private String output() {
+ return output.toString();
+ }
+ }
+}
diff --git a/lib/src/test/java/zeroecho/core/storage/KeyringFilesystemSecurityTest.java b/lib/src/test/java/zeroecho/core/storage/KeyringFilesystemSecurityTest.java
new file mode 100644
index 0000000..9720d62
--- /dev/null
+++ b/lib/src/test/java/zeroecho/core/storage/KeyringFilesystemSecurityTest.java
@@ -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 DIRECTORY_PERMISSIONS = Set.of(
+ PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE,
+ PosixFilePermission.OWNER_EXECUTE);
+ private static final Set 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 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 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 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 putFailure = new AtomicReference<>();
+ AtomicReference 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 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> 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 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 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 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 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);
+ }
+ }
+}
diff --git a/lib/src/test/java/zeroecho/core/storage/KeyringImportRegistryTest.java b/lib/src/test/java/zeroecho/core/storage/KeyringImportRegistryTest.java
new file mode 100644
index 0000000..72a1d4f
--- /dev/null
+++ b/lib/src/test/java/zeroecho/core/storage/KeyringImportRegistryTest.java
@@ -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 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 mappings,
+ KeyringStore.Kind kind) {
+ return mappings.stream().filter(mapping -> mapping.kind() == kind).count();
+ }
+
+ private static void roundTripAsymmetricMappings(
+ List mappings) throws Exception {
+ List 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 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 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");
+ }
+}
diff --git a/lib/src/test/java/zeroecho/core/storage/KeyringNonceReservationTest.java b/lib/src/test/java/zeroecho/core/storage/KeyringNonceReservationTest.java
new file mode 100644
index 0000000..9da5ed4
--- /dev/null
+++ b/lib/src/test/java/zeroecho/core/storage/KeyringNonceReservationTest.java
@@ -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 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 requests() {
+ return List.copyOf(requests);
+ }
+ }
+}
diff --git a/lib/src/test/java/zeroecho/core/storage/KeyringStoreDynamicTest.java b/lib/src/test/java/zeroecho/core/storage/KeyringStoreDynamicTest.java
deleted file mode 100644
index afcbc30..0000000
--- a/lib/src/test/java/zeroecho/core/storage/KeyringStoreDynamicTest.java
+++ /dev/null
@@ -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 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 genSpecType = (Class) bi.specType();
- AsymmetricKeyPairGenerator 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 genSpecType = (Class) bi.specType();
- SymmetricKeyGenerator 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 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();
- }
-}
diff --git a/lib/src/test/java/zeroecho/core/storage/KeyringStoreSecurityTest.java b/lib/src/test/java/zeroecho/core/storage/KeyringStoreSecurityTest.java
deleted file mode 100644
index 1144cc5..0000000
--- a/lib/src/test/java/zeroecho/core/storage/KeyringStoreSecurityTest.java
+++ /dev/null
@@ -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;
- }
- }
-}
diff --git a/lib/src/test/java/zeroecho/core/storage/KeyringStoreTest.java b/lib/src/test/java/zeroecho/core/storage/KeyringStoreTest.java
new file mode 100644
index 0000000..c3f947f
--- /dev/null
+++ b/lib/src/test/java/zeroecho/core/storage/KeyringStoreTest.java
@@ -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 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");
+ }
+}
diff --git a/lib/src/test/java/zeroecho/sdk/util/PasswordTest.java b/lib/src/test/java/zeroecho/sdk/util/PasswordTest.java
index 94bf3c7..cc42f6d 100644
--- a/lib/src/test/java/zeroecho/sdk/util/PasswordTest.java
+++ b/lib/src/test/java/zeroecho/sdk/util/PasswordTest.java
@@ -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() {
diff --git a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java
index 5ef64b1..8a8e204 100644
--- a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java
+++ b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java
@@ -91,6 +91,8 @@ import zeroecho.core.io.TailStrippingInputStream;
import zeroecho.core.spec.AlgorithmKeySpec;
import zeroecho.core.spec.ContextSpec;
import zeroecho.core.storage.KeyringStore;
+import zeroecho.core.storage.KeyringPassword;
+import zeroecho.core.spi.KeyringUnlockProvider;
import zeroecho.sdk.ZeroEchoSession;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
@@ -275,6 +277,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
private final String keyRefPrefix;
private final boolean requireComponentSuffix;
private final ZeroEchoSession session;
+ private final KeyringUnlockProvider keyringUnlockProvider;
private final ConcurrentMap statuses;
private final ConcurrentMap fingerprints;
@@ -288,19 +291,33 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
private final ReentrantLock timeWatermarkLock;
private final AtomicReference boundNamespace;
private final ReentrantLock domainLock;
+ private final ReentrantLock keyringLifecycleLock;
private final BiConsumer cleanupObserver;
private volatile KeyringStore keyringOrNull; // NOPMD
+ private boolean closed;
/* default */ ZeroEchoLibSignatureWorkflow(String id, Path keyringPath, Path operationRoot, Clock clock,
- Duration operationHorizon, String keyRefPrefix, boolean requireComponentSuffix) {
+ Duration operationHorizon, String keyRefPrefix, boolean requireComponentSuffix,
+ KeyringUnlockProvider keyringUnlockProvider) {
this(id, keyringPath, operationRoot, clock, operationHorizon, keyRefPrefix, requireComponentSuffix,
+ keyringUnlockProvider,
(category, cleared) -> {
});
}
/* default */ ZeroEchoLibSignatureWorkflow(String id, Path keyringPath, Path operationRoot, Clock clock,
Duration operationHorizon, String keyRefPrefix, boolean requireComponentSuffix,
+ KeyringUnlockProvider keyringUnlockProvider, KeyringStore keyring) {
+ this(id, keyringPath, operationRoot, clock, operationHorizon, keyRefPrefix,
+ requireComponentSuffix, keyringUnlockProvider);
+ this.keyringOrNull = java.util.Objects.requireNonNull(
+ keyring, "keyring must not be null");
+ }
+
+ /* default */ ZeroEchoLibSignatureWorkflow(String id, Path keyringPath, Path operationRoot, Clock clock,
+ Duration operationHorizon, String keyRefPrefix, boolean requireComponentSuffix,
+ KeyringUnlockProvider keyringUnlockProvider,
BiConsumer cleanupObserver) {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("id must not be blank");
@@ -323,6 +340,9 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
if (cleanupObserver == null) {
throw new IllegalArgumentException("cleanupObserver must not be null");
}
+ if (keyringUnlockProvider == null) {
+ throw new IllegalArgumentException("keyringUnlockProvider must not be null");
+ }
this.id = id;
this.keyringPath = keyringPath;
this.operationRoot = operationRoot.toAbsolutePath().normalize();
@@ -331,6 +351,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
this.keyRefPrefix = keyRefPrefix;
this.requireComponentSuffix = requireComponentSuffix;
this.cleanupObserver = cleanupObserver;
+ this.keyringUnlockProvider = keyringUnlockProvider;
this.session = new ZeroEchoSession();
this.statuses = new ConcurrentHashMap<>();
@@ -340,6 +361,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
this.sinks = new ConcurrentHashMap<>();
this.operationLocks = new ConcurrentHashMap<>();
this.domainLock = new ReentrantLock();
+ this.keyringLifecycleLock = new ReentrantLock();
this.timeWatermarkLock = new ReentrantLock();
try {
Files.createDirectories(this.operationRoot);
@@ -451,6 +473,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
* @throws IllegalArgumentException if {@code request} is {@code null}
*/
@Override
+ @SuppressWarnings("PMD.CloseResource")
public PkiId submitSign(SignRequest request) {
if (request == null) {
throw new IllegalArgumentException("request must not be null");
@@ -837,13 +860,28 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
*
*/
@Override
+ @SuppressWarnings("PMD.CloseResource")
public void close() {
+ KeyringStore keyring;
+ keyringLifecycleLock.lock();
+ try {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ keyring = this.keyringOrNull;
+ this.keyringOrNull = null;
+ } finally {
+ keyringLifecycleLock.unlock();
+ }
this.statuses.clear();
this.fingerprints.clear();
this.fences.clear();
this.requests.clear();
this.sinks.clear();
- this.keyringOrNull = null;
+ if (keyring != null) {
+ keyring.close();
+ }
try {
this.ownershipLock.release();
this.ownershipChannel.close();
@@ -852,14 +890,37 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
}
}
- private KeyringStore requireKeyringOrThrow() throws IOException {
- KeyringStore ks = this.keyringOrNull;
- if (ks != null) {
- return ks;
+ @SuppressWarnings("PMD.CloseResource")
+ private KeyringStore requireKeyringOrThrow() throws IOException, GeneralSecurityException {
+ keyringLifecycleLock.lock();
+ try {
+ if (closed) {
+ throw new IOException("Signature workflow is closed");
+ }
+ KeyringStore ks = this.keyringOrNull;
+ if (ks != null) {
+ return ks;
+ }
+ try (KeyringPassword password = acquireKeyringPassword()) {
+ KeyringStore loaded = KeyringStore.open(this.keyringPath, password);
+ if (closed) {
+ loaded.close();
+ throw new IOException("Signature workflow is closed");
+ }
+ this.keyringOrNull = loaded;
+ return loaded;
+ }
+ } finally {
+ keyringLifecycleLock.unlock();
}
- KeyringStore loaded = KeyringStore.load(this.session, this.keyringPath);
- this.keyringOrNull = loaded;
- return loaded;
+ }
+
+ private KeyringPassword acquireKeyringPassword() throws IOException {
+ KeyringPassword password = this.keyringUnlockProvider.acquire();
+ if (password == null) {
+ throw new IOException("Keyring unlock provider returned no password");
+ }
+ return password;
}
private static void enforceAlgorithmMatchOrThrow(String requested, String stored) throws InvalidRequestException {
@@ -900,6 +961,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
return new KeyRefParts(publicAlias, publicAlias);
}
+ @SuppressWarnings("PMD.CloseResource")
private PublicKey resolvePublicKeyOrThrow(VerifyRequest request)
throws InvalidRequestException, IOException, GeneralSecurityException {
diff --git a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java
index f99e6bc..ab33350 100644
--- a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java
+++ b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java
@@ -33,16 +33,23 @@
******************************************************************************/
package zeroecho.pki.impl.crypto.zeroecholib;
+import java.io.IOException;
import java.nio.file.Path;
+import java.security.GeneralSecurityException;
import java.time.Clock;
import java.time.Duration;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
+import zeroecho.core.spi.KeyringUnlockProvider;
+import zeroecho.core.storage.KeyringPassword;
+import zeroecho.core.storage.KeyringStore;
+import zeroecho.pki.api.PkiException;
import zeroecho.pki.spi.ProviderConfig;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.pki.spi.crypto.SignatureWorkflowProvider;
+import zeroecho.pki.spi.crypto.SignatureWorkflowRuntimeDependencies;
/**
* Production provider bridging PKI signature workflow to ZeroEcho lib based on
@@ -69,6 +76,15 @@ import zeroecho.pki.spi.crypto.SignatureWorkflowProvider;
*
*/
public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWorkflowProvider {
+ /** Stable failure code for a missing explicit keyring unlock provider. */
+ public static final String DC_KEYRING_UNLOCK_PROVIDER_REQUIRED =
+ "KEYRING_UNLOCK_PROVIDER_REQUIRED";
+ /** Stable failure code for an unlock-provider acquisition failure. */
+ public static final String DC_KEYRING_UNLOCK_PROVIDER_FAILED =
+ "KEYRING_UNLOCK_PROVIDER_FAILED";
+ /** Stable failure code for an I/O failure while opening the keyring. */
+ public static final String DC_KEYRING_OPEN_FAILED = "KEYRING_OPEN_FAILED";
+
private static final Logger LOG = Logger.getLogger(ZeroEchoLibSignatureWorkflowProvider.class.getName());
private static final String KEY_KEYRING_PATH = "keyringPath";
@@ -76,6 +92,29 @@ public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWork
private static final String KEY_OPERATION_HORIZON = "operationHorizon";
private static final String KEY_KEYREF_PREFIX = "keyRefPrefix";
private static final String KEY_REQUIRE_SUFFIX = "requireComponentSuffix";
+ private final KeyringUnlockProvider keyringUnlockProvider;
+
+ /**
+ * Creates a service-loadable provider without unlock material.
+ *
+ * {@link #allocate(ProviderConfig)} fails until an explicitly injected
+ * provider instance is used. Service configuration text can never contain
+ * an unlock secret.
+ */
+ public ZeroEchoLibSignatureWorkflowProvider() {
+ this.keyringUnlockProvider = null;
+ }
+
+ /**
+ * Creates a provider with an explicit headless unlock source.
+ *
+ * @param keyringUnlockProvider provider returning a fresh destroyable
+ * password for each keyring open
+ */
+ public ZeroEchoLibSignatureWorkflowProvider(KeyringUnlockProvider keyringUnlockProvider) {
+ this.keyringUnlockProvider = java.util.Objects.requireNonNull(
+ keyringUnlockProvider, "keyringUnlockProvider must not be null");
+ }
@Override
public String id() {
@@ -127,6 +166,38 @@ public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWork
@Override
public SignatureWorkflow allocate(final ProviderConfig config) {
+ KeyringUnlockProvider provider = keyringUnlockProvider;
+ if (provider == null) {
+ throw new PkiException(DC_KEYRING_UNLOCK_PROVIDER_REQUIRED);
+ }
+ return allocate(config, provider);
+ }
+
+ /**
+ * Allocates a workflow using explicit runtime dependencies.
+ *
+ * @param config structural provider configuration
+ * @param dependencies explicit process-local runtime dependencies
+ * @return opened workflow owning an unlocked keyring
+ * @throws PkiException if the keyring unlock provider is absent or fails
+ * @throws RuntimeException if workflow allocation otherwise fails
+ */
+ @Override
+ public SignatureWorkflow allocate(final ProviderConfig config,
+ SignatureWorkflowRuntimeDependencies dependencies) {
+ java.util.Objects.requireNonNull(dependencies, "dependencies must not be null");
+ KeyringUnlockProvider provider = dependencies.keyringUnlockProvider()
+ .orElse(keyringUnlockProvider);
+ if (provider == null) {
+ throw new PkiException(DC_KEYRING_UNLOCK_PROVIDER_REQUIRED);
+ }
+ return allocate(config, provider);
+ }
+
+ // Cleanup must cover every constructor failure, including unchecked failures.
+ @SuppressWarnings("PMD.AvoidCatchingGenericException")
+ private SignatureWorkflow allocate(final ProviderConfig config,
+ KeyringUnlockProvider unlockProvider) {
validateConfig(config);
String keyringPath = config.require(KEY_KEYRING_PATH);
Path operationRoot = Path.of(config.require(KEY_OPERATION_ROOT));
@@ -134,7 +205,42 @@ public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWork
String prefix = config.get(KEY_KEYREF_PREFIX).orElse("zeroecho-lib:");
boolean requireSuffix = config.get(KEY_REQUIRE_SUFFIX).map(Boolean::parseBoolean).orElse(Boolean.TRUE);
- return new ZeroEchoLibSignatureWorkflow(id(), Path.of(keyringPath), operationRoot, Clock.systemUTC(),
- operationHorizon, prefix, requireSuffix);
+ KeyringStore keyring = openKeyring(Path.of(keyringPath), unlockProvider);
+ try {
+ return new ZeroEchoLibSignatureWorkflow(id(), Path.of(keyringPath), operationRoot,
+ Clock.systemUTC(), operationHorizon, prefix, requireSuffix,
+ unlockProvider, keyring);
+ } catch (RuntimeException | Error failure) {
+ keyring.close();
+ throw failure;
+ }
+ }
+
+ /*
+ * The unlock provider is arbitrary application code. Its throwable message
+ * and cause are intentionally removed at this security boundary.
+ */
+ @SuppressWarnings({
+ "PMD.AvoidCatchingGenericException",
+ "PMD.PreserveStackTrace"
+ })
+ private static KeyringStore openKeyring(Path keyringPath,
+ KeyringUnlockProvider unlockProvider) {
+ KeyringPassword password;
+ try {
+ password = unlockProvider.acquire();
+ } catch (IOException | RuntimeException failure) {
+ throw new PkiException(DC_KEYRING_UNLOCK_PROVIDER_FAILED);
+ }
+ if (password == null) {
+ throw new PkiException(DC_KEYRING_UNLOCK_PROVIDER_FAILED);
+ }
+ try (password) {
+ try {
+ return KeyringStore.open(keyringPath, password);
+ } catch (IOException | GeneralSecurityException failure) {
+ throw new PkiException(DC_KEYRING_OPEN_FAILED);
+ }
+ }
}
}
diff --git a/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java b/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java
index 10b3499..c2a02b6 100644
--- a/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java
+++ b/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java
@@ -48,6 +48,7 @@ import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.audit.AuditSinkProvider;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.pki.spi.crypto.SignatureWorkflowProvider;
+import zeroecho.pki.spi.crypto.SignatureWorkflowRuntimeDependencies;
import zeroecho.pki.spi.framework.CredentialFramework;
import zeroecho.pki.spi.framework.CredentialFrameworkProvider;
import zeroecho.pki.spi.store.PkiStore;
@@ -203,9 +204,19 @@ public final class PkiBootstrap {
* Opens a {@link SignatureWorkflow} using {@link SignatureWorkflowProvider}
* discovered via ServiceLoader.
*
+ * Runtime capabilities are supplied explicitly and are not represented
+ * in system properties or {@link ProviderConfig}. Providers that do not use
+ * a software keyring ignore an absent keyring dependency; a keyring-backed
+ * provider rejects it before allocating a workflow.
+ *
+ * @param dependencies explicit process-local runtime dependencies
* @return signature workflow (never {@code null})
+ * @throws NullPointerException if {@code dependencies} is {@code null}
+ * @throws RuntimeException if provider selection or workflow allocation fails
*/
- public static SignatureWorkflow openSignatureWorkflow() {
+ public static SignatureWorkflow openSignatureWorkflow(
+ SignatureWorkflowRuntimeDependencies dependencies) {
+ Objects.requireNonNull(dependencies, "dependencies must not be null");
String requestedId = System.getProperty(PROP_CRYPTO_WORKFLOW_BACKEND);
SignatureWorkflowProvider provider = SpiSelector.select(SignatureWorkflowProvider.class, requestedId,
@@ -224,7 +235,7 @@ public final class PkiBootstrap {
LOG.info("Selected crypto workflow provider: " + provider.id() + " (keys: " + props.keySet() + ")");
}
- return provider.allocate(config);
+ return provider.allocate(config, dependencies);
}
/**
diff --git a/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflowProvider.java b/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflowProvider.java
index cc7f0b5..f31822d 100644
--- a/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflowProvider.java
+++ b/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflowProvider.java
@@ -33,11 +33,37 @@
******************************************************************************/
package zeroecho.pki.spi.crypto;
+import java.util.Objects;
+
+import zeroecho.pki.spi.ProviderConfig;
import zeroecho.pki.spi.ConfigurableProvider;
/**
* ServiceLoader provider for {@link SignatureWorkflow}.
+ *
+ * Service loading discovers provider factories only. Runtime capabilities,
+ * including keyring unlock providers, are supplied explicitly through
+ * {@link #allocate(ProviderConfig, SignatureWorkflowRuntimeDependencies)} and
+ * are never stored in textual provider configuration.
*/
public interface SignatureWorkflowProvider extends ConfigurableProvider {
- // marker
+ /**
+ * Allocates a workflow using explicit process-local runtime dependencies.
+ *
+ * The default implementation supports providers that need no additional
+ * runtime capability. A provider requiring a software keyring must override
+ * this method and reject an absent unlock provider before opening any
+ * workflow resource.
+ *
+ * @param config structural provider configuration
+ * @param dependencies explicit process-local runtime dependencies
+ * @return allocated workflow
+ * @throws NullPointerException if {@code dependencies} is {@code null}
+ * @throws RuntimeException if allocation fails
+ */
+ default SignatureWorkflow allocate(ProviderConfig config,
+ SignatureWorkflowRuntimeDependencies dependencies) {
+ Objects.requireNonNull(dependencies, "dependencies must not be null");
+ return allocate(config);
+ }
}
diff --git a/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflowRuntimeDependencies.java b/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflowRuntimeDependencies.java
new file mode 100644
index 0000000..cf21fe1
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflowRuntimeDependencies.java
@@ -0,0 +1,65 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ ******************************************************************************/
+package zeroecho.pki.spi.crypto;
+
+import java.util.Objects;
+import java.util.Optional;
+
+import zeroecho.core.spi.KeyringUnlockProvider;
+
+/**
+ * Immutable runtime dependencies supplied when opening a signature workflow.
+ *
+ * These dependencies are process-local capabilities. They are never encoded
+ * in {@link zeroecho.pki.spi.ProviderConfig}, persisted, or discovered through
+ * {@link java.util.ServiceLoader}. An absent keyring unlock provider is valid
+ * only for workflow implementations that do not use a software keyring.
+ *
+ * Instances are immutable and safe for concurrent use. This object does not
+ * acquire or retain password material.
+ */
+public final class SignatureWorkflowRuntimeDependencies {
+ private static final SignatureWorkflowRuntimeDependencies NONE =
+ new SignatureWorkflowRuntimeDependencies(null);
+
+ private final KeyringUnlockProvider keyringUnlockProvider;
+
+ private SignatureWorkflowRuntimeDependencies(KeyringUnlockProvider keyringUnlockProvider) {
+ this.keyringUnlockProvider = keyringUnlockProvider;
+ }
+
+ /**
+ * Returns dependencies without a software-keyring unlock provider.
+ *
+ * @return immutable empty runtime dependencies
+ */
+ public static SignatureWorkflowRuntimeDependencies none() {
+ return NONE;
+ }
+
+ /**
+ * Creates dependencies containing an explicit software-keyring unlock
+ * provider.
+ *
+ * @param provider provider returning a fresh destroyable password for each
+ * keyring open
+ * @return immutable runtime dependencies containing {@code provider}
+ * @throws NullPointerException if {@code provider} is {@code null}
+ */
+ public static SignatureWorkflowRuntimeDependencies withKeyringUnlockProvider(
+ KeyringUnlockProvider provider) {
+ return new SignatureWorkflowRuntimeDependencies(
+ Objects.requireNonNull(provider, "provider must not be null"));
+ }
+
+ /**
+ * Returns the explicitly supplied software-keyring unlock provider.
+ *
+ * @return provider when one was supplied, otherwise an empty optional
+ */
+ public Optional keyringUnlockProvider() {
+ return Optional.ofNullable(keyringUnlockProvider);
+ }
+}
diff --git a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/TestKeyringUnlocks.java b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/TestKeyringUnlocks.java
new file mode 100644
index 0000000..2109caa
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/TestKeyringUnlocks.java
@@ -0,0 +1,14 @@
+package zeroecho.pki.impl.crypto.zeroecholib;
+
+import zeroecho.core.spi.KeyringUnlockProvider;
+import zeroecho.core.storage.KeyringPassword;
+
+public final class TestKeyringUnlocks {
+ private TestKeyringUnlocks() {
+ }
+
+ public static KeyringUnlockProvider provider() {
+ return () -> new KeyringPassword(
+ new char[] { 'p', 'k', 'i', '-', 't', 'e', 's', 't', '-', 'k', 'e', 'y' });
+ }
+}
diff --git a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java
index 4fa8742..0204b54 100644
--- a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java
@@ -67,7 +67,7 @@ public final class ZeroEchoLibKeyRefParsingTest {
System.out.println("signing_requires_prv_suffix_in_strict_mode_ok");
try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", Path.of("nonexistent"),
- tempDir.resolve("operations-1"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true)) {
+ tempDir.resolve("operations-1"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true, TestKeyringUnlocks.provider())) {
AccessContext ctx = new AccessContext(new Principal("TEST", "unit"), new Purpose("UNIT_TEST"),
Optional.empty(), Optional.empty());
@@ -95,7 +95,7 @@ public final class ZeroEchoLibKeyRefParsingTest {
System.out.println("verify_with_publicKeyEncoded_invalid_spki_fails_with_crypto_failure_ok");
try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", Path.of("nonexistent"),
- tempDir.resolve("operations-2"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true)) {
+ tempDir.resolve("operations-2"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true, TestKeyringUnlocks.provider())) {
AccessContext ctx = new AccessContext(new Principal("TEST", "unit"), new Purpose("UNIT_TEST"),
Optional.empty(), Optional.empty());
@@ -123,7 +123,7 @@ public final class ZeroEchoLibKeyRefParsingTest {
System.out.println("status_unknown_operation_is_deterministic_ok");
try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", Path.of("nonexistent"),
- tempDir.resolve("operations-3"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true)) {
+ tempDir.resolve("operations-3"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true, TestKeyringUnlocks.provider())) {
PkiId unknown = new PkiId("00000000-0000-0000-0000-000000000000");
SignatureWorkflow.OperationStatus st = wf.status(unknown);
diff --git a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowPersistenceTest.java b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowPersistenceTest.java
index 21a4b92..0e764f8 100644
--- a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowPersistenceTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowPersistenceTest.java
@@ -90,10 +90,12 @@ final class ZeroEchoLibSignatureWorkflowPersistenceTest {
Instant now = Instant.parse("2026-02-03T04:05:06.789Z");
Path keyring = root.resolve("keyring.txt");
KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
- KeyringStore keyringStore = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
- keyringStore.putPrivate("test.prv", "RSA", new RsaPrivateKeySpec(pair.getPrivate().getEncoded()));
- keyringStore.putPublic("test.pub", "RSA", new RsaPublicKeySpec(pair.getPublic().getEncoded()));
- keyringStore.save(keyring);
+ try (zeroecho.core.storage.KeyringPassword password =
+ TestKeyringUnlocks.provider().acquire();
+ KeyringStore keyringStore = KeyringStore.create(keyring, password)) {
+ keyringStore.putPrivate("test.prv", "RSA", pair.getPrivate());
+ keyringStore.putPublic("test.pub", "RSA", pair.getPublic());
+ }
List cleared = new ArrayList<>();
List records = new ArrayList<>();
@@ -120,7 +122,7 @@ final class ZeroEchoLibSignatureWorkflowPersistenceTest {
logger.addHandler(handler);
try (ZeroEchoLibSignatureWorkflow workflow = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", keyring,
root.resolve("cleanup-operations"), Clock.fixed(now, ZoneOffset.UTC), Duration.ofDays(90),
- "zeroecho-lib:", true, (category, bytes) -> cleared.add(new ObservedBuffer(category, bytes)));
+ "zeroecho-lib:", true, TestKeyringUnlocks.provider(), (category, bytes) -> cleared.add(new ObservedBuffer(category, bytes)));
SignatureWorkflow.Registration registration = workflow.register((operationId, status) -> {
throw new IllegalStateException("DO_NOT_LOG_SIGNATURE_SENTINEL");
})) {
@@ -194,10 +196,12 @@ final class ZeroEchoLibSignatureWorkflowPersistenceTest {
Instant base = Instant.parse("2026-02-03T04:05:06Z");
Path keyring = root.resolve("keyring.txt");
KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
- KeyringStore keyringStore = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
- keyringStore.putPrivate("test.prv", "RSA", new RsaPrivateKeySpec(pair.getPrivate().getEncoded()));
- keyringStore.putPublic("test.pub", "RSA", new RsaPublicKeySpec(pair.getPublic().getEncoded()));
- keyringStore.save(keyring);
+ try (zeroecho.core.storage.KeyringPassword password =
+ TestKeyringUnlocks.provider().acquire();
+ KeyringStore keyringStore = KeyringStore.create(keyring, password)) {
+ keyringStore.putPrivate("test.prv", "RSA", pair.getPrivate());
+ keyringStore.putPublic("test.pub", "RSA", pair.getPublic());
+ }
PkiId onTimeId = SigningSubmissionId.create(NAMESPACE, base, new SecureRandom()).id();
Clock onTimeClock = Clock.fixed(base, ZoneOffset.UTC);
@@ -360,7 +364,7 @@ final class ZeroEchoLibSignatureWorkflowPersistenceTest {
private static ZeroEchoLibSignatureWorkflow workflow(Path root, Path operations, Path keyring, Clock clock) {
return new ZeroEchoLibSignatureWorkflow("zeroecho-lib", keyring, operations, clock,
- Duration.ofDays(90), "zeroecho-lib:", true);
+ Duration.ofDays(90), "zeroecho-lib:", true, TestKeyringUnlocks.provider());
}
private static SignatureWorkflow.SignRequest request(PkiId id, long fence, byte[] payload) {
diff --git a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest.java b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest.java
index d84e5bc..0dd25d8 100644
--- a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest.java
@@ -71,11 +71,14 @@ public final class ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest {
System.out.println("verifyFromSpkiDerEcdsaSucceeds");
Path keyring = tempDir.resolve("keyring.txt");
- KeyringStore ks = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
- ks.save(keyring);
+ try (zeroecho.core.storage.KeyringPassword password =
+ TestKeyringUnlocks.provider().acquire();
+ KeyringStore ignored = KeyringStore.create(keyring, password)) {
+ // Empty keyring is sufficient for encoded-key verification.
+ }
try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("wf", keyring,
- tempDir.resolve("operations"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true)) {
+ tempDir.resolve("operations"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true, TestKeyringUnlocks.provider())) {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
kpg.initialize(new ECGenParameterSpec("secp256r1"));
KeyPair kp = kpg.generateKeyPair();
diff --git a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedTest.java b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedTest.java
index ddb9bff..f660d0e 100644
--- a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedTest.java
@@ -66,11 +66,14 @@ public final class ZeroEchoLibSignatureWorkflowVerifyEncodedTest {
System.out.println("verifyFromSpkiDerSucceeds");
Path keyring = tempDir.resolve("keyring.txt");
- KeyringStore ks = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
- ks.save(keyring);
+ try (zeroecho.core.storage.KeyringPassword password =
+ TestKeyringUnlocks.provider().acquire();
+ KeyringStore ignored = KeyringStore.create(keyring, password)) {
+ // Empty keyring is sufficient for encoded-key verification.
+ }
try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("wf", keyring,
- tempDir.resolve("operations"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true)) {
+ tempDir.resolve("operations"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true, TestKeyringUnlocks.provider())) {
KeyPair kp = KeyPairGenerator.getInstance("RSA").generateKeyPair();
byte[] payload = "pqc-ready".getBytes(java.nio.charset.StandardCharsets.UTF_8);
diff --git a/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifierTest.java b/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifierTest.java
index 5b870c6..18a15f9 100644
--- a/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifierTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifierTest.java
@@ -48,6 +48,8 @@ import org.bouncycastle.asn1.x500.X500Name;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import zeroecho.core.storage.KeyringPassword;
+import zeroecho.core.storage.KeyringStore;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.issuance.VerificationPolicy;
@@ -68,8 +70,12 @@ public final class WorkflowProofOfPossessionVerifierTest {
public void verifyRsaCsrViaWorkflow_ok() throws Exception {
System.out.println("verifyRsaCsrViaWorkflow_ok");
- Path keyringPath = tempDir.resolve("keyring.txt");
- java.nio.file.Files.writeString(keyringPath, "", java.nio.charset.StandardCharsets.UTF_8);
+ Path keyringPath = tempDir.resolve("keyring.zek");
+ try (KeyringPassword password =
+ zeroecho.pki.impl.crypto.zeroecholib.TestKeyringUnlocks.provider().acquire();
+ KeyringStore ignored = KeyringStore.create(keyringPath, password)) {
+ // Encoded-key verification needs no persisted key entry.
+ }
System.out.println("...keyringPath=" + keyringPath.getFileName());
KeyPair kp = KeyPairGenerator.getInstance("RSA").generateKeyPair();
@@ -86,7 +92,9 @@ public final class WorkflowProofOfPossessionVerifierTest {
ParsedCertificationRequest parsed = new BcX509CertificationRequestParser().parse(req);
- ZeroEchoLibSignatureWorkflowProvider provider = new ZeroEchoLibSignatureWorkflowProvider();
+ ZeroEchoLibSignatureWorkflowProvider provider =
+ new ZeroEchoLibSignatureWorkflowProvider(
+ zeroecho.pki.impl.crypto.zeroecholib.TestKeyringUnlocks.provider());
ProviderConfig cfg = new ProviderConfig(provider.id(), Map.of("keyringPath", keyringPath.toString(),
"operationRoot", tempDir.resolve("signing-operations").toString()));
SignatureWorkflow wf = provider.allocate(cfg);
diff --git a/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java b/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java
index 2cbc2c2..c67a36c 100644
--- a/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java
+++ b/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java
@@ -34,21 +34,52 @@
package zeroecho.pki.spi.bootstrap;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+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.nio.file.Path;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.SecureRandom;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
import java.util.Properties;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import zeroecho.core.spi.KeyringUnlockProvider;
+import zeroecho.core.storage.KeyringPassword;
+import zeroecho.core.storage.KeyringStore;
+import zeroecho.pki.api.EncodedObject;
+import zeroecho.pki.api.Encoding;
+import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.Principal;
+import zeroecho.pki.api.audit.AccessContext;
+import zeroecho.pki.api.audit.Purpose;
+import zeroecho.pki.api.orch.SigningSubmissionId;
+import zeroecho.pki.api.PkiException;
+import zeroecho.pki.impl.crypto.zeroecholib.ZeroEchoLibSignatureWorkflowProvider;
+import zeroecho.pki.spi.ProviderConfig;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
+import zeroecho.pki.spi.crypto.SignatureWorkflowRuntimeDependencies;
import zeroecho.pki.spi.framework.CredentialFramework;
import zeroecho.pki.spi.store.PkiStore;
import zeroecho.pki.util.async.AsyncBus;
@@ -68,6 +99,10 @@ import zeroecho.pki.util.async.AsyncBus;
*
*/
public final class PkiBootstrapTest {
+ private static final char[] KEYRING_PASSWORD =
+ { 'b', 'o', 'o', 't', 's', 't', 'r', 'a', 'p', '-', 't', 'e', 's', 't' };
+ private static final String SIGNING_NAMESPACE =
+ "0123456789abcdef0123456789abcdef.zeroecho-lib";
@TempDir
private Path tempDir;
@@ -257,24 +292,192 @@ public final class PkiBootstrapTest {
}
@Test
- public void openSignatureWorkflow_zeroEchoLib_usesConfiguredKeyringPath() {
+ public void openSignatureWorkflow_zeroEchoLib_usesConfiguredKeyringPath()
+ throws Exception {
System.out.println("openSignatureWorkflow_zeroEchoLib_usesConfiguredKeyringPath");
+ Path keyringPath = this.tempDir.resolve("workflow").resolve("keyring.zek");
+ createSigningKeyring(keyringPath);
System.setProperty("zeroecho.pki.crypto.workflow", "zeroecho-lib");
System.setProperty("zeroecho.pki.crypto.workflow.keyringPath",
- this.tempDir.resolve("workflow").resolve("keyring.zek").toString());
+ keyringPath.toString());
System.setProperty("zeroecho.pki.crypto.workflow.operationRoot",
this.tempDir.resolve("workflow").resolve("operations").toString());
System.setProperty("zeroecho.pki.crypto.workflow.keyRefPrefix", "test-prefix:");
System.setProperty("zeroecho.pki.crypto.workflow.requireComponentSuffix", "false");
- SignatureWorkflow workflow = PkiBootstrap.openSignatureWorkflow();
- assertNotNull(workflow);
+ AtomicInteger acquisitions = new AtomicInteger();
+ AtomicReference suppliedPassword = new AtomicReference<>();
+ KeyringUnlockProvider unlockProvider = () -> {
+ acquisitions.incrementAndGet();
+ KeyringPassword password = password();
+ suppliedPassword.set(password);
+ return password;
+ };
+ SignatureWorkflowRuntimeDependencies dependencies =
+ SignatureWorkflowRuntimeDependencies.withKeyringUnlockProvider(unlockProvider);
- String workflowClassName = workflow.getClass().getName();
- System.out.println("...workflowClass=" + workflowClassName);
+ try (SignatureWorkflow workflow = PkiBootstrap.openSignatureWorkflow(dependencies)) {
+ assertNotNull(workflow);
+ String workflowClassName = workflow.getClass().getName();
+ System.out.println("...workflowClass=" + workflowClassName);
+ System.out.println("...unlockAcquisitions=" + acquisitions.get());
- assertEquals("zeroecho.pki.impl.crypto.zeroecholib.ZeroEchoLibSignatureWorkflow", workflowClassName);
+ assertEquals("zeroecho.pki.impl.crypto.zeroecholib.ZeroEchoLibSignatureWorkflow",
+ workflowClassName);
+ assertEquals(1, acquisitions.get());
+ assertTrue(suppliedPassword.get().isDestroyed());
+
+ byte[] payload = { 1, 2, 3, 4 };
+ AccessContext access = new AccessContext(new Principal("TEST", "bootstrap"),
+ new Purpose("BOOTSTRAP_TEST"), Optional.empty(), Optional.empty());
+ PkiId submissionId = SigningSubmissionId.create(SIGNING_NAMESPACE,
+ Instant.now(), new SecureRandom()).id();
+ SignatureWorkflow.SignRequest request = SignatureWorkflow.SignRequest.create(
+ submissionId, SIGNING_NAMESPACE, 1L, access,
+ new KeyRef("test-prefix:bootstrap"), "SHA256withRSA",
+ new EncodedObject(Encoding.BINARY, payload),
+ Optional.of(Encoding.BINARY), Optional.empty());
+ workflow.submitSign(request);
+ assertEquals(SignatureWorkflow.State.SUCCEEDED,
+ workflow.status(submissionId).state());
+ assertTrue(workflow.status(submissionId).result().orElseThrow()
+ .signature().orElseThrow().bytes().length > 0);
+ }
+
+ try (KeyringPassword password = password();
+ KeyringStore reopened = KeyringStore.open(keyringPath, password)) {
+ assertTrue(reopened.contains("bootstrap.prv"));
+ }
+
+ System.out.println("...ok");
+ }
+
+ @Test
+ public void openSignatureWorkflow_zeroEchoLib_requiresExplicitUnlockProvider() {
+ System.out.println("openSignatureWorkflow_zeroEchoLib_requiresExplicitUnlockProvider");
+
+ configureWorkflow(this.tempDir.resolve("missing-provider"));
+
+ PkiException exception = assertThrows(PkiException.class,
+ () -> PkiBootstrap.openSignatureWorkflow(
+ SignatureWorkflowRuntimeDependencies.none()));
+ System.out.println("...code=" + exception.getMessage());
+
+ assertEquals(ZeroEchoLibSignatureWorkflowProvider.DC_KEYRING_UNLOCK_PROVIDER_REQUIRED,
+ exception.getMessage());
+ assertNull(exception.getCause());
+ assertEquals(0, exception.getSuppressed().length);
+
+ System.out.println("...ok");
+ }
+
+ @Test
+ public void openSignatureWorkflow_zeroEchoLib_sanitizesUnlockProviderFailure() {
+ System.out.println("openSignatureWorkflow_zeroEchoLib_sanitizesUnlockProviderFailure");
+
+ String sentinel = "DO_NOT_LOG_KEYRING_UNLOCK_SENTINEL";
+ Path root = this.tempDir.resolve("provider-failure");
+ Path keyringPath = root.resolve("keyring.zek");
+ createSigningKeyring(keyringPath);
+ configureWorkflow(root);
+
+ List records = new ArrayList<>();
+ Logger bootstrapLogger = Logger.getLogger(PkiBootstrap.class.getName());
+ Level oldLevel = bootstrapLogger.getLevel();
+ Handler handler = collectingHandler(records);
+ bootstrapLogger.addHandler(handler);
+ bootstrapLogger.setLevel(Level.ALL);
+ try {
+ KeyringUnlockProvider failing = () -> {
+ throw new IOException(sentinel);
+ };
+ PkiException exception = assertThrows(PkiException.class,
+ () -> PkiBootstrap.openSignatureWorkflow(
+ SignatureWorkflowRuntimeDependencies
+ .withKeyringUnlockProvider(failing)));
+ System.out.println("...code=" + exception.getMessage());
+
+ assertEquals(ZeroEchoLibSignatureWorkflowProvider
+ .DC_KEYRING_UNLOCK_PROVIDER_FAILED, exception.getMessage());
+ assertNull(exception.getCause());
+ assertEquals(0, exception.getSuppressed().length);
+ assertFalse(exception.toString().contains(sentinel));
+ assertTrue(records.stream().noneMatch(record ->
+ String.valueOf(record.getMessage()).contains(sentinel)));
+
+ try (SignatureWorkflow workflow = PkiBootstrap.openSignatureWorkflow(
+ SignatureWorkflowRuntimeDependencies.withKeyringUnlockProvider(
+ () -> password()))) {
+ assertNotNull(workflow);
+ }
+ } finally {
+ bootstrapLogger.removeHandler(handler);
+ bootstrapLogger.setLevel(oldLevel);
+ }
+
+ System.out.println("...ok");
+ }
+
+ @Test
+ public void openSignatureWorkflow_zeroEchoLib_wrongPasswordFailsUniformly()
+ throws Exception {
+ System.out.println("openSignatureWorkflow_zeroEchoLib_wrongPasswordFailsUniformly");
+
+ Path root = this.tempDir.resolve("wrong-password");
+ createSigningKeyring(root.resolve("keyring.zek"));
+ configureWorkflow(root);
+ AtomicReference suppliedPassword = new AtomicReference<>();
+ KeyringUnlockProvider wrongProvider = () -> {
+ KeyringPassword password =
+ new KeyringPassword(new char[] { 'w', 'r', 'o', 'n', 'g' });
+ suppliedPassword.set(password);
+ return password;
+ };
+
+ PkiException exception = assertThrows(PkiException.class,
+ () -> PkiBootstrap.openSignatureWorkflow(
+ SignatureWorkflowRuntimeDependencies
+ .withKeyringUnlockProvider(wrongProvider)));
+ assertEquals(ZeroEchoLibSignatureWorkflowProvider.DC_KEYRING_OPEN_FAILED,
+ exception.getMessage());
+ assertNull(exception.getCause());
+ assertEquals(0, exception.getSuppressed().length);
+ assertTrue(suppliedPassword.get().isDestroyed());
+
+ try (SignatureWorkflow workflow = PkiBootstrap.openSignatureWorkflow(
+ SignatureWorkflowRuntimeDependencies.withKeyringUnlockProvider(
+ () -> password()))) {
+ assertNotNull(workflow);
+ }
+
+ System.out.println("...ok");
+ }
+
+ @Test
+ public void signatureWorkflowBootstrapApi_requiresRuntimeDependencies() {
+ System.out.println("signatureWorkflowBootstrapApi_requiresRuntimeDependencies");
+
+ java.lang.reflect.Method[] methods = PkiBootstrap.class.getDeclaredMethods();
+ long workflowOpeners = java.util.Arrays.stream(methods)
+ .filter(method -> "openSignatureWorkflow".equals(method.getName()))
+ .count();
+ java.lang.reflect.Method opener = java.util.Arrays.stream(methods)
+ .filter(method -> "openSignatureWorkflow".equals(method.getName()))
+ .findFirst().orElseThrow();
+ System.out.println("...workflowOpeners=" + workflowOpeners);
+
+ assertEquals(1L, workflowOpeners);
+ assertEquals(List.of(SignatureWorkflowRuntimeDependencies.class),
+ List.of(opener.getParameterTypes()));
+ assertTrue(java.util.Arrays.stream(PkiBootstrap.class.getDeclaredFields())
+ .noneMatch(field -> KeyringUnlockProvider.class.equals(field.getType())));
+ assertTrue(ProviderConfig.class.getRecordComponents()[1].getType()
+ .equals(Map.class));
+ assertTrue(java.util.Arrays.stream(PkiBootstrap.class.getMethods())
+ .noneMatch(method -> java.util.Arrays.stream(method.getParameterTypes())
+ .anyMatch(String.class::equals)
+ && "openSignatureWorkflow".equals(method.getName())));
System.out.println("...ok");
}
@@ -306,4 +509,51 @@ public final class PkiBootstrapTest {
}
}
}
+
+ private void configureWorkflow(Path root) {
+ System.setProperty("zeroecho.pki.crypto.workflow", "zeroecho-lib");
+ System.setProperty("zeroecho.pki.crypto.workflow.keyringPath",
+ root.resolve("keyring.zek").toString());
+ System.setProperty("zeroecho.pki.crypto.workflow.operationRoot",
+ root.resolve("operations").toString());
+ System.setProperty("zeroecho.pki.crypto.workflow.keyRefPrefix", "test-prefix:");
+ System.setProperty("zeroecho.pki.crypto.workflow.requireComponentSuffix", "false");
+ }
+
+ private static void createSigningKeyring(Path keyringPath) {
+ try {
+ KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
+ try (KeyringPassword password = password();
+ KeyringStore keyring = KeyringStore.create(keyringPath, password)) {
+ keyring.putPrivate("bootstrap.prv", "RSA", pair.getPrivate());
+ keyring.putPublic("bootstrap.pub", "RSA", pair.getPublic());
+ }
+ } catch (Exception failure) {
+ throw new IllegalStateException("Unable to create bootstrap test keyring",
+ failure);
+ }
+ }
+
+ private static KeyringPassword password() {
+ return new KeyringPassword(KEYRING_PASSWORD);
+ }
+
+ private static Handler collectingHandler(List records) {
+ return new Handler() {
+ @Override
+ public void publish(LogRecord record) {
+ records.add(record);
+ }
+
+ @Override
+ public void flush() {
+ // No buffered output.
+ }
+
+ @Override
+ public void close() {
+ // No owned resource.
+ }
+ };
+ }
}
diff --git a/samples/src/test/java/demo/AesTest.java b/samples/src/test/java/demo/AesTest.java
index f73b8c3..6d755e9 100644
--- a/samples/src/test/java/demo/AesTest.java
+++ b/samples/src/test/java/demo/AesTest.java
@@ -44,6 +44,7 @@ import java.util.logging.Logger;
import javax.crypto.SecretKey;
+import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import conflux.Ctx;
@@ -62,6 +63,7 @@ import zeroecho.sdk.builders.core.PlainBytesBuilder;
import zeroecho.sdk.content.api.DataContent;
import zeroecho.sdk.ZeroEchoSession;
+@Tag("sample")
class AesTest {
private static final Logger LOG = Logger.getLogger(AesTest.class.getName());
private final ZeroEchoSession zeroEchoSession = new ZeroEchoSession();
diff --git a/samples/src/test/java/demo/AgreementVariantsTest.java b/samples/src/test/java/demo/AgreementVariantsTest.java
index d959018..1480885 100644
--- a/samples/src/test/java/demo/AgreementVariantsTest.java
+++ b/samples/src/test/java/demo/AgreementVariantsTest.java
@@ -41,6 +41,7 @@ import java.util.logging.Level;
import java.util.logging.Logger;
import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import zeroecho.core.CryptoAlgorithm;
@@ -107,6 +108,7 @@ import zeroecho.sdk.util.BouncyCastleActivator;
* for didactic reasons.
*
*/
+@Tag("sample")
class AgreementVariantsTest {
private final ZeroEchoSession session = new ZeroEchoSession();
diff --git a/samples/src/test/java/demo/CombinedDeliveryTest.java b/samples/src/test/java/demo/CombinedDeliveryTest.java
index c7cf2b6..cab260c 100644
--- a/samples/src/test/java/demo/CombinedDeliveryTest.java
+++ b/samples/src/test/java/demo/CombinedDeliveryTest.java
@@ -43,6 +43,7 @@ import java.util.logging.Level;
import java.util.logging.Logger;
import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
@@ -61,6 +62,7 @@ import zeroecho.sdk.Pbkdf2Limits;
import zeroecho.sdk.ZeroEchoSession;
import zeroecho.sdk.util.BouncyCastleActivator;
+@Tag("sample")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class CombinedDeliveryTest {
private static final Logger LOG = Logger.getLogger(CombinedDeliveryTest.class.getName());
diff --git a/samples/src/test/java/demo/HybridDerivedAesDemoTest.java b/samples/src/test/java/demo/HybridDerivedAesDemoTest.java
index 930e5df..e3443e7 100644
--- a/samples/src/test/java/demo/HybridDerivedAesDemoTest.java
+++ b/samples/src/test/java/demo/HybridDerivedAesDemoTest.java
@@ -42,6 +42,7 @@ import java.util.Arrays;
import java.util.logging.Logger;
import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import zeroecho.core.alg.kyber.KyberKeyGenSpec;
@@ -77,6 +78,7 @@ import zeroecho.sdk.util.BouncyCastleActivator;
* HKDF salt) and injects key/IV/AAD into existing streaming builders.
*
*/
+@Tag("sample")
class HybridDerivedAesDemoTest {
private final ZeroEchoSession session = new ZeroEchoSession();
diff --git a/samples/src/test/java/demo/HybridKexDemoTest.java b/samples/src/test/java/demo/HybridKexDemoTest.java
index 282e33a..2470f54 100644
--- a/samples/src/test/java/demo/HybridKexDemoTest.java
+++ b/samples/src/test/java/demo/HybridKexDemoTest.java
@@ -40,6 +40,7 @@ import java.util.Arrays;
import java.util.logging.Logger;
import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import zeroecho.core.alg.common.agreement.KeyPairKey;
@@ -102,6 +103,7 @@ import zeroecho.sdk.util.BouncyCastleActivator;
* explicit close blocks keeps the handshake lifecycle visible to the reader.
*
*/
+@Tag("sample")
class HybridKexDemoTest {
private final ZeroEchoSession session = new ZeroEchoSession();
diff --git a/samples/src/test/java/demo/HybridSigningAesTest.java b/samples/src/test/java/demo/HybridSigningAesTest.java
index abf29c8..8890d98 100644
--- a/samples/src/test/java/demo/HybridSigningAesTest.java
+++ b/samples/src/test/java/demo/HybridSigningAesTest.java
@@ -46,6 +46,7 @@ import java.util.logging.Logger;
import javax.crypto.SecretKey;
import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import zeroecho.core.alg.ed25519.Ed25519KeyGenSpec;
@@ -77,6 +78,7 @@ import zeroecho.sdk.util.BouncyCastleActivator;
* with AND verification.
*
*/
+@Tag("sample")
class HybridSigningAesTest {
private static final Logger LOG = Logger.getLogger(HybridSigningAesTest.class.getName());
diff --git a/samples/src/test/java/demo/PostQuantumTest.java b/samples/src/test/java/demo/PostQuantumTest.java
index e485d55..789bbd9 100644
--- a/samples/src/test/java/demo/PostQuantumTest.java
+++ b/samples/src/test/java/demo/PostQuantumTest.java
@@ -44,6 +44,7 @@ import java.util.logging.Level;
import java.util.logging.Logger;
import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
@@ -57,6 +58,7 @@ import zeroecho.sdk.content.api.DataContent;
import zeroecho.sdk.util.BouncyCastleActivator;
import zeroecho.sdk.ZeroEchoSession;
+@Tag("sample")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PostQuantumTest {
private final ZeroEchoSession session = new ZeroEchoSession();
diff --git a/samples/src/test/java/demo/SigningAesTest.java b/samples/src/test/java/demo/SigningAesTest.java
index 4a3756a..c634a09 100644
--- a/samples/src/test/java/demo/SigningAesTest.java
+++ b/samples/src/test/java/demo/SigningAesTest.java
@@ -45,6 +45,7 @@ import java.util.logging.Logger;
import javax.crypto.SecretKey;
+import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import zeroecho.core.alg.rsa.RsaKeyGenSpec;
@@ -59,6 +60,7 @@ import zeroecho.sdk.builders.core.PlainBytesBuilder;
import zeroecho.sdk.content.api.DataContent;
import zeroecho.sdk.ZeroEchoSession;
+@Tag("sample")
class SigningAesTest {
private final ZeroEchoSession session = new ZeroEchoSession();
private static final Logger LOG = Logger.getLogger(SigningAesTest.class.getName());