From 8b2f3df41f9fa4cb27d4a5c84310011d4cb09135 Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Wed, 29 Jul 2026 23:18:22 +0200 Subject: [PATCH] security(lib,storage): enforce single-use encryption contexts, encrypt keyring and harden key import --- app/src/main/java/zeroecho/Guard.java | 116 +- app/src/main/java/zeroecho/Kem.java | 38 +- .../java/zeroecho/KeyStoreManagement.java | 351 +-- .../main/java/zeroecho/KeyringUnlocks.java | 64 + app/src/main/java/zeroecho/Tag.java | 47 +- app/src/test/java/zeroecho/GuardTest.java | 28 +- app/src/test/java/zeroecho/KemTest.java | 21 +- .../java/zeroecho/KeyStoreManagementTest.java | 14 +- app/src/test/java/zeroecho/TagTest.java | 29 +- .../java/zeroecho/TestKeyringUnlocks.java | 14 + .../ext/integrations/covert/TextualCodec.java | 2 +- .../zeroecho/core/alg/aes/AesAlgorithm.java | 2 +- .../core/alg/aes/AesCipherContext.java | 2 +- .../chacha/AbstractChaChaCipherContext.java | 2 +- .../alg/chacha/ChaCha20Poly1305Algorithm.java | 2 +- .../core/alg/chacha/ChaChaAlgorithm.java | 2 +- .../core/spi/KeyringUnlockProvider.java | 29 + .../core/storage/KeyringException.java | 58 + .../core/storage/KeyringFileOperations.java | 97 + .../core/storage/KeyringImportRegistry.java | 452 ++++ .../storage/KeyringNonceReservationKdf.java | 76 + .../core/storage/KeyringPassword.java | 120 + .../core/storage/KeyringProtection.java | 42 + .../core/storage/KeyringRandomBytes.java | 21 + .../zeroecho/core/storage/KeyringStore.java | 2283 ++++++++++++----- .../zeroecho/core/storage/package-info.java | 121 +- .../zeroecho/core/util/RandomSupport.java | 70 + .../java/zeroecho/sdk/guard/Encryptor.java | 2 +- .../zeroecho/sdk/guard/KemCtxRecipient.java | 2 +- .../zeroecho/sdk/guard/PasswordRecipient.java | 2 +- .../main/java/zeroecho/sdk/util/Password.java | 2 + .../java/zeroecho/sdk/util/RandomSupport.java | 115 - .../java/zeroecho/sdk/util/package-info.java | 3 - .../core/alg/aes/AesRandomSupportTest.java | 2 +- .../storage/KeyringAlgorithmCoverageTest.java | 413 +++ .../storage/KeyringAtomicPersistenceTest.java | 426 +++ .../KeyringCryptographicFormatTest.java | 1084 ++++++++ .../KeyringFilesystemSecurityTest.java | 865 +++++++ .../storage/KeyringImportRegistryTest.java | 219 ++ .../storage/KeyringNonceReservationTest.java | 504 ++++ .../core/storage/KeyringStoreDynamicTest.java | 392 --- .../storage/KeyringStoreSecurityTest.java | 138 - .../core/storage/KeyringStoreTest.java | 344 +++ .../java/zeroecho/sdk/util/PasswordTest.java | 2 + .../ZeroEchoLibSignatureWorkflow.java | 80 +- .../ZeroEchoLibSignatureWorkflowProvider.java | 110 +- .../pki/spi/bootstrap/PkiBootstrap.java | 15 +- .../spi/crypto/SignatureWorkflowProvider.java | 28 +- .../SignatureWorkflowRuntimeDependencies.java | 65 + .../zeroecholib/TestKeyringUnlocks.java | 14 + .../ZeroEchoLibKeyRefParsingTest.java | 6 +- ...hoLibSignatureWorkflowPersistenceTest.java | 24 +- ...gnatureWorkflowVerifyEncodedEcdsaTest.java | 9 +- ...LibSignatureWorkflowVerifyEncodedTest.java | 9 +- ...WorkflowProofOfPossessionVerifierTest.java | 14 +- .../pki/spi/bootstrap/PkiBootstrapTest.java | 264 +- samples/src/test/java/demo/AesTest.java | 2 + .../test/java/demo/AgreementVariantsTest.java | 2 + .../test/java/demo/CombinedDeliveryTest.java | 2 + .../java/demo/HybridDerivedAesDemoTest.java | 2 + .../src/test/java/demo/HybridKexDemoTest.java | 2 + .../test/java/demo/HybridSigningAesTest.java | 2 + .../src/test/java/demo/PostQuantumTest.java | 2 + .../src/test/java/demo/SigningAesTest.java | 2 + 64 files changed, 7473 insertions(+), 1799 deletions(-) create mode 100644 app/src/main/java/zeroecho/KeyringUnlocks.java create mode 100644 app/src/test/java/zeroecho/TestKeyringUnlocks.java create mode 100644 lib/src/main/java/zeroecho/core/spi/KeyringUnlockProvider.java create mode 100644 lib/src/main/java/zeroecho/core/storage/KeyringException.java create mode 100644 lib/src/main/java/zeroecho/core/storage/KeyringFileOperations.java create mode 100644 lib/src/main/java/zeroecho/core/storage/KeyringImportRegistry.java create mode 100644 lib/src/main/java/zeroecho/core/storage/KeyringNonceReservationKdf.java create mode 100644 lib/src/main/java/zeroecho/core/storage/KeyringPassword.java create mode 100644 lib/src/main/java/zeroecho/core/storage/KeyringProtection.java create mode 100644 lib/src/main/java/zeroecho/core/storage/KeyringRandomBytes.java create mode 100644 lib/src/main/java/zeroecho/core/util/RandomSupport.java delete mode 100644 lib/src/main/java/zeroecho/sdk/util/RandomSupport.java create mode 100644 lib/src/test/java/zeroecho/core/storage/KeyringAlgorithmCoverageTest.java create mode 100644 lib/src/test/java/zeroecho/core/storage/KeyringAtomicPersistenceTest.java create mode 100644 lib/src/test/java/zeroecho/core/storage/KeyringCryptographicFormatTest.java create mode 100644 lib/src/test/java/zeroecho/core/storage/KeyringFilesystemSecurityTest.java create mode 100644 lib/src/test/java/zeroecho/core/storage/KeyringImportRegistryTest.java create mode 100644 lib/src/test/java/zeroecho/core/storage/KeyringNonceReservationTest.java delete mode 100644 lib/src/test/java/zeroecho/core/storage/KeyringStoreDynamicTest.java delete mode 100644 lib/src/test/java/zeroecho/core/storage/KeyringStoreSecurityTest.java create mode 100644 lib/src/test/java/zeroecho/core/storage/KeyringStoreTest.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflowRuntimeDependencies.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/TestKeyringUnlocks.java 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 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 publicSpec, + Class 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 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 specClass = registeredSpecClass(record.algorithm, record.kind, - record.specClass); - return unmarshalSpec(specClass, record.specPayload); - } - - private Class 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());