security(lib,storage): enforce single-use encryption contexts, encrypt keyring and harden key import
This commit is contained in:
@@ -56,6 +56,7 @@ import zeroecho.core.KeyUsage;
|
|||||||
import zeroecho.core.context.EncryptionContext;
|
import zeroecho.core.context.EncryptionContext;
|
||||||
import zeroecho.core.context.KemContext;
|
import zeroecho.core.context.KemContext;
|
||||||
import zeroecho.core.err.UnsupportedRoleException;
|
import zeroecho.core.err.UnsupportedRoleException;
|
||||||
|
import zeroecho.core.spi.KeyringUnlockProvider;
|
||||||
import zeroecho.core.storage.KeyringStore;
|
import zeroecho.core.storage.KeyringStore;
|
||||||
import zeroecho.sdk.Pbkdf2Limits;
|
import zeroecho.sdk.Pbkdf2Limits;
|
||||||
import zeroecho.sdk.ZeroEchoSession;
|
import zeroecho.sdk.ZeroEchoSession;
|
||||||
@@ -66,7 +67,7 @@ import zeroecho.sdk.guard.MultiRecipientContent;
|
|||||||
import zeroecho.sdk.guard.MultiRecipientDataSourceBuilder;
|
import zeroecho.sdk.guard.MultiRecipientDataSourceBuilder;
|
||||||
import zeroecho.sdk.guard.RecipientKekSizes;
|
import zeroecho.sdk.guard.RecipientKekSizes;
|
||||||
import zeroecho.sdk.guard.UnlockMaterial;
|
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
|
* Guard is a unified subcommand that encrypts and decrypts using a
|
||||||
@@ -111,6 +112,7 @@ import zeroecho.sdk.util.RandomSupport;
|
|||||||
* --alg chacha-aead
|
* --alg chacha-aead
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("PMD.CyclomaticComplexity")
|
||||||
public final class Guard {
|
public final class Guard {
|
||||||
|
|
||||||
private Guard() {
|
private Guard() {
|
||||||
@@ -128,7 +130,26 @@ public final class Guard {
|
|||||||
* @throws IOException on I/O errors
|
* @throws IOException on I/O errors
|
||||||
* @throws GeneralSecurityException on cryptographic setup or keyring 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 {
|
throws ParseException, IOException, GeneralSecurityException {
|
||||||
// ---- operation selection
|
// ---- operation selection
|
||||||
final Option OPT_ENCRYPT = Option.builder("e").longOpt("encrypt").hasArg().argName("in-file")
|
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(
|
final int kekLen = RecipientKekSizes.requireSupported(
|
||||||
Integer.parseInt(cmd.getOptionValue(OPT_PSW_KEK, "32")));
|
Integer.parseInt(cmd.getOptionValue(OPT_PSW_KEK, "32")));
|
||||||
|
|
||||||
final KeyringStore ks = loadKeyringIfPresent(session, cmd, OPT_KEYRING);
|
KeyringStore ks = loadKeyringIfPresent(cmd, OPT_KEYRING,
|
||||||
for (String alias : cmd.getOptionValues(OPT_TO_ALIAS) == null ? new String[0]
|
keyringUnlockProvider);
|
||||||
: cmd.getOptionValues(OPT_TO_ALIAS)) {
|
try (ks) {
|
||||||
addRecipientFromAlias(session, env, ks, alias, kekLen, saltLen, false);
|
for (String alias : cmd.getOptionValues(OPT_TO_ALIAS) == null ? new String[0]
|
||||||
}
|
: cmd.getOptionValues(OPT_TO_ALIAS)) {
|
||||||
for (String psw : cmd.getOptionValues(OPT_TO_PSW) == null ? new String[0]
|
addRecipientFromAlias(session, env, ks, alias, kekLen, saltLen, false);
|
||||||
: cmd.getOptionValues(OPT_TO_PSW)) {
|
|
||||||
char[] passwordChars = psw.toCharArray();
|
|
||||||
try {
|
|
||||||
env.addPasswordRecipient(passwordChars, iter, saltLen, kekLen);
|
|
||||||
} finally {
|
|
||||||
Arrays.fill(passwordChars, '\0');
|
|
||||||
}
|
}
|
||||||
}
|
for (String psw : cmd.getOptionValues(OPT_TO_PSW) == null ? new String[0]
|
||||||
for (String alias : cmd.getOptionValues(OPT_DECOY_ALIAS) == null ? new String[0]
|
: cmd.getOptionValues(OPT_TO_PSW)) {
|
||||||
: cmd.getOptionValues(OPT_DECOY_ALIAS)) {
|
char[] passwordChars = psw.toCharArray();
|
||||||
addRecipientFromAlias(session, env, ks, alias, kekLen, saltLen, true);
|
try {
|
||||||
}
|
env.addPasswordRecipient(passwordChars, iter, saltLen, kekLen);
|
||||||
for (String psw : cmd.getOptionValues(OPT_DECOY_PSW) == null ? new String[0]
|
} finally {
|
||||||
: cmd.getOptionValues(OPT_DECOY_PSW)) {
|
Arrays.fill(passwordChars, '\0');
|
||||||
char[] passwordChars = psw.toCharArray();
|
}
|
||||||
try {
|
|
||||||
env.addPasswordRecipientDecoy(passwordChars, iter, saltLen, kekLen);
|
|
||||||
} finally {
|
|
||||||
Arrays.fill(passwordChars, '\0');
|
|
||||||
}
|
}
|
||||||
}
|
for (String alias : cmd.getOptionValues(OPT_DECOY_ALIAS) == null ? new String[0]
|
||||||
final int rndCount = Integer.parseInt(cmd.getOptionValue(OPT_DECOY_PSW_RAND, "0"));
|
: cmd.getOptionValues(OPT_DECOY_ALIAS)) {
|
||||||
for (int i = 0; i < rndCount; i++) {
|
addRecipientFromAlias(session, env, ks, alias, kekLen, saltLen, true);
|
||||||
char[] passwordChars = randomPassword();
|
}
|
||||||
try {
|
for (String psw : cmd.getOptionValues(OPT_DECOY_PSW) == null ? new String[0]
|
||||||
env.addPasswordRecipientDecoy(passwordChars, iter, saltLen, kekLen);
|
: cmd.getOptionValues(OPT_DECOY_PSW)) {
|
||||||
} finally {
|
char[] passwordChars = psw.toCharArray();
|
||||||
Arrays.fill(passwordChars, '\0');
|
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 {
|
} else {
|
||||||
@@ -400,9 +424,11 @@ public final class Guard {
|
|||||||
throw new ParseException("Specify exactly one of --priv-alias or --password for decryption");
|
throw new ParseException("Specify exactly one of --priv-alias or --password for decryption");
|
||||||
}
|
}
|
||||||
if (privAlias != null) {
|
if (privAlias != null) {
|
||||||
final KeyringStore ks = requireKeyring(session, cmd, OPT_KEYRING);
|
try (KeyringStore ks = requireKeyring(cmd, OPT_KEYRING,
|
||||||
final KeyringStore.PrivateWithId pr = ks.getPrivateWithId(privAlias);
|
keyringUnlockProvider)) {
|
||||||
borrowedUnlockMaterial = new UnlockMaterial.Private(pr.key());
|
final KeyringStore.PrivateWithId pr = ks.getPrivateWithId(privAlias);
|
||||||
|
borrowedUnlockMaterial = new UnlockMaterial.Private(pr.key());
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
char[] passwordChars = password.toCharArray();
|
char[] passwordChars = password.toCharArray();
|
||||||
try {
|
try {
|
||||||
@@ -575,19 +601,21 @@ public final class Guard {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static KeyringStore loadKeyringIfPresent(ZeroEchoSession session, CommandLine cmd, Option optKs)
|
private static KeyringStore loadKeyringIfPresent(CommandLine cmd, Option optKs,
|
||||||
throws IOException {
|
KeyringUnlockProvider unlockProvider)
|
||||||
|
throws IOException, GeneralSecurityException {
|
||||||
if (!cmd.hasOption(optKs)) {
|
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)
|
private static KeyringStore requireKeyring(CommandLine cmd, Option optKs,
|
||||||
throws IOException, ParseException {
|
KeyringUnlockProvider unlockProvider)
|
||||||
|
throws IOException, ParseException, GeneralSecurityException {
|
||||||
if (!cmd.hasOption(optKs)) {
|
if (!cmd.hasOption(optKs)) {
|
||||||
throw new ParseException("--keyring <file> is required when aliases are used");
|
throw new ParseException("--keyring <file> is required when aliases are used");
|
||||||
}
|
}
|
||||||
return KeyringStore.load(session, Paths.get(cmd.getOptionValue(optKs)));
|
return KeyringUnlocks.open(Paths.get(cmd.getOptionValue(optKs)), unlockProvider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import org.apache.commons.cli.ParseException;
|
|||||||
import zeroecho.core.AlgorithmFamily;
|
import zeroecho.core.AlgorithmFamily;
|
||||||
import zeroecho.core.CatalogSelector;
|
import zeroecho.core.CatalogSelector;
|
||||||
import zeroecho.core.KeyUsage;
|
import zeroecho.core.KeyUsage;
|
||||||
|
import zeroecho.core.spi.KeyringUnlockProvider;
|
||||||
import zeroecho.core.storage.KeyringStore;
|
import zeroecho.core.storage.KeyringStore;
|
||||||
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
|
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
|
||||||
import zeroecho.sdk.builders.alg.ChaChaDataContentBuilder;
|
import zeroecho.sdk.builders.alg.ChaChaDataContentBuilder;
|
||||||
@@ -203,7 +204,37 @@ public final class Kem { // NOPMD
|
|||||||
// no instances
|
// 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();
|
ZeroEchoSession session = new ZeroEchoSession();
|
||||||
defineOptions(opts);
|
defineOptions(opts);
|
||||||
CommandLineParser parser = new DefaultParser();
|
CommandLineParser parser = new DefaultParser();
|
||||||
@@ -242,7 +273,7 @@ public final class Kem { // NOPMD
|
|||||||
|
|
||||||
final String kemId = cmd.getOptionValue(OPT_KEM.getLongOpt());
|
final String kemId = cmd.getOptionValue(OPT_KEM.getLongOpt());
|
||||||
final Path keyringPath = Path.of(cmd.getOptionValue(OPT_KEYRING.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
|
// Configure KEM envelope
|
||||||
KemDataContentBuilder kem = KemDataContentBuilder.builder(session).kem(kemId);
|
KemDataContentBuilder kem = KemDataContentBuilder.builder(session).kem(kemId);
|
||||||
@@ -343,7 +374,8 @@ public final class Kem { // NOPMD
|
|||||||
}
|
}
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -33,19 +33,13 @@
|
|||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
package zeroecho;
|
package zeroecho;
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStreamReader;
|
|
||||||
import java.io.PrintWriter;
|
import java.io.PrintWriter;
|
||||||
import java.lang.reflect.Constructor;
|
|
||||||
import java.lang.reflect.Method;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.security.GeneralSecurityException;
|
import java.security.GeneralSecurityException;
|
||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Base64;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -65,21 +59,19 @@ import zeroecho.core.CryptoAlgorithms;
|
|||||||
import zeroecho.core.KeyOperation;
|
import zeroecho.core.KeyOperation;
|
||||||
import zeroecho.core.KeyOperationInfo;
|
import zeroecho.core.KeyOperationInfo;
|
||||||
import zeroecho.core.spec.AlgorithmKeySpec;
|
import zeroecho.core.spec.AlgorithmKeySpec;
|
||||||
|
import zeroecho.core.spi.KeyringUnlockProvider;
|
||||||
import zeroecho.core.storage.KeyringStore;
|
import zeroecho.core.storage.KeyringStore;
|
||||||
import zeroecho.sdk.ZeroEchoSession;
|
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.
|
||||||
*
|
*
|
||||||
* <h2>Overview</h2> The {@code KeyStoreManagement} subcommand provides
|
* <h2>Overview</h2> The {@code KeyStoreManagement} subcommand provides
|
||||||
* lifecycle operations on key material stored in
|
* lifecycle operations on key material stored in
|
||||||
* {@link zeroecho.core.storage.KeyringStore}. It supports listing algorithms
|
* {@link zeroecho.core.storage.KeyringStore}. It supports listing algorithms
|
||||||
* and aliases, generating key pairs or symmetric keys, exporting/importing
|
* and aliases and generating key pairs or symmetric keys. Passwords are
|
||||||
* versioned text snippets, and overwriting existing entries.
|
* supplied through a destroyable unlock provider and are never accepted as
|
||||||
*
|
* command-line text.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* <h2>Usage</h2> Invoked as: <pre>{@code
|
* <h2>Usage</h2> Invoked as: <pre>{@code
|
||||||
* ZeroEcho -K [options]
|
* ZeroEcho -K [options]
|
||||||
@@ -92,9 +84,6 @@ import zeroecho.sdk.ZeroEchoSession;
|
|||||||
* <li>{@code --list-aliases} - list aliases present in the keystore.</li>
|
* <li>{@code --list-aliases} - list aliases present in the keystore.</li>
|
||||||
* <li>{@code --generate} - generate a new key pair or secret and store under
|
* <li>{@code --generate} - generate a new key pair or secret and store under
|
||||||
* the given alias.</li>
|
* the given alias.</li>
|
||||||
* <li>{@code --export} - export one or more aliases as a versioned
|
|
||||||
* snippet.</li>
|
|
||||||
* <li>{@code --import} - import a versioned snippet into the keystore.</li>
|
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <h2>General options</h2>
|
* <h2>General options</h2>
|
||||||
@@ -116,38 +105,18 @@ import zeroecho.sdk.ZeroEchoSession;
|
|||||||
* .prv).</li>
|
* .prv).</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <h2>Export options</h2>
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code --aliases a,b,c} - comma-separated list of aliases to export
|
|
||||||
* (default: all).</li>
|
|
||||||
* <li>{@code --out <file|-} - output file path (default: "-" for stdout).</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <h2>Import options</h2>
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code --in <file|-} - input file path (default: "-" for stdin).</li>
|
|
||||||
* <li>{@code --overwrite} - allow replacing existing aliases when
|
|
||||||
* importing.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <h2>Examples</h2> <pre>{@code
|
* <h2>Examples</h2> <pre>{@code
|
||||||
* # List available algorithms
|
* # List available algorithms
|
||||||
* ZeroEcho -K --list-algorithms
|
* ZeroEcho -K --list-algorithms
|
||||||
*
|
*
|
||||||
* # Generate a new RSA key pair and store as alice.pub / alice.prv
|
* # 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"
|
* # 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
|
* # List aliases in the keystore
|
||||||
* ZeroEcho -K --list-aliases --keystore keys.txt
|
* ZeroEcho -K --list-aliases --keystore keys.zekr
|
||||||
*
|
|
||||||
* # 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
|
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*
|
*
|
||||||
* <h2>Exit codes</h2>
|
* <h2>Exit codes</h2>
|
||||||
@@ -159,9 +128,7 @@ import zeroecho.sdk.ZeroEchoSession;
|
|||||||
*
|
*
|
||||||
* @since 1.0
|
* @since 1.0
|
||||||
*/
|
*/
|
||||||
public final class KeyStoreManagement { // NOPMD
|
public final class KeyStoreManagement {
|
||||||
|
|
||||||
private final static String STD_IN_OUT = "-";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Option constants (centralized for maintainability)
|
// Option constants (centralized for maintainability)
|
||||||
@@ -197,21 +164,6 @@ public final class KeyStoreManagement { // NOPMD
|
|||||||
private static final Option OVERWRITE_OPTION = Option.builder().longOpt("overwrite")
|
private static final Option OVERWRITE_OPTION = Option.builder().longOpt("overwrite")
|
||||||
.desc("Overwrite existing aliases on conflict").get();
|
.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. */
|
/** Prevents instantiation. */
|
||||||
private KeyStoreManagement() {
|
private KeyStoreManagement() {
|
||||||
}
|
}
|
||||||
@@ -232,7 +184,25 @@ public final class KeyStoreManagement { // NOPMD
|
|||||||
* @throws ParseException if the parser fails
|
* @throws ParseException if the parser fails
|
||||||
* @throws IOException if I/O 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();
|
ZeroEchoSession session = new ZeroEchoSession();
|
||||||
defineOptions(dispatcherOptions);
|
defineOptions(dispatcherOptions);
|
||||||
CommandLineParser parser = new DefaultParser();
|
CommandLineParser parser = new DefaultParser();
|
||||||
@@ -248,26 +218,17 @@ public final class KeyStoreManagement { // NOPMD
|
|||||||
}
|
}
|
||||||
|
|
||||||
Path keyringPath = Path.of(cmd.getOptionValue(KEYSTORE_OPTION.getLongOpt()));
|
Path keyringPath = Path.of(cmd.getOptionValue(KEYSTORE_OPTION.getLongOpt()));
|
||||||
KeyringStore store = Files.exists(keyringPath) ? KeyringStore.load(session, keyringPath)
|
try (KeyringStore store = Files.exists(keyringPath)
|
||||||
: new KeyringStore(session);
|
? KeyringUnlocks.open(keyringPath, unlockProvider)
|
||||||
|
: KeyringUnlocks.create(keyringPath, unlockProvider)) {
|
||||||
if (cmd.hasOption(LIST_ALIASES_OPTION.getLongOpt())) {
|
if (cmd.hasOption(LIST_ALIASES_OPTION.getLongOpt())) {
|
||||||
listAliases(store);
|
listAliases(store);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (cmd.hasOption(GENERATE_OPTION.getLongOpt())) {
|
if (cmd.hasOption(GENERATE_OPTION.getLongOpt())) {
|
||||||
doGenerate(session, store, cmd);
|
doGenerate(session, store, cmd);
|
||||||
store.save(keyringPath);
|
return 0;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new IllegalArgumentException("No operation selected");
|
throw new IllegalArgumentException("No operation selected");
|
||||||
@@ -287,8 +248,6 @@ public final class KeyStoreManagement { // NOPMD
|
|||||||
actions.addOption(LIST_ALGORITHMS_OPTION);
|
actions.addOption(LIST_ALGORITHMS_OPTION);
|
||||||
actions.addOption(LIST_ALIASES_OPTION);
|
actions.addOption(LIST_ALIASES_OPTION);
|
||||||
actions.addOption(GENERATE_OPTION);
|
actions.addOption(GENERATE_OPTION);
|
||||||
actions.addOption(EXPORT_OPTION);
|
|
||||||
actions.addOption(IMPORT_OPTION);
|
|
||||||
options.addOptionGroup(actions);
|
options.addOptionGroup(actions);
|
||||||
|
|
||||||
options.addOption(ALG_OPTION);
|
options.addOption(ALG_OPTION);
|
||||||
@@ -298,9 +257,6 @@ public final class KeyStoreManagement { // NOPMD
|
|||||||
options.addOption(PRV_SUFFIX_OPTION);
|
options.addOption(PRV_SUFFIX_OPTION);
|
||||||
options.addOption(OVERWRITE_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
|
* @param cmd parsed command line
|
||||||
*/
|
*/
|
||||||
public static void doGenerate(final ZeroEchoSession session, final KeyringStore store,
|
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 algId = required(cmd, ALG_OPTION, "--alg is required for --generate");
|
||||||
String aliasBase = required(cmd, ALIAS_OPTION, "--alias is required for --generate");
|
String aliasBase = required(cmd, ALIAS_OPTION, "--alias is required for --generate");
|
||||||
String kind = cmd.getOptionValue(KIND_OPTION.getLongOpt());
|
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,
|
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);
|
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();
|
KeyPair pair = generated.pair();
|
||||||
byte[] publicEncoding = pair.getPublic() == null ? null : pair.getPublic().getEncoded();
|
if (pair.getPublic() == null || pair.getPrivate() == null) {
|
||||||
byte[] privateEncoding = pair.getPrivate() == null ? null : pair.getPrivate().getEncoded();
|
throw new IllegalStateException("Generated key pair is incomplete");
|
||||||
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);
|
|
||||||
|
|
||||||
String publicAlias = aliasBase + publicSuffix;
|
String publicAlias = aliasBase + publicSuffix;
|
||||||
String privateAlias = aliasBase + privateSuffix;
|
String privateAlias = aliasBase + privateSuffix;
|
||||||
ensureWritable(store, publicAlias, overwrite);
|
ensureWritable(store, publicAlias, overwrite);
|
||||||
ensureWritable(store, privateAlias, overwrite);
|
ensureWritable(store, privateAlias, overwrite);
|
||||||
store.putPublic(publicAlias, algorithmId, publicSpec);
|
store.putPublic(publicAlias, algorithmId, pair.getPublic());
|
||||||
store.putPrivate(privateAlias, algorithmId, privateSpec);
|
store.putPrivate(privateAlias, algorithmId, pair.getPrivate());
|
||||||
|
|
||||||
PrintWriter out = new PrintWriter(System.out, true, StandardCharsets.UTF_8); // NOPMD
|
PrintWriter out = new PrintWriter(System.out, true, StandardCharsets.UTF_8); // NOPMD
|
||||||
out.printf("Generated %s -> %s, %s%n", algorithmId, publicAlias, privateAlias);
|
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);
|
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,
|
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);
|
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);
|
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
|
PrintWriter out = new PrintWriter(System.out, true, StandardCharsets.UTF_8); // NOPMD
|
||||||
out.printf("Generated %s -> %s%n", algorithmId, alias);
|
out.printf("Generated %s -> %s%n", algorithmId, alias);
|
||||||
@@ -515,163 +435,6 @@ public final class KeyStoreManagement { // NOPMD
|
|||||||
NONE
|
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<String> 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) {
|
private static String required(CommandLine cmd, Option opt, String message) {
|
||||||
if (!cmd.hasOption(opt.getLongOpt())) {
|
if (!cmd.hasOption(opt.getLongOpt())) {
|
||||||
throw new IllegalArgumentException(message);
|
throw new IllegalArgumentException(message);
|
||||||
@@ -679,20 +442,6 @@ public final class KeyStoreManagement { // NOPMD
|
|||||||
return cmd.getOptionValue(opt.getLongOpt());
|
return cmd.getOptionValue(opt.getLongOpt());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<String> parseCsv(String csv) {
|
|
||||||
List<String> 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.
|
* Ensures that an alias can be written under the current collision policy.
|
||||||
*
|
*
|
||||||
|
|||||||
64
app/src/main/java/zeroecho/KeyringUnlocks.java
Normal file
64
app/src/main/java/zeroecho/KeyringUnlocks.java
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ import zeroecho.core.alg.digest.DigestSpec;
|
|||||||
import zeroecho.core.err.VerificationException;
|
import zeroecho.core.err.VerificationException;
|
||||||
import zeroecho.core.spec.ContextSpec;
|
import zeroecho.core.spec.ContextSpec;
|
||||||
import zeroecho.core.spec.VoidSpec;
|
import zeroecho.core.spec.VoidSpec;
|
||||||
|
import zeroecho.core.spi.KeyringUnlockProvider;
|
||||||
import zeroecho.core.storage.KeyringStore;
|
import zeroecho.core.storage.KeyringStore;
|
||||||
import zeroecho.core.tag.TagEngineBuilder;
|
import zeroecho.core.tag.TagEngineBuilder;
|
||||||
import zeroecho.sdk.builders.TagTrailerDataContentBuilder;
|
import zeroecho.sdk.builders.TagTrailerDataContentBuilder;
|
||||||
@@ -157,7 +158,25 @@ public final class Tag { // NOPMD
|
|||||||
* @throws GeneralSecurityException if a cryptographic error occurs during
|
* @throws GeneralSecurityException if a cryptographic error occurs during
|
||||||
* signature or digest processing
|
* 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();
|
ZeroEchoSession session = new ZeroEchoSession();
|
||||||
Options opts = root;
|
Options opts = root;
|
||||||
opts.addOption(TYPE_OPT);
|
opts.addOption(TYPE_OPT);
|
||||||
@@ -198,19 +217,19 @@ public final class Tag { // NOPMD
|
|||||||
|
|
||||||
if (TYPE_SIGNATURE.equals(type)) {
|
if (TYPE_SIGNATURE.equals(type)) {
|
||||||
String ksPath = require(cli, KS_OPT, "--ks <file> is required for --type signature");
|
String ksPath = require(cli, KS_OPT, "--ks <file> is required for --type signature");
|
||||||
KeyringStore keyring = KeyringStore.load(session, Path.of(ksPath));
|
try (KeyringStore keyring = KeyringUnlocks.open(Path.of(ksPath), unlockProvider)) {
|
||||||
ContextSpec spec = VoidSpec.INSTANCE;
|
ContextSpec spec = VoidSpec.INSTANCE;
|
||||||
|
if (produce) {
|
||||||
if (produce) {
|
String privAlias = require(cli, PRIV_OPT, "signature produce requires --priv <alias>");
|
||||||
String privAlias = require(cli, PRIV_OPT, "signature produce requires --priv <alias>");
|
PrivateKey priv = keyring.getPrivate(privAlias);
|
||||||
PrivateKey priv = keyring.getPrivate(privAlias);
|
tail = new TagTrailerDataContentBuilder<>(
|
||||||
tail = new TagTrailerDataContentBuilder<>(TagEngineBuilder.signature(session, alg, priv, spec))
|
TagEngineBuilder.signature(session, alg, priv, spec)).build(true);
|
||||||
.build(true);
|
} else {
|
||||||
} else {
|
String pubAlias = require(cli, PUB_OPT, "signature verify requires --pub <alias>");
|
||||||
String pubAlias = require(cli, PUB_OPT, "signature verify requires --pub <alias>");
|
PublicKey pub = keyring.getPublic(pubAlias);
|
||||||
PublicKey pub = keyring.getPublic(pubAlias);
|
tail = new TagTrailerDataContentBuilder<>(
|
||||||
tail = new TagTrailerDataContentBuilder<>(TagEngineBuilder.signature(session, alg, pub, spec))
|
TagEngineBuilder.signature(session, alg, pub, spec)).build(false);
|
||||||
.build(false);
|
}
|
||||||
}
|
}
|
||||||
} else { // digest
|
} else { // digest
|
||||||
DigestSpec spec = parseDigest(alg);
|
DigestSpec spec = parseDigest(alg);
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ public class GuardTest {
|
|||||||
"--pbkdf2-max", TEST_PBKDF2_MAXIMUM, "--pbkdf2-hard-max", TEST_PBKDF2_MAXIMUM, "--alg",
|
"--pbkdf2-max", TEST_PBKDF2_MAXIMUM, "--pbkdf2-hard-max", TEST_PBKDF2_MAXIMUM, "--alg",
|
||||||
"aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex", aadHex };
|
"aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex", aadHex };
|
||||||
System.out.println("...encrypt: " + Arrays.toString(encArgs));
|
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");
|
assertEquals(0, e, "... encrypt expected exit code 0");
|
||||||
|
|
||||||
// Decrypt (using password)
|
// Decrypt (using password)
|
||||||
@@ -135,7 +135,7 @@ public class GuardTest {
|
|||||||
"--pbkdf2-max", TEST_PBKDF2_MAXIMUM, "--pbkdf2-hard-max", TEST_PBKDF2_MAXIMUM, "--alg",
|
"--pbkdf2-max", TEST_PBKDF2_MAXIMUM, "--pbkdf2-hard-max", TEST_PBKDF2_MAXIMUM, "--alg",
|
||||||
"aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex", aadHex };
|
"aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex", aadHex };
|
||||||
System.out.println("...decrypt: " + Arrays.toString(decArgs));
|
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");
|
assertEquals(0, d, "... decrypt expected exit code 0");
|
||||||
|
|
||||||
assertArrayEquals(Files.readAllBytes(in), Files.readAllBytes(dec), "AES-GCM password round-trip mismatch");
|
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" };
|
String[] arguments = { "--encrypt", input.toString(), "--to-psw", "controlled", "--alg", "aes-gcm" };
|
||||||
|
|
||||||
ParseException failure = assertThrows(ParseException.class,
|
ParseException failure = assertThrows(ParseException.class,
|
||||||
() -> Guard.main(arguments, new Options()));
|
() -> Guard.main(arguments, new Options(), TestKeyringUnlocks.provider()));
|
||||||
|
|
||||||
assertTrue(failure.getMessage().contains("--pbkdf2-max"));
|
assertTrue(failure.getMessage().contains("--pbkdf2-max"));
|
||||||
System.out.println("...rejected=missingLimits");
|
System.out.println("...rejected=missingLimits");
|
||||||
@@ -170,7 +170,7 @@ public class GuardTest {
|
|||||||
"--alg", "aes-gcm" };
|
"--alg", "aes-gcm" };
|
||||||
|
|
||||||
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
|
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(failure.getMessage().contains("exactly 16 or 32"));
|
||||||
assertTrue(Files.notExists(output));
|
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",
|
"--to-alias", rsa.pub, "--alg", "aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex",
|
||||||
aadAes };
|
aadAes };
|
||||||
System.out.println("...AES encrypt: " + Arrays.toString(encArgs));
|
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");
|
assertEquals(0, e, "... AES encrypt rc");
|
||||||
|
|
||||||
String[] decArgs = { "--decrypt", enc.toString(), "--output", dec.toString(), "--keyring", ring.toString(),
|
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",
|
"--priv-alias", rsa.prv, "--alg", "aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex",
|
||||||
aadAes };
|
aadAes };
|
||||||
System.out.println("...AES decrypt: " + Arrays.toString(decArgs));
|
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");
|
assertEquals(0, d, "... AES decrypt rc");
|
||||||
|
|
||||||
assertArrayEquals(Files.readAllBytes(in), Files.readAllBytes(dec), "RSA AES-GCM round-trip mismatch");
|
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(),
|
String[] encArgs = { "--encrypt", in.toString(), "--output", enc.toString(), "--keyring", ring.toString(),
|
||||||
"--to-alias", rsa.pub, "--alg", "chacha-aead", "--aad-hex", aadCha };
|
"--to-alias", rsa.pub, "--alg", "chacha-aead", "--aad-hex", aadCha };
|
||||||
System.out.println("...ChaCha encrypt: " + Arrays.toString(encArgs));
|
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");
|
assertEquals(0, e, "... ChaCha encrypt rc");
|
||||||
|
|
||||||
String[] decArgs = { "--decrypt", enc.toString(), "--output", dec.toString(), "--keyring", ring.toString(),
|
String[] decArgs = { "--decrypt", enc.toString(), "--output", dec.toString(), "--keyring", ring.toString(),
|
||||||
"--priv-alias", rsa.prv, "--alg", "chacha-aead", "--aad-hex", aadCha };
|
"--priv-alias", rsa.prv, "--alg", "chacha-aead", "--aad-hex", aadCha };
|
||||||
System.out.println("...ChaCha decrypt: " + Arrays.toString(decArgs));
|
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");
|
assertEquals(0, d, "... ChaCha decrypt rc");
|
||||||
|
|
||||||
assertArrayEquals(Files.readAllBytes(in), Files.readAllBytes(dec),
|
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",
|
"--pbkdf2-max", TEST_PBKDF2_MAXIMUM, "--pbkdf2-hard-max", TEST_PBKDF2_MAXIMUM, "--alg",
|
||||||
"aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex", aad };
|
"aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex", aad };
|
||||||
System.out.println("...encrypt: " + Arrays.toString(encArgs));
|
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");
|
assertEquals(0, e, "... encrypt rc");
|
||||||
|
|
||||||
// Decrypt via private RSA key
|
// 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",
|
"--priv-alias", rsa.prv, "--alg", "aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex",
|
||||||
aad };
|
aad };
|
||||||
System.out.println("...decrypt(private): " + Arrays.toString(decPriv));
|
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");
|
assertEquals(0, d1, "... decrypt(private) rc");
|
||||||
assertArrayEquals(Files.readAllBytes(in), Files.readAllBytes(dec1),
|
assertArrayEquals(Files.readAllBytes(in), Files.readAllBytes(dec1),
|
||||||
"mixed recipients decrypt(private) mismatch");
|
"mixed recipients decrypt(private) mismatch");
|
||||||
@@ -311,7 +311,7 @@ public class GuardTest {
|
|||||||
"--pbkdf2-max", TEST_PBKDF2_MAXIMUM, "--pbkdf2-hard-max", TEST_PBKDF2_MAXIMUM, "--alg",
|
"--pbkdf2-max", TEST_PBKDF2_MAXIMUM, "--pbkdf2-hard-max", TEST_PBKDF2_MAXIMUM, "--alg",
|
||||||
"aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex", aad };
|
"aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex", aad };
|
||||||
System.out.println("...decrypt(password): " + Arrays.toString(decPwd));
|
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");
|
assertEquals(0, d2, "... decrypt(password) rc");
|
||||||
assertArrayEquals(Files.readAllBytes(in), Files.readAllBytes(dec2),
|
assertArrayEquals(Files.readAllBytes(in), Files.readAllBytes(dec2),
|
||||||
"mixed recipients decrypt(password) mismatch");
|
"mixed recipients decrypt(password) mismatch");
|
||||||
@@ -337,14 +337,14 @@ public class GuardTest {
|
|||||||
String[] encArgs = { "--encrypt", in.toString(), "--output", enc.toString(), "--to-psw", pwd,
|
String[] encArgs = { "--encrypt", in.toString(), "--output", enc.toString(), "--to-psw", pwd,
|
||||||
"--pbkdf2-max", TEST_PBKDF2_MAXIMUM, "--pbkdf2-hard-max", TEST_PBKDF2_MAXIMUM, "--alg",
|
"--pbkdf2-max", TEST_PBKDF2_MAXIMUM, "--pbkdf2-hard-max", TEST_PBKDF2_MAXIMUM, "--alg",
|
||||||
"aes-gcm", "--tag-bits", "128" };
|
"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");
|
assertEquals(0, e, "... encrypt rc");
|
||||||
|
|
||||||
// Supply both options on purpose
|
// Supply both options on purpose
|
||||||
Exception ex = assertThrows(Exception.class, () -> {
|
Exception ex = assertThrows(Exception.class, () -> {
|
||||||
String[] bad = { "--decrypt", enc.toString(), "--output", tmp.resolve("out-neg.bin").toString(),
|
String[] bad = { "--decrypt", enc.toString(), "--output", tmp.resolve("out-neg.bin").toString(),
|
||||||
"--password", pwd, "--priv-alias", "whatever", "--alg", "aes-gcm" };
|
"--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("...got expected exception: " + ex);
|
||||||
System.out.println("...ok");
|
System.out.println("...ok");
|
||||||
@@ -377,7 +377,7 @@ public class GuardTest {
|
|||||||
String[] genArgs = { "--keystore", ring.toString(), "--generate", "--alg", algId, "--alias", baseAlias,
|
String[] genArgs = { "--keystore", ring.toString(), "--generate", "--alg", algId, "--alias", baseAlias,
|
||||||
"--kind", "asym" };
|
"--kind", "asym" };
|
||||||
System.out.println("...KeyStoreManagement generate: " + Arrays.toString(genArgs));
|
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) {
|
if (rc != 0) {
|
||||||
throw new GeneralSecurityException("KeyStoreManagement failed with rc=" + rc + " for " + algId);
|
throw new GeneralSecurityException("KeyStoreManagement failed with rc=" + rc + " for " + algId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,9 +165,12 @@ public class KemTest {
|
|||||||
KeyAliases aliases = generateKemIntoKeyStore(ring, kemId, "alias-" + shortId(kemId));
|
KeyAliases aliases = generateKemIntoKeyStore(ring, kemId, "alias-" + shortId(kemId));
|
||||||
|
|
||||||
// Sanity: re-open to ensure the file is valid
|
// Sanity: re-open to ensure the file is valid
|
||||||
KeyringStore ks = KeyringStore.load(new zeroecho.sdk.ZeroEchoSession(), ring);
|
try (zeroecho.core.storage.KeyringPassword password =
|
||||||
if (!(ks.contains(aliases.pub) && ks.contains(aliases.prv))) {
|
TestKeyringUnlocks.provider().acquire();
|
||||||
throw new IllegalStateException("Keyring does not contain expected aliases for " + kemId);
|
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
|
// AES-GCM round-trip
|
||||||
@@ -181,7 +184,7 @@ public class KemTest {
|
|||||||
int e = Kem.main(new String[] { "--encrypt", plain.toString(), "--output", enc.toString(),
|
int e = Kem.main(new String[] { "--encrypt", plain.toString(), "--output", enc.toString(),
|
||||||
"--keyring", ring.toString(), "--pub", aliases.pub, "--kem", kemId, "--aes", "--aes-cipher",
|
"--keyring", ring.toString(), "--pub", aliases.pub, "--kem", kemId, "--aes", "--aes-cipher",
|
||||||
"gcm", "--aes-tag-bits", Integer.toString(gcmTagBits), "--header", "--aad", aadAes },
|
"gcm", "--aes-tag-bits", Integer.toString(gcmTagBits), "--header", "--aad", aadAes },
|
||||||
new Options());
|
new Options(), TestKeyringUnlocks.provider());
|
||||||
if (e != 0) {
|
if (e != 0) {
|
||||||
throw new IllegalStateException("AES encrypt rc=" + e);
|
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(),
|
int d = Kem.main(new String[] { "--decrypt", enc.toString(), "--output", dec.toString(),
|
||||||
"--keyring", ring.toString(), "--priv", aliases.prv, "--kem", kemId, "--aes",
|
"--keyring", ring.toString(), "--priv", aliases.prv, "--kem", kemId, "--aes",
|
||||||
"--aes-cipher", "gcm", "--aes-tag-bits", Integer.toString(gcmTagBits), "--header", "--aad",
|
"--aes-cipher", "gcm", "--aes-tag-bits", Integer.toString(gcmTagBits), "--header", "--aad",
|
||||||
aadAes }, new Options());
|
aadAes }, new Options(), TestKeyringUnlocks.provider());
|
||||||
if (d != 0) {
|
if (d != 0) {
|
||||||
throw new IllegalStateException("AES decrypt rc=" + d);
|
throw new IllegalStateException("AES decrypt rc=" + d);
|
||||||
}
|
}
|
||||||
@@ -208,14 +211,14 @@ public class KemTest {
|
|||||||
System.out.println("...[" + kemId + "] ChaCha encrypt");
|
System.out.println("...[" + kemId + "] ChaCha encrypt");
|
||||||
int e = Kem.main(new String[] { "--encrypt", plain.toString(), "--output", enc.toString(),
|
int e = Kem.main(new String[] { "--encrypt", plain.toString(), "--output", enc.toString(),
|
||||||
"--keyring", ring.toString(), "--pub", aliases.pub, "--kem", kemId, "--chacha",
|
"--keyring", ring.toString(), "--pub", aliases.pub, "--kem", kemId, "--chacha",
|
||||||
"--aad", aadChaCha, "--header" }, new Options());
|
"--aad", aadChaCha, "--header" }, new Options(), TestKeyringUnlocks.provider());
|
||||||
if (e != 0) {
|
if (e != 0) {
|
||||||
throw new IllegalStateException("ChaCha encrypt rc=" + e);
|
throw new IllegalStateException("ChaCha encrypt rc=" + e);
|
||||||
}
|
}
|
||||||
System.out.println("...[" + kemId + "] ChaCha decrypt");
|
System.out.println("...[" + kemId + "] ChaCha decrypt");
|
||||||
int d = Kem.main(new String[] { "--decrypt", enc.toString(), "--output", dec.toString(),
|
int d = Kem.main(new String[] { "--decrypt", enc.toString(), "--output", dec.toString(),
|
||||||
"--keyring", ring.toString(), "--priv", aliases.prv, "--kem", kemId, "--chacha",
|
"--keyring", ring.toString(), "--priv", aliases.prv, "--kem", kemId, "--chacha",
|
||||||
"--aad", aadChaCha, "--header" }, new Options());
|
"--aad", aadChaCha, "--header" }, new Options(), TestKeyringUnlocks.provider());
|
||||||
if (d != 0) {
|
if (d != 0) {
|
||||||
throw new IllegalStateException("ChaCha decrypt rc=" + d);
|
throw new IllegalStateException("ChaCha decrypt rc=" + d);
|
||||||
}
|
}
|
||||||
@@ -256,7 +259,7 @@ public class KemTest {
|
|||||||
ByteArrayOutputStream sink = new ByteArrayOutputStream();
|
ByteArrayOutputStream sink = new ByteArrayOutputStream();
|
||||||
System.setOut(new PrintStream(sink, true, StandardCharsets.UTF_8));
|
System.setOut(new PrintStream(sink, true, StandardCharsets.UTF_8));
|
||||||
try {
|
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) {
|
if (rc != 0) {
|
||||||
throw new IllegalStateException("--list-kems rc=" + rc);
|
throw new IllegalStateException("--list-kems rc=" + rc);
|
||||||
}
|
}
|
||||||
@@ -284,7 +287,7 @@ public class KemTest {
|
|||||||
String[] genArgs = { "--keystore", ring.toString(), "--generate", "--alg", kemId, "--alias", baseAlias,
|
String[] genArgs = { "--keystore", ring.toString(), "--generate", "--alg", kemId, "--alias", baseAlias,
|
||||||
"--kind", "asym" };
|
"--kind", "asym" };
|
||||||
System.out.println("...KeyStoreManagement generate: " + Arrays.toString(genArgs));
|
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) {
|
if (rc != 0) {
|
||||||
throw new GeneralSecurityException("KeyStoreManagement failed with rc=" + rc + " for " + kemId);
|
throw new GeneralSecurityException("KeyStoreManagement failed with rc=" + rc + " for " + kemId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ public class KeyStoreManagementTest {
|
|||||||
String[] argv = new String[] { "--keystore", ring.toString(), "--generate", "--alg", id, "--alias",
|
String[] argv = new String[] { "--keystore", ring.toString(), "--generate", "--alg", id, "--alias",
|
||||||
alias, "--kind", "asym" };
|
alias, "--kind", "asym" };
|
||||||
try {
|
try {
|
||||||
int rc = KeyStoreManagement.main(argv, dispatcher);
|
int rc = KeyStoreManagement.main(argv, dispatcher, TestKeyringUnlocks.provider());
|
||||||
System.out.println(" rc=" + rc);
|
System.out.println(" rc=" + rc);
|
||||||
assertTrue(rc == 0, "asymmetric generation failed for " + id);
|
assertTrue(rc == 0, "asymmetric generation failed for " + id);
|
||||||
attempted++;
|
attempted++;
|
||||||
@@ -125,7 +125,7 @@ public class KeyStoreManagementTest {
|
|||||||
String[] argv = new String[] { "--keystore", ring.toString(), "--generate", "--alg", id, "--alias",
|
String[] argv = new String[] { "--keystore", ring.toString(), "--generate", "--alg", id, "--alias",
|
||||||
alias, "--kind", "sym" };
|
alias, "--kind", "sym" };
|
||||||
try {
|
try {
|
||||||
int rc = KeyStoreManagement.main(argv, dispatcher);
|
int rc = KeyStoreManagement.main(argv, dispatcher, TestKeyringUnlocks.provider());
|
||||||
System.out.println(" rc=" + rc);
|
System.out.println(" rc=" + rc);
|
||||||
assertTrue(rc == 0, "symmetric generation failed for " + id);
|
assertTrue(rc == 0, "symmetric generation failed for " + id);
|
||||||
attempted++;
|
attempted++;
|
||||||
@@ -139,7 +139,14 @@ public class KeyStoreManagementTest {
|
|||||||
assertTrue(attempted > 0, "No generation attempts were successful");
|
assertTrue(attempted > 0, "No generation attempts were successful");
|
||||||
|
|
||||||
// Verify by reloading and materializing.
|
// 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<String> aliases = store.aliases();
|
List<String> aliases = store.aliases();
|
||||||
System.out.println("Reloaded aliases (" + aliases.size() + "): " + 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");
|
assertTrue(ok > 0, "No entries could be materialized back");
|
||||||
|
store.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- helpers ----
|
// ---- helpers ----
|
||||||
|
|||||||
@@ -127,8 +127,11 @@ public class TagTest {
|
|||||||
Path ring = tmp.resolve("ring-ed25519.txt");
|
Path ring = tmp.resolve("ring-ed25519.txt");
|
||||||
KeyAliases ed = generateIntoKeyStore(ring, "Ed25519", "ed");
|
KeyAliases ed = generateIntoKeyStore(ring, "Ed25519", "ed");
|
||||||
// sanity
|
// sanity
|
||||||
KeyringStore ks = KeyringStore.load(new zeroecho.sdk.ZeroEchoSession(), ring);
|
try (zeroecho.core.storage.KeyringPassword password =
|
||||||
assertTrue(ks.contains(ed.pub) && ks.contains(ed.prv), "missing expected aliases");
|
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);
|
byte[] pt = randomBytes(4096);
|
||||||
Path plain = tmp.resolve("plain.bin");
|
Path plain = tmp.resolve("plain.bin");
|
||||||
@@ -139,12 +142,12 @@ public class TagTest {
|
|||||||
// produce
|
// produce
|
||||||
String[] produce = { "--type", "signature", "--mode", "produce", "--alg", "Ed25519", "--ks", ring.toString(),
|
String[] produce = { "--type", "signature", "--mode", "produce", "--alg", "Ed25519", "--ks", ring.toString(),
|
||||||
"--priv", ed.prv, "--in", plain.toString(), "--out", signed.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)
|
// verify (match)
|
||||||
String[] verify = { "--type", "signature", "--mode", "verify", "--alg", "Ed25519", "--ks", ring.toString(),
|
String[] verify = { "--type", "signature", "--mode", "verify", "--alg", "Ed25519", "--ks", ring.toString(),
|
||||||
"--pub", ed.pub, "--in", signed.toString(), "--out", recovered.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");
|
assertArrayEquals(pt, Files.readAllBytes(recovered), "round-trip mismatch");
|
||||||
|
|
||||||
@@ -168,7 +171,7 @@ public class TagTest {
|
|||||||
assertEquals(0,
|
assertEquals(0,
|
||||||
Tag.main(new String[] { "--type", "signature", "--mode", "produce", "--alg", "Ed25519", "--ks",
|
Tag.main(new String[] { "--type", "signature", "--mode", "produce", "--alg", "Ed25519", "--ks",
|
||||||
ring.toString(), "--priv", ed.prv, "--in", plain.toString(), "--out", signed.toString() },
|
ring.toString(), "--priv", ed.prv, "--in", plain.toString(), "--out", signed.toString() },
|
||||||
new Options()));
|
new Options(), TestKeyringUnlocks.provider()));
|
||||||
|
|
||||||
// corrupt last byte -> break signature
|
// corrupt last byte -> break signature
|
||||||
flipLastByte(signed);
|
flipLastByte(signed);
|
||||||
@@ -178,7 +181,7 @@ public class TagTest {
|
|||||||
Tag.main(
|
Tag.main(
|
||||||
new String[] { "--type", "signature", "--mode", "verify", "--alg", "Ed25519", "--ks",
|
new String[] { "--type", "signature", "--mode", "verify", "--alg", "Ed25519", "--ks",
|
||||||
ring.toString(), "--pub", ed.pub, "--in", signed.toString(), "--out", out.toString() },
|
ring.toString(), "--pub", ed.pub, "--in", signed.toString(), "--out", out.toString() },
|
||||||
new Options()));
|
new Options(), TestKeyringUnlocks.provider()));
|
||||||
|
|
||||||
assertTrue(Files.notExists(out, LinkOption.NOFOLLOW_LINKS));
|
assertTrue(Files.notExists(out, LinkOption.NOFOLLOW_LINKS));
|
||||||
|
|
||||||
@@ -198,11 +201,11 @@ public class TagTest {
|
|||||||
|
|
||||||
// produce
|
// produce
|
||||||
assertEquals(0, Tag.main(new String[] { "--type", "digest", "--mode", "produce", "--alg", "SHA-256", "--in",
|
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)
|
// verify (match)
|
||||||
assertEquals(0, Tag.main(new String[] { "--type", "digest", "--mode", "verify", "--alg", "SHA-256", "--in",
|
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");
|
assertArrayEquals(pt, Files.readAllBytes(recovered), "digest round-trip mismatch");
|
||||||
|
|
||||||
@@ -221,14 +224,14 @@ public class TagTest {
|
|||||||
|
|
||||||
// produce
|
// produce
|
||||||
assertEquals(0, Tag.main(new String[] { "--type", "digest", "--mode", "produce", "--alg", "SHA-256", "--in",
|
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
|
// corrupt last byte -> break digest
|
||||||
flipLastByte(tagged);
|
flipLastByte(tagged);
|
||||||
|
|
||||||
// verify (mismatch): expect throw + default marker ("digest invalid")
|
// verify (mismatch): expect throw + default marker ("digest invalid")
|
||||||
assertEquals(1, Tag.main(new String[] { "--type", "digest", "--mode", "verify", "--alg", "SHA-256", "--in",
|
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));
|
assertTrue(Files.notExists(out, LinkOption.NOFOLLOW_LINKS));
|
||||||
|
|
||||||
@@ -252,7 +255,7 @@ public class TagTest {
|
|||||||
|
|
||||||
assertEquals(0, Tag.main(
|
assertEquals(0, Tag.main(
|
||||||
new String[] { "--type", "digest", "--mode", "produce", "--alg", "SHA-256", "--in", "-", "--out", "-" },
|
new String[] { "--type", "digest", "--mode", "produce", "--alg", "SHA-256", "--in", "-", "--out", "-" },
|
||||||
new Options()));
|
new Options(), TestKeyringUnlocks.provider()));
|
||||||
|
|
||||||
// save produced bytes
|
// save produced bytes
|
||||||
Path tagged = tmp.resolve("stdio-tagged.bin");
|
Path tagged = tmp.resolve("stdio-tagged.bin");
|
||||||
@@ -264,7 +267,7 @@ public class TagTest {
|
|||||||
System.setOut(new PrintStream(verifiedSink, true, StandardCharsets.UTF_8));
|
System.setOut(new PrintStream(verifiedSink, true, StandardCharsets.UTF_8));
|
||||||
|
|
||||||
assertEquals(0, Tag.main(new String[] { "--type", "digest", "--mode", "verify", "--alg", "SHA-256", "--in",
|
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");
|
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 {
|
private static KeyAliases generateIntoKeyStore(Path ring, String algId, String baseAlias) throws Exception {
|
||||||
String[] genArgs = { "--keystore", ring.toString(), "--generate", "--alg", algId, "--alias", baseAlias,
|
String[] genArgs = { "--keystore", ring.toString(), "--generate", "--alg", algId, "--alias", baseAlias,
|
||||||
"--kind", "asym" };
|
"--kind", "asym" };
|
||||||
int rc = KeyStoreManagement.main(genArgs, new Options());
|
int rc = KeyStoreManagement.main(genArgs, new Options(), TestKeyringUnlocks.provider());
|
||||||
if (rc != 0) {
|
if (rc != 0) {
|
||||||
throw new GeneralSecurityException("KeyStoreManagement failed with rc=" + rc + " for " + algId);
|
throw new GeneralSecurityException("KeyStoreManagement failed with rc=" + rc + " for " + algId);
|
||||||
}
|
}
|
||||||
|
|||||||
14
app/src/test/java/zeroecho/TestKeyringUnlocks.java
Normal file
14
app/src/test/java/zeroecho/TestKeyringUnlocks.java
Normal file
@@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ import java.util.NavigableMap;
|
|||||||
import java.util.Queue;
|
import java.util.Queue;
|
||||||
import java.util.TreeMap;
|
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
|
* A utility class for generating pseudo-random textual content based on
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import zeroecho.core.context.EncryptionContext;
|
|||||||
import zeroecho.core.spec.VoidSpec;
|
import zeroecho.core.spec.VoidSpec;
|
||||||
import zeroecho.core.spi.SymmetricKeyGenerator;
|
import zeroecho.core.spi.SymmetricKeyGenerator;
|
||||||
import zeroecho.core.spi.SymmetricKeyImporter;
|
import zeroecho.core.spi.SymmetricKeyImporter;
|
||||||
import zeroecho.sdk.util.RandomSupport;
|
import zeroecho.core.util.RandomSupport;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AES algorithm registration and capability wiring.
|
* AES algorithm registration and capability wiring.
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ import zeroecho.core.context.EncryptionContext;
|
|||||||
import zeroecho.core.err.ProviderFailureException;
|
import zeroecho.core.err.ProviderFailureException;
|
||||||
import zeroecho.core.io.CipherTransformInputStreamBuilder;
|
import zeroecho.core.io.CipherTransformInputStreamBuilder;
|
||||||
import zeroecho.core.spi.ContextAware;
|
import zeroecho.core.spi.ContextAware;
|
||||||
import zeroecho.sdk.util.RandomSupport;
|
import zeroecho.core.util.RandomSupport;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Streaming AES cipher context for GCM / CBC / CTR.
|
* Streaming AES cipher context for GCM / CBC / CTR.
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ import zeroecho.core.context.EncryptionContext;
|
|||||||
import zeroecho.core.err.ProviderFailureException;
|
import zeroecho.core.err.ProviderFailureException;
|
||||||
import zeroecho.core.io.CipherTransformInputStreamBuilder;
|
import zeroecho.core.io.CipherTransformInputStreamBuilder;
|
||||||
import zeroecho.core.spi.ContextAware;
|
import zeroecho.core.spi.ContextAware;
|
||||||
import zeroecho.sdk.util.RandomSupport;
|
import zeroecho.core.util.RandomSupport;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <h2>Abstract streaming cipher context for ChaCha algorithms</h2>
|
* <h2>Abstract streaming cipher context for ChaCha algorithms</h2>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
package zeroecho.core.alg.chacha;
|
package zeroecho.core.alg.chacha;
|
||||||
|
|
||||||
import zeroecho.core.SymmetricHeaderCodec;
|
import zeroecho.core.SymmetricHeaderCodec;
|
||||||
import zeroecho.sdk.util.RandomSupport;
|
import zeroecho.core.util.RandomSupport;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <h2>ChaCha20-Poly1305 (AEAD) algorithm</h2>
|
* <h2>ChaCha20-Poly1305 (AEAD) algorithm</h2>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
package zeroecho.core.alg.chacha;
|
package zeroecho.core.alg.chacha;
|
||||||
|
|
||||||
import zeroecho.sdk.util.RandomSupport;
|
import zeroecho.core.util.RandomSupport;
|
||||||
/**
|
/**
|
||||||
* <h2>ChaCha20 (stream) algorithm</h2>
|
* <h2>ChaCha20 (stream) algorithm</h2>
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2026, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.core.spi;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import zeroecho.core.storage.KeyringPassword;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supplies a fresh destroyable password for one keyring open operation.
|
||||||
|
*
|
||||||
|
* <p>Ownership of the returned object transfers to the receiver, which must
|
||||||
|
* destroy it in a {@code finally} block immediately after the keyring has been
|
||||||
|
* opened. Implementations must not source passwords from immutable strings,
|
||||||
|
* process arguments, system properties, environment fallbacks, persistent
|
||||||
|
* files, or global mutable state.</p>
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface KeyringUnlockProvider {
|
||||||
|
/**
|
||||||
|
* Supplies unlock material for one operation.
|
||||||
|
*
|
||||||
|
* @return a fresh password owner whose ownership transfers to the receiver
|
||||||
|
* @throws IOException if unlock material cannot be supplied
|
||||||
|
*/
|
||||||
|
KeyringPassword acquire() throws IOException;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2026, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.core.storage;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redacted checked failure raised by encrypted keyring operations.
|
||||||
|
*
|
||||||
|
* <p>The public message contains only the stable error code. Filesystem paths,
|
||||||
|
* aliases, key material, ciphertext, and provider-controlled messages are
|
||||||
|
* deliberately excluded.</p>
|
||||||
|
*/
|
||||||
|
public final class KeyringException extends IOException {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable keyring failure categories.
|
||||||
|
*/
|
||||||
|
public enum Code {
|
||||||
|
KEYRING_ALREADY_OPEN,
|
||||||
|
KEYRING_FILESYSTEM_UNSUPPORTED,
|
||||||
|
KEYRING_FORMAT_INVALID,
|
||||||
|
KEYRING_LIMIT_EXCEEDED,
|
||||||
|
KEYRING_UNLOCK_FAILED,
|
||||||
|
KEYRING_IO_FAILED,
|
||||||
|
KEYRING_DURABILITY_UNCONFIRMED,
|
||||||
|
KEYRING_CLOSED,
|
||||||
|
KEYRING_NON_EXPORTABLE_KEY,
|
||||||
|
KEYRING_IMPORT_MAPPING_INVALID,
|
||||||
|
KEYRING_IMPORT_METADATA_INVALID,
|
||||||
|
KEYRING_KEY_NOT_CANONICALIZABLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Code code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a redacted keyring exception.
|
||||||
|
*
|
||||||
|
* @param code stable error code
|
||||||
|
*/
|
||||||
|
public KeyringException(Code code) {
|
||||||
|
super(Objects.requireNonNull(code, "code must not be null").name());
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the stable error code.
|
||||||
|
*
|
||||||
|
* @return failure code
|
||||||
|
*/
|
||||||
|
public Code code() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2026, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.core.storage;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.channels.FileChannel;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.LinkOption;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.nio.file.attribute.FileAttribute;
|
||||||
|
import java.nio.file.attribute.PosixFilePermission;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrow package-local boundary for atomic keyring persistence operations.
|
||||||
|
*/
|
||||||
|
interface KeyringFileOperations {
|
||||||
|
KeyringFileOperations NIO = new NioKeyringFileOperations();
|
||||||
|
|
||||||
|
/** Atomic persistence destination. */
|
||||||
|
enum Target {
|
||||||
|
MAIN_IMAGE,
|
||||||
|
NONCE_RESERVATION
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates one owner-only temporary file beside its destination. */
|
||||||
|
Path createTemporary(Target target, Path parent, String prefix, String suffix,
|
||||||
|
FileAttribute<Set<PosixFilePermission>> permissions) throws IOException;
|
||||||
|
|
||||||
|
/** Writes the complete encrypted image to its temporary file. */
|
||||||
|
void writeTemporary(Target target, Path temporary, byte[] image) throws IOException;
|
||||||
|
|
||||||
|
/** Forces temporary-file contents to durable storage. */
|
||||||
|
void forceTemporary(Target target, Path temporary) throws IOException;
|
||||||
|
|
||||||
|
/** Atomically replaces the destination with the temporary file. */
|
||||||
|
void atomicReplace(Target target, Path temporary, Path destination) throws IOException;
|
||||||
|
|
||||||
|
/** Forces the destination directory after atomic replacement. */
|
||||||
|
void forceDirectory(Target target, Path parent) throws IOException;
|
||||||
|
|
||||||
|
/** Deletes a temporary file that did not become authoritative. */
|
||||||
|
void deleteTemporary(Target target, Path temporary) throws IOException;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Production NIO implementation of the atomic persistence boundary. */
|
||||||
|
final class NioKeyringFileOperations implements KeyringFileOperations {
|
||||||
|
@Override
|
||||||
|
public Path createTemporary(Target target, Path parent, String prefix, String suffix,
|
||||||
|
FileAttribute<Set<PosixFilePermission>> permissions) throws IOException {
|
||||||
|
return Files.createTempFile(parent, prefix, suffix, permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeTemporary(Target target, Path temporary, byte[] image)
|
||||||
|
throws IOException {
|
||||||
|
try (FileChannel channel = FileChannel.open(temporary,
|
||||||
|
StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
ByteBuffer buffer = ByteBuffer.wrap(image);
|
||||||
|
while (buffer.hasRemaining()) {
|
||||||
|
channel.write(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void forceTemporary(Target target, Path temporary) throws IOException {
|
||||||
|
try (FileChannel channel = FileChannel.open(temporary,
|
||||||
|
StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
channel.force(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void atomicReplace(Target target, Path temporary, Path destination)
|
||||||
|
throws IOException {
|
||||||
|
Files.move(temporary, destination, StandardCopyOption.ATOMIC_MOVE,
|
||||||
|
StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void forceDirectory(Target target, Path parent) throws IOException {
|
||||||
|
try (FileChannel directory = FileChannel.open(parent, StandardOpenOption.READ)) {
|
||||||
|
directory.force(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteTemporary(Target target, Path temporary) throws IOException {
|
||||||
|
Files.deleteIfExists(temporary);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2026, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.core.storage;
|
||||||
|
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
import java.security.Key;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import javax.security.auth.DestroyFailedException;
|
||||||
|
import javax.security.auth.Destroyable;
|
||||||
|
|
||||||
|
import zeroecho.core.CryptoAlgorithm;
|
||||||
|
import zeroecho.core.CryptoAlgorithms;
|
||||||
|
import zeroecho.core.KeyOperation;
|
||||||
|
import zeroecho.core.KeyOperationInfo;
|
||||||
|
import zeroecho.core.alg.aes.AesKeyImportSpec;
|
||||||
|
import zeroecho.core.alg.bike.BikePrivateKeySpec;
|
||||||
|
import zeroecho.core.alg.bike.BikePublicKeySpec;
|
||||||
|
import zeroecho.core.alg.chacha.ChaChaKeyImportSpec;
|
||||||
|
import zeroecho.core.alg.cmce.CmcePrivateKeySpec;
|
||||||
|
import zeroecho.core.alg.cmce.CmcePublicKeySpec;
|
||||||
|
import zeroecho.core.alg.dh.DhPrivateKeySpec;
|
||||||
|
import zeroecho.core.alg.dh.DhPublicKeySpec;
|
||||||
|
import zeroecho.core.alg.ecdsa.EcdsaPrivateKeySpec;
|
||||||
|
import zeroecho.core.alg.ecdsa.EcdsaPublicKeySpec;
|
||||||
|
import zeroecho.core.alg.ed25519.Ed25519PrivateKeySpec;
|
||||||
|
import zeroecho.core.alg.ed25519.Ed25519PublicKeySpec;
|
||||||
|
import zeroecho.core.alg.ed448.Ed448PrivateKeySpec;
|
||||||
|
import zeroecho.core.alg.ed448.Ed448PublicKeySpec;
|
||||||
|
import zeroecho.core.alg.elgamal.ElgamalPrivateKeySpec;
|
||||||
|
import zeroecho.core.alg.elgamal.ElgamalPublicKeySpec;
|
||||||
|
import zeroecho.core.alg.frodo.FrodoPrivateKeySpec;
|
||||||
|
import zeroecho.core.alg.frodo.FrodoPublicKeySpec;
|
||||||
|
import zeroecho.core.alg.hmac.HmacKeyImportSpec;
|
||||||
|
import zeroecho.core.alg.hqc.HqcPrivateKeySpec;
|
||||||
|
import zeroecho.core.alg.hqc.HqcPublicKeySpec;
|
||||||
|
import zeroecho.core.alg.kyber.KyberPrivateKeySpec;
|
||||||
|
import zeroecho.core.alg.kyber.KyberPublicKeySpec;
|
||||||
|
import zeroecho.core.alg.mldsa.MldsaPrivateKeySpec;
|
||||||
|
import zeroecho.core.alg.mldsa.MldsaPublicKeySpec;
|
||||||
|
import zeroecho.core.alg.ntru.NtruPrivateKeySpec;
|
||||||
|
import zeroecho.core.alg.ntru.NtruPublicKeySpec;
|
||||||
|
import zeroecho.core.alg.ntruprime.NtrulPrimePrivateKeySpec;
|
||||||
|
import zeroecho.core.alg.ntruprime.NtrulPrimePublicKeySpec;
|
||||||
|
import zeroecho.core.alg.ntruprime.SntruPrimePrivateKeySpec;
|
||||||
|
import zeroecho.core.alg.ntruprime.SntruPrimePublicKeySpec;
|
||||||
|
import zeroecho.core.alg.rsa.RsaPrivateKeySpec;
|
||||||
|
import zeroecho.core.alg.rsa.RsaPublicKeySpec;
|
||||||
|
import zeroecho.core.alg.saber.SaberPrivateKeySpec;
|
||||||
|
import zeroecho.core.alg.saber.SaberPublicKeySpec;
|
||||||
|
import zeroecho.core.alg.slhdsa.SlhDsaPrivateKeySpec;
|
||||||
|
import zeroecho.core.alg.slhdsa.SlhDsaPublicKeySpec;
|
||||||
|
import zeroecho.core.alg.sphincsplus.SphincsPlusPrivateKeySpec;
|
||||||
|
import zeroecho.core.alg.sphincsplus.SphincsPlusPublicKeySpec;
|
||||||
|
import zeroecho.core.alg.xdh.XdhPrivateKeySpec;
|
||||||
|
import zeroecho.core.alg.xdh.XdhPublicKeySpec;
|
||||||
|
import zeroecho.core.spec.AlgorithmKeySpec;
|
||||||
|
import zeroecho.core.spi.PrivateKeyImporter;
|
||||||
|
import zeroecho.core.spi.PublicKeyImporter;
|
||||||
|
import zeroecho.core.spi.SymmetricKeyImporter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closed trusted mapping from persistent key identities to canonical registered
|
||||||
|
* import operations.
|
||||||
|
*
|
||||||
|
* <p>Provider identity is deliberately absent. Standard encoded key material
|
||||||
|
* is reconstructed by the current runtime's canonical ZeroEcho importer. The
|
||||||
|
* original JCA provider is neither persisted nor reproduced.</p>
|
||||||
|
*/
|
||||||
|
final class KeyringImportRegistry {
|
||||||
|
private static final String ALGORITHM_AES = "AES";
|
||||||
|
private static final String ALGORITHM_HMAC = "HMAC";
|
||||||
|
private static final String ALGORITHM_CHACHA20 = "CHACHA20";
|
||||||
|
private static final String ALGORITHM_CHACHA20_POLY1305 = "CHACHA20-POLY1305";
|
||||||
|
private static final Map<Class<? extends AlgorithmKeySpec>,
|
||||||
|
Function<byte[], ? extends AlgorithmKeySpec>> SPEC_FACTORIES =
|
||||||
|
createSpecFactories();
|
||||||
|
private static final Map<Tuple, PersistentMapping> MAPPINGS = createMappings();
|
||||||
|
|
||||||
|
private KeyringImportRegistry() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closed HMAC variants admitted by the persistent format.
|
||||||
|
*/
|
||||||
|
/* default */
|
||||||
|
enum HmacVariant {
|
||||||
|
NONE(0, null),
|
||||||
|
SHA256(1, "HmacSHA256"),
|
||||||
|
SHA384(2, "HmacSHA384"),
|
||||||
|
SHA512(3, "HmacSHA512");
|
||||||
|
|
||||||
|
private final int code;
|
||||||
|
private final String jcaName;
|
||||||
|
|
||||||
|
HmacVariant(int code, String jcaName) {
|
||||||
|
this.code = code;
|
||||||
|
this.jcaName = jcaName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* default */ int code() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* default */ String jcaName() {
|
||||||
|
return jcaName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* default */ static HmacVariant fromCode(int code) throws KeyringException {
|
||||||
|
for (HmacVariant value : values()) {
|
||||||
|
if (value.code == code) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new KeyringException(KeyringException.Code.KEYRING_IMPORT_METADATA_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* default */ static HmacVariant forStoredKey(String algorithmId, Key key)
|
||||||
|
throws KeyringException {
|
||||||
|
if (!ALGORITHM_HMAC.equals(algorithmId)) {
|
||||||
|
return NONE;
|
||||||
|
}
|
||||||
|
String algorithm = key.getAlgorithm();
|
||||||
|
for (HmacVariant value : values()) {
|
||||||
|
if (value != NONE && value.jcaName.equals(algorithm)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new KeyringException(KeyringException.Code.KEYRING_IMPORT_METADATA_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable description used by the finite importer-matrix test.
|
||||||
|
*
|
||||||
|
* @param algorithmId canonical ZeroEcho algorithm identifier
|
||||||
|
* @param kind key kind
|
||||||
|
* @param encoding standard encoding
|
||||||
|
* @param hmacVariant closed HMAC variant, or {@link HmacVariant#NONE}
|
||||||
|
* @param specType exact registered importer specification type
|
||||||
|
*/
|
||||||
|
/* default */
|
||||||
|
record PersistentMapping(String algorithmId, KeyringStore.Kind kind,
|
||||||
|
KeyringStore.Encoding encoding, HmacVariant hmacVariant,
|
||||||
|
Class<? extends AlgorithmKeySpec> specType) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("PMD.AvoidCatchingGenericException")
|
||||||
|
/* default */ static Key importKey(String algorithmId, KeyringStore.Kind kind,
|
||||||
|
KeyringStore.Encoding encoding, HmacVariant hmacVariant, byte[] encoded)
|
||||||
|
throws GeneralSecurityException, KeyringException {
|
||||||
|
PersistentMapping mapping = requireMapping(algorithmId, kind, encoding, hmacVariant);
|
||||||
|
CryptoAlgorithm algorithm = requireAlgorithm(mapping.algorithmId);
|
||||||
|
AlgorithmKeySpec spec = createSpec(mapping, encoded);
|
||||||
|
Throwable primary = null;
|
||||||
|
try {
|
||||||
|
return invokeImporter(algorithm, kind, spec);
|
||||||
|
} catch (GeneralSecurityException | RuntimeException exception) {
|
||||||
|
primary = exception;
|
||||||
|
throw exception;
|
||||||
|
} finally {
|
||||||
|
KeyringStore.destroyTemporarySpec(spec, primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* default */ static void validateMapping(String algorithmId, KeyringStore.Kind kind,
|
||||||
|
KeyringStore.Encoding encoding, HmacVariant hmacVariant)
|
||||||
|
throws KeyringException {
|
||||||
|
PersistentMapping mapping = requireMapping(algorithmId, kind, encoding, hmacVariant);
|
||||||
|
CryptoAlgorithm algorithm = requireAlgorithm(mapping.algorithmId);
|
||||||
|
List<KeyOperationInfo> operations = matchingOperations(algorithm, kind);
|
||||||
|
if (operations.size() != 1 || operations.get(0).specType() != mapping.specType) {
|
||||||
|
throw new KeyringException(KeyringException.Code.KEYRING_IMPORT_MAPPING_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings({ "PMD.PreserveStackTrace", "PMD.AvoidCatchingGenericException" })
|
||||||
|
/* default */ static void validateCanonical(String algorithmId, KeyringStore.Kind kind,
|
||||||
|
KeyringStore.Encoding encoding, HmacVariant hmacVariant, Key source,
|
||||||
|
byte[] encoded)
|
||||||
|
throws KeyringException {
|
||||||
|
Key imported = null;
|
||||||
|
byte[] canonical = null;
|
||||||
|
try {
|
||||||
|
if (!matchesSourceAlgorithm(source, algorithmId, hmacVariant)) {
|
||||||
|
throw new KeyringException(
|
||||||
|
KeyringException.Code.KEYRING_KEY_NOT_CANONICALIZABLE);
|
||||||
|
}
|
||||||
|
imported = importKey(algorithmId, kind, encoding, hmacVariant, encoded);
|
||||||
|
canonical = imported.getEncoded();
|
||||||
|
if (canonical == null || !matchesFormat(imported.getFormat(), encoding)
|
||||||
|
|| !MessageDigest.isEqual(encoded, canonical)
|
||||||
|
|| !matchesAlgorithm(imported, algorithmId, hmacVariant)) {
|
||||||
|
throw new KeyringException(
|
||||||
|
KeyringException.Code.KEYRING_KEY_NOT_CANONICALIZABLE);
|
||||||
|
}
|
||||||
|
} catch (GeneralSecurityException | RuntimeException exception) {
|
||||||
|
throw new KeyringException(
|
||||||
|
KeyringException.Code.KEYRING_KEY_NOT_CANONICALIZABLE);
|
||||||
|
} finally {
|
||||||
|
if (canonical != null) {
|
||||||
|
Arrays.fill(canonical, (byte) 0);
|
||||||
|
}
|
||||||
|
destroyImported(imported);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* default */ static List<PersistentMapping> mappings() {
|
||||||
|
return List.copyOf(MAPPINGS.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PersistentMapping requireMapping(String algorithmId,
|
||||||
|
KeyringStore.Kind kind, KeyringStore.Encoding encoding,
|
||||||
|
HmacVariant hmacVariant) throws KeyringException {
|
||||||
|
PersistentMapping mapping = MAPPINGS.get(
|
||||||
|
new Tuple(algorithmId, kind, encoding, hmacVariant));
|
||||||
|
if (mapping == null) {
|
||||||
|
throw new KeyringException(KeyringException.Code.KEYRING_IMPORT_MAPPING_INVALID);
|
||||||
|
}
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("PMD.PreserveStackTrace")
|
||||||
|
private static CryptoAlgorithm requireAlgorithm(String algorithmId)
|
||||||
|
throws KeyringException {
|
||||||
|
try {
|
||||||
|
return CryptoAlgorithms.require(algorithmId);
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
throw new KeyringException(KeyringException.Code.KEYRING_IMPORT_MAPPING_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<KeyOperationInfo> matchingOperations(CryptoAlgorithm algorithm,
|
||||||
|
KeyringStore.Kind kind) {
|
||||||
|
return algorithm.keyOperations().stream()
|
||||||
|
.filter(info -> info.operation() == operation(kind))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyOperation operation(KeyringStore.Kind kind) {
|
||||||
|
return switch (kind) {
|
||||||
|
case PUBLIC_KEY -> KeyOperation.ASYMMETRIC_PUBLIC_IMPORT;
|
||||||
|
case PRIVATE_KEY -> KeyOperation.ASYMMETRIC_PRIVATE_IMPORT;
|
||||||
|
case SECRET_KEY -> KeyOperation.SYMMETRIC_IMPORT;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings({ "rawtypes", "unchecked" })
|
||||||
|
private static Key invokeImporter(CryptoAlgorithm algorithm, KeyringStore.Kind kind,
|
||||||
|
AlgorithmKeySpec spec) throws GeneralSecurityException {
|
||||||
|
return switch (kind) {
|
||||||
|
case PUBLIC_KEY -> ((PublicKeyImporter) algorithm.publicKeyImporter(spec.getClass()))
|
||||||
|
.importPublic(spec);
|
||||||
|
case PRIVATE_KEY -> ((PrivateKeyImporter) algorithm.privateKeyImporter(spec.getClass()))
|
||||||
|
.importPrivate(spec);
|
||||||
|
case SECRET_KEY -> ((SymmetricKeyImporter) algorithm.symmetricKeyImporter(spec.getClass()))
|
||||||
|
.importSecret(spec);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AlgorithmKeySpec createSpec(PersistentMapping mapping, byte[] encoded)
|
||||||
|
throws KeyringException {
|
||||||
|
if (mapping.specType == HmacKeyImportSpec.class) {
|
||||||
|
return new HmacKeyImportSpec(mapping.hmacVariant.jcaName, encoded);
|
||||||
|
}
|
||||||
|
Function<byte[], ? extends AlgorithmKeySpec> factory =
|
||||||
|
SPEC_FACTORIES.get(mapping.specType);
|
||||||
|
AlgorithmKeySpec result = factory == null ? null : factory.apply(encoded);
|
||||||
|
if (result == null) {
|
||||||
|
throw new KeyringException(KeyringException.Code.KEYRING_IMPORT_MAPPING_INVALID);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean matchesFormat(String actual, KeyringStore.Encoding encoding) {
|
||||||
|
return actual != null && switch (encoding) {
|
||||||
|
case X509 -> "X.509".equalsIgnoreCase(actual) || "X509".equalsIgnoreCase(actual);
|
||||||
|
case PKCS8 -> "PKCS#8".equalsIgnoreCase(actual) || "PKCS8".equalsIgnoreCase(actual);
|
||||||
|
case RAW -> "RAW".equalsIgnoreCase(actual);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean matchesAlgorithm(Key imported, String algorithmId,
|
||||||
|
HmacVariant hmacVariant) {
|
||||||
|
if (ALGORITHM_HMAC.equals(algorithmId)) {
|
||||||
|
return hmacVariant.jcaName.equals(imported.getAlgorithm());
|
||||||
|
}
|
||||||
|
if (ALGORITHM_AES.equals(algorithmId)) {
|
||||||
|
return ALGORITHM_AES.equals(imported.getAlgorithm());
|
||||||
|
}
|
||||||
|
if (ALGORITHM_CHACHA20.equals(algorithmId)
|
||||||
|
|| ALGORITHM_CHACHA20_POLY1305.equals(algorithmId)) {
|
||||||
|
return "ChaCha20".equals(imported.getAlgorithm());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean matchesSourceAlgorithm(Key source, String algorithmId,
|
||||||
|
HmacVariant hmacVariant) {
|
||||||
|
if (ALGORITHM_HMAC.equals(algorithmId)) {
|
||||||
|
return hmacVariant.jcaName.equals(source.getAlgorithm());
|
||||||
|
}
|
||||||
|
if (ALGORITHM_AES.equals(algorithmId)) {
|
||||||
|
return ALGORITHM_AES.equals(source.getAlgorithm());
|
||||||
|
}
|
||||||
|
if (ALGORITHM_CHACHA20.equals(algorithmId)
|
||||||
|
|| ALGORITHM_CHACHA20_POLY1305.equals(algorithmId)) {
|
||||||
|
return "ChaCha20".equals(source.getAlgorithm());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("PMD.EmptyCatchBlock")
|
||||||
|
private static void destroyImported(Key imported) {
|
||||||
|
if (imported instanceof Destroyable destroyable) {
|
||||||
|
try {
|
||||||
|
destroyable.destroy();
|
||||||
|
} catch (DestroyFailedException exception) {
|
||||||
|
// Some JCA key implementations advertise but do not support destruction.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<Class<? extends AlgorithmKeySpec>,
|
||||||
|
Function<byte[], ? extends AlgorithmKeySpec>> createSpecFactories() {
|
||||||
|
return Map.ofEntries(
|
||||||
|
Map.entry(AesKeyImportSpec.class, AesKeyImportSpec::fromRaw),
|
||||||
|
Map.entry(ChaChaKeyImportSpec.class, ChaChaKeyImportSpec::fromRaw),
|
||||||
|
Map.entry(BikePublicKeySpec.class, BikePublicKeySpec::new),
|
||||||
|
Map.entry(BikePrivateKeySpec.class, BikePrivateKeySpec::new),
|
||||||
|
Map.entry(CmcePublicKeySpec.class, CmcePublicKeySpec::new),
|
||||||
|
Map.entry(CmcePrivateKeySpec.class, CmcePrivateKeySpec::new),
|
||||||
|
Map.entry(DhPublicKeySpec.class, DhPublicKeySpec::new),
|
||||||
|
Map.entry(DhPrivateKeySpec.class, DhPrivateKeySpec::new),
|
||||||
|
Map.entry(EcdsaPublicKeySpec.class, EcdsaPublicKeySpec::new),
|
||||||
|
Map.entry(EcdsaPrivateKeySpec.class, EcdsaPrivateKeySpec::new),
|
||||||
|
Map.entry(Ed25519PublicKeySpec.class, Ed25519PublicKeySpec::new),
|
||||||
|
Map.entry(Ed25519PrivateKeySpec.class, Ed25519PrivateKeySpec::new),
|
||||||
|
Map.entry(Ed448PublicKeySpec.class, Ed448PublicKeySpec::new),
|
||||||
|
Map.entry(Ed448PrivateKeySpec.class, Ed448PrivateKeySpec::new),
|
||||||
|
Map.entry(ElgamalPublicKeySpec.class, ElgamalPublicKeySpec::new),
|
||||||
|
Map.entry(ElgamalPrivateKeySpec.class, ElgamalPrivateKeySpec::new),
|
||||||
|
Map.entry(FrodoPublicKeySpec.class, FrodoPublicKeySpec::new),
|
||||||
|
Map.entry(FrodoPrivateKeySpec.class, FrodoPrivateKeySpec::new),
|
||||||
|
Map.entry(HqcPublicKeySpec.class, HqcPublicKeySpec::new),
|
||||||
|
Map.entry(HqcPrivateKeySpec.class, HqcPrivateKeySpec::new),
|
||||||
|
Map.entry(KyberPublicKeySpec.class, KyberPublicKeySpec::new),
|
||||||
|
Map.entry(KyberPrivateKeySpec.class, KyberPrivateKeySpec::new),
|
||||||
|
Map.entry(MldsaPublicKeySpec.class, MldsaPublicKeySpec::new),
|
||||||
|
Map.entry(MldsaPrivateKeySpec.class, MldsaPrivateKeySpec::new),
|
||||||
|
Map.entry(NtruPublicKeySpec.class, NtruPublicKeySpec::new),
|
||||||
|
Map.entry(NtruPrivateKeySpec.class, NtruPrivateKeySpec::new),
|
||||||
|
Map.entry(NtrulPrimePublicKeySpec.class, NtrulPrimePublicKeySpec::new),
|
||||||
|
Map.entry(NtrulPrimePrivateKeySpec.class, NtrulPrimePrivateKeySpec::new),
|
||||||
|
Map.entry(SntruPrimePublicKeySpec.class, SntruPrimePublicKeySpec::new),
|
||||||
|
Map.entry(SntruPrimePrivateKeySpec.class, SntruPrimePrivateKeySpec::new),
|
||||||
|
Map.entry(RsaPublicKeySpec.class, RsaPublicKeySpec::new),
|
||||||
|
Map.entry(RsaPrivateKeySpec.class, RsaPrivateKeySpec::new),
|
||||||
|
Map.entry(SaberPublicKeySpec.class, SaberPublicKeySpec::new),
|
||||||
|
Map.entry(SaberPrivateKeySpec.class, SaberPrivateKeySpec::new),
|
||||||
|
Map.entry(SlhDsaPublicKeySpec.class, SlhDsaPublicKeySpec::new),
|
||||||
|
Map.entry(SlhDsaPrivateKeySpec.class, SlhDsaPrivateKeySpec::new),
|
||||||
|
Map.entry(SphincsPlusPublicKeySpec.class, SphincsPlusPublicKeySpec::new),
|
||||||
|
Map.entry(SphincsPlusPrivateKeySpec.class, SphincsPlusPrivateKeySpec::new),
|
||||||
|
Map.entry(XdhPublicKeySpec.class, XdhPublicKeySpec::new),
|
||||||
|
Map.entry(XdhPrivateKeySpec.class, XdhPrivateKeySpec::new));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<Tuple, PersistentMapping> createMappings() {
|
||||||
|
Map<Tuple, PersistentMapping> mappings = new LinkedHashMap<>();
|
||||||
|
addAsymmetric(mappings, "BIKE", BikePublicKeySpec.class, BikePrivateKeySpec.class);
|
||||||
|
addAsymmetric(mappings, "CMCE", CmcePublicKeySpec.class, CmcePrivateKeySpec.class);
|
||||||
|
addAsymmetric(mappings, "DH", DhPublicKeySpec.class, DhPrivateKeySpec.class);
|
||||||
|
addAsymmetric(mappings, "ECDSA", EcdsaPublicKeySpec.class, EcdsaPrivateKeySpec.class);
|
||||||
|
addAsymmetric(mappings, "Ed25519", Ed25519PublicKeySpec.class,
|
||||||
|
Ed25519PrivateKeySpec.class);
|
||||||
|
addAsymmetric(mappings, "Ed448", Ed448PublicKeySpec.class,
|
||||||
|
Ed448PrivateKeySpec.class);
|
||||||
|
addAsymmetric(mappings, "ElGamal", ElgamalPublicKeySpec.class,
|
||||||
|
ElgamalPrivateKeySpec.class);
|
||||||
|
addAsymmetric(mappings, "Frodo", FrodoPublicKeySpec.class,
|
||||||
|
FrodoPrivateKeySpec.class);
|
||||||
|
addAsymmetric(mappings, "HQC", HqcPublicKeySpec.class, HqcPrivateKeySpec.class);
|
||||||
|
addAsymmetric(mappings, "ML-KEM", KyberPublicKeySpec.class,
|
||||||
|
KyberPrivateKeySpec.class);
|
||||||
|
addAsymmetric(mappings, "ML-DSA", MldsaPublicKeySpec.class,
|
||||||
|
MldsaPrivateKeySpec.class);
|
||||||
|
addAsymmetric(mappings, "NTRU", NtruPublicKeySpec.class, NtruPrivateKeySpec.class);
|
||||||
|
addAsymmetric(mappings, "NTRULPRime", NtrulPrimePublicKeySpec.class,
|
||||||
|
NtrulPrimePrivateKeySpec.class);
|
||||||
|
addAsymmetric(mappings, "SNTRUPrime", SntruPrimePublicKeySpec.class,
|
||||||
|
SntruPrimePrivateKeySpec.class);
|
||||||
|
addAsymmetric(mappings, "RSA", RsaPublicKeySpec.class, RsaPrivateKeySpec.class);
|
||||||
|
addAsymmetric(mappings, "SABER", SaberPublicKeySpec.class,
|
||||||
|
SaberPrivateKeySpec.class);
|
||||||
|
addAsymmetric(mappings, "SLH-DSA", SlhDsaPublicKeySpec.class,
|
||||||
|
SlhDsaPrivateKeySpec.class);
|
||||||
|
addAsymmetric(mappings, "SPHINCS+", SphincsPlusPublicKeySpec.class,
|
||||||
|
SphincsPlusPrivateKeySpec.class);
|
||||||
|
addAsymmetric(mappings, "Xdh", XdhPublicKeySpec.class, XdhPrivateKeySpec.class);
|
||||||
|
add(mappings, ALGORITHM_AES, KeyringStore.Kind.SECRET_KEY,
|
||||||
|
KeyringStore.Encoding.RAW,
|
||||||
|
HmacVariant.NONE, AesKeyImportSpec.class);
|
||||||
|
add(mappings, ALGORITHM_CHACHA20, KeyringStore.Kind.SECRET_KEY,
|
||||||
|
KeyringStore.Encoding.RAW,
|
||||||
|
HmacVariant.NONE, ChaChaKeyImportSpec.class);
|
||||||
|
add(mappings, ALGORITHM_CHACHA20_POLY1305, KeyringStore.Kind.SECRET_KEY,
|
||||||
|
KeyringStore.Encoding.RAW, HmacVariant.NONE, ChaChaKeyImportSpec.class);
|
||||||
|
add(mappings, ALGORITHM_HMAC, KeyringStore.Kind.SECRET_KEY,
|
||||||
|
KeyringStore.Encoding.RAW,
|
||||||
|
HmacVariant.SHA256, HmacKeyImportSpec.class);
|
||||||
|
add(mappings, ALGORITHM_HMAC, KeyringStore.Kind.SECRET_KEY,
|
||||||
|
KeyringStore.Encoding.RAW,
|
||||||
|
HmacVariant.SHA384, HmacKeyImportSpec.class);
|
||||||
|
add(mappings, ALGORITHM_HMAC, KeyringStore.Kind.SECRET_KEY,
|
||||||
|
KeyringStore.Encoding.RAW,
|
||||||
|
HmacVariant.SHA512, HmacKeyImportSpec.class);
|
||||||
|
return Collections.unmodifiableMap(mappings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addAsymmetric(Map<Tuple, PersistentMapping> mappings,
|
||||||
|
String algorithmId, Class<? extends AlgorithmKeySpec> publicSpec,
|
||||||
|
Class<? extends AlgorithmKeySpec> privateSpec) {
|
||||||
|
add(mappings, algorithmId, KeyringStore.Kind.PUBLIC_KEY,
|
||||||
|
KeyringStore.Encoding.X509, HmacVariant.NONE, publicSpec);
|
||||||
|
add(mappings, algorithmId, KeyringStore.Kind.PRIVATE_KEY,
|
||||||
|
KeyringStore.Encoding.PKCS8, HmacVariant.NONE, privateSpec);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void add(Map<Tuple, PersistentMapping> mappings, String algorithmId,
|
||||||
|
KeyringStore.Kind kind, KeyringStore.Encoding encoding,
|
||||||
|
HmacVariant hmacVariant, Class<? extends AlgorithmKeySpec> specType) {
|
||||||
|
Tuple tuple = new Tuple(algorithmId, kind, encoding, hmacVariant);
|
||||||
|
PersistentMapping mapping = new PersistentMapping(algorithmId, kind,
|
||||||
|
encoding, hmacVariant, specType);
|
||||||
|
if (mappings.put(tuple, mapping) != null) {
|
||||||
|
throw new IllegalStateException("Duplicate persistent key importer tuple");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Tuple(String algorithmId, KeyringStore.Kind kind,
|
||||||
|
KeyringStore.Encoding encoding, HmacVariant hmacVariant) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
120
lib/src/main/java/zeroecho/core/storage/KeyringPassword.java
Normal file
120
lib/src/main/java/zeroecho/core/storage/KeyringPassword.java
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2026, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.core.storage;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
import javax.security.auth.DestroyFailedException;
|
||||||
|
import javax.security.auth.Destroyable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroyable owner of a keyring password.
|
||||||
|
*
|
||||||
|
* <p>The constructor and {@link #copy()} use defensive copies. Callers retain
|
||||||
|
* ownership of the array supplied to the constructor and must clear it. The
|
||||||
|
* returned copy belongs to the receiver and must be cleared immediately after
|
||||||
|
* key derivation. This object never creates an immutable password
|
||||||
|
* {@link String}.</p>
|
||||||
|
*
|
||||||
|
* <p>Instances are thread-safe. Destruction is idempotent and makes subsequent
|
||||||
|
* access fail deterministically.</p>
|
||||||
|
*/
|
||||||
|
public final class KeyringPassword implements Destroyable, AutoCloseable {
|
||||||
|
private final ReentrantLock lifecycleLock = new ReentrantLock();
|
||||||
|
private final char[] password;
|
||||||
|
private boolean destroyed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a password owner.
|
||||||
|
*
|
||||||
|
* @param password password characters, which are defensively copied
|
||||||
|
* @throws NullPointerException if {@code password} is {@code null}
|
||||||
|
* @throws IllegalArgumentException if {@code password} is empty
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("PMD.UseVarargs")
|
||||||
|
public KeyringPassword(char[] password) {
|
||||||
|
Objects.requireNonNull(password, "password must not be null");
|
||||||
|
if (password.length == 0) {
|
||||||
|
throw new IllegalArgumentException("password must not be empty");
|
||||||
|
}
|
||||||
|
this.password = password.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a receiver-owned password copy.
|
||||||
|
*
|
||||||
|
* @return a new mutable array that the receiver must clear
|
||||||
|
* @throws IllegalStateException if this object has been destroyed
|
||||||
|
*/
|
||||||
|
public char[] copy() {
|
||||||
|
lifecycleLock.lock();
|
||||||
|
try {
|
||||||
|
if (destroyed) {
|
||||||
|
throw new IllegalStateException("Keyring password is destroyed");
|
||||||
|
}
|
||||||
|
return password.clone();
|
||||||
|
} finally {
|
||||||
|
lifecycleLock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the owned password characters.
|
||||||
|
*
|
||||||
|
* @throws DestroyFailedException never thrown by this implementation
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void destroy() throws DestroyFailedException {
|
||||||
|
lifecycleLock.lock();
|
||||||
|
try {
|
||||||
|
if (!destroyed) {
|
||||||
|
Arrays.fill(password, '\0');
|
||||||
|
destroyed = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
lifecycleLock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports whether the password has been destroyed.
|
||||||
|
*
|
||||||
|
* @return {@code true} after destruction
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isDestroyed() {
|
||||||
|
lifecycleLock.lock();
|
||||||
|
try {
|
||||||
|
return destroyed;
|
||||||
|
} finally {
|
||||||
|
lifecycleLock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys the password.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings("PMD.PreserveStackTrace")
|
||||||
|
public void close() {
|
||||||
|
try {
|
||||||
|
destroy();
|
||||||
|
} catch (DestroyFailedException impossible) {
|
||||||
|
throw new IllegalStateException("Keyring password destruction failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a redacted representation.
|
||||||
|
*
|
||||||
|
* @return a constant redacted value
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "KeyringPassword[REDACTED]";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2026, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.core.storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills keyring randomness buffers.
|
||||||
|
*
|
||||||
|
* <p>This package-private seam supports deterministic format tests; production
|
||||||
|
* creation uses the authoritative shared secure random source.</p>
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
interface KeyringRandomBytes {
|
||||||
|
/**
|
||||||
|
* Fills a destination buffer.
|
||||||
|
*
|
||||||
|
* @param destination buffer to fill completely
|
||||||
|
*/
|
||||||
|
void nextBytes(byte[] destination);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -32,94 +32,51 @@
|
|||||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
/**
|
/**
|
||||||
* Human-editable key storage persisted in a compact UTF-8 text format.
|
* Encrypted local software-keyring storage.
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* This package provides a lightweight keyring that applications can read and
|
* {@link zeroecho.core.storage.KeyringStore} binds an unlocked store to one
|
||||||
* write without specialized tooling. The store keeps entries in insertion
|
* filesystem path and holds exclusive process-lifetime ownership until close.
|
||||||
* order, allows simple alias-based lookups, and materializes keys through the
|
* It wraps one random store master key with PBKDF2-HMAC-SHA-256 and
|
||||||
* core catalog.
|
* AES-256-GCM, encrypts every entry independently, and authenticates the
|
||||||
|
* ordered entry manifest. Mutations use owner-only temporary files and atomic
|
||||||
|
* replacement.
|
||||||
* </p>
|
* </p>
|
||||||
*
|
*
|
||||||
* <h2>Key elements</h2>
|
|
||||||
* <ul>
|
|
||||||
* <li>{@link KeyringStore} - in-memory map of aliases to immutable records with
|
|
||||||
* helpers to add, load, save, import, export, and resolve keys.</li>
|
|
||||||
* <li>{@link KeyringStore.Record} - value object describing one entry: alias,
|
|
||||||
* algorithm id, {@link KeyringStore.Record.Kind kind}, spec class, and a
|
|
||||||
* {@link zeroecho.core.marshal.PairSeq} payload.</li>
|
|
||||||
* <li><i>Resolution helpers</i> - {@link KeyringStore#getPublic(String)},
|
|
||||||
* {@link KeyringStore#getPrivate(String)}, and
|
|
||||||
* {@link KeyringStore#getSecret(String)} plus
|
|
||||||
* {@link KeyringStore.PublicWithId}, {@link KeyringStore.PrivateWithId},
|
|
||||||
* {@link KeyringStore.SecretWithId} to return the algorithm id together with
|
|
||||||
* the key.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <h2>File format</h2>
|
|
||||||
* <p>
|
* <p>
|
||||||
* Files begin with a magic header followed by one or more <code>@entry</code>
|
* Nonce-reservation authentication is cryptographically separated from AES
|
||||||
* blocks. Keys that belong to the spec payload are prefixed with
|
* entry and manifest encryption. A dedicated 256-bit MAC key is derived with
|
||||||
* <code>s.</code> to avoid collisions with top-level fields; in-memory they are
|
* HKDF-HMAC-SHA-256 from the store master key, canonical binary store UUID, and
|
||||||
* stored without the prefix. Lines beginning with <code>#</code> are comments.
|
* fixed nonce-reservation domain. It is never persisted, remains owned by the
|
||||||
* A blank line terminates an entry.
|
* open store, and is destroyed on close. Only the current sidecar version is
|
||||||
|
* accepted.
|
||||||
* </p>
|
* </p>
|
||||||
*
|
*
|
||||||
* <pre>{@code
|
|
||||||
* # KeyringStore v1
|
|
||||||
* @entry
|
|
||||||
* alias=my-rsa
|
|
||||||
* algorithm=RSA
|
|
||||||
* kind=PUBLIC_KEY
|
|
||||||
* spec=zeroecho.core.alg.rsa.RsaPublicKeySpec
|
|
||||||
* s.x509B64=MIIBIjANBgkqh...
|
|
||||||
* }</pre>
|
|
||||||
*
|
|
||||||
* <h2>Spec marshaling contract</h2>
|
|
||||||
* <p>
|
* <p>
|
||||||
* Each spec class named in the <code>spec</code> field must expose two public
|
* Unlock passwords are destroyable, transfer ownership to the receiver, and
|
||||||
* static methods discovered by reflection:
|
* are destroyed immediately after the master key is unwrapped. The unlocked
|
||||||
|
* store retains the master key, its domain-separated nonce-reservation MAC
|
||||||
|
* key, and encrypted entry records; closing the store clears this material.
|
||||||
|
* The store requires a POSIX filesystem on which owner-only permissions can be
|
||||||
|
* verified. A directory-force failure after atomic replacement makes the open
|
||||||
|
* instance unusable until close and authenticated reopen resolves which
|
||||||
|
* complete image is current. Non-exportable keys must remain behind an
|
||||||
|
* external provider reference.
|
||||||
* </p>
|
* </p>
|
||||||
* <ul>
|
*
|
||||||
* <li><code>static PairSeq marshal(SpecType spec)</code></li>
|
|
||||||
* <li><code>static SpecType unmarshal(PairSeq pairs)</code></li>
|
|
||||||
* </ul>
|
|
||||||
* <p>
|
* <p>
|
||||||
* {@link KeyringStore} validates each persisted specification type against the
|
* Only the current binary format is accepted. Earlier plaintext development
|
||||||
* selected algorithm's registered import operation before invoking these
|
* formats are rejected without migration. Persisted input contains no Java
|
||||||
* methods.
|
* class or provider names and cannot trigger runtime class loading. Import
|
||||||
|
* specifications are selected from a closed mapping of canonical ZeroEcho
|
||||||
|
* algorithm, key kind, standard encoding, and (only for HMAC) one of the
|
||||||
|
* explicit SHA-256, SHA-384, or SHA-512 variants. Standard encodings are
|
||||||
|
* reconstructed through the current runtime's canonical importer; the
|
||||||
|
* originating JCA provider identity is not persisted or guaranteed. Keys that
|
||||||
|
* are provider-bound, non-exportable, or not canonically reconstructable must
|
||||||
|
* remain behind an external provider reference.
|
||||||
* </p>
|
* </p>
|
||||||
*
|
*
|
||||||
* <h2>Typical usage</h2> <pre>{@code
|
|
||||||
* // Create and persist a keyring.
|
|
||||||
* zeroecho.sdk.ZeroEchoSession session = new zeroecho.sdk.ZeroEchoSession();
|
|
||||||
* KeyringStore ks = new KeyringStore(session);
|
|
||||||
* ks.putPublic("site-signing", "Ed25519", myEd25519PublicSpec);
|
|
||||||
* ks.putPrivate("site-signing", "Ed25519", myEd25519PrivateSpec);
|
|
||||||
* ks.save(java.nio.file.Path.of("keyring.txt"));
|
|
||||||
*
|
|
||||||
* // Load and resolve a key later.
|
|
||||||
* KeyringStore reloaded = KeyringStore.load(session, java.nio.file.Path.of("keyring.txt"));
|
|
||||||
* java.security.PublicKey pub = reloaded.getPublic("site-signing");
|
|
||||||
* }</pre>
|
|
||||||
*
|
|
||||||
* <h2>Notes and recommendations</h2>
|
|
||||||
* <ul>
|
|
||||||
* <li>Persistence is plaintext. Treat files as sensitive, protect with OS
|
|
||||||
* permissions, and avoid committing to VCS.</li>
|
|
||||||
* <li>Resolution delegates to {@link zeroecho.core.CryptoAlgorithms}; the
|
|
||||||
* algorithm id must be one that the catalog recognizes.</li>
|
|
||||||
* <li>Records are immutable;
|
|
||||||
* {@link KeyringStore#putPublic(String, String, zeroecho.core.spec.AlgorithmKeySpec)},
|
|
||||||
* {@link KeyringStore#putPrivate(String, String, zeroecho.core.spec.AlgorithmKeySpec)},
|
|
||||||
* and
|
|
||||||
* {@link KeyringStore#putSecret(String, String, zeroecho.core.spec.AlgorithmKeySpec)}
|
|
||||||
* replace entries by alias.</li>
|
|
||||||
* <li>Lookups validate kind; for example,
|
|
||||||
* {@link KeyringStore#getPublic(String)} fails if the alias stores a private
|
|
||||||
* key.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* @since 1.0
|
* @since 1.0
|
||||||
*/
|
*/
|
||||||
package zeroecho.core.storage;
|
package zeroecho.core.storage;
|
||||||
|
|||||||
70
lib/src/main/java/zeroecho/core/util/RandomSupport.java
Normal file
70
lib/src/main/java/zeroecho/core/util/RandomSupport.java
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2026, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.core.util;
|
||||||
|
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the authoritative process-wide cryptographic random source.
|
||||||
|
*/
|
||||||
|
public final class RandomSupport {
|
||||||
|
private static final Logger LOG = Logger.getLogger(RandomSupport.class.getName());
|
||||||
|
private static final SecureRandom RANDOM = createRandom();
|
||||||
|
|
||||||
|
private RandomSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the shared thread-safe random source.
|
||||||
|
*
|
||||||
|
* @return shared secure random generator
|
||||||
|
*/
|
||||||
|
public static SecureRandom getRandom() {
|
||||||
|
return RANDOM;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allocates and fills a random byte array.
|
||||||
|
*
|
||||||
|
* @param size required array length
|
||||||
|
* @return random bytes
|
||||||
|
*/
|
||||||
|
public static byte[] generateRandom(int size) {
|
||||||
|
return generateRandom(new byte[size]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills the supplied array with random bytes.
|
||||||
|
*
|
||||||
|
* @param buffer destination array
|
||||||
|
* @return {@code buffer}
|
||||||
|
*/
|
||||||
|
public static byte[] generateRandom(byte[] buffer) {
|
||||||
|
RANDOM.nextBytes(buffer);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a uniform value below the bound.
|
||||||
|
*
|
||||||
|
* @param bound exclusive positive upper bound
|
||||||
|
* @return generated value
|
||||||
|
*/
|
||||||
|
public static int nextInt(int bound) {
|
||||||
|
return RANDOM.nextInt(bound);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SecureRandom createRandom() {
|
||||||
|
try {
|
||||||
|
return SecureRandom.getInstanceStrong();
|
||||||
|
} catch (NoSuchAlgorithmException exception) {
|
||||||
|
LOG.log(Level.WARNING, "Strong SecureRandom unavailable; using the platform default");
|
||||||
|
return new SecureRandom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ import zeroecho.sdk.builders.alg.AesDataContentBuilder;
|
|||||||
import zeroecho.sdk.builders.alg.ChaChaDataContentBuilder;
|
import zeroecho.sdk.builders.alg.ChaChaDataContentBuilder;
|
||||||
import zeroecho.sdk.content.api.DataContent;
|
import zeroecho.sdk.content.api.DataContent;
|
||||||
import zeroecho.sdk.content.api.EncryptedContent;
|
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
|
* Encrypting stage that emits the recipient table followed by the symmetric
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import java.util.Objects;
|
|||||||
import zeroecho.core.context.KemContext;
|
import zeroecho.core.context.KemContext;
|
||||||
import zeroecho.core.context.KemContext.KemResult;
|
import zeroecho.core.context.KemContext.KemResult;
|
||||||
import zeroecho.core.io.Util;
|
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
|
* Recipient implementation that derives a key-encryption key (KEK) via a
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import javax.security.auth.Destroyable;
|
|||||||
|
|
||||||
import zeroecho.core.io.Util;
|
import zeroecho.core.io.Util;
|
||||||
import zeroecho.sdk.Pbkdf2Limits;
|
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
|
* Password recipient that derives a KEK via PBKDF2(HMAC-SHA-256) and wraps the
|
||||||
|
|||||||
@@ -33,6 +33,8 @@
|
|||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
package zeroecho.sdk.util;
|
package zeroecho.sdk.util;
|
||||||
|
|
||||||
|
import zeroecho.core.util.RandomSupport;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility class for generating random passwords and secure random byte arrays.
|
* Utility class for generating random passwords and secure random byte arrays.
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
/*******************************************************************************
|
|
||||||
* Copyright (C) 2026, Leo Galambos
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* Redistribution and use in source and binary forms, with or without modification,
|
|
||||||
* are permitted provided that the following conditions are met:
|
|
||||||
*
|
|
||||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
|
||||||
* list of conditions and the following disclaimer.
|
|
||||||
*
|
|
||||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
||||||
* this list of conditions and the following disclaimer in the documentation
|
|
||||||
* and/or other materials provided with the distribution.
|
|
||||||
*
|
|
||||||
* 3. All advertising materials mentioning features or use of this software must
|
|
||||||
* display the following acknowledgement:
|
|
||||||
* This product includes software developed by the Egothor project.
|
|
||||||
*
|
|
||||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
|
||||||
* may be used to endorse or promote products derived from this software without
|
|
||||||
* specific prior written permission.
|
|
||||||
*
|
|
||||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
||||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
||||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
||||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
|
||||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
||||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
||||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
|
||||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
||||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
||||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
||||||
******************************************************************************/
|
|
||||||
package zeroecho.sdk.util;
|
|
||||||
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.security.SecureRandom;
|
|
||||||
import java.util.logging.Level;
|
|
||||||
import java.util.logging.Logger;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility class providing support for secure random number generation.
|
|
||||||
* <p>
|
|
||||||
* This class provides cryptographically secure random data through one
|
|
||||||
* process-wide, thread-safe {@link SecureRandom}. It selects the platform's
|
|
||||||
* strong implementation when available and otherwise uses the platform default.
|
|
||||||
* </p>
|
|
||||||
*
|
|
||||||
* @author Leo Galambos
|
|
||||||
*/
|
|
||||||
public final class RandomSupport {
|
|
||||||
private static final Logger LOG = Logger.getLogger(RandomSupport.class.getName());
|
|
||||||
|
|
||||||
/** Shared {@link SecureRandom} instance. */
|
|
||||||
private static final SecureRandom RANDOM = createRandom();
|
|
||||||
|
|
||||||
private RandomSupport() {
|
|
||||||
// this is a utility class
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the shared {@link SecureRandom} instance.
|
|
||||||
*
|
|
||||||
* @return the shared random source
|
|
||||||
*/
|
|
||||||
public static SecureRandom getRandom() {
|
|
||||||
return RANDOM;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SecureRandom createRandom() {
|
|
||||||
try {
|
|
||||||
return SecureRandom.getInstanceStrong();
|
|
||||||
} catch (NoSuchAlgorithmException exception) {
|
|
||||||
LOG.log(Level.WARNING, "Strong SecureRandom unavailable; using the platform default");
|
|
||||||
return new SecureRandom();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a secure random byte array of the specified size.
|
|
||||||
*
|
|
||||||
* @param size The size of the byte array to generate.
|
|
||||||
* @return A byte array filled with cryptographically secure random bytes.
|
|
||||||
*/
|
|
||||||
public static byte[] generateRandom(final int size) {
|
|
||||||
return generateRandom(new byte[size]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fills the given byte array with random bytes and returns it.
|
|
||||||
* <p>
|
|
||||||
* This method uses a thread-safe {@code SecureRandom} instance to generate
|
|
||||||
* cryptographically strong random values.
|
|
||||||
*
|
|
||||||
* @param buffer the byte array to fill with random bytes
|
|
||||||
* @return the same byte array, now containing random data
|
|
||||||
* @throws NullPointerException if {@code buffer} is {@code null}
|
|
||||||
*/
|
|
||||||
public static byte[] generateRandom(final byte[] buffer) {
|
|
||||||
RANDOM.nextBytes(buffer);
|
|
||||||
return buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a uniformly distributed value between zero (inclusive) and the
|
|
||||||
* specified bound (exclusive).
|
|
||||||
*
|
|
||||||
* @param bound exclusive upper bound; must be positive
|
|
||||||
* @return a uniformly distributed value
|
|
||||||
* @throws IllegalArgumentException if {@code bound} is not positive
|
|
||||||
*/
|
|
||||||
public static int nextInt(final int bound) {
|
|
||||||
return RANDOM.nextInt(bound);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -60,9 +60,6 @@
|
|||||||
* stream.</li>
|
* stream.</li>
|
||||||
* <li>{@link Password} - helpers for generating random bytes and printable
|
* <li>{@link Password} - helpers for generating random bytes and printable
|
||||||
* passwords.</li>
|
* passwords.</li>
|
||||||
* <li>{@link RandomSupport} - shared or per-call
|
|
||||||
* {@link java.security.SecureRandom} access with thread-safe helpers for
|
|
||||||
* filling arrays.</li>
|
|
||||||
* <li>{@link X509Support} - minimal PEM-based load and print helpers for
|
* <li>{@link X509Support} - minimal PEM-based load and print helpers for
|
||||||
* certificates, private keys, and certificate signing requests.</li>
|
* certificates, private keys, and certificate signing requests.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import zeroecho.core.context.EncryptionContext;
|
|||||||
import zeroecho.core.spec.VoidSpec;
|
import zeroecho.core.spec.VoidSpec;
|
||||||
import zeroecho.core.spi.ContextAware;
|
import zeroecho.core.spi.ContextAware;
|
||||||
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
|
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
|
||||||
import zeroecho.sdk.util.RandomSupport;
|
import zeroecho.core.util.RandomSupport;
|
||||||
|
|
||||||
class AesRandomSupportTest {
|
class AesRandomSupportTest {
|
||||||
private static final SecretKey KEY = new SecretKeySpec(new byte[16], "AES");
|
private static final SecretKey KEY = new SecretKeySpec(new byte[16], "AES");
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
package zeroecho.core.storage;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.lang.reflect.Modifier;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.Key;
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.security.PublicKey;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import javax.security.auth.Destroyable;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.DynamicTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.TestFactory;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import zeroecho.core.CryptoAlgorithm;
|
||||||
|
import zeroecho.core.CryptoAlgorithms;
|
||||||
|
import zeroecho.core.KeyOperation;
|
||||||
|
import zeroecho.core.KeyOperationInfo;
|
||||||
|
import zeroecho.core.KeyUsage;
|
||||||
|
import zeroecho.core.context.AgreementContext;
|
||||||
|
import zeroecho.core.context.EncryptionContext;
|
||||||
|
import zeroecho.core.context.KemContext;
|
||||||
|
import zeroecho.core.context.SignatureContext;
|
||||||
|
import zeroecho.core.io.TailStrippingInputStream;
|
||||||
|
import zeroecho.core.spec.AlgorithmKeySpec;
|
||||||
|
import zeroecho.core.spi.AsymmetricKeyPairGenerator;
|
||||||
|
import zeroecho.sdk.ZeroEchoSession;
|
||||||
|
import zeroecho.sdk.util.BouncyCastleActivator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes every approved persistent importer through the encrypted store and
|
||||||
|
* then proves that the reconstructed key remains operational.
|
||||||
|
*/
|
||||||
|
class KeyringAlgorithmCoverageTest {
|
||||||
|
private static final byte[] MESSAGE = "keyring-algorithm-matrix".getBytes(
|
||||||
|
java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
@TempDir
|
||||||
|
Path temporaryDirectory;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeProviders() {
|
||||||
|
BouncyCastleActivator.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void persistentImporterUniverseHasAcceptedCardinality() {
|
||||||
|
List<KeyringImportRegistry.PersistentMapping> mappings =
|
||||||
|
KeyringImportRegistry.mappings();
|
||||||
|
assertEquals(19, count(mappings, KeyringStore.Kind.PUBLIC_KEY));
|
||||||
|
assertEquals(19, count(mappings, KeyringStore.Kind.PRIVATE_KEY));
|
||||||
|
assertEquals(6, count(mappings, KeyringStore.Kind.SECRET_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicApiRequiresUnlockMaterialAndExposesNoPlaintextStorePath() {
|
||||||
|
List<Method> publicMethods = Arrays.stream(KeyringStore.class.getDeclaredMethods())
|
||||||
|
.filter(method -> Modifier.isPublic(method.getModifiers()))
|
||||||
|
.toList();
|
||||||
|
publicMethods.stream()
|
||||||
|
.filter(method -> "create".equals(method.getName())
|
||||||
|
|| "open".equals(method.getName()))
|
||||||
|
.forEach(method -> {
|
||||||
|
assertTrue(Arrays.asList(method.getParameterTypes())
|
||||||
|
.contains(KeyringPassword.class));
|
||||||
|
assertTrue(Arrays.stream(method.getParameterTypes())
|
||||||
|
.noneMatch(String.class::equals));
|
||||||
|
});
|
||||||
|
assertTrue(publicMethods.stream().noneMatch(method -> switch (method.getName()) {
|
||||||
|
case "exportText", "importText", "save", "load" -> true;
|
||||||
|
default -> false;
|
||||||
|
}));
|
||||||
|
assertTrue(Arrays.stream(KeyringStore.class.getFields())
|
||||||
|
.noneMatch(field -> field.getType() == byte[].class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestFactory
|
||||||
|
Stream<DynamicTest> asymmetricEncryptedStoreRoundTrips() {
|
||||||
|
List<String> algorithms = KeyringImportRegistry.mappings().stream()
|
||||||
|
.filter(mapping -> mapping.kind() == KeyringStore.Kind.PUBLIC_KEY)
|
||||||
|
.map(KeyringImportRegistry.PersistentMapping::algorithmId)
|
||||||
|
.toList();
|
||||||
|
return algorithms.stream().map(algorithmId -> DynamicTest.dynamicTest(
|
||||||
|
algorithmId + " PUBLIC/X.509 + PRIVATE/PKCS#8",
|
||||||
|
() -> roundTripAsymmetric(algorithmId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestFactory
|
||||||
|
Stream<DynamicTest> secretEncryptedStoreRoundTrips() {
|
||||||
|
return KeyringImportRegistry.mappings().stream()
|
||||||
|
.filter(mapping -> mapping.kind() == KeyringStore.Kind.SECRET_KEY)
|
||||||
|
.map(mapping -> DynamicTest.dynamicTest(secretDisplayName(mapping),
|
||||||
|
() -> roundTripSecret(mapping)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void roundTripAsymmetric(String algorithmId) throws Exception {
|
||||||
|
KeyPair original = generatePair(algorithmId);
|
||||||
|
Path path = temporaryDirectory.resolve("asymmetric-" + safeName(algorithmId) + ".zek");
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.create(path, password)) {
|
||||||
|
store.putPublic("matrix", algorithmId, original.getPublic());
|
||||||
|
store.putPrivate("matrix", algorithmId, original.getPrivate());
|
||||||
|
}
|
||||||
|
|
||||||
|
PublicKey reconstructedPublic = null;
|
||||||
|
PrivateKey reconstructedPrivate = null;
|
||||||
|
byte[] originalPublic = null;
|
||||||
|
byte[] reconstructedPublicBytes = null;
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.open(path, password)) {
|
||||||
|
KeyringStore.PublicWithId publicWithId = store.getPublicWithId("matrix");
|
||||||
|
KeyringStore.PrivateWithId privateWithId = store.getPrivateWithId("matrix");
|
||||||
|
assertEquals(algorithmId, publicWithId.algorithm());
|
||||||
|
assertEquals(algorithmId, privateWithId.algorithm());
|
||||||
|
reconstructedPublic = publicWithId.key();
|
||||||
|
reconstructedPrivate = privateWithId.key();
|
||||||
|
originalPublic = original.getPublic().getEncoded();
|
||||||
|
reconstructedPublicBytes = reconstructedPublic.getEncoded();
|
||||||
|
assertArrayEquals(originalPublic, reconstructedPublicBytes);
|
||||||
|
proveAsymmetricOperation(algorithmId, reconstructedPublic,
|
||||||
|
reconstructedPrivate);
|
||||||
|
} finally {
|
||||||
|
wipe(originalPublic);
|
||||||
|
wipe(reconstructedPublicBytes);
|
||||||
|
destroy(reconstructedPublic);
|
||||||
|
destroy(reconstructedPrivate);
|
||||||
|
destroy(original.getPublic());
|
||||||
|
destroy(original.getPrivate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void roundTripSecret(KeyringImportRegistry.PersistentMapping mapping)
|
||||||
|
throws Exception {
|
||||||
|
byte[] material = new byte[32];
|
||||||
|
Arrays.fill(material, secretFill(mapping));
|
||||||
|
String jcaName = secretJcaName(mapping);
|
||||||
|
SecretKey original = new SecretKeySpec(material, jcaName);
|
||||||
|
Path path = temporaryDirectory.resolve("secret-" + safeName(secretDisplayName(mapping))
|
||||||
|
+ ".zek");
|
||||||
|
SecretKey reconstructed = null;
|
||||||
|
byte[] reconstructedBytes = null;
|
||||||
|
try {
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.create(path, password)) {
|
||||||
|
store.putSecret("matrix", mapping.algorithmId(), original);
|
||||||
|
}
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.open(path, password)) {
|
||||||
|
KeyringStore.SecretWithId withId = store.getSecretWithId("matrix");
|
||||||
|
assertEquals(mapping.algorithmId(), withId.algorithm());
|
||||||
|
reconstructed = withId.key();
|
||||||
|
reconstructedBytes = reconstructed.getEncoded();
|
||||||
|
assertEquals(material.length, reconstructedBytes.length);
|
||||||
|
assertArrayEquals(material, reconstructedBytes);
|
||||||
|
proveSecretOperation(mapping, reconstructed);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
wipe(material);
|
||||||
|
wipe(reconstructedBytes);
|
||||||
|
destroy(reconstructed);
|
||||||
|
destroy(original);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void proveAsymmetricOperation(String algorithmId,
|
||||||
|
PublicKey publicKey, PrivateKey privateKey) throws Exception {
|
||||||
|
CryptoAlgorithm algorithm = CryptoAlgorithms.require(algorithmId);
|
||||||
|
if (algorithm.roles().contains(KeyUsage.SIGN)
|
||||||
|
&& algorithm.roles().contains(KeyUsage.VERIFY)) {
|
||||||
|
proveSignature(algorithmId, publicKey, privateKey);
|
||||||
|
} else if (algorithm.roles().contains(KeyUsage.ENCAPSULATE)
|
||||||
|
&& algorithm.roles().contains(KeyUsage.DECAPSULATE)) {
|
||||||
|
proveKem(algorithmId, publicKey, privateKey);
|
||||||
|
} else if (algorithm.roles().contains(KeyUsage.AGREEMENT)) {
|
||||||
|
proveAgreement(algorithmId, publicKey, privateKey);
|
||||||
|
} else {
|
||||||
|
assertTrue(algorithm.roles().contains(KeyUsage.ENCRYPT)
|
||||||
|
&& algorithm.roles().contains(KeyUsage.DECRYPT));
|
||||||
|
proveEncryption(algorithmId, publicKey, privateKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void proveSignature(String algorithmId, PublicKey publicKey,
|
||||||
|
PrivateKey privateKey) throws Exception {
|
||||||
|
ZeroEchoSession session = new ZeroEchoSession();
|
||||||
|
AtomicReference<byte[]> signatureHolder = new AtomicReference<>();
|
||||||
|
byte[] signature = null;
|
||||||
|
try (SignatureContext signer = session.createContext(
|
||||||
|
algorithmId, KeyUsage.SIGN, privateKey);
|
||||||
|
InputStream signed = new TailStrippingInputStream(
|
||||||
|
signer.wrap(new ByteArrayInputStream(MESSAGE)),
|
||||||
|
signer.tagLength(), 512) {
|
||||||
|
@Override
|
||||||
|
protected void processTail(byte[] tail) {
|
||||||
|
signatureHolder.set(tail == null ? null : tail.clone());
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
assertArrayEquals(MESSAGE, signed.readAllBytes());
|
||||||
|
signature = signatureHolder.get();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
assertTrue(signature != null && signature.length > 0);
|
||||||
|
try (SignatureContext verifier = session.createContext(
|
||||||
|
algorithmId, KeyUsage.VERIFY, publicKey);
|
||||||
|
InputStream verified = verificationStream(verifier, signature)) {
|
||||||
|
assertArrayEquals(MESSAGE, verified.readAllBytes());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
wipe(signature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InputStream verificationStream(SignatureContext verifier,
|
||||||
|
byte[] signature) throws IOException {
|
||||||
|
verifier.setVerificationApproach(verifier.getVerificationCore().getThrowOnMismatch());
|
||||||
|
verifier.setExpectedTag(signature);
|
||||||
|
return verifier.wrap(new ByteArrayInputStream(MESSAGE));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void proveKem(String algorithmId, PublicKey publicKey,
|
||||||
|
PrivateKey privateKey) throws Exception {
|
||||||
|
ZeroEchoSession session = new ZeroEchoSession();
|
||||||
|
byte[] encapsulated = null;
|
||||||
|
byte[] senderSecret = null;
|
||||||
|
byte[] recipientSecret = null;
|
||||||
|
try (KemContext sender = session.createContext(
|
||||||
|
algorithmId, KeyUsage.ENCAPSULATE, publicKey);
|
||||||
|
KemContext recipient = session.createContext(
|
||||||
|
algorithmId, KeyUsage.DECAPSULATE, privateKey)) {
|
||||||
|
KemContext.KemResult result = sender.encapsulate();
|
||||||
|
encapsulated = result.ciphertext();
|
||||||
|
senderSecret = result.sharedSecret();
|
||||||
|
recipientSecret = recipient.decapsulate(encapsulated);
|
||||||
|
assertArrayEquals(senderSecret, recipientSecret);
|
||||||
|
} finally {
|
||||||
|
wipe(encapsulated);
|
||||||
|
wipe(senderSecret);
|
||||||
|
wipe(recipientSecret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void proveAgreement(String algorithmId, PublicKey publicKey,
|
||||||
|
PrivateKey privateKey) throws Exception {
|
||||||
|
KeyPair peer = generatePair(algorithmId);
|
||||||
|
ZeroEchoSession session = new ZeroEchoSession();
|
||||||
|
byte[] firstSecret = null;
|
||||||
|
byte[] secondSecret = null;
|
||||||
|
try (AgreementContext first = session.createContext(
|
||||||
|
algorithmId, KeyUsage.AGREEMENT, privateKey);
|
||||||
|
AgreementContext second = session.createContext(
|
||||||
|
algorithmId, KeyUsage.AGREEMENT, peer.getPrivate())) {
|
||||||
|
first.setPeerPublic(peer.getPublic());
|
||||||
|
second.setPeerPublic(publicKey);
|
||||||
|
firstSecret = first.deriveSecret();
|
||||||
|
secondSecret = second.deriveSecret();
|
||||||
|
assertArrayEquals(firstSecret, secondSecret);
|
||||||
|
} finally {
|
||||||
|
wipe(firstSecret);
|
||||||
|
wipe(secondSecret);
|
||||||
|
destroy(peer.getPublic());
|
||||||
|
destroy(peer.getPrivate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void proveEncryption(String algorithmId, Key encryptionKey,
|
||||||
|
Key decryptionKey) throws Exception {
|
||||||
|
ZeroEchoSession session = new ZeroEchoSession();
|
||||||
|
conflux.CtxInterface operationContext =
|
||||||
|
conflux.Ctx.INSTANCE.getContext("keyring-matrix-" + algorithmId);
|
||||||
|
byte[] ciphertext = null;
|
||||||
|
byte[] plaintext = null;
|
||||||
|
try {
|
||||||
|
try (EncryptionContext encryption = session.createContext(
|
||||||
|
algorithmId, KeyUsage.ENCRYPT, encryptionKey)) {
|
||||||
|
if (encryption instanceof zeroecho.core.spi.ContextAware contextAware) {
|
||||||
|
contextAware.setContext(operationContext);
|
||||||
|
}
|
||||||
|
try (InputStream encrypted = encryption.attach(
|
||||||
|
new ByteArrayInputStream(MESSAGE))) {
|
||||||
|
ciphertext = encrypted.readAllBytes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try (EncryptionContext decryption = session.createContext(
|
||||||
|
algorithmId, KeyUsage.DECRYPT, decryptionKey)) {
|
||||||
|
if (decryption instanceof zeroecho.core.spi.ContextAware contextAware) {
|
||||||
|
contextAware.setContext(operationContext);
|
||||||
|
}
|
||||||
|
try (InputStream decrypted = decryption.attach(
|
||||||
|
new ByteArrayInputStream(ciphertext))) {
|
||||||
|
plaintext = decrypted.readAllBytes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertArrayEquals(MESSAGE, plaintext);
|
||||||
|
} finally {
|
||||||
|
wipe(ciphertext);
|
||||||
|
wipe(plaintext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void proveSecretOperation(
|
||||||
|
KeyringImportRegistry.PersistentMapping mapping, SecretKey key)
|
||||||
|
throws Exception {
|
||||||
|
if ("HMAC".equals(mapping.algorithmId())) {
|
||||||
|
proveMac(key);
|
||||||
|
} else {
|
||||||
|
proveEncryption(mapping.algorithmId(), key, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void proveMac(SecretKey key) throws Exception {
|
||||||
|
byte[] first = null;
|
||||||
|
byte[] second = null;
|
||||||
|
try {
|
||||||
|
Mac producer = Mac.getInstance(key.getAlgorithm());
|
||||||
|
producer.init(key);
|
||||||
|
first = producer.doFinal(MESSAGE);
|
||||||
|
Mac verifier = Mac.getInstance(key.getAlgorithm());
|
||||||
|
verifier.init(key);
|
||||||
|
second = verifier.doFinal(MESSAGE);
|
||||||
|
assertArrayEquals(first, second);
|
||||||
|
} finally {
|
||||||
|
wipe(first);
|
||||||
|
wipe(second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings({ "rawtypes", "unchecked" })
|
||||||
|
private static KeyPair generatePair(String algorithmId) throws Exception {
|
||||||
|
CryptoAlgorithm algorithm = CryptoAlgorithms.require(algorithmId);
|
||||||
|
KeyOperationInfo generation = algorithm.keyOperations().stream()
|
||||||
|
.filter(info -> info.operation()
|
||||||
|
== KeyOperation.ASYMMETRIC_KEY_PAIR_GENERATE)
|
||||||
|
.filter(info -> info.defaultSpec() != null)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new AssertionError(
|
||||||
|
"No default key-pair generation mapping for " + algorithmId));
|
||||||
|
AsymmetricKeyPairGenerator generator =
|
||||||
|
algorithm.asymmetricKeyPairGenerator(generation.specType());
|
||||||
|
return generator.generateKeyPair((AlgorithmKeySpec) generation.defaultSpec());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long count(List<KeyringImportRegistry.PersistentMapping> mappings,
|
||||||
|
KeyringStore.Kind kind) {
|
||||||
|
return mappings.stream().filter(mapping -> mapping.kind() == kind).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String secretDisplayName(
|
||||||
|
KeyringImportRegistry.PersistentMapping mapping) {
|
||||||
|
return mapping.algorithmId() + "/" + mapping.hmacVariant().name()
|
||||||
|
+ " SECRET/RAW";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String secretJcaName(
|
||||||
|
KeyringImportRegistry.PersistentMapping mapping) {
|
||||||
|
return switch (mapping.algorithmId()) {
|
||||||
|
case "AES" -> "AES";
|
||||||
|
case "CHACHA20", "CHACHA20-POLY1305" -> "ChaCha20";
|
||||||
|
case "HMAC" -> mapping.hmacVariant().jcaName();
|
||||||
|
default -> throw new AssertionError("Unexpected secret mapping");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte secretFill(
|
||||||
|
KeyringImportRegistry.PersistentMapping mapping) {
|
||||||
|
return (byte) (mapping.hmacVariant().code() + mapping.algorithmId().length() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safeName(String value) {
|
||||||
|
return value.replaceAll("[^A-Za-z0-9]", "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyringPassword password() {
|
||||||
|
char[] value = "keyring-matrix-password".toCharArray();
|
||||||
|
try {
|
||||||
|
return new KeyringPassword(value);
|
||||||
|
} finally {
|
||||||
|
Arrays.fill(value, '\0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void destroy(Key key) {
|
||||||
|
if (key instanceof Destroyable destroyable) {
|
||||||
|
try {
|
||||||
|
destroyable.destroy();
|
||||||
|
} catch (Exception exception) {
|
||||||
|
// Provider keys may advertise but not implement destruction.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void wipe(byte[] value) {
|
||||||
|
if (value != null) {
|
||||||
|
Arrays.fill(value, (byte) 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
package zeroecho.core.storage;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.attribute.FileAttribute;
|
||||||
|
import java.nio.file.attribute.PosixFilePermission;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import zeroecho.core.storage.KeyringFileOperations.Target;
|
||||||
|
|
||||||
|
class KeyringAtomicPersistenceTest {
|
||||||
|
private static final char[] PASSWORD = { 'a', 't', 'o', 'm', 'i', 'c' };
|
||||||
|
private static final byte[] ENTRY_A = material((byte) 0x31);
|
||||||
|
private static final byte[] ENTRY_B = material((byte) 0x72);
|
||||||
|
private static final Set<PosixFilePermission> FILE_PERMISSIONS = Set.of(
|
||||||
|
PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE);
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path temporaryDirectory;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mainImagePreCommitFailuresPreserveAuthoritativeState() throws Exception {
|
||||||
|
start("mainImagePreCommitFailuresPreserveAuthoritativeState");
|
||||||
|
for (FailureStage stage : List.of(FailureStage.CREATE_TEMP,
|
||||||
|
FailureStage.WRITE_TEMP, FailureStage.FORCE_TEMP,
|
||||||
|
FailureStage.ATOMIC_MOVE)) {
|
||||||
|
Path path = initialized("main-" + stage + ".zek");
|
||||||
|
byte[] before = Files.readAllBytes(path);
|
||||||
|
FailingFileOperations operations =
|
||||||
|
new FailingFileOperations(Target.MAIN_IMAGE, stage, false);
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.open(path, password,
|
||||||
|
KeyringProtection.standard(), deterministicRandom(50),
|
||||||
|
operations)) {
|
||||||
|
long highWater = longField(store, "nonceHighWater");
|
||||||
|
KeyringException failure = assertThrows(KeyringException.class,
|
||||||
|
() -> put(store, "B", ENTRY_B));
|
||||||
|
assertSafe(failure, KeyringException.Code.KEYRING_IO_FAILED);
|
||||||
|
assertTrue(store.contains("A"));
|
||||||
|
assertFalse(store.contains("B"));
|
||||||
|
assertArrayEquals(before, Files.readAllBytes(path));
|
||||||
|
assertEquals(highWater + 2, longField(store, "nonceHighWater"));
|
||||||
|
assertAlreadyOpen(path);
|
||||||
|
put(store, "B", ENTRY_B);
|
||||||
|
assertTrue(store.contains("B"));
|
||||||
|
} finally {
|
||||||
|
wipe(before);
|
||||||
|
}
|
||||||
|
assertReopened(path, true);
|
||||||
|
assertSentinelAbsent(path.getParent(), ENTRY_B);
|
||||||
|
}
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mainImageDirectoryForceFailurePoisonsUntilReopen() throws Exception {
|
||||||
|
start("mainImageDirectoryForceFailurePoisonsUntilReopen");
|
||||||
|
Path path = initialized("main-directory.zek");
|
||||||
|
FailingFileOperations operations = new FailingFileOperations(
|
||||||
|
Target.MAIN_IMAGE, FailureStage.FORCE_DIRECTORY, false);
|
||||||
|
KeyringStore store;
|
||||||
|
try (KeyringPassword password = password()) {
|
||||||
|
store = KeyringStore.open(path, password, KeyringProtection.standard(),
|
||||||
|
deterministicRandom(70), operations);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
KeyringException failure = assertThrows(KeyringException.class,
|
||||||
|
() -> put(store, "B", ENTRY_B));
|
||||||
|
assertSafe(failure, KeyringException.Code.KEYRING_DURABILITY_UNCONFIRMED);
|
||||||
|
assertTrue(store.isDestroyed());
|
||||||
|
assertThrows(IllegalStateException.class, () -> store.contains("A"));
|
||||||
|
assertThrows(KeyringException.class, () -> put(store, "C", ENTRY_B));
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
assertReopened(path, true);
|
||||||
|
assertSentinelAbsent(path.getParent(), ENTRY_B);
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sidecarPreCommitFailuresIssueNoUncommittedNonce() throws Exception {
|
||||||
|
start("sidecarPreCommitFailuresIssueNoUncommittedNonce");
|
||||||
|
for (FailureStage stage : List.of(FailureStage.CREATE_TEMP,
|
||||||
|
FailureStage.WRITE_TEMP, FailureStage.FORCE_TEMP,
|
||||||
|
FailureStage.ATOMIC_MOVE)) {
|
||||||
|
Path path = initialized("sidecar-" + stage + ".zek");
|
||||||
|
byte[] mainBefore = Files.readAllBytes(path);
|
||||||
|
byte[] sidecarBefore = Files.readAllBytes(sidecar(path));
|
||||||
|
FailingFileOperations operations =
|
||||||
|
new FailingFileOperations(Target.NONCE_RESERVATION, stage, false);
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.open(path, password,
|
||||||
|
KeyringProtection.standard(), deterministicRandom(90),
|
||||||
|
operations)) {
|
||||||
|
long highWater = longField(store, "nonceHighWater");
|
||||||
|
KeyringException failure = assertThrows(KeyringException.class,
|
||||||
|
() -> put(store, "B", ENTRY_B));
|
||||||
|
assertSafe(failure, KeyringException.Code.KEYRING_IO_FAILED);
|
||||||
|
assertEquals(highWater, longField(store, "nonceHighWater"));
|
||||||
|
assertFalse(store.contains("B"));
|
||||||
|
assertArrayEquals(mainBefore, Files.readAllBytes(path));
|
||||||
|
assertArrayEquals(sidecarBefore, Files.readAllBytes(sidecar(path)));
|
||||||
|
put(store, "B", ENTRY_B);
|
||||||
|
assertEquals(highWater + 2, longField(store, "nonceHighWater"));
|
||||||
|
} finally {
|
||||||
|
wipe(mainBefore);
|
||||||
|
wipe(sidecarBefore);
|
||||||
|
}
|
||||||
|
assertReopened(path, true);
|
||||||
|
assertSentinelAbsent(path.getParent(), ENTRY_B);
|
||||||
|
}
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sidecarDirectoryForceFailureIssuesNothingUntilReopen() throws Exception {
|
||||||
|
start("sidecarDirectoryForceFailureIssuesNothingUntilReopen");
|
||||||
|
Path path = initialized("sidecar-directory.zek");
|
||||||
|
long initialHighWater;
|
||||||
|
FailingFileOperations operations = new FailingFileOperations(
|
||||||
|
Target.NONCE_RESERVATION, FailureStage.FORCE_DIRECTORY, false);
|
||||||
|
KeyringStore store;
|
||||||
|
try (KeyringPassword password = password()) {
|
||||||
|
store = KeyringStore.open(path, password, KeyringProtection.standard(),
|
||||||
|
deterministicRandom(110), operations);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
initialHighWater = longField(store, "nonceHighWater");
|
||||||
|
KeyringException failure = assertThrows(KeyringException.class,
|
||||||
|
() -> put(store, "B", ENTRY_B));
|
||||||
|
assertSafe(failure, KeyringException.Code.KEYRING_DURABILITY_UNCONFIRMED);
|
||||||
|
assertFalse(store.isDestroyed());
|
||||||
|
assertThrows(IllegalStateException.class, () -> store.contains("A"));
|
||||||
|
assertAlreadyOpen(path);
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore reopened = KeyringStore.open(path, password,
|
||||||
|
KeyringProtection.standard(), deterministicRandom(130))) {
|
||||||
|
assertTrue(reopened.contains("A"));
|
||||||
|
assertFalse(reopened.contains("B"));
|
||||||
|
assertEquals(initialHighWater + 1, longField(reopened, "nonceHighWater"));
|
||||||
|
put(reopened, "B", ENTRY_B);
|
||||||
|
assertEquals(initialHighWater + 3, longField(reopened, "nonceHighWater"));
|
||||||
|
}
|
||||||
|
assertReopened(path, true);
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cleanupFailuresPreservePrimaryAndEncryptedResiduals() throws Exception {
|
||||||
|
start("cleanupFailuresPreservePrimaryAndEncryptedResiduals");
|
||||||
|
assertCleanupFailure(Target.MAIN_IMAGE, "cleanup-main.zek");
|
||||||
|
assertCleanupFailure(Target.NONCE_RESERVATION, "cleanup-sidecar.zek");
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertCleanupFailure(Target target, String file) throws Exception {
|
||||||
|
Path path = initialized(file);
|
||||||
|
FailingFileOperations operations = new FailingFileOperations(
|
||||||
|
target, FailureStage.WRITE_TEMP, true);
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.open(path, password,
|
||||||
|
KeyringProtection.standard(), deterministicRandom(150),
|
||||||
|
operations)) {
|
||||||
|
KeyringException failure = assertThrows(KeyringException.class,
|
||||||
|
() -> put(store, "B", ENTRY_B));
|
||||||
|
assertSafe(failure, KeyringException.Code.KEYRING_IO_FAILED);
|
||||||
|
assertEquals(1, failure.getSuppressed().length);
|
||||||
|
assertSafe((KeyringException) failure.getSuppressed()[0],
|
||||||
|
KeyringException.Code.KEYRING_IO_FAILED);
|
||||||
|
assertTrue(store.contains("A"));
|
||||||
|
assertFalse(store.contains("B"));
|
||||||
|
Path residual = operations.firstTemporary();
|
||||||
|
assertTrue(Files.isRegularFile(residual));
|
||||||
|
assertEquals(FILE_PERMISSIONS, Files.getPosixFilePermissions(residual));
|
||||||
|
assertSentinelAbsent(residual.getParent(), ENTRY_B);
|
||||||
|
put(store, "B", ENTRY_B);
|
||||||
|
assertNotEquals(residual, operations.lastTemporary());
|
||||||
|
assertTrue(store.contains("B"));
|
||||||
|
}
|
||||||
|
assertReopened(path, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path initialized(String file) throws Exception {
|
||||||
|
Path path = temporaryDirectory.resolve(file);
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.create(path, password,
|
||||||
|
KeyringProtection.standard(), deterministicRandom(1))) {
|
||||||
|
put(store, "A", ENTRY_A);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void put(KeyringStore store, String alias, byte[] material)
|
||||||
|
throws Exception {
|
||||||
|
store.putSecret(alias, "AES", new SecretKeySpec(material, "AES"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertReopened(Path path, boolean hasB) throws Exception {
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.open(path, password)) {
|
||||||
|
assertTrue(store.contains("A"));
|
||||||
|
assertEquals(hasB, store.contains("B"));
|
||||||
|
assertSecretEquals(ENTRY_A, store.getSecret("A"));
|
||||||
|
if (hasB) {
|
||||||
|
assertSecretEquals(ENTRY_B, store.getSecret("B"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertSecretEquals(byte[] expected, SecretKey key) {
|
||||||
|
byte[] encoded = key.getEncoded();
|
||||||
|
try {
|
||||||
|
assertArrayEquals(expected, encoded);
|
||||||
|
} finally {
|
||||||
|
wipe(encoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertAlreadyOpen(Path path) throws Exception {
|
||||||
|
try (KeyringPassword password = password()) {
|
||||||
|
KeyringException failure = assertThrows(KeyringException.class,
|
||||||
|
() -> KeyringStore.open(path, password));
|
||||||
|
assertSafe(failure, KeyringException.Code.KEYRING_ALREADY_OPEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertSafe(KeyringException failure,
|
||||||
|
KeyringException.Code expected) {
|
||||||
|
assertEquals(expected, failure.code());
|
||||||
|
assertEquals(expected.name(), failure.getMessage());
|
||||||
|
assertNull(failure.getCause());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertSentinelAbsent(Path directory, byte[] sentinel)
|
||||||
|
throws IOException {
|
||||||
|
try (java.util.stream.Stream<Path> paths = Files.list(directory)) {
|
||||||
|
for (Path current : paths.toList()) {
|
||||||
|
if (Files.isRegularFile(current)) {
|
||||||
|
byte[] image = Files.readAllBytes(current);
|
||||||
|
try {
|
||||||
|
assertEquals(-1, indexOf(image, sentinel));
|
||||||
|
} finally {
|
||||||
|
wipe(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int indexOf(byte[] haystack, byte[] needle) {
|
||||||
|
outer:
|
||||||
|
for (int index = 0; index <= haystack.length - needle.length; index++) {
|
||||||
|
for (int offset = 0; offset < needle.length; offset++) {
|
||||||
|
if (haystack[index + offset] != needle[offset]) {
|
||||||
|
continue outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long longField(KeyringStore store, String name) throws Exception {
|
||||||
|
Field field = KeyringStore.class.getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
return field.getLong(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path sidecar(Path keyring) {
|
||||||
|
return keyring.resolveSibling(keyring.getFileName() + ".nonce");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyringPassword password() {
|
||||||
|
return new KeyringPassword(PASSWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyringRandomBytes deterministicRandom(int initial) {
|
||||||
|
AtomicInteger value = new AtomicInteger(initial);
|
||||||
|
return destination -> {
|
||||||
|
int base = value.getAndIncrement();
|
||||||
|
for (int index = 0; index < destination.length; index++) {
|
||||||
|
destination[index] = (byte) (base + index);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] material(byte value) {
|
||||||
|
byte[] material = new byte[32];
|
||||||
|
Arrays.fill(material, value);
|
||||||
|
return material;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void wipe(byte[] value) {
|
||||||
|
if (value != null) {
|
||||||
|
Arrays.fill(value, (byte) 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void start(String method) {
|
||||||
|
System.out.println(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ok() {
|
||||||
|
System.out.println("...ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum FailureStage {
|
||||||
|
CREATE_TEMP,
|
||||||
|
WRITE_TEMP,
|
||||||
|
FORCE_TEMP,
|
||||||
|
ATOMIC_MOVE,
|
||||||
|
FORCE_DIRECTORY,
|
||||||
|
DELETE_TEMP
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class FailingFileOperations implements KeyringFileOperations {
|
||||||
|
private final Target target;
|
||||||
|
private final FailureStage primaryStage;
|
||||||
|
private final boolean failCleanup;
|
||||||
|
private final List<Path> temporaryPaths = new ArrayList<>();
|
||||||
|
private boolean primaryFailed;
|
||||||
|
private boolean cleanupFailed;
|
||||||
|
|
||||||
|
private FailingFileOperations(Target target, FailureStage primaryStage,
|
||||||
|
boolean failCleanup) {
|
||||||
|
this.target = target;
|
||||||
|
this.primaryStage = primaryStage;
|
||||||
|
this.failCleanup = failCleanup;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Path createTemporary(Target actualTarget, Path parent, String prefix,
|
||||||
|
String suffix, FileAttribute<Set<PosixFilePermission>> permissions)
|
||||||
|
throws IOException {
|
||||||
|
failBefore(actualTarget, FailureStage.CREATE_TEMP);
|
||||||
|
Path temporary = NIO.createTemporary(actualTarget, parent, prefix, suffix,
|
||||||
|
permissions);
|
||||||
|
if (actualTarget == target) {
|
||||||
|
temporaryPaths.add(temporary);
|
||||||
|
}
|
||||||
|
return temporary;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeTemporary(Target actualTarget, Path temporary, byte[] image)
|
||||||
|
throws IOException {
|
||||||
|
NIO.writeTemporary(actualTarget, temporary, image);
|
||||||
|
failAfter(actualTarget, FailureStage.WRITE_TEMP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void forceTemporary(Target actualTarget, Path temporary)
|
||||||
|
throws IOException {
|
||||||
|
NIO.forceTemporary(actualTarget, temporary);
|
||||||
|
failAfter(actualTarget, FailureStage.FORCE_TEMP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void atomicReplace(Target actualTarget, Path temporary, Path destination)
|
||||||
|
throws IOException {
|
||||||
|
failBefore(actualTarget, FailureStage.ATOMIC_MOVE);
|
||||||
|
NIO.atomicReplace(actualTarget, temporary, destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void forceDirectory(Target actualTarget, Path parent) throws IOException {
|
||||||
|
failBefore(actualTarget, FailureStage.FORCE_DIRECTORY);
|
||||||
|
NIO.forceDirectory(actualTarget, parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteTemporary(Target actualTarget, Path temporary)
|
||||||
|
throws IOException {
|
||||||
|
if (actualTarget == target && failCleanup && !cleanupFailed) {
|
||||||
|
cleanupFailed = true;
|
||||||
|
throw new IOException("DELETE_TEMP_SENTINEL");
|
||||||
|
}
|
||||||
|
NIO.deleteTemporary(actualTarget, temporary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void failBefore(Target actualTarget, FailureStage stage)
|
||||||
|
throws IOException {
|
||||||
|
if (actualTarget == target && primaryStage == stage && !primaryFailed) {
|
||||||
|
primaryFailed = true;
|
||||||
|
throw new IOException(stage.name() + "_SENTINEL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void failAfter(Target actualTarget, FailureStage stage)
|
||||||
|
throws IOException {
|
||||||
|
failBefore(actualTarget, stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path firstTemporary() {
|
||||||
|
return temporaryPaths.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path lastTemporary() {
|
||||||
|
return temporaryPaths.get(temporaryPaths.size() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,865 @@
|
|||||||
|
package zeroecho.core.storage;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.InvocationTargetException;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.net.URLClassLoader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.FileSystem;
|
||||||
|
import java.nio.file.FileSystems;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.attribute.PosixFilePermission;
|
||||||
|
import java.nio.file.attribute.UserPrincipal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||||
|
import java.util.logging.LogManager;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
class KeyringFilesystemSecurityTest {
|
||||||
|
private static final char[] PASSWORD = { 'f', 'i', 'l', 'e', 's', 'y', 's' };
|
||||||
|
private static final byte[] CHILD_PASSWORD = { 'f', 'i', 'l', 'e', 's', 'y', 's' };
|
||||||
|
private static final long TIMEOUT_SECONDS = 15L;
|
||||||
|
private static final Set<PosixFilePermission> DIRECTORY_PERMISSIONS = Set.of(
|
||||||
|
PosixFilePermission.OWNER_READ,
|
||||||
|
PosixFilePermission.OWNER_WRITE,
|
||||||
|
PosixFilePermission.OWNER_EXECUTE);
|
||||||
|
private static final Set<PosixFilePermission> FILE_PERMISSIONS = Set.of(
|
||||||
|
PosixFilePermission.OWNER_READ,
|
||||||
|
PosixFilePermission.OWNER_WRITE);
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path temporaryDirectory;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void secureCreationAndUnsafePermissionsMatrix() throws Exception {
|
||||||
|
start("secureCreationAndUnsafePermissionsMatrix");
|
||||||
|
Path parent = temporaryDirectory.resolve("secure");
|
||||||
|
Path path = parent.resolve("keys.zek");
|
||||||
|
createPopulated(path, 1);
|
||||||
|
assertEquals(DIRECTORY_PERMISSIONS, Files.getPosixFilePermissions(parent));
|
||||||
|
assertEquals(FILE_PERMISSIONS, Files.getPosixFilePermissions(path));
|
||||||
|
assertEquals(FILE_PERMISSIONS, Files.getPosixFilePermissions(lock(path)));
|
||||||
|
assertEquals(FILE_PERMISSIONS, Files.getPosixFilePermissions(sidecar(path)));
|
||||||
|
try (java.util.stream.Stream<Path> files = Files.list(parent)) {
|
||||||
|
assertTrue(files.noneMatch(value -> value.getFileName().toString().endsWith(".tmp")));
|
||||||
|
}
|
||||||
|
assertOpenSucceeds(path);
|
||||||
|
|
||||||
|
for (PosixFilePermission unsafe : List.of(
|
||||||
|
PosixFilePermission.GROUP_READ,
|
||||||
|
PosixFilePermission.GROUP_WRITE,
|
||||||
|
PosixFilePermission.OTHERS_READ,
|
||||||
|
PosixFilePermission.OTHERS_WRITE)) {
|
||||||
|
Path unsafeParent = temporaryDirectory.resolve("parent-" + unsafe.name());
|
||||||
|
Path unsafeStore = unsafeParent.resolve("keys.zek");
|
||||||
|
createPopulated(unsafeStore, unsafe.ordinal() + 10);
|
||||||
|
Set<PosixFilePermission> permissions = new java.util.HashSet<>(
|
||||||
|
DIRECTORY_PERMISSIONS);
|
||||||
|
permissions.add(unsafe);
|
||||||
|
Files.setPosixFilePermissions(unsafeParent, permissions);
|
||||||
|
assertRedactedFailure(unsafeStore,
|
||||||
|
KeyringException.Code.KEYRING_FILESYSTEM_UNSUPPORTED);
|
||||||
|
Files.setPosixFilePermissions(unsafeParent, DIRECTORY_PERMISSIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertUnsafeFilePermissions("main", Artifact.MAIN);
|
||||||
|
assertUnsafeFilePermissions("lock", Artifact.LOCK);
|
||||||
|
assertUnsafeFilePermissions("sidecar", Artifact.SIDECAR);
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nonPosixAndOwnershipMismatchFailClosed() throws Exception {
|
||||||
|
start("nonPosixAndOwnershipMismatchFailClosed");
|
||||||
|
Path archive = temporaryDirectory.resolve("non-posix.zip");
|
||||||
|
URI uri = URI.create("jar:" + archive.toUri());
|
||||||
|
try (FileSystem fileSystem = FileSystems.newFileSystem(uri, Map.of("create", "true"));
|
||||||
|
KeyringPassword password = password()) {
|
||||||
|
KeyringException failure = assertThrows(KeyringException.class,
|
||||||
|
() -> KeyringStore.create(fileSystem.getPath("/keys.zek"), password));
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_FILESYSTEM_UNSUPPORTED, failure.code());
|
||||||
|
assertEquals(failure.code().name(), failure.getMessage());
|
||||||
|
assertNull(failure.getCause());
|
||||||
|
}
|
||||||
|
|
||||||
|
Path path = temporaryDirectory.resolve("owner-mismatch.zek");
|
||||||
|
createPopulated(path, 31);
|
||||||
|
Method validator = KeyringStore.class.getDeclaredMethod("validateExistingFile",
|
||||||
|
Path.class, UserPrincipal.class);
|
||||||
|
validator.setAccessible(true);
|
||||||
|
UserPrincipal other = () -> "controlled-other-owner";
|
||||||
|
InvocationTargetException failure = assertThrows(InvocationTargetException.class,
|
||||||
|
() -> validator.invoke(null, path, other));
|
||||||
|
assertTrue(failure.getCause() instanceof KeyringException);
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_FILESYSTEM_UNSUPPORTED,
|
||||||
|
((KeyringException) failure.getCause()).code());
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void symlinkAndHardLinkMatrixFailsClosed() throws Exception {
|
||||||
|
start("symlinkAndHardLinkMatrixFailsClosed");
|
||||||
|
assertKeyringSymlinkRejected();
|
||||||
|
assertArtifactSymlinkRejected(Artifact.LOCK);
|
||||||
|
assertArtifactSymlinkRejected(Artifact.SIDECAR);
|
||||||
|
assertParentSymlinkRejected();
|
||||||
|
assertArtifactHardLinkRejected(Artifact.MAIN);
|
||||||
|
assertArtifactHardLinkRejected(Artifact.LOCK);
|
||||||
|
assertArtifactHardLinkRejected(Artifact.SIDECAR);
|
||||||
|
assertPrecreatedTemporarySymlinkIgnored();
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sameJvmOwnershipAndIndependentStores() throws Exception {
|
||||||
|
start("sameJvmOwnershipAndIndependentStores");
|
||||||
|
Path path = temporaryDirectory.resolve("shared.zek");
|
||||||
|
byte[] material = material((byte) 0x41);
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore first = KeyringStore.create(path, password,
|
||||||
|
KeyringProtection.standard(), deterministicRandom(41));
|
||||||
|
KeyringPassword secondPassword = password()) {
|
||||||
|
first.putSecret("shared", "AES", new SecretKeySpec(material, "AES"));
|
||||||
|
KeyringException contention = assertThrows(KeyringException.class,
|
||||||
|
() -> KeyringStore.open(path, secondPassword));
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_ALREADY_OPEN, contention.code());
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_ALREADY_OPEN.name(),
|
||||||
|
contention.getMessage());
|
||||||
|
assertNull(contention.getCause());
|
||||||
|
assertArrayEquals(material, first.getSecret("shared").getEncoded());
|
||||||
|
} finally {
|
||||||
|
wipe(material);
|
||||||
|
}
|
||||||
|
assertOpenSucceeds(path);
|
||||||
|
|
||||||
|
Path firstPath = temporaryDirectory.resolve("independent-one.zek");
|
||||||
|
Path secondPath = temporaryDirectory.resolve("independent-two.zek");
|
||||||
|
try (KeyringPassword firstPassword = password();
|
||||||
|
KeyringPassword secondPassword = password();
|
||||||
|
KeyringStore first = KeyringStore.create(firstPath, firstPassword);
|
||||||
|
KeyringStore second = KeyringStore.create(secondPath, secondPassword)) {
|
||||||
|
assertTrue(first.aliases().isEmpty());
|
||||||
|
assertTrue(second.aliases().isEmpty());
|
||||||
|
}
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forkedJvmOwnershipReleasesGracefullyAndAfterTermination() throws Exception {
|
||||||
|
start("forkedJvmOwnershipReleasesGracefullyAndAfterTermination");
|
||||||
|
Path graceful = temporaryDirectory.resolve("fork-graceful.zek");
|
||||||
|
createPopulated(graceful, 51);
|
||||||
|
try (ChildOwner child = ChildOwner.start(graceful)) {
|
||||||
|
child.sendPassword(CHILD_PASSWORD);
|
||||||
|
child.expect("KEYRING_OPEN");
|
||||||
|
assertAlreadyOpen(graceful);
|
||||||
|
child.send("PING");
|
||||||
|
child.expect("KEYRING_USABLE");
|
||||||
|
child.send("CLOSE");
|
||||||
|
child.expect("KEYRING_CLOSED");
|
||||||
|
child.awaitExit(0);
|
||||||
|
assertFalse(child.outputContains("filesys"));
|
||||||
|
}
|
||||||
|
assertOpenSucceeds(graceful);
|
||||||
|
|
||||||
|
Path abrupt = temporaryDirectory.resolve("fork-abrupt.zek");
|
||||||
|
createPopulated(abrupt, 61);
|
||||||
|
try (ChildOwner child = ChildOwner.start(abrupt)) {
|
||||||
|
child.sendPassword(CHILD_PASSWORD);
|
||||||
|
child.expect("KEYRING_OPEN");
|
||||||
|
assertAlreadyOpen(abrupt);
|
||||||
|
child.destroyForcibly();
|
||||||
|
}
|
||||||
|
assertOpenSucceeds(abrupt);
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void concurrentReadsAndSerializedMutationsRemainConsistent() throws Exception {
|
||||||
|
start("concurrentReadsAndSerializedMutationsRemainConsistent");
|
||||||
|
Path path = temporaryDirectory.resolve("concurrent.zek");
|
||||||
|
byte[] first = material((byte) 0x12);
|
||||||
|
byte[] second = material((byte) 0x34);
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.create(path, password,
|
||||||
|
KeyringProtection.standard(), deterministicRandom(71))) {
|
||||||
|
store.putSecret("one", "AES", new SecretKeySpec(first, "AES"));
|
||||||
|
store.putSecret("two", "AES", new SecretKeySpec(second, "AES"));
|
||||||
|
runConcurrentReaders(store, first, second);
|
||||||
|
runConcurrentPuts(store);
|
||||||
|
} finally {
|
||||||
|
wipe(first);
|
||||||
|
wipe(second);
|
||||||
|
}
|
||||||
|
assertOpenSucceeds(path);
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void closeWaitsForAdmittedOperationsAndClearsKeysOnce() throws Exception {
|
||||||
|
start("closeWaitsForAdmittedOperationsAndClearsKeysOnce");
|
||||||
|
Path path = temporaryDirectory.resolve("close-coordination.zek");
|
||||||
|
KeyringStore store;
|
||||||
|
try (KeyringPassword password = password()) {
|
||||||
|
store = KeyringStore.create(path, password);
|
||||||
|
}
|
||||||
|
byte[] master = bytesField(store, "masterKey");
|
||||||
|
byte[] macKey = bytesField(store, "nonceReservationMacKey");
|
||||||
|
ReentrantReadWriteLock lock = lockField(store);
|
||||||
|
lock.readLock().lock();
|
||||||
|
CountDownLatch started = new CountDownLatch(2);
|
||||||
|
AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||||
|
Thread first = closeThread(store, started, failure, "keyring-close-one");
|
||||||
|
Thread second = closeThread(store, started, failure, "keyring-close-two");
|
||||||
|
first.start();
|
||||||
|
second.start();
|
||||||
|
assertTrue(started.await(TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
awaitQueued(lock, 2);
|
||||||
|
assertFalse(store.isDestroyed());
|
||||||
|
lock.readLock().unlock();
|
||||||
|
first.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
|
||||||
|
second.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
|
||||||
|
assertFalse(first.isAlive());
|
||||||
|
assertFalse(second.isAlive());
|
||||||
|
assertNull(failure.get());
|
||||||
|
assertTrue(store.isDestroyed());
|
||||||
|
assertTrue(allZero(master));
|
||||||
|
assertTrue(allZero(macKey));
|
||||||
|
assertThrows(IllegalStateException.class, store::aliases);
|
||||||
|
store.close();
|
||||||
|
assertOpenSucceeds(path);
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void admittedMutationCompletesBeforeCloseAndLaterMutationFails() throws Exception {
|
||||||
|
start("admittedMutationCompletesBeforeCloseAndLaterMutationFails");
|
||||||
|
Path path = temporaryDirectory.resolve("mutation-close.zek");
|
||||||
|
BlockingRandom random = new BlockingRandom(81);
|
||||||
|
KeyringStore store;
|
||||||
|
try (KeyringPassword password = password()) {
|
||||||
|
store = KeyringStore.create(path, password,
|
||||||
|
KeyringProtection.standard(), random);
|
||||||
|
}
|
||||||
|
byte[] material = material((byte) 0x5c);
|
||||||
|
AtomicReference<Throwable> putFailure = new AtomicReference<>();
|
||||||
|
AtomicReference<Throwable> closeFailure = new AtomicReference<>();
|
||||||
|
random.arm();
|
||||||
|
Thread mutation = new Thread(() -> {
|
||||||
|
try {
|
||||||
|
store.putSecret("admitted", "AES",
|
||||||
|
new SecretKeySpec(material, "AES"));
|
||||||
|
} catch (Throwable throwable) {
|
||||||
|
putFailure.set(throwable);
|
||||||
|
}
|
||||||
|
}, "keyring-admitted-mutation");
|
||||||
|
mutation.start();
|
||||||
|
assertTrue(random.awaitEntered());
|
||||||
|
Thread closer = new Thread(() -> {
|
||||||
|
try {
|
||||||
|
store.close();
|
||||||
|
} catch (Throwable throwable) {
|
||||||
|
closeFailure.set(throwable);
|
||||||
|
}
|
||||||
|
}, "keyring-close-after-mutation");
|
||||||
|
closer.start();
|
||||||
|
awaitQueued(lockField(store), 1);
|
||||||
|
random.release();
|
||||||
|
mutation.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
|
||||||
|
closer.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
|
||||||
|
assertNull(putFailure.get());
|
||||||
|
assertNull(closeFailure.get());
|
||||||
|
assertTrue(store.isDestroyed());
|
||||||
|
assertThrows(KeyringException.class,
|
||||||
|
() -> store.putSecret("late", "AES",
|
||||||
|
new SecretKeySpec(material, "AES")));
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore reopened = KeyringStore.open(path, password)) {
|
||||||
|
assertArrayEquals(material, reopened.getSecret("admitted").getEncoded());
|
||||||
|
} finally {
|
||||||
|
wipe(material);
|
||||||
|
}
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertUnsafeFilePermissions(String name, Artifact artifact) throws Exception {
|
||||||
|
Path path = temporaryDirectory.resolve("unsafe-" + name + ".zek");
|
||||||
|
createPopulated(path, artifact.ordinal() + 20);
|
||||||
|
Path target = artifact.path(path);
|
||||||
|
Set<PosixFilePermission> permissions = new java.util.HashSet<>(FILE_PERMISSIONS);
|
||||||
|
permissions.add(PosixFilePermission.GROUP_READ);
|
||||||
|
Files.setPosixFilePermissions(target, permissions);
|
||||||
|
assertRedactedFailure(path, KeyringException.Code.KEYRING_FILESYSTEM_UNSUPPORTED);
|
||||||
|
Files.setPosixFilePermissions(target, FILE_PERMISSIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertKeyringSymlinkRejected() throws Exception {
|
||||||
|
Path target = temporaryDirectory.resolve("symlink-main-target.zek");
|
||||||
|
createPopulated(target, 91);
|
||||||
|
byte[] before = Files.readAllBytes(target);
|
||||||
|
Path link = temporaryDirectory.resolve("symlink-main.zek");
|
||||||
|
Files.createSymbolicLink(link, target.getFileName());
|
||||||
|
try {
|
||||||
|
assertRedactedFailure(link, KeyringException.Code.KEYRING_FILESYSTEM_UNSUPPORTED);
|
||||||
|
assertArrayEquals(before, Files.readAllBytes(target));
|
||||||
|
} finally {
|
||||||
|
wipe(before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertArtifactSymlinkRejected(Artifact artifact) throws Exception {
|
||||||
|
Path path = temporaryDirectory.resolve("symlink-" + artifact.name() + ".zek");
|
||||||
|
createPopulated(path, 101 + artifact.ordinal());
|
||||||
|
Path original = artifact.path(path);
|
||||||
|
Path saved = original.resolveSibling(original.getFileName() + ".saved");
|
||||||
|
Files.move(original, saved);
|
||||||
|
Path target = temporaryDirectory.resolve("symlink-target-" + artifact.name());
|
||||||
|
Files.write(target, new byte[] { 7 });
|
||||||
|
ownerOnly(target);
|
||||||
|
byte[] before = Files.readAllBytes(target);
|
||||||
|
Files.createSymbolicLink(original, target.getFileName());
|
||||||
|
try {
|
||||||
|
assertRedactedFailure(path, KeyringException.Code.KEYRING_FILESYSTEM_UNSUPPORTED);
|
||||||
|
assertArrayEquals(before, Files.readAllBytes(target));
|
||||||
|
} finally {
|
||||||
|
wipe(before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertParentSymlinkRejected() throws Exception {
|
||||||
|
Path real = temporaryDirectory.resolve("real-parent");
|
||||||
|
Path path = real.resolve("keys.zek");
|
||||||
|
createPopulated(path, 111);
|
||||||
|
Path link = temporaryDirectory.resolve("linked-parent");
|
||||||
|
Files.createSymbolicLink(link, real.getFileName());
|
||||||
|
assertRedactedFailure(link.resolve("keys.zek"),
|
||||||
|
KeyringException.Code.KEYRING_FILESYSTEM_UNSUPPORTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertArtifactHardLinkRejected(Artifact artifact) throws Exception {
|
||||||
|
Path path = temporaryDirectory.resolve("hard-" + artifact.name() + ".zek");
|
||||||
|
createPopulated(path, 121 + artifact.ordinal());
|
||||||
|
Path target = artifact.path(path);
|
||||||
|
Path extra = target.resolveSibling(target.getFileName() + ".hard");
|
||||||
|
Files.createLink(extra, target);
|
||||||
|
try {
|
||||||
|
assertRedactedFailure(path, KeyringException.Code.KEYRING_FILESYSTEM_UNSUPPORTED);
|
||||||
|
} finally {
|
||||||
|
Files.delete(extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertPrecreatedTemporarySymlinkIgnored() throws Exception {
|
||||||
|
Path path = temporaryDirectory.resolve("temp-race.zek");
|
||||||
|
createPopulated(path, 131);
|
||||||
|
Path target = temporaryDirectory.resolve("temp-target");
|
||||||
|
Files.write(target, new byte[] { 9, 8, 7 });
|
||||||
|
ownerOnly(target);
|
||||||
|
byte[] before = Files.readAllBytes(target);
|
||||||
|
Path malicious = temporaryDirectory.resolve(".temp-race.zek.precreated.tmp");
|
||||||
|
Files.createSymbolicLink(malicious, target.getFileName());
|
||||||
|
byte[] material = material((byte) 0x63);
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.open(path, password)) {
|
||||||
|
store.putSecret("safe", "AES", new SecretKeySpec(material, "AES"));
|
||||||
|
assertArrayEquals(before, Files.readAllBytes(target));
|
||||||
|
} finally {
|
||||||
|
wipe(before);
|
||||||
|
wipe(material);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runConcurrentReaders(KeyringStore store, byte[] first, byte[] second)
|
||||||
|
throws Exception {
|
||||||
|
ExecutorService executor = Executors.newFixedThreadPool(8);
|
||||||
|
CountDownLatch start = new CountDownLatch(1);
|
||||||
|
List<Future<Boolean>> results = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
for (int index = 0; index < 8; index++) {
|
||||||
|
int selected = index;
|
||||||
|
results.add(executor.submit(() -> {
|
||||||
|
start.await();
|
||||||
|
if (selected == 7) {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> store.getSecret("missing"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String alias = selected % 2 == 0 ? "one" : "two";
|
||||||
|
byte[] expected = selected % 2 == 0 ? first : second;
|
||||||
|
SecretKey key = store.getSecret(alias);
|
||||||
|
byte[] encoded = key.getEncoded();
|
||||||
|
try {
|
||||||
|
return Arrays.equals(expected, encoded);
|
||||||
|
} finally {
|
||||||
|
wipe(encoded);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
start.countDown();
|
||||||
|
for (Future<Boolean> result : results) {
|
||||||
|
assertTrue(result.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
executor.shutdownNow();
|
||||||
|
assertTrue(executor.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runConcurrentPuts(KeyringStore store) throws Exception {
|
||||||
|
byte[] third = material((byte) 0x71);
|
||||||
|
byte[] fourth = material((byte) 0x72);
|
||||||
|
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||||
|
CountDownLatch start = new CountDownLatch(1);
|
||||||
|
try {
|
||||||
|
Future<?> one = executor.submit(() -> {
|
||||||
|
start.await();
|
||||||
|
store.putSecret("three", "AES", new SecretKeySpec(third, "AES"));
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
Future<?> two = executor.submit(() -> {
|
||||||
|
start.await();
|
||||||
|
store.putSecret("four", "AES", new SecretKeySpec(fourth, "AES"));
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
start.countDown();
|
||||||
|
one.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
two.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
assertArrayEquals(third, store.getSecret("three").getEncoded());
|
||||||
|
assertArrayEquals(fourth, store.getSecret("four").getEncoded());
|
||||||
|
} finally {
|
||||||
|
executor.shutdownNow();
|
||||||
|
assertTrue(executor.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
wipe(third);
|
||||||
|
wipe(fourth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createPopulated(Path path, int seed) throws Exception {
|
||||||
|
byte[] material = material((byte) seed);
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.create(path, password,
|
||||||
|
KeyringProtection.standard(), deterministicRandom(seed))) {
|
||||||
|
store.putSecret("child", "AES", new SecretKeySpec(material, "AES"));
|
||||||
|
} finally {
|
||||||
|
wipe(material);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertOpenSucceeds(Path path) throws Exception {
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.open(path, password)) {
|
||||||
|
assertFalse(store.isDestroyed());
|
||||||
|
store.aliases();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertAlreadyOpen(Path path) throws Exception {
|
||||||
|
try (KeyringPassword password = password()) {
|
||||||
|
KeyringException failure = assertThrows(KeyringException.class,
|
||||||
|
() -> KeyringStore.open(path, password));
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_ALREADY_OPEN, failure.code());
|
||||||
|
assertEquals(failure.code().name(), failure.getMessage());
|
||||||
|
assertNull(failure.getCause());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertRedactedFailure(Path path, KeyringException.Code code)
|
||||||
|
throws Exception {
|
||||||
|
try (KeyringPassword password = password()) {
|
||||||
|
KeyringException failure = assertThrows(KeyringException.class,
|
||||||
|
() -> KeyringStore.open(path, password));
|
||||||
|
assertEquals(code, failure.code());
|
||||||
|
assertEquals(code.name(), failure.getMessage());
|
||||||
|
assertNull(failure.getCause());
|
||||||
|
assertEquals(0, failure.getSuppressed().length);
|
||||||
|
assertFalse(failure.getMessage().contains(path.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Thread closeThread(KeyringStore store, CountDownLatch started,
|
||||||
|
AtomicReference<Throwable> failure, String name) {
|
||||||
|
return new Thread(() -> {
|
||||||
|
started.countDown();
|
||||||
|
try {
|
||||||
|
store.close();
|
||||||
|
} catch (Throwable throwable) {
|
||||||
|
failure.compareAndSet(null, throwable);
|
||||||
|
}
|
||||||
|
}, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void awaitQueued(ReentrantReadWriteLock lock, int minimum)
|
||||||
|
throws Exception {
|
||||||
|
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(TIMEOUT_SECONDS);
|
||||||
|
while (lock.getQueueLength() < minimum && System.nanoTime() < deadline) {
|
||||||
|
Thread.onSpinWait();
|
||||||
|
}
|
||||||
|
assertTrue(lock.getQueueLength() >= minimum);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReentrantReadWriteLock lockField(KeyringStore store) throws Exception {
|
||||||
|
Field field = KeyringStore.class.getDeclaredField("lifecycleLock");
|
||||||
|
field.setAccessible(true);
|
||||||
|
return (ReentrantReadWriteLock) field.get(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] bytesField(KeyringStore store, String name) throws Exception {
|
||||||
|
Field field = KeyringStore.class.getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
return (byte[]) field.get(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean allZero(byte[] value) {
|
||||||
|
for (byte current : value) {
|
||||||
|
if (current != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] material(byte value) {
|
||||||
|
byte[] result = new byte[32];
|
||||||
|
Arrays.fill(result, value);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyringPassword password() {
|
||||||
|
return new KeyringPassword(PASSWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyringRandomBytes deterministicRandom(int initial) {
|
||||||
|
AtomicInteger value = new AtomicInteger(initial);
|
||||||
|
return destination -> {
|
||||||
|
int base = value.getAndIncrement();
|
||||||
|
for (int index = 0; index < destination.length; index++) {
|
||||||
|
destination[index] = (byte) (base + index);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ownerOnly(Path path) throws Exception {
|
||||||
|
Files.setPosixFilePermissions(path, FILE_PERMISSIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path lock(Path keyring) {
|
||||||
|
return keyring.resolveSibling(keyring.getFileName() + ".lock");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path sidecar(Path keyring) {
|
||||||
|
return keyring.resolveSibling(keyring.getFileName() + ".nonce");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void wipe(byte[] value) {
|
||||||
|
if (value != null) {
|
||||||
|
Arrays.fill(value, (byte) 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void start(String method) {
|
||||||
|
System.out.println(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ok() {
|
||||||
|
System.out.println("...ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Artifact {
|
||||||
|
MAIN {
|
||||||
|
@Override
|
||||||
|
Path path(Path keyring) {
|
||||||
|
return keyring;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
LOCK {
|
||||||
|
@Override
|
||||||
|
Path path(Path keyring) {
|
||||||
|
return lock(keyring);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
SIDECAR {
|
||||||
|
@Override
|
||||||
|
Path path(Path keyring) {
|
||||||
|
return sidecar(keyring);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
abstract Path path(Path keyring);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class BlockingRandom implements KeyringRandomBytes {
|
||||||
|
private final AtomicInteger value;
|
||||||
|
private final AtomicBoolean armed = new AtomicBoolean();
|
||||||
|
private final AtomicBoolean blocked = new AtomicBoolean();
|
||||||
|
private final CountDownLatch entered = new CountDownLatch(1);
|
||||||
|
private final CountDownLatch release = new CountDownLatch(1);
|
||||||
|
|
||||||
|
private BlockingRandom(int initial) {
|
||||||
|
value = new AtomicInteger(initial);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void arm() {
|
||||||
|
armed.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean awaitEntered() throws InterruptedException {
|
||||||
|
return entered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void release() {
|
||||||
|
release.countDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void nextBytes(byte[] destination) {
|
||||||
|
if (armed.get() && blocked.compareAndSet(false, true)) {
|
||||||
|
entered.countDown();
|
||||||
|
try {
|
||||||
|
if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||||
|
throw new IllegalStateException("controlled random release timed out");
|
||||||
|
}
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IllegalStateException("controlled random interrupted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int base = value.getAndIncrement();
|
||||||
|
for (int index = 0; index < destination.length; index++) {
|
||||||
|
destination[index] = (byte) (base + index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ChildOwner implements AutoCloseable {
|
||||||
|
private final Process process;
|
||||||
|
private final BufferedReader output;
|
||||||
|
private final OutputStream input;
|
||||||
|
private final ExecutorService reader = Executors.newSingleThreadExecutor();
|
||||||
|
private final StringBuilder transcript = new StringBuilder();
|
||||||
|
|
||||||
|
private ChildOwner(Process process) {
|
||||||
|
this.process = process;
|
||||||
|
output = new BufferedReader(new InputStreamReader(process.getInputStream(),
|
||||||
|
StandardCharsets.UTF_8));
|
||||||
|
input = process.getOutputStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChildOwner start(Path path) throws Exception {
|
||||||
|
String executable = System.getProperty("os.name", "")
|
||||||
|
.toLowerCase(java.util.Locale.ROOT).contains("win")
|
||||||
|
? "java.exe" : "java";
|
||||||
|
Path java = Path.of(System.getProperty("java.home"), "bin", executable);
|
||||||
|
Process process = new ProcessBuilder(java.toString(), "-cp", childClasspath(),
|
||||||
|
KeyringStoreLockProcess.class.getName(), path.toString())
|
||||||
|
.redirectErrorStream(true).start();
|
||||||
|
return new ChildOwner(process);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendPassword(byte[] password) throws IOException {
|
||||||
|
byte[] copy = password.clone();
|
||||||
|
try {
|
||||||
|
input.write(copy);
|
||||||
|
input.write('\n');
|
||||||
|
input.flush();
|
||||||
|
} finally {
|
||||||
|
wipe(copy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void send(String command) throws IOException {
|
||||||
|
byte[] encoded = command.getBytes(StandardCharsets.US_ASCII);
|
||||||
|
try {
|
||||||
|
input.write(encoded);
|
||||||
|
input.write('\n');
|
||||||
|
input.flush();
|
||||||
|
} finally {
|
||||||
|
wipe(encoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void expect(String expected) throws Exception {
|
||||||
|
Future<String> future = reader.submit(output::readLine);
|
||||||
|
String line;
|
||||||
|
try {
|
||||||
|
line = future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
} catch (ExecutionException exception) {
|
||||||
|
throw new IOException("child protocol failed");
|
||||||
|
} catch (TimeoutException exception) {
|
||||||
|
future.cancel(true);
|
||||||
|
throw new IOException("child protocol timed out");
|
||||||
|
}
|
||||||
|
transcript.append(line);
|
||||||
|
assertEquals(expected, line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean outputContains(String value) {
|
||||||
|
return transcript.toString().contains(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void awaitExit(int expected) throws Exception {
|
||||||
|
assertTrue(process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
assertEquals(expected, process.exitValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void destroyForcibly() throws Exception {
|
||||||
|
process.destroyForcibly();
|
||||||
|
assertTrue(process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws Exception {
|
||||||
|
if (process.isAlive()) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
input.close();
|
||||||
|
output.close();
|
||||||
|
reader.shutdownNow();
|
||||||
|
assertTrue(reader.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String childClasspath() throws Exception {
|
||||||
|
Set<String> entries = new LinkedHashSet<>();
|
||||||
|
String configured = System.getProperty("java.class.path", "");
|
||||||
|
if (!configured.isBlank()) {
|
||||||
|
entries.addAll(Arrays.asList(configured.split(java.io.File.pathSeparator)));
|
||||||
|
}
|
||||||
|
ClassLoader loader = KeyringFilesystemSecurityTest.class.getClassLoader();
|
||||||
|
while (loader != null) {
|
||||||
|
if (loader instanceof URLClassLoader urls) {
|
||||||
|
for (URL url : urls.getURLs()) {
|
||||||
|
if ("file".equals(url.getProtocol())) {
|
||||||
|
entries.add(Path.of(url.toURI()).toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loader = loader.getParent();
|
||||||
|
}
|
||||||
|
entries.add(codeSource(KeyringFilesystemSecurityTest.class));
|
||||||
|
entries.add(codeSource(KeyringStore.class));
|
||||||
|
return String.join(java.io.File.pathSeparator, entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String codeSource(Class<?> type) throws Exception {
|
||||||
|
return Path.of(type.getProtectionDomain().getCodeSource().getLocation().toURI())
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class KeyringStoreLockProcess {
|
||||||
|
private KeyringStoreLockProcess() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
if (args.length != 1) {
|
||||||
|
System.out.println("SETUP_FAILED");
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
LogManager.getLogManager().reset();
|
||||||
|
byte[] passwordBytes = null;
|
||||||
|
char[] passwordChars = null;
|
||||||
|
try {
|
||||||
|
passwordBytes = readSecret(System.in, 64);
|
||||||
|
passwordChars = new char[passwordBytes.length];
|
||||||
|
for (int index = 0; index < passwordBytes.length; index++) {
|
||||||
|
passwordChars[index] = (char) Byte.toUnsignedInt(passwordBytes[index]);
|
||||||
|
}
|
||||||
|
try (KeyringPassword password = new KeyringPassword(passwordChars);
|
||||||
|
KeyringStore store = KeyringStore.open(Path.of(args[0]), password);
|
||||||
|
BufferedReader control = new BufferedReader(new InputStreamReader(
|
||||||
|
System.in, StandardCharsets.US_ASCII))) {
|
||||||
|
Arrays.fill(passwordChars, '\0');
|
||||||
|
Arrays.fill(passwordBytes, (byte) 0);
|
||||||
|
System.out.println("KEYRING_OPEN");
|
||||||
|
System.out.flush();
|
||||||
|
String command;
|
||||||
|
while ((command = control.readLine()) != null) {
|
||||||
|
if ("PING".equals(command)) {
|
||||||
|
store.aliases();
|
||||||
|
System.out.println("KEYRING_USABLE");
|
||||||
|
System.out.flush();
|
||||||
|
} else if ("CLOSE".equals(command)) {
|
||||||
|
store.close();
|
||||||
|
System.out.println("KEYRING_CLOSED");
|
||||||
|
System.out.flush();
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
System.out.println("PROTOCOL_FAILED");
|
||||||
|
System.out.flush();
|
||||||
|
System.exit(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception exception) {
|
||||||
|
System.out.println("SETUP_FAILED");
|
||||||
|
System.exit(4);
|
||||||
|
} finally {
|
||||||
|
if (passwordChars != null) {
|
||||||
|
Arrays.fill(passwordChars, '\0');
|
||||||
|
}
|
||||||
|
wipe(passwordBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] readSecret(InputStream input, int maximum) throws IOException {
|
||||||
|
byte[] buffer = new byte[maximum];
|
||||||
|
int count = 0;
|
||||||
|
try {
|
||||||
|
int current;
|
||||||
|
while ((current = input.read()) >= 0 && current != '\n') {
|
||||||
|
if (count >= maximum) {
|
||||||
|
throw new IOException("secret input exceeded test protocol limit");
|
||||||
|
}
|
||||||
|
buffer[count++] = (byte) current;
|
||||||
|
}
|
||||||
|
if (current < 0) {
|
||||||
|
throw new IOException("secret input ended early");
|
||||||
|
}
|
||||||
|
return Arrays.copyOf(buffer, count);
|
||||||
|
} finally {
|
||||||
|
wipe(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void wipe(byte[] value) {
|
||||||
|
if (value != null) {
|
||||||
|
Arrays.fill(value, (byte) 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package zeroecho.core.storage;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
import java.security.Key;
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.PublicKey;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import javax.security.auth.Destroyable;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import zeroecho.core.CryptoAlgorithm;
|
||||||
|
import zeroecho.core.CryptoAlgorithms;
|
||||||
|
import zeroecho.core.KeyOperation;
|
||||||
|
import zeroecho.core.KeyOperationInfo;
|
||||||
|
import zeroecho.core.spec.AlgorithmKeySpec;
|
||||||
|
import zeroecho.core.spi.AsymmetricKeyPairGenerator;
|
||||||
|
import zeroecho.sdk.util.BouncyCastleActivator;
|
||||||
|
|
||||||
|
class KeyringImportRegistryTest {
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeProviders() {
|
||||||
|
BouncyCastleActivator.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void persistentImporterMatrixIsClosedUniqueAndExecutable() throws Exception {
|
||||||
|
start("persistentImporterMatrixIsClosedUniqueAndExecutable");
|
||||||
|
List<KeyringImportRegistry.PersistentMapping> mappings =
|
||||||
|
KeyringImportRegistry.mappings();
|
||||||
|
assertEquals(19, count(mappings, KeyringStore.Kind.PUBLIC_KEY));
|
||||||
|
assertEquals(19, count(mappings, KeyringStore.Kind.PRIVATE_KEY));
|
||||||
|
assertEquals(6, count(mappings, KeyringStore.Kind.SECRET_KEY));
|
||||||
|
assertEquals(mappings.size(), mappings.stream().distinct().count());
|
||||||
|
|
||||||
|
for (KeyringImportRegistry.PersistentMapping mapping : mappings) {
|
||||||
|
KeyringImportRegistry.validateMapping(mapping.algorithmId(), mapping.kind(),
|
||||||
|
mapping.encoding(), mapping.hmacVariant());
|
||||||
|
}
|
||||||
|
roundTripAsymmetricMappings(mappings);
|
||||||
|
roundTripSecretMappings(mappings);
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_IMPORT_MAPPING_INVALID,
|
||||||
|
assertThrows(KeyringException.class,
|
||||||
|
() -> KeyringImportRegistry.validateMapping("RSA",
|
||||||
|
KeyringStore.Kind.SECRET_KEY, KeyringStore.Encoding.RAW,
|
||||||
|
KeyringImportRegistry.HmacVariant.NONE)).code());
|
||||||
|
System.out.println("...mapping-count=" + mappings.size());
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void alternateProviderStandardEncodingUsesCanonicalImporter(
|
||||||
|
@TempDir Path temporaryDirectory) throws Exception {
|
||||||
|
start("alternateProviderStandardEncodingUsesCanonicalImporter");
|
||||||
|
java.security.KeyPairGenerator generator =
|
||||||
|
java.security.KeyPairGenerator.getInstance("RSA", "BC");
|
||||||
|
generator.initialize(2048);
|
||||||
|
KeyPair pair = generator.generateKeyPair();
|
||||||
|
byte[] encoded = pair.getPublic().getEncoded();
|
||||||
|
Key imported = null;
|
||||||
|
PublicKey reopened = null;
|
||||||
|
byte[] importedEncoding = null;
|
||||||
|
byte[] reopenedEncoding = null;
|
||||||
|
try {
|
||||||
|
imported = KeyringImportRegistry.importKey("RSA",
|
||||||
|
KeyringStore.Kind.PUBLIC_KEY, KeyringStore.Encoding.X509,
|
||||||
|
KeyringImportRegistry.HmacVariant.NONE, encoded);
|
||||||
|
importedEncoding = imported.getEncoded();
|
||||||
|
assertArrayEquals(encoded, importedEncoding);
|
||||||
|
assertEquals("RSA", imported.getAlgorithm());
|
||||||
|
assertNotEquals(pair.getPublic().getClass(), imported.getClass());
|
||||||
|
Path path = temporaryDirectory.resolve("alternate-provider.zek");
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.create(path, password)) {
|
||||||
|
store.putPublic("alternate", "RSA", pair.getPublic());
|
||||||
|
}
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.open(path, password)) {
|
||||||
|
reopened = store.getPublic("alternate");
|
||||||
|
reopenedEncoding = reopened.getEncoded();
|
||||||
|
assertArrayEquals(encoded, reopenedEncoding);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
wipe(encoded);
|
||||||
|
wipe(importedEncoding);
|
||||||
|
wipe(reopenedEncoding);
|
||||||
|
destroy(imported);
|
||||||
|
destroy(reopened);
|
||||||
|
}
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long count(List<KeyringImportRegistry.PersistentMapping> mappings,
|
||||||
|
KeyringStore.Kind kind) {
|
||||||
|
return mappings.stream().filter(mapping -> mapping.kind() == kind).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void roundTripAsymmetricMappings(
|
||||||
|
List<KeyringImportRegistry.PersistentMapping> mappings) throws Exception {
|
||||||
|
List<String> algorithms = mappings.stream()
|
||||||
|
.filter(mapping -> mapping.kind() == KeyringStore.Kind.PUBLIC_KEY)
|
||||||
|
.map(KeyringImportRegistry.PersistentMapping::algorithmId)
|
||||||
|
.toList();
|
||||||
|
for (String algorithmId : algorithms) {
|
||||||
|
KeyPair pair = generatePair(algorithmId);
|
||||||
|
roundTrip(mapping(mappings, algorithmId, KeyringStore.Kind.PUBLIC_KEY),
|
||||||
|
pair.getPublic());
|
||||||
|
roundTrip(mapping(mappings, algorithmId, KeyringStore.Kind.PRIVATE_KEY),
|
||||||
|
pair.getPrivate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void roundTripSecretMappings(
|
||||||
|
List<KeyringImportRegistry.PersistentMapping> mappings) throws Exception {
|
||||||
|
for (KeyringImportRegistry.PersistentMapping mapping : mappings) {
|
||||||
|
if (mapping.kind() != KeyringStore.Kind.SECRET_KEY) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String jcaName = switch (mapping.algorithmId()) {
|
||||||
|
case "AES" -> "AES";
|
||||||
|
case "CHACHA20", "CHACHA20-POLY1305" -> "ChaCha20";
|
||||||
|
case "HMAC" -> mapping.hmacVariant().jcaName();
|
||||||
|
default -> throw new AssertionError("Unexpected secret mapping");
|
||||||
|
};
|
||||||
|
byte[] material = new byte[32];
|
||||||
|
Arrays.fill(material, (byte) (mapping.hmacVariant().code() + 1));
|
||||||
|
try {
|
||||||
|
roundTrip(mapping, new SecretKeySpec(material, jcaName));
|
||||||
|
} finally {
|
||||||
|
wipe(material);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings({ "rawtypes", "unchecked" })
|
||||||
|
private static KeyPair generatePair(String algorithmId) throws Exception {
|
||||||
|
CryptoAlgorithm algorithm = CryptoAlgorithms.require(algorithmId);
|
||||||
|
KeyOperationInfo generation = algorithm.keyOperations().stream()
|
||||||
|
.filter(info -> info.operation() == KeyOperation.ASYMMETRIC_KEY_PAIR_GENERATE)
|
||||||
|
.filter(info -> info.defaultSpec() != null)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new AssertionError(
|
||||||
|
"No default key-pair generation mapping for " + algorithmId));
|
||||||
|
AsymmetricKeyPairGenerator generator =
|
||||||
|
algorithm.asymmetricKeyPairGenerator(generation.specType());
|
||||||
|
return generator.generateKeyPair((AlgorithmKeySpec) generation.defaultSpec());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyringImportRegistry.PersistentMapping mapping(
|
||||||
|
List<KeyringImportRegistry.PersistentMapping> mappings, String algorithmId,
|
||||||
|
KeyringStore.Kind kind) {
|
||||||
|
return mappings.stream()
|
||||||
|
.filter(candidate -> candidate.algorithmId().equals(algorithmId)
|
||||||
|
&& candidate.kind() == kind)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void roundTrip(KeyringImportRegistry.PersistentMapping mapping,
|
||||||
|
Key source) throws Exception {
|
||||||
|
byte[] encoded = source.getEncoded();
|
||||||
|
Key imported = null;
|
||||||
|
byte[] reconstructed = null;
|
||||||
|
try {
|
||||||
|
imported = KeyringImportRegistry.importKey(mapping.algorithmId(), mapping.kind(),
|
||||||
|
mapping.encoding(), mapping.hmacVariant(), encoded);
|
||||||
|
reconstructed = imported.getEncoded();
|
||||||
|
assertArrayEquals(encoded, reconstructed);
|
||||||
|
} finally {
|
||||||
|
wipe(encoded);
|
||||||
|
wipe(reconstructed);
|
||||||
|
destroy(imported);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void destroy(Key key) {
|
||||||
|
if (key instanceof Destroyable destroyable) {
|
||||||
|
try {
|
||||||
|
destroyable.destroy();
|
||||||
|
} catch (Exception exception) {
|
||||||
|
// Provider keys may advertise but not implement destruction.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void wipe(byte[] value) {
|
||||||
|
if (value != null) {
|
||||||
|
Arrays.fill(value, (byte) 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyringPassword password() {
|
||||||
|
char[] value = "alternate-provider-test".toCharArray();
|
||||||
|
try {
|
||||||
|
return new KeyringPassword(value);
|
||||||
|
} finally {
|
||||||
|
Arrays.fill(value, '\0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void start(String method) {
|
||||||
|
System.out.println(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ok() {
|
||||||
|
System.out.println("...ok");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
package zeroecho.core.storage;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
class KeyringNonceReservationTest {
|
||||||
|
private static final int MAGIC_BYTES = 8;
|
||||||
|
private static final int VERSION_OFFSET = MAGIC_BYTES;
|
||||||
|
private static final int HIGH_WATER_OFFSET =
|
||||||
|
MAGIC_BYTES + Integer.BYTES + KeyringStore.UUID_BYTES + Integer.BYTES;
|
||||||
|
private static final int TAG_BYTES = 32;
|
||||||
|
private static final int CURRENT_SIDECAR_VERSION = 2;
|
||||||
|
private static final char[] PASSWORD = { 'n', 'o', 'n', 'c', 'e' };
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path temporaryDirectory;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hkdfUsesFixedRfc5869Contract() throws Exception {
|
||||||
|
start("hkdfUsesFixedRfc5869Contract");
|
||||||
|
byte[] masterKey = new byte[32];
|
||||||
|
byte[] storeId = new byte[16];
|
||||||
|
for (int index = 0; index < masterKey.length; index++) {
|
||||||
|
masterKey[index] = (byte) index;
|
||||||
|
}
|
||||||
|
for (int index = 0; index < storeId.length; index++) {
|
||||||
|
storeId[index] = (byte) (0xa0 + index);
|
||||||
|
}
|
||||||
|
byte[] expected = HexFormat.of().parseHex(
|
||||||
|
"54e0e054749745a3ef5e2cc5a5c16bafed6f39df9daa4ff412bac74d56bd27b9");
|
||||||
|
byte[] first = null;
|
||||||
|
byte[] second = null;
|
||||||
|
byte[] changedMaster = null;
|
||||||
|
byte[] changedStore = null;
|
||||||
|
try {
|
||||||
|
first = KeyringNonceReservationKdf.derive(masterKey, storeId);
|
||||||
|
second = KeyringNonceReservationKdf.derive(masterKey, storeId);
|
||||||
|
assertArrayEquals(expected, first);
|
||||||
|
assertArrayEquals(first, second);
|
||||||
|
assertEquals(32, first.length);
|
||||||
|
assertFalse(MessageDigest.isEqual(masterKey, first));
|
||||||
|
|
||||||
|
masterKey[0] ^= 1;
|
||||||
|
changedMaster = KeyringNonceReservationKdf.derive(masterKey, storeId);
|
||||||
|
assertFalse(MessageDigest.isEqual(first, changedMaster));
|
||||||
|
masterKey[0] ^= 1;
|
||||||
|
|
||||||
|
storeId[0] ^= 1;
|
||||||
|
changedStore = KeyringNonceReservationKdf.derive(masterKey, storeId);
|
||||||
|
assertFalse(MessageDigest.isEqual(first, changedStore));
|
||||||
|
} finally {
|
||||||
|
wipe(masterKey);
|
||||||
|
wipe(storeId);
|
||||||
|
wipe(expected);
|
||||||
|
wipe(first);
|
||||||
|
wipe(second);
|
||||||
|
wipe(changedMaster);
|
||||||
|
wipe(changedStore);
|
||||||
|
}
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void currentSidecarSurvivesRestartAndDerivedKeyIsCleared() throws Exception {
|
||||||
|
start("currentSidecarSurvivesRestartAndDerivedKeyIsCleared");
|
||||||
|
Path path = temporaryDirectory.resolve("current.zek");
|
||||||
|
byte[] retainedMacKey;
|
||||||
|
byte[] macKeyCopy;
|
||||||
|
try (KeyringPassword password = password()) {
|
||||||
|
KeyringStore store = KeyringStore.create(path, password,
|
||||||
|
KeyringProtection.standard(), deterministicRandom());
|
||||||
|
retainedMacKey = field(store, "nonceReservationMacKey");
|
||||||
|
macKeyCopy = retainedMacKey.clone();
|
||||||
|
assertEquals(CURRENT_SIDECAR_VERSION,
|
||||||
|
ByteBuffer.wrap(Files.readAllBytes(sidecar(path)),
|
||||||
|
VERSION_OFFSET, Integer.BYTES).getInt());
|
||||||
|
store.close();
|
||||||
|
store.close();
|
||||||
|
assertTrue(allZero(retainedMacKey));
|
||||||
|
}
|
||||||
|
byte[] main = Files.readAllBytes(path);
|
||||||
|
byte[] reservation = Files.readAllBytes(sidecar(path));
|
||||||
|
try {
|
||||||
|
assertFalse(indexOf(main, macKeyCopy) >= 0);
|
||||||
|
assertFalse(indexOf(reservation, macKeyCopy) >= 0);
|
||||||
|
} finally {
|
||||||
|
wipe(main);
|
||||||
|
wipe(reservation);
|
||||||
|
wipe(macKeyCopy);
|
||||||
|
}
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore reopened = KeyringStore.open(path, password)) {
|
||||||
|
assertTrue(reopened.aliases().isEmpty());
|
||||||
|
}
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void directMasterKeyMacAndSidecarTamperingFailClosed() throws Exception {
|
||||||
|
start("directMasterKeyMacAndSidecarTamperingFailClosed");
|
||||||
|
assertRejected(sidecarWithDirectMasterKeyMac("direct-master.zek"));
|
||||||
|
assertRejected(sidecarWithBitFlippedTag("tag-bit.zek"));
|
||||||
|
assertRejected(sidecarWithTamperedHighWater("high-water.zek"));
|
||||||
|
assertRejected(sidecarWithVersion("old-version.zek", 1, true));
|
||||||
|
assertRejected(sidecarWithVersion("future-version.zek", 3, false));
|
||||||
|
assertRejected(sidecarTruncated("truncated.zek"));
|
||||||
|
assertRejected(sidecarWithTrailingByte("trailing.zek"));
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sidecarCannotBeCopiedAcrossStores() throws Exception {
|
||||||
|
start("sidecarCannotBeCopiedAcrossStores");
|
||||||
|
Path first = createAndClose("first.zek", 1);
|
||||||
|
Path second = createAndClose("second.zek", 101);
|
||||||
|
byte[] copied = Files.readAllBytes(sidecar(first));
|
||||||
|
try {
|
||||||
|
Files.write(sidecar(second), copied);
|
||||||
|
} finally {
|
||||||
|
wipe(copied);
|
||||||
|
}
|
||||||
|
assertOpenRejectedWithoutMutation(second);
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void restartAndAbandonedReservationAdvanceMonotonically() throws Exception {
|
||||||
|
start("restartAndAbandonedReservationAdvanceMonotonically");
|
||||||
|
Path path = temporaryDirectory.resolve("abandoned.zek");
|
||||||
|
Path saved = temporaryDirectory.resolve("abandoned.saved");
|
||||||
|
byte[] material = new byte[32];
|
||||||
|
Arrays.fill(material, (byte) 0x4a);
|
||||||
|
try {
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.create(path, password,
|
||||||
|
KeyringProtection.standard(), deterministicRandom())) {
|
||||||
|
assertEquals(1L, longField(store, "nonceHighWater"));
|
||||||
|
Files.move(path, saved);
|
||||||
|
Files.createDirectory(path);
|
||||||
|
assertThrows(IOException.class,
|
||||||
|
() -> store.putSecret("failed", "AES",
|
||||||
|
new SecretKeySpec(material, "AES")));
|
||||||
|
assertEquals(3L, sidecarHighWater(sidecar(path)));
|
||||||
|
assertEquals(3L, longField(store, "nonceHighWater"));
|
||||||
|
Files.delete(path);
|
||||||
|
Files.move(saved, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore reopened = KeyringStore.open(path, password,
|
||||||
|
KeyringProtection.standard(), deterministicRandom(51))) {
|
||||||
|
assertEquals(3L, longField(reopened, "nonceHighWater"));
|
||||||
|
assertTrue(reopened.aliases().isEmpty());
|
||||||
|
reopened.putSecret("accepted", "AES",
|
||||||
|
new SecretKeySpec(material, "AES"));
|
||||||
|
assertEquals(5L, longField(reopened, "nonceHighWater"));
|
||||||
|
assertEquals(5L, sidecarHighWater(sidecar(path)));
|
||||||
|
}
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore reopened = KeyringStore.open(path, password)) {
|
||||||
|
assertEquals(5L, longField(reopened, "nonceHighWater"));
|
||||||
|
assertArrayEquals(material, reopened.getSecret("accepted").getEncoded());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
wipe(material);
|
||||||
|
}
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failedSidecarReservationDoesNotAdvanceOrEncrypt() throws Exception {
|
||||||
|
start("failedSidecarReservationDoesNotAdvanceOrEncrypt");
|
||||||
|
Path path = temporaryDirectory.resolve("sidecar-write-failure.zek");
|
||||||
|
Path reservation = sidecar(path);
|
||||||
|
Path saved = temporaryDirectory.resolve("sidecar.saved");
|
||||||
|
byte[] material = new byte[32];
|
||||||
|
Arrays.fill(material, (byte) 0x35);
|
||||||
|
RecordingRandom random = new RecordingRandom(1);
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.create(path, password,
|
||||||
|
KeyringProtection.standard(), random)) {
|
||||||
|
byte[] mainBefore = Files.readAllBytes(path);
|
||||||
|
try {
|
||||||
|
assertEquals(List.of(32, 32, 16, 11, 3), random.requests());
|
||||||
|
Files.move(reservation, saved);
|
||||||
|
Files.createDirectory(reservation);
|
||||||
|
assertThrows(IOException.class,
|
||||||
|
() -> store.putSecret("failed", "AES",
|
||||||
|
new SecretKeySpec(material, "AES")));
|
||||||
|
assertEquals(1L, longField(store, "nonceHighWater"));
|
||||||
|
assertArrayEquals(mainBefore, Files.readAllBytes(path));
|
||||||
|
assertEquals(List.of(32, 32, 16, 11, 3, 16), random.requests());
|
||||||
|
|
||||||
|
Files.delete(reservation);
|
||||||
|
Files.move(saved, reservation);
|
||||||
|
store.putSecret("accepted", "AES",
|
||||||
|
new SecretKeySpec(material, "AES"));
|
||||||
|
assertEquals(3L, longField(store, "nonceHighWater"));
|
||||||
|
assertEquals(List.of(32, 32, 16, 11, 3, 16, 16), random.requests());
|
||||||
|
} finally {
|
||||||
|
wipe(mainBefore);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
wipe(material);
|
||||||
|
}
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nonceAllocationUsesOneDurableCounterPerEncryption() throws Exception {
|
||||||
|
start("nonceAllocationUsesOneDurableCounterPerEncryption");
|
||||||
|
Path path = temporaryDirectory.resolve("allocation.zek");
|
||||||
|
byte[] first = new byte[32];
|
||||||
|
byte[] second = new byte[32];
|
||||||
|
Arrays.fill(first, (byte) 0x11);
|
||||||
|
Arrays.fill(second, (byte) 0x22);
|
||||||
|
RecordingRandom random = new RecordingRandom(7);
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.create(path, password,
|
||||||
|
KeyringProtection.standard(), random)) {
|
||||||
|
assertEquals(1L, longField(store, "nonceHighWater"));
|
||||||
|
assertEquals(List.of(32, 32, 16, 11, 3), random.requests());
|
||||||
|
store.putSecret("one", "AES", new SecretKeySpec(first, "AES"));
|
||||||
|
assertEquals(3L, longField(store, "nonceHighWater"));
|
||||||
|
store.putSecret("two", "AES", new SecretKeySpec(second, "AES"));
|
||||||
|
assertEquals(5L, longField(store, "nonceHighWater"));
|
||||||
|
store.putSecret("one", "AES", new SecretKeySpec(second, "AES"));
|
||||||
|
assertEquals(7L, longField(store, "nonceHighWater"));
|
||||||
|
assertEquals(List.of(32, 32, 16, 11, 3, 16, 16, 16), random.requests());
|
||||||
|
assertEquals(7L, sidecarHighWater(sidecar(path)));
|
||||||
|
} finally {
|
||||||
|
wipe(first);
|
||||||
|
wipe(second);
|
||||||
|
}
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path sidecarWithDirectMasterKeyMac(String file) throws Exception {
|
||||||
|
Path path = temporaryDirectory.resolve(file);
|
||||||
|
byte[] image;
|
||||||
|
byte[] master;
|
||||||
|
try (KeyringPassword password = password()) {
|
||||||
|
KeyringStore store = KeyringStore.create(path, password,
|
||||||
|
KeyringProtection.standard(), deterministicRandom());
|
||||||
|
image = Files.readAllBytes(sidecar(path));
|
||||||
|
master = field(store, "masterKey").clone();
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
byte[] tag = null;
|
||||||
|
try {
|
||||||
|
tag = hmac(master, authenticated(image));
|
||||||
|
replaceTag(image, tag);
|
||||||
|
Files.write(sidecar(path), image);
|
||||||
|
return path;
|
||||||
|
} finally {
|
||||||
|
wipe(image);
|
||||||
|
wipe(master);
|
||||||
|
wipe(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path sidecarWithBitFlippedTag(String file) throws Exception {
|
||||||
|
Path path = createAndClose(file);
|
||||||
|
byte[] image = Files.readAllBytes(sidecar(path));
|
||||||
|
try {
|
||||||
|
image[image.length - 1] ^= 1;
|
||||||
|
Files.write(sidecar(path), image);
|
||||||
|
return path;
|
||||||
|
} finally {
|
||||||
|
wipe(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path sidecarWithTamperedHighWater(String file) throws Exception {
|
||||||
|
Path path = createAndClose(file);
|
||||||
|
byte[] image = Files.readAllBytes(sidecar(path));
|
||||||
|
try {
|
||||||
|
image[HIGH_WATER_OFFSET + Long.BYTES - 1] ^= 1;
|
||||||
|
Files.write(sidecar(path), image);
|
||||||
|
return path;
|
||||||
|
} finally {
|
||||||
|
wipe(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path sidecarWithVersion(String file, int version, boolean useMasterKey)
|
||||||
|
throws Exception {
|
||||||
|
Path path = temporaryDirectory.resolve(file);
|
||||||
|
byte[] image;
|
||||||
|
byte[] key;
|
||||||
|
try (KeyringPassword password = password()) {
|
||||||
|
KeyringStore store = KeyringStore.create(path, password,
|
||||||
|
KeyringProtection.standard(), deterministicRandom());
|
||||||
|
image = Files.readAllBytes(sidecar(path));
|
||||||
|
key = field(store, useMasterKey ? "masterKey" : "nonceReservationMacKey").clone();
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
byte[] tag = null;
|
||||||
|
try {
|
||||||
|
ByteBuffer.wrap(image, VERSION_OFFSET, Integer.BYTES).putInt(version);
|
||||||
|
tag = hmac(key, authenticated(image));
|
||||||
|
replaceTag(image, tag);
|
||||||
|
Files.write(sidecar(path), image);
|
||||||
|
return path;
|
||||||
|
} finally {
|
||||||
|
wipe(image);
|
||||||
|
wipe(key);
|
||||||
|
wipe(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path sidecarTruncated(String file) throws Exception {
|
||||||
|
Path path = createAndClose(file);
|
||||||
|
byte[] image = Files.readAllBytes(sidecar(path));
|
||||||
|
try {
|
||||||
|
Files.write(sidecar(path), Arrays.copyOf(image, image.length - 1));
|
||||||
|
return path;
|
||||||
|
} finally {
|
||||||
|
wipe(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path sidecarWithTrailingByte(String file) throws Exception {
|
||||||
|
Path path = createAndClose(file);
|
||||||
|
Files.write(sidecar(path), new byte[] { 0 },
|
||||||
|
java.nio.file.StandardOpenOption.APPEND);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertRejected(Path path) throws Exception {
|
||||||
|
assertOpenRejectedWithoutMutation(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertOpenRejectedWithoutMutation(Path path) throws Exception {
|
||||||
|
byte[] mainBefore = Files.readAllBytes(path);
|
||||||
|
byte[] sidecarBefore = Files.readAllBytes(sidecar(path));
|
||||||
|
try (KeyringPassword password = password()) {
|
||||||
|
KeyringException exception = assertThrows(KeyringException.class,
|
||||||
|
() -> KeyringStore.open(path, password));
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_FORMAT_INVALID, exception.code());
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_FORMAT_INVALID.name(),
|
||||||
|
exception.getMessage());
|
||||||
|
assertArrayEquals(mainBefore, Files.readAllBytes(path));
|
||||||
|
assertArrayEquals(sidecarBefore, Files.readAllBytes(sidecar(path)));
|
||||||
|
} finally {
|
||||||
|
wipe(mainBefore);
|
||||||
|
wipe(sidecarBefore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path createAndClose(String file) throws Exception {
|
||||||
|
return createAndClose(file, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path createAndClose(String file, int randomSeed) throws Exception {
|
||||||
|
Path path = temporaryDirectory.resolve(file);
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore ignored = KeyringStore.create(path, password,
|
||||||
|
KeyringProtection.standard(), deterministicRandom(randomSeed))) {
|
||||||
|
// Creation writes the initial durable reservation.
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] authenticated(byte[] image) {
|
||||||
|
return Arrays.copyOf(image, image.length - TAG_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void replaceTag(byte[] image, byte[] tag) {
|
||||||
|
System.arraycopy(tag, 0, image, image.length - TAG_BYTES, TAG_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] hmac(byte[] key, byte[] input) throws Exception {
|
||||||
|
try {
|
||||||
|
Mac mac = Mac.getInstance("HmacSHA256");
|
||||||
|
mac.init(new SecretKeySpec(key, "HmacSHA256"));
|
||||||
|
return mac.doFinal(input);
|
||||||
|
} finally {
|
||||||
|
wipe(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] field(KeyringStore store, String name) throws Exception {
|
||||||
|
Field field = KeyringStore.class.getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
return (byte[]) field.get(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long longField(KeyringStore store, String name) throws Exception {
|
||||||
|
Field field = KeyringStore.class.getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
return field.getLong(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long sidecarHighWater(Path path) throws Exception {
|
||||||
|
byte[] image = Files.readAllBytes(path);
|
||||||
|
try {
|
||||||
|
return ByteBuffer.wrap(image, HIGH_WATER_OFFSET, Long.BYTES).getLong();
|
||||||
|
} finally {
|
||||||
|
wipe(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path sidecar(Path keyring) {
|
||||||
|
return keyring.resolveSibling(keyring.getFileName() + ".nonce");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyringPassword password() {
|
||||||
|
return new KeyringPassword(PASSWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyringRandomBytes deterministicRandom() {
|
||||||
|
return deterministicRandom(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyringRandomBytes deterministicRandom(int initialValue) {
|
||||||
|
AtomicInteger value = new AtomicInteger(initialValue);
|
||||||
|
return destination -> {
|
||||||
|
int base = value.getAndIncrement();
|
||||||
|
for (int index = 0; index < destination.length; index++) {
|
||||||
|
destination[index] = (byte) (base + index);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean allZero(byte[] value) {
|
||||||
|
for (byte current : value) {
|
||||||
|
if (current != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int indexOf(byte[] haystack, byte[] needle) {
|
||||||
|
outer:
|
||||||
|
for (int index = 0; index <= haystack.length - needle.length; index++) {
|
||||||
|
for (int offset = 0; offset < needle.length; offset++) {
|
||||||
|
if (haystack[index + offset] != needle[offset]) {
|
||||||
|
continue outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void wipe(byte[] value) {
|
||||||
|
if (value != null) {
|
||||||
|
Arrays.fill(value, (byte) 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void start(String method) {
|
||||||
|
System.out.println(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ok() {
|
||||||
|
System.out.println("...ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class RecordingRandom implements KeyringRandomBytes {
|
||||||
|
private final AtomicInteger value;
|
||||||
|
private final List<Integer> requests = new ArrayList<>();
|
||||||
|
|
||||||
|
private RecordingRandom(int initialValue) {
|
||||||
|
value = new AtomicInteger(initialValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void nextBytes(byte[] destination) {
|
||||||
|
requests.add(destination.length);
|
||||||
|
int base = value.getAndIncrement();
|
||||||
|
for (int index = 0; index < destination.length; index++) {
|
||||||
|
destination[index] = (byte) (base + index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Integer> requests() {
|
||||||
|
return List.copyOf(requests);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
/*******************************************************************************
|
|
||||||
* Copyright (C) 2026, Leo Galambos
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* Redistribution and use in source and binary forms, with or without modification,
|
|
||||||
* are permitted provided that the following conditions are met:
|
|
||||||
*
|
|
||||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
|
||||||
* list of conditions and the following disclaimer.
|
|
||||||
*
|
|
||||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
||||||
* this list of conditions and the following disclaimer in the documentation
|
|
||||||
* and/or other materials provided with the distribution.
|
|
||||||
*
|
|
||||||
* 3. All advertising materials mentioning features or use of this software must
|
|
||||||
* display the following acknowledgement:
|
|
||||||
* This product includes software developed by the Egothor project.
|
|
||||||
*
|
|
||||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
|
||||||
* may be used to endorse or promote products derived from this software without
|
|
||||||
* specific prior written permission.
|
|
||||||
*
|
|
||||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
||||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
||||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
||||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
|
||||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
||||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
||||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
|
||||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
||||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
||||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
||||||
******************************************************************************/
|
|
||||||
package zeroecho.core.storage;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
import java.lang.reflect.Constructor;
|
|
||||||
import java.lang.reflect.Method;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.security.KeyPair;
|
|
||||||
import java.security.PrivateKey;
|
|
||||||
import java.security.PublicKey;
|
|
||||||
import java.security.SecureRandom;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.Base64;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
import javax.crypto.SecretKey;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
|
||||||
|
|
||||||
import zeroecho.core.CryptoAlgorithm;
|
|
||||||
import zeroecho.core.CryptoAlgorithms;
|
|
||||||
import zeroecho.core.alg.rsa.RsaKeyGenSpec;
|
|
||||||
import zeroecho.core.alg.rsa.RsaPrivateKeySpec;
|
|
||||||
import zeroecho.core.alg.rsa.RsaPublicKeySpec;
|
|
||||||
import zeroecho.core.spec.AlgorithmKeySpec;
|
|
||||||
import zeroecho.core.KeyOperation;
|
|
||||||
import zeroecho.core.KeyOperationInfo;
|
|
||||||
import zeroecho.core.spi.AsymmetricKeyPairGenerator;
|
|
||||||
import zeroecho.core.spi.SymmetricKeyGenerator;
|
|
||||||
import zeroecho.sdk.util.BouncyCastleActivator;
|
|
||||||
|
|
||||||
public class KeyringStoreDynamicTest {
|
|
||||||
|
|
||||||
@BeforeAll
|
|
||||||
static void setupProviders() {
|
|
||||||
BouncyCastleActivator.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void logBegin(Object... params) {
|
|
||||||
String thisClass = KeyringStoreDynamicTest.class.getName();
|
|
||||||
String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
|
|
||||||
.walk(frames -> frames
|
|
||||||
.dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logBegin"))
|
|
||||||
.findFirst().map(StackWalker.StackFrame::getMethodName).orElse("<?>"));
|
|
||||||
System.out.println(method + "(" + Arrays.deepToString(params) + ")");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void logEnd() {
|
|
||||||
String thisClass = KeyringStoreDynamicTest.class.getName();
|
|
||||||
String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
|
|
||||||
.walk(frames -> frames
|
|
||||||
.dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logEnd"))
|
|
||||||
.findFirst().map(StackWalker.StackFrame::getMethodName).orElse("<?>"));
|
|
||||||
System.out.println(method + "...ok");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] randomBytes(int len) {
|
|
||||||
byte[] b = new byte[len];
|
|
||||||
new SecureRandom().nextBytes(b);
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String encLen(byte[] der) {
|
|
||||||
if (der == null) {
|
|
||||||
return "0";
|
|
||||||
}
|
|
||||||
|
|
||||||
String str = Base64.getEncoder().withoutPadding().encodeToString(der);
|
|
||||||
if (str.length() > 64) {
|
|
||||||
str = str.substring(0, 64) + "...";
|
|
||||||
}
|
|
||||||
|
|
||||||
return der.length + " / b64 " + str;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static AlgorithmKeySpec makeImportSpec(Class<?> specType, byte[] material, String algId, Object defaultSpec)
|
|
||||||
throws Exception {
|
|
||||||
try {
|
|
||||||
Method m = specType.getMethod("fromRaw", byte[].class);
|
|
||||||
Object spec = m.invoke(null, material);
|
|
||||||
return (AlgorithmKeySpec) spec;
|
|
||||||
} catch (NoSuchMethodException ignored) {
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Method m = specType.getMethod("fromRaw", String.class, byte[].class);
|
|
||||||
String name = deriveVariantNameForImport(algId, defaultSpec);
|
|
||||||
Object spec = m.invoke(null, name, material);
|
|
||||||
return (AlgorithmKeySpec) spec;
|
|
||||||
} catch (NoSuchMethodException ignored) {
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Method m = specType.getMethod("of", byte[].class);
|
|
||||||
Object spec = m.invoke(null, material);
|
|
||||||
return (AlgorithmKeySpec) spec;
|
|
||||||
} catch (NoSuchMethodException ignored) {
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Constructor<?> c = specType.getConstructor(byte[].class);
|
|
||||||
Object spec = c.newInstance(new Object[] { material });
|
|
||||||
return (AlgorithmKeySpec) spec;
|
|
||||||
} catch (NoSuchMethodException ignored) {
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Constructor<?> c = specType.getConstructor(String.class);
|
|
||||||
Object spec = c.newInstance(Base64.getEncoder().encodeToString(material));
|
|
||||||
return (AlgorithmKeySpec) spec;
|
|
||||||
} catch (NoSuchMethodException ignored) {
|
|
||||||
}
|
|
||||||
throw new IllegalStateException("No usable import factory/ctor found for " + specType.getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String deriveVariantNameForImport(String algId, Object defaultSpec) {
|
|
||||||
if (defaultSpec != null) {
|
|
||||||
try {
|
|
||||||
Method m = defaultSpec.getClass().getMethod("macName");
|
|
||||||
Object v = m.invoke(defaultSpec);
|
|
||||||
if (v instanceof String) {
|
|
||||||
return (String) v;
|
|
||||||
}
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ("HMAC".equalsIgnoreCase(algId)) {
|
|
||||||
return "HmacSHA256";
|
|
||||||
}
|
|
||||||
return algId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean looksLikeImportSpecForPublic(Class<?> specType) {
|
|
||||||
String n = specType.getSimpleName();
|
|
||||||
return n.contains("Public") || n.endsWith("PublicKeySpec");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean looksLikeImportSpecForPrivate(Class<?> specType) {
|
|
||||||
String n = specType.getSimpleName();
|
|
||||||
return n.contains("Private") || n.endsWith("PrivateKeySpec");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean looksLikeImportSpecForSecret(Class<?> specType) {
|
|
||||||
String n = specType.getSimpleName();
|
|
||||||
return n.contains("Import") || n.endsWith("KeyImportSpec") || n.endsWith("SecretSpec");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testExport(@TempDir Path tempDir) throws Exception {
|
|
||||||
logBegin();
|
|
||||||
|
|
||||||
Path keyringPath = tempDir.resolve("keyring-" + System.nanoTime() + ".txt");
|
|
||||||
KeyringStore store = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
|
|
||||||
|
|
||||||
zeroecho.sdk.ZeroEchoSession session = new zeroecho.sdk.ZeroEchoSession();
|
|
||||||
KeyPair kp = session.keyBuilders().asymmetric().generateKeyPair("RSA", RsaKeyGenSpec.rsa4096());
|
|
||||||
store.putPrivate("alice.priv", "RSA", new RsaPrivateKeySpec(kp.getPrivate().getEncoded()));
|
|
||||||
store.putPublic("alice.pub", "RSA", new RsaPublicKeySpec(kp.getPublic().getEncoded()));
|
|
||||||
|
|
||||||
kp = session.keyBuilders().asymmetric().generateKeyPair("RSA", RsaKeyGenSpec.rsa4096());
|
|
||||||
store.putPrivate("bob.priv", "RSA", new RsaPrivateKeySpec(kp.getPrivate().getEncoded()));
|
|
||||||
store.putPublic("bob.pub", "RSA", new RsaPublicKeySpec(kp.getPublic().getEncoded()));
|
|
||||||
store.save(keyringPath);
|
|
||||||
|
|
||||||
store = KeyringStore.load(new zeroecho.sdk.ZeroEchoSession(), keyringPath);
|
|
||||||
String s = store.exportText(Collections.singleton("alice.pub"));
|
|
||||||
|
|
||||||
assertTrue(s.contains("# KeyringStore v1\n"));
|
|
||||||
assertTrue(s.contains("\n@entry\n"));
|
|
||||||
assertTrue(s.contains("\nalias=alice.pub\n"));
|
|
||||||
assertTrue(s.contains("\nalgorithm=RSA\n"));
|
|
||||||
assertTrue(s.contains("\nkind=PUBLIC_KEY\n"));
|
|
||||||
assertTrue(s.contains("\nspec=zeroecho.core.alg.rsa.RsaPublicKeySpec\n"));
|
|
||||||
assertTrue(s.contains("\ns.type=RSA-PUB\n"));
|
|
||||||
assertTrue(s.contains("\ns.x509.b64="));
|
|
||||||
|
|
||||||
logEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void keyring_dynamic_population_roundtrip_and_dump(@TempDir Path tempDir) throws Exception {
|
|
||||||
logBegin();
|
|
||||||
|
|
||||||
KeyringStore store = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
|
|
||||||
|
|
||||||
Set<String> ids = CryptoAlgorithms.available();
|
|
||||||
System.out.println("...algorithms discovered: " + ids);
|
|
||||||
|
|
||||||
int totalAdded = 0;
|
|
||||||
|
|
||||||
for (String id : ids) {
|
|
||||||
CryptoAlgorithm alg = CryptoAlgorithms.require(id);
|
|
||||||
System.out.println("\n-- " + id + " --");
|
|
||||||
|
|
||||||
if (alg.keyOperations().stream()
|
|
||||||
.anyMatch(info -> info.operation() == KeyOperation.ASYMMETRIC_KEY_PAIR_GENERATE)) {
|
|
||||||
int perAlg = 0;
|
|
||||||
for (KeyOperationInfo bi : alg.keyOperations()) {
|
|
||||||
if (bi.operation() != KeyOperation.ASYMMETRIC_KEY_PAIR_GENERATE || bi.defaultSpec() == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Class<AlgorithmKeySpec> genSpecType = (Class<AlgorithmKeySpec>) bi.specType();
|
|
||||||
AsymmetricKeyPairGenerator<AlgorithmKeySpec> b = alg.asymmetricKeyPairGenerator(genSpecType);
|
|
||||||
|
|
||||||
AlgorithmKeySpec genSpec = bi.defaultSpec();
|
|
||||||
KeyPair kp = b.generateKeyPair(genSpec);
|
|
||||||
PublicKey pub = kp.getPublic();
|
|
||||||
PrivateKey prv = kp.getPrivate();
|
|
||||||
|
|
||||||
Class<?> pubImpType = null;
|
|
||||||
Class<?> prvImpType = null;
|
|
||||||
for (KeyOperationInfo x : alg.keyOperations()) {
|
|
||||||
if (x.operation() == KeyOperation.ASYMMETRIC_PUBLIC_IMPORT) {
|
|
||||||
pubImpType = x.specType();
|
|
||||||
} else if (x.operation() == KeyOperation.ASYMMETRIC_PRIVATE_IMPORT) {
|
|
||||||
prvImpType = x.specType();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (pubImpType != null) {
|
|
||||||
AlgorithmKeySpec pubSpec = makeImportSpec(pubImpType, pub.getEncoded(), id,
|
|
||||||
bi.defaultSpec());
|
|
||||||
String alias = id.toLowerCase() + "-pub-" + perAlg;
|
|
||||||
store.putPublic(alias, id, pubSpec);
|
|
||||||
System.out.println("..." + alias + " saved, len=" + encLen(pub.getEncoded()));
|
|
||||||
totalAdded++;
|
|
||||||
} else {
|
|
||||||
System.out.println("...*** SKIP *** no public import spec for " + id);
|
|
||||||
}
|
|
||||||
if (prvImpType != null) {
|
|
||||||
AlgorithmKeySpec prvSpec = makeImportSpec(prvImpType, prv.getEncoded(), id,
|
|
||||||
bi.defaultSpec());
|
|
||||||
String alias = id.toLowerCase() + "-prv-" + perAlg;
|
|
||||||
store.putPrivate(alias, id, prvSpec);
|
|
||||||
System.out.println("..." + alias + " saved, len=" + encLen(prv.getEncoded()));
|
|
||||||
totalAdded++;
|
|
||||||
} else {
|
|
||||||
System.out.println("...*** SKIP *** no private import spec for " + id);
|
|
||||||
}
|
|
||||||
|
|
||||||
perAlg++;
|
|
||||||
if (perAlg >= 3) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (Throwable t) {
|
|
||||||
System.out.println("...*** SKIP asym for " + id + " *** " + t.getClass().getSimpleName() + ": "
|
|
||||||
+ t.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (alg.keyOperations().stream()
|
|
||||||
.anyMatch(info -> info.operation() == KeyOperation.SYMMETRIC_GENERATE)) {
|
|
||||||
int perAlg = 0;
|
|
||||||
for (KeyOperationInfo bi : alg.keyOperations()) {
|
|
||||||
if (bi.operation() != KeyOperation.SYMMETRIC_GENERATE || bi.defaultSpec() == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Class<AlgorithmKeySpec> genSpecType = (Class<AlgorithmKeySpec>) bi.specType();
|
|
||||||
SymmetricKeyGenerator<AlgorithmKeySpec> b = alg.symmetricKeyGenerator(genSpecType);
|
|
||||||
|
|
||||||
AlgorithmKeySpec genSpec = bi.defaultSpec();
|
|
||||||
SecretKey sk = b.generateSecret(genSpec);
|
|
||||||
|
|
||||||
Class<?> impType = null;
|
|
||||||
for (KeyOperationInfo x : alg.keyOperations()) {
|
|
||||||
if (x.operation() == KeyOperation.SYMMETRIC_IMPORT
|
|
||||||
&& looksLikeImportSpecForSecret(x.specType())) {
|
|
||||||
impType = x.specType();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (impType != null) {
|
|
||||||
byte[] raw = sk.getEncoded();
|
|
||||||
if (raw == null) {
|
|
||||||
raw = randomBytes(32);
|
|
||||||
}
|
|
||||||
AlgorithmKeySpec imp = makeImportSpec(impType, raw, id, bi.defaultSpec());
|
|
||||||
String alias = id.toLowerCase() + "-sec-" + perAlg;
|
|
||||||
store.putSecret(alias, id, imp);
|
|
||||||
System.out.println("..." + alias + " saved, len=" + raw.length);
|
|
||||||
totalAdded++;
|
|
||||||
} else {
|
|
||||||
System.out.println("...*** SKIP *** no symmetric import spec for " + id);
|
|
||||||
}
|
|
||||||
perAlg++;
|
|
||||||
if (perAlg >= 3) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (Throwable t) {
|
|
||||||
System.out.println("...*** SKIP sym for " + id + " *** " + t.getClass().getSimpleName() + ": "
|
|
||||||
+ t.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist using JUnit-managed temp directory
|
|
||||||
Path keyringPath = tempDir.resolve("keyring-" + System.nanoTime() + ".txt");
|
|
||||||
store.save(keyringPath);
|
|
||||||
System.out.println("\n...saved keyring: " + keyringPath.getFileName());
|
|
||||||
System.out.println("...entries stored: " + totalAdded);
|
|
||||||
|
|
||||||
KeyringStore loaded = KeyringStore.load(new zeroecho.sdk.ZeroEchoSession(), keyringPath);
|
|
||||||
assertTrue(loaded.aliases().size() >= Math.min(totalAdded, 1), "no entries reloaded");
|
|
||||||
|
|
||||||
int ok = 0;
|
|
||||||
for (String alias : loaded.aliases()) {
|
|
||||||
boolean success = false;
|
|
||||||
try {
|
|
||||||
PublicKey k = loaded.getPublic(alias);
|
|
||||||
if (k != null && k.getEncoded() != null) {
|
|
||||||
System.out.println("..." + alias + " OK public len=" + encLen(k.getEncoded()));
|
|
||||||
success = true;
|
|
||||||
}
|
|
||||||
} catch (Throwable ignore) {
|
|
||||||
}
|
|
||||||
if (!success) {
|
|
||||||
try {
|
|
||||||
PrivateKey k = loaded.getPrivate(alias);
|
|
||||||
if (k != null && k.getEncoded() != null) {
|
|
||||||
System.out.println("..." + alias + " OK private len=" + encLen(k.getEncoded()));
|
|
||||||
success = true;
|
|
||||||
}
|
|
||||||
} catch (Throwable ignore) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!success) {
|
|
||||||
try {
|
|
||||||
SecretKey k = loaded.getSecret(alias);
|
|
||||||
if (k != null && k.getEncoded() != null) {
|
|
||||||
System.out.println("..." + alias + " OK secret len=" + encLen(k.getEncoded()));
|
|
||||||
success = true;
|
|
||||||
}
|
|
||||||
} catch (Throwable ignore) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (success) {
|
|
||||||
ok++;
|
|
||||||
} else {
|
|
||||||
System.out.println("...*** WARN *** could not reconstruct: " + alias);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assertTrue(ok > 0, "nothing reconstructed from keyring");
|
|
||||||
|
|
||||||
System.out.println("\n===== KEYRING DUMP BEGIN =====");
|
|
||||||
List<String> lines = Files.readAllLines(keyringPath, StandardCharsets.UTF_8);
|
|
||||||
for (String ln : lines) {
|
|
||||||
System.out.printf(ln.length() > 80 ? "%.77s...%n" : "%s%n", ln);
|
|
||||||
}
|
|
||||||
System.out.println("===== KEYRING DUMP END =====\n");
|
|
||||||
|
|
||||||
logEnd();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
/*******************************************************************************
|
|
||||||
* Copyright (C) 2026, Leo Galambos
|
|
||||||
* All rights reserved.
|
|
||||||
******************************************************************************/
|
|
||||||
package zeroecho.core.storage;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
import java.security.GeneralSecurityException;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
|
|
||||||
import javax.security.auth.DestroyFailedException;
|
|
||||||
import javax.security.auth.Destroyable;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import zeroecho.core.spec.AlgorithmKeySpec;
|
|
||||||
import zeroecho.sdk.ZeroEchoSession;
|
|
||||||
|
|
||||||
class KeyringStoreSecurityTest {
|
|
||||||
private static final AtomicBoolean UNREGISTERED_INITIALIZED = new AtomicBoolean();
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rejectsUnregisteredPersistedSpecBeforeClassInitialization() throws Exception {
|
|
||||||
System.out.println("rejectsUnregisteredPersistedSpecBeforeClassInitialization");
|
|
||||||
KeyringStore store = new KeyringStore(new ZeroEchoSession());
|
|
||||||
String text = "# KeyringStore v1\n"
|
|
||||||
+ "@entry\n"
|
|
||||||
+ "alias=attacker.pub\n"
|
|
||||||
+ "algorithm=RSA\n"
|
|
||||||
+ "kind=PUBLIC_KEY\n"
|
|
||||||
+ "spec=zeroecho.core.storage.KeyringStoreSecurityTest$UnregisteredSpec\n\n";
|
|
||||||
store.importText(text, false);
|
|
||||||
|
|
||||||
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
|
|
||||||
() -> store.getPublic("attacker"));
|
|
||||||
|
|
||||||
assertFalse(UNREGISTERED_INITIALIZED.get());
|
|
||||||
assertTrue(failure.getMessage().contains("not registered"));
|
|
||||||
System.out.println("...classInitialized=false");
|
|
||||||
System.out.println("rejectsUnregisteredPersistedSpecBeforeClassInitialization...ok");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rejectsSpecRegisteredForDifferentOperation() throws Exception {
|
|
||||||
System.out.println("rejectsSpecRegisteredForDifferentOperation");
|
|
||||||
KeyringStore store = new KeyringStore(new ZeroEchoSession());
|
|
||||||
String text = "# KeyringStore v1\n"
|
|
||||||
+ "@entry\n"
|
|
||||||
+ "alias=mismatch.pub\n"
|
|
||||||
+ "algorithm=RSA\n"
|
|
||||||
+ "kind=PUBLIC_KEY\n"
|
|
||||||
+ "spec=zeroecho.core.alg.rsa.RsaPrivateKeySpec\n\n";
|
|
||||||
store.importText(text, false);
|
|
||||||
|
|
||||||
assertThrows(IllegalArgumentException.class, () -> store.getPublic("mismatch"));
|
|
||||||
System.out.println("...mismatchedOperationRejected=true");
|
|
||||||
System.out.println("rejectsSpecRegisteredForDifferentOperation...ok");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void temporarySpecDestructionIsIdempotentAndObservable() throws Exception {
|
|
||||||
System.out.println("temporarySpecDestructionIsIdempotentAndObservable");
|
|
||||||
ControlledDestroyableSpec spec = new ControlledDestroyableSpec(false);
|
|
||||||
|
|
||||||
KeyringStore.destroyTemporarySpec(spec, null);
|
|
||||||
KeyringStore.destroyTemporarySpec(spec, null);
|
|
||||||
|
|
||||||
assertTrue(spec.isDestroyed());
|
|
||||||
assertEquals(1, spec.destroyCalls);
|
|
||||||
System.out.println("...destroyCalls=" + spec.destroyCalls);
|
|
||||||
System.out.println("temporarySpecDestructionIsIdempotentAndObservable...ok");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void destructionFailureIsSuppressedOnPrimaryFailure() throws Exception {
|
|
||||||
System.out.println("destructionFailureIsSuppressedOnPrimaryFailure");
|
|
||||||
ControlledDestroyableSpec spec = new ControlledDestroyableSpec(true);
|
|
||||||
IllegalStateException primary = new IllegalStateException("controlled primary");
|
|
||||||
|
|
||||||
KeyringStore.destroyTemporarySpec(spec, primary);
|
|
||||||
|
|
||||||
assertEquals(1, primary.getSuppressed().length);
|
|
||||||
assertTrue(primary.getSuppressed()[0] instanceof DestroyFailedException);
|
|
||||||
System.out.println("...suppressedFailures=1");
|
|
||||||
System.out.println("destructionFailureIsSuppressedOnPrimaryFailure...ok");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void destructionFailureWithoutPrimaryUsesSecurityExceptionFamily() {
|
|
||||||
System.out.println("destructionFailureWithoutPrimaryUsesSecurityExceptionFamily");
|
|
||||||
ControlledDestroyableSpec spec = new ControlledDestroyableSpec(true);
|
|
||||||
|
|
||||||
GeneralSecurityException failure = assertThrows(GeneralSecurityException.class,
|
|
||||||
() -> KeyringStore.destroyTemporarySpec(spec, null));
|
|
||||||
|
|
||||||
assertSame(DestroyFailedException.class, failure.getCause().getClass());
|
|
||||||
System.out.println("...failureType=" + failure.getClass().getSimpleName());
|
|
||||||
System.out.println("destructionFailureWithoutPrimaryUsesSecurityExceptionFamily...ok");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A deliberately unregistered type whose initialization must never occur.
|
|
||||||
*/
|
|
||||||
public static final class UnregisteredSpec implements AlgorithmKeySpec {
|
|
||||||
static {
|
|
||||||
UNREGISTERED_INITIALIZED.set(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class ControlledDestroyableSpec implements AlgorithmKeySpec, Destroyable {
|
|
||||||
private final boolean fail;
|
|
||||||
private boolean destroyed;
|
|
||||||
private int destroyCalls;
|
|
||||||
|
|
||||||
private ControlledDestroyableSpec(boolean fail) {
|
|
||||||
this.fail = fail;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void destroy() throws DestroyFailedException {
|
|
||||||
destroyCalls++;
|
|
||||||
if (fail) {
|
|
||||||
throw new DestroyFailedException("controlled destruction failure");
|
|
||||||
}
|
|
||||||
destroyed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isDestroyed() {
|
|
||||||
return destroyed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
344
lib/src/test/java/zeroecho/core/storage/KeyringStoreTest.java
Normal file
344
lib/src/test/java/zeroecho/core/storage/KeyringStoreTest.java
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
package zeroecho.core.storage;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
class KeyringStoreTest {
|
||||||
|
private static final char[] PASSWORD = new char[] { 'c', 'o', 'r', 'r', 'e', 'c', 't' };
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path temporaryDirectory;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void encryptedRoundTripAndPlaintextAbsence() throws Exception {
|
||||||
|
start("encryptedRoundTripAndPlaintextAbsence");
|
||||||
|
Path path = temporaryDirectory.resolve("keys.zek");
|
||||||
|
byte[] aesBytes = new byte[32];
|
||||||
|
Arrays.fill(aesBytes, (byte) 0x5a);
|
||||||
|
SecretKey aes = new SecretKeySpec(aesBytes, "AES");
|
||||||
|
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||||
|
generator.initialize(2048);
|
||||||
|
KeyPair pair = generator.generateKeyPair();
|
||||||
|
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.create(path, password,
|
||||||
|
KeyringProtection.standard(), deterministicRandom())) {
|
||||||
|
store.putPublic("rsa", "RSA", pair.getPublic());
|
||||||
|
store.putPrivate("rsa", "RSA", pair.getPrivate());
|
||||||
|
store.putSecret("aes", "AES", aes);
|
||||||
|
assertArrayEquals(aesBytes, store.getSecret("aes").getEncoded());
|
||||||
|
assertArrayEquals(pair.getPublic().getEncoded(), store.getPublic("rsa").getEncoded());
|
||||||
|
assertArrayEquals(pair.getPrivate().getEncoded(), store.getPrivate("rsa").getEncoded());
|
||||||
|
}
|
||||||
|
byte[] persisted = Files.readAllBytes(path);
|
||||||
|
try {
|
||||||
|
assertFalse(indexOf(persisted, aesBytes) >= 0);
|
||||||
|
assertFalse(new String(persisted, StandardCharsets.ISO_8859_1).contains("java."));
|
||||||
|
System.out.println("...encrypted-bytes=" + persisted.length);
|
||||||
|
} finally {
|
||||||
|
Arrays.fill(persisted, (byte) 0);
|
||||||
|
Arrays.fill(aesBytes, (byte) 0);
|
||||||
|
}
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wrongPasswordAndCorruptionFailUniformly() throws Exception {
|
||||||
|
start("wrongPasswordAndCorruptionFailUniformly");
|
||||||
|
Path path = temporaryDirectory.resolve("wrong.zek");
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore ignored = KeyringStore.create(path, password)) {
|
||||||
|
// empty current-format store
|
||||||
|
}
|
||||||
|
try (KeyringPassword wrong = new KeyringPassword(new char[] { 'w', 'r', 'o', 'n', 'g' })) {
|
||||||
|
KeyringException exception = assertThrows(KeyringException.class,
|
||||||
|
() -> KeyringStore.open(path, wrong));
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_UNLOCK_FAILED, exception.code());
|
||||||
|
}
|
||||||
|
byte[] bytes = Files.readAllBytes(path);
|
||||||
|
bytes[bytes.length - 1] ^= 1;
|
||||||
|
Files.write(path, bytes);
|
||||||
|
Arrays.fill(bytes, (byte) 0);
|
||||||
|
try (KeyringPassword password = password()) {
|
||||||
|
KeyringException exception = assertThrows(KeyringException.class,
|
||||||
|
() -> KeyringStore.open(path, password));
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_FORMAT_INVALID, exception.code());
|
||||||
|
}
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void plaintextV1AndDuplicateOwnerAreRejected() throws Exception {
|
||||||
|
start("plaintextV1AndDuplicateOwnerAreRejected");
|
||||||
|
Path old = temporaryDirectory.resolve("old.txt");
|
||||||
|
Files.writeString(old, "# KeyringStore v1\n", StandardCharsets.UTF_8);
|
||||||
|
Files.setPosixFilePermissions(old, java.util.Set.of(
|
||||||
|
java.nio.file.attribute.PosixFilePermission.OWNER_READ,
|
||||||
|
java.nio.file.attribute.PosixFilePermission.OWNER_WRITE));
|
||||||
|
try (KeyringPassword password = password()) {
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_FORMAT_INVALID,
|
||||||
|
assertThrows(KeyringException.class,
|
||||||
|
() -> KeyringStore.open(old, password)).code());
|
||||||
|
}
|
||||||
|
|
||||||
|
Path path = temporaryDirectory.resolve("owned.zek");
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore first = KeyringStore.create(path, password);
|
||||||
|
KeyringPassword secondPassword = password()) {
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_ALREADY_OPEN,
|
||||||
|
assertThrows(KeyringException.class,
|
||||||
|
() -> KeyringStore.open(path, secondPassword)).code());
|
||||||
|
assertTrue(first.aliases().isEmpty());
|
||||||
|
}
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore reopened = KeyringStore.open(path, password)) {
|
||||||
|
assertTrue(reopened.aliases().isEmpty());
|
||||||
|
}
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void closeIsRepeatSafeAndRejectsUse() throws Exception {
|
||||||
|
start("closeIsRepeatSafeAndRejectsUse");
|
||||||
|
Path path = temporaryDirectory.resolve("closed.zek");
|
||||||
|
KeyringStore store;
|
||||||
|
try (KeyringPassword password = password()) {
|
||||||
|
store = KeyringStore.create(path, password);
|
||||||
|
}
|
||||||
|
store.close();
|
||||||
|
store.close();
|
||||||
|
assertTrue(store.isDestroyed());
|
||||||
|
assertThrows(IllegalStateException.class, store::aliases);
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void trailingDataAndNonExportableKeysAreRejected() throws Exception {
|
||||||
|
start("trailingDataAndNonExportableKeysAreRejected");
|
||||||
|
Path path = temporaryDirectory.resolve("strict.zek");
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.create(path, password)) {
|
||||||
|
SecretKey nonExportable = new SecretKey() {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getAlgorithm() {
|
||||||
|
return "AES";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getFormat() {
|
||||||
|
return "RAW";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] getEncoded() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
KeyringException exception = assertThrows(KeyringException.class,
|
||||||
|
() -> store.putSecret("opaque", "AES", nonExportable));
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_NON_EXPORTABLE_KEY, exception.code());
|
||||||
|
assertTrue(store.aliases().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
Files.write(path, new byte[] { 1 }, java.nio.file.StandardOpenOption.APPEND);
|
||||||
|
try (KeyringPassword password = password()) {
|
||||||
|
KeyringException exception = assertThrows(KeyringException.class,
|
||||||
|
() -> KeyringStore.open(path, password));
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_FORMAT_INVALID, exception.code());
|
||||||
|
}
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hmacVariantsAreClosedAndRejectedBeforeMutation() throws Exception {
|
||||||
|
start("hmacVariantsAreClosedAndRejectedBeforeMutation");
|
||||||
|
Path path = temporaryDirectory.resolve("hmac.zek");
|
||||||
|
List<String> accepted = List.of("HmacSHA256", "HmacSHA384", "HmacSHA512");
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.create(path, password,
|
||||||
|
KeyringProtection.standard(), deterministicRandom())) {
|
||||||
|
for (String variant : accepted) {
|
||||||
|
byte[] material = new byte[64];
|
||||||
|
Arrays.fill(material, (byte) variant.length());
|
||||||
|
try {
|
||||||
|
String alias = variant + ".key";
|
||||||
|
store.putSecret(alias, "HMAC", new SecretKeySpec(material, variant));
|
||||||
|
assertArrayEquals(material, store.getSecret(alias).getEncoded());
|
||||||
|
} finally {
|
||||||
|
Arrays.fill(material, (byte) 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byte[] before = Files.readAllBytes(path);
|
||||||
|
try {
|
||||||
|
for (String rejected : List.of("HmacMD5", "HmacSHA1", "HmacSHA224",
|
||||||
|
"hmacsha256", "HmacSha384", " HmacSHA512", "HmacSHA512 ",
|
||||||
|
"BC:HmacSHA256", "", "X".repeat(4097))) {
|
||||||
|
SecretKey key = controlledSecret(rejected, new byte[32]);
|
||||||
|
KeyringException exception = assertThrows(KeyringException.class,
|
||||||
|
() -> store.putSecret("rejected", "HMAC", key));
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_IMPORT_METADATA_INVALID,
|
||||||
|
exception.code());
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_IMPORT_METADATA_INVALID.name(),
|
||||||
|
exception.getMessage());
|
||||||
|
assertArrayEquals(before, Files.readAllBytes(path));
|
||||||
|
assertFalse(store.contains("rejected"));
|
||||||
|
}
|
||||||
|
SecretKey mismatched = controlledSecret("HmacSHA256", new byte[32]);
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_KEY_NOT_CANONICALIZABLE,
|
||||||
|
assertThrows(KeyringException.class,
|
||||||
|
() -> store.putSecret("wrong", "AES", mismatched)).code());
|
||||||
|
assertArrayEquals(before, Files.readAllBytes(path));
|
||||||
|
} finally {
|
||||||
|
Arrays.fill(before, (byte) 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void providerDraftAndUnknownHmacVariantAreRejectedStructurally() throws Exception {
|
||||||
|
start("providerDraftAndUnknownHmacVariantAreRejectedStructurally");
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_IMPORT_METADATA_INVALID,
|
||||||
|
assertThrows(KeyringException.class,
|
||||||
|
() -> KeyringImportRegistry.HmacVariant.fromCode(99)).code());
|
||||||
|
byte[] staleDraft = staleProviderEntryPlaintext();
|
||||||
|
try {
|
||||||
|
java.lang.reflect.Method decoder = KeyringStore.class.getDeclaredMethod(
|
||||||
|
"decodeEntryPlaintext", byte[].class);
|
||||||
|
decoder.setAccessible(true);
|
||||||
|
java.lang.reflect.InvocationTargetException failure =
|
||||||
|
assertThrows(java.lang.reflect.InvocationTargetException.class,
|
||||||
|
() -> decoder.invoke(null, (Object) staleDraft));
|
||||||
|
assertTrue(failure.getCause() instanceof KeyringException);
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_FORMAT_INVALID,
|
||||||
|
((KeyringException) failure.getCause()).code());
|
||||||
|
} finally {
|
||||||
|
Arrays.fill(staleDraft, (byte) 0);
|
||||||
|
}
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void providerBoundOrNoncanonicalKeysFailBeforeMutation() throws Exception {
|
||||||
|
start("providerBoundOrNoncanonicalKeysFailBeforeMutation");
|
||||||
|
Path path = temporaryDirectory.resolve("provider-bound.zek");
|
||||||
|
try (KeyringPassword password = password();
|
||||||
|
KeyringStore store = KeyringStore.create(path, password)) {
|
||||||
|
byte[] before = Files.readAllBytes(path);
|
||||||
|
try {
|
||||||
|
SecretKey unsupported = controlledSecret("ProviderAES", new byte[32]);
|
||||||
|
KeyringException failure = assertThrows(KeyringException.class,
|
||||||
|
() -> store.putSecret("bad", "AES", unsupported));
|
||||||
|
assertEquals(KeyringException.Code.KEYRING_KEY_NOT_CANONICALIZABLE,
|
||||||
|
failure.code());
|
||||||
|
assertArrayEquals(before, Files.readAllBytes(path));
|
||||||
|
assertFalse(store.contains("bad"));
|
||||||
|
} finally {
|
||||||
|
Arrays.fill(before, (byte) 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyringPassword password() {
|
||||||
|
return new KeyringPassword(PASSWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyringRandomBytes deterministicRandom() {
|
||||||
|
AtomicInteger value = new AtomicInteger(1);
|
||||||
|
return destination -> {
|
||||||
|
int base = value.getAndIncrement();
|
||||||
|
for (int index = 0; index < destination.length; index++) {
|
||||||
|
destination[index] = (byte) (base + index);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SecretKey controlledSecret(String algorithm, byte[] encoded) {
|
||||||
|
return new SecretKey() {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getAlgorithm() {
|
||||||
|
return algorithm;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getFormat() {
|
||||||
|
return "RAW";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] getEncoded() {
|
||||||
|
return encoded.clone();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] staleProviderEntryPlaintext() throws Exception {
|
||||||
|
java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream();
|
||||||
|
try (java.io.DataOutputStream out = new java.io.DataOutputStream(bytes)) {
|
||||||
|
out.writeInt(2);
|
||||||
|
writeString(out, "legacy.pub");
|
||||||
|
writeString(out, "ML-DSA");
|
||||||
|
out.writeByte(KeyringStore.Kind.PUBLIC_KEY.ordinal() + 1);
|
||||||
|
out.writeByte(KeyringStore.Encoding.X509.ordinal() + 1);
|
||||||
|
writeString(out, "BC");
|
||||||
|
out.writeInt(3);
|
||||||
|
out.write(new byte[] { 1, 2, 3 });
|
||||||
|
}
|
||||||
|
return bytes.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeString(java.io.DataOutputStream out, String value)
|
||||||
|
throws java.io.IOException {
|
||||||
|
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||||
|
try {
|
||||||
|
out.writeInt(bytes.length);
|
||||||
|
out.write(bytes);
|
||||||
|
} finally {
|
||||||
|
Arrays.fill(bytes, (byte) 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int indexOf(byte[] haystack, byte[] needle) {
|
||||||
|
outer:
|
||||||
|
for (int index = 0; index <= haystack.length - needle.length; index++) {
|
||||||
|
for (int offset = 0; offset < needle.length; offset++) {
|
||||||
|
if (haystack[index + offset] != needle[offset]) {
|
||||||
|
continue outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void start(String method) {
|
||||||
|
System.out.println(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ok() {
|
||||||
|
System.out.println("...ok");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ import java.util.concurrent.Future;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import zeroecho.core.util.RandomSupport;
|
||||||
|
|
||||||
class PasswordTest {
|
class PasswordTest {
|
||||||
@Test
|
@Test
|
||||||
void canonicalRandomFacadeValidatesNullAndAcceptsEmptyArrays() {
|
void canonicalRandomFacadeValidatesNullAndAcceptsEmptyArrays() {
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ import zeroecho.core.io.TailStrippingInputStream;
|
|||||||
import zeroecho.core.spec.AlgorithmKeySpec;
|
import zeroecho.core.spec.AlgorithmKeySpec;
|
||||||
import zeroecho.core.spec.ContextSpec;
|
import zeroecho.core.spec.ContextSpec;
|
||||||
import zeroecho.core.storage.KeyringStore;
|
import zeroecho.core.storage.KeyringStore;
|
||||||
|
import zeroecho.core.storage.KeyringPassword;
|
||||||
|
import zeroecho.core.spi.KeyringUnlockProvider;
|
||||||
import zeroecho.sdk.ZeroEchoSession;
|
import zeroecho.sdk.ZeroEchoSession;
|
||||||
import zeroecho.pki.api.EncodedObject;
|
import zeroecho.pki.api.EncodedObject;
|
||||||
import zeroecho.pki.api.Encoding;
|
import zeroecho.pki.api.Encoding;
|
||||||
@@ -275,6 +277,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
|||||||
private final String keyRefPrefix;
|
private final String keyRefPrefix;
|
||||||
private final boolean requireComponentSuffix;
|
private final boolean requireComponentSuffix;
|
||||||
private final ZeroEchoSession session;
|
private final ZeroEchoSession session;
|
||||||
|
private final KeyringUnlockProvider keyringUnlockProvider;
|
||||||
|
|
||||||
private final ConcurrentMap<PkiId, OperationStatus> statuses;
|
private final ConcurrentMap<PkiId, OperationStatus> statuses;
|
||||||
private final ConcurrentMap<PkiId, String> fingerprints;
|
private final ConcurrentMap<PkiId, String> fingerprints;
|
||||||
@@ -288,19 +291,33 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
|||||||
private final ReentrantLock timeWatermarkLock;
|
private final ReentrantLock timeWatermarkLock;
|
||||||
private final AtomicReference<String> boundNamespace;
|
private final AtomicReference<String> boundNamespace;
|
||||||
private final ReentrantLock domainLock;
|
private final ReentrantLock domainLock;
|
||||||
|
private final ReentrantLock keyringLifecycleLock;
|
||||||
private final BiConsumer<String, byte[]> cleanupObserver;
|
private final BiConsumer<String, byte[]> cleanupObserver;
|
||||||
|
|
||||||
private volatile KeyringStore keyringOrNull; // NOPMD
|
private volatile KeyringStore keyringOrNull; // NOPMD
|
||||||
|
private boolean closed;
|
||||||
|
|
||||||
/* default */ ZeroEchoLibSignatureWorkflow(String id, Path keyringPath, Path operationRoot, Clock clock,
|
/* 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,
|
this(id, keyringPath, operationRoot, clock, operationHorizon, keyRefPrefix, requireComponentSuffix,
|
||||||
|
keyringUnlockProvider,
|
||||||
(category, cleared) -> {
|
(category, cleared) -> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* default */ ZeroEchoLibSignatureWorkflow(String id, Path keyringPath, Path operationRoot, Clock clock,
|
/* default */ ZeroEchoLibSignatureWorkflow(String id, Path keyringPath, Path operationRoot, Clock clock,
|
||||||
Duration operationHorizon, String keyRefPrefix, boolean requireComponentSuffix,
|
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<String, byte[]> cleanupObserver) {
|
BiConsumer<String, byte[]> cleanupObserver) {
|
||||||
if (id == null || id.isBlank()) {
|
if (id == null || id.isBlank()) {
|
||||||
throw new IllegalArgumentException("id must not be blank");
|
throw new IllegalArgumentException("id must not be blank");
|
||||||
@@ -323,6 +340,9 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
|||||||
if (cleanupObserver == null) {
|
if (cleanupObserver == null) {
|
||||||
throw new IllegalArgumentException("cleanupObserver must not be null");
|
throw new IllegalArgumentException("cleanupObserver must not be null");
|
||||||
}
|
}
|
||||||
|
if (keyringUnlockProvider == null) {
|
||||||
|
throw new IllegalArgumentException("keyringUnlockProvider must not be null");
|
||||||
|
}
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.keyringPath = keyringPath;
|
this.keyringPath = keyringPath;
|
||||||
this.operationRoot = operationRoot.toAbsolutePath().normalize();
|
this.operationRoot = operationRoot.toAbsolutePath().normalize();
|
||||||
@@ -331,6 +351,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
|||||||
this.keyRefPrefix = keyRefPrefix;
|
this.keyRefPrefix = keyRefPrefix;
|
||||||
this.requireComponentSuffix = requireComponentSuffix;
|
this.requireComponentSuffix = requireComponentSuffix;
|
||||||
this.cleanupObserver = cleanupObserver;
|
this.cleanupObserver = cleanupObserver;
|
||||||
|
this.keyringUnlockProvider = keyringUnlockProvider;
|
||||||
this.session = new ZeroEchoSession();
|
this.session = new ZeroEchoSession();
|
||||||
|
|
||||||
this.statuses = new ConcurrentHashMap<>();
|
this.statuses = new ConcurrentHashMap<>();
|
||||||
@@ -340,6 +361,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
|||||||
this.sinks = new ConcurrentHashMap<>();
|
this.sinks = new ConcurrentHashMap<>();
|
||||||
this.operationLocks = new ConcurrentHashMap<>();
|
this.operationLocks = new ConcurrentHashMap<>();
|
||||||
this.domainLock = new ReentrantLock();
|
this.domainLock = new ReentrantLock();
|
||||||
|
this.keyringLifecycleLock = new ReentrantLock();
|
||||||
this.timeWatermarkLock = new ReentrantLock();
|
this.timeWatermarkLock = new ReentrantLock();
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(this.operationRoot);
|
Files.createDirectories(this.operationRoot);
|
||||||
@@ -451,6 +473,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
|||||||
* @throws IllegalArgumentException if {@code request} is {@code null}
|
* @throws IllegalArgumentException if {@code request} is {@code null}
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
|
@SuppressWarnings("PMD.CloseResource")
|
||||||
public PkiId submitSign(SignRequest request) {
|
public PkiId submitSign(SignRequest request) {
|
||||||
if (request == null) {
|
if (request == null) {
|
||||||
throw new IllegalArgumentException("request must not be null");
|
throw new IllegalArgumentException("request must not be null");
|
||||||
@@ -837,13 +860,28 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
|||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
|
@SuppressWarnings("PMD.CloseResource")
|
||||||
public void close() {
|
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.statuses.clear();
|
||||||
this.fingerprints.clear();
|
this.fingerprints.clear();
|
||||||
this.fences.clear();
|
this.fences.clear();
|
||||||
this.requests.clear();
|
this.requests.clear();
|
||||||
this.sinks.clear();
|
this.sinks.clear();
|
||||||
this.keyringOrNull = null;
|
if (keyring != null) {
|
||||||
|
keyring.close();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
this.ownershipLock.release();
|
this.ownershipLock.release();
|
||||||
this.ownershipChannel.close();
|
this.ownershipChannel.close();
|
||||||
@@ -852,14 +890,37 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private KeyringStore requireKeyringOrThrow() throws IOException {
|
@SuppressWarnings("PMD.CloseResource")
|
||||||
KeyringStore ks = this.keyringOrNull;
|
private KeyringStore requireKeyringOrThrow() throws IOException, GeneralSecurityException {
|
||||||
if (ks != null) {
|
keyringLifecycleLock.lock();
|
||||||
return ks;
|
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 {
|
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);
|
return new KeyRefParts(publicAlias, publicAlias);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("PMD.CloseResource")
|
||||||
private PublicKey resolvePublicKeyOrThrow(VerifyRequest request)
|
private PublicKey resolvePublicKeyOrThrow(VerifyRequest request)
|
||||||
throws InvalidRequestException, IOException, GeneralSecurityException {
|
throws InvalidRequestException, IOException, GeneralSecurityException {
|
||||||
|
|
||||||
|
|||||||
@@ -33,16 +33,23 @@
|
|||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
package zeroecho.pki.impl.crypto.zeroecholib;
|
package zeroecho.pki.impl.crypto.zeroecholib;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
import java.util.logging.Logger;
|
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.ProviderConfig;
|
||||||
import zeroecho.pki.spi.crypto.SignatureWorkflow;
|
import zeroecho.pki.spi.crypto.SignatureWorkflow;
|
||||||
import zeroecho.pki.spi.crypto.SignatureWorkflowProvider;
|
import zeroecho.pki.spi.crypto.SignatureWorkflowProvider;
|
||||||
|
import zeroecho.pki.spi.crypto.SignatureWorkflowRuntimeDependencies;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Production provider bridging PKI signature workflow to ZeroEcho lib based on
|
* Production provider bridging PKI signature workflow to ZeroEcho lib based on
|
||||||
@@ -69,6 +76,15 @@ import zeroecho.pki.spi.crypto.SignatureWorkflowProvider;
|
|||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
public final class ZeroEchoLibSignatureWorkflowProvider implements 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 Logger LOG = Logger.getLogger(ZeroEchoLibSignatureWorkflowProvider.class.getName());
|
||||||
|
|
||||||
private static final String KEY_KEYRING_PATH = "keyringPath";
|
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_OPERATION_HORIZON = "operationHorizon";
|
||||||
private static final String KEY_KEYREF_PREFIX = "keyRefPrefix";
|
private static final String KEY_KEYREF_PREFIX = "keyRefPrefix";
|
||||||
private static final String KEY_REQUIRE_SUFFIX = "requireComponentSuffix";
|
private static final String KEY_REQUIRE_SUFFIX = "requireComponentSuffix";
|
||||||
|
private final KeyringUnlockProvider keyringUnlockProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a service-loadable provider without unlock material.
|
||||||
|
*
|
||||||
|
* <p>{@link #allocate(ProviderConfig)} fails until an explicitly injected
|
||||||
|
* provider instance is used. Service configuration text can never contain
|
||||||
|
* an unlock secret.</p>
|
||||||
|
*/
|
||||||
|
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
|
@Override
|
||||||
public String id() {
|
public String id() {
|
||||||
@@ -127,6 +166,38 @@ public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWork
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public SignatureWorkflow allocate(final ProviderConfig config) {
|
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);
|
validateConfig(config);
|
||||||
String keyringPath = config.require(KEY_KEYRING_PATH);
|
String keyringPath = config.require(KEY_KEYRING_PATH);
|
||||||
Path operationRoot = Path.of(config.require(KEY_OPERATION_ROOT));
|
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:");
|
String prefix = config.get(KEY_KEYREF_PREFIX).orElse("zeroecho-lib:");
|
||||||
boolean requireSuffix = config.get(KEY_REQUIRE_SUFFIX).map(Boolean::parseBoolean).orElse(Boolean.TRUE);
|
boolean requireSuffix = config.get(KEY_REQUIRE_SUFFIX).map(Boolean::parseBoolean).orElse(Boolean.TRUE);
|
||||||
|
|
||||||
return new ZeroEchoLibSignatureWorkflow(id(), Path.of(keyringPath), operationRoot, Clock.systemUTC(),
|
KeyringStore keyring = openKeyring(Path.of(keyringPath), unlockProvider);
|
||||||
operationHorizon, prefix, requireSuffix);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import zeroecho.pki.spi.audit.AuditSink;
|
|||||||
import zeroecho.pki.spi.audit.AuditSinkProvider;
|
import zeroecho.pki.spi.audit.AuditSinkProvider;
|
||||||
import zeroecho.pki.spi.crypto.SignatureWorkflow;
|
import zeroecho.pki.spi.crypto.SignatureWorkflow;
|
||||||
import zeroecho.pki.spi.crypto.SignatureWorkflowProvider;
|
import zeroecho.pki.spi.crypto.SignatureWorkflowProvider;
|
||||||
|
import zeroecho.pki.spi.crypto.SignatureWorkflowRuntimeDependencies;
|
||||||
import zeroecho.pki.spi.framework.CredentialFramework;
|
import zeroecho.pki.spi.framework.CredentialFramework;
|
||||||
import zeroecho.pki.spi.framework.CredentialFrameworkProvider;
|
import zeroecho.pki.spi.framework.CredentialFrameworkProvider;
|
||||||
import zeroecho.pki.spi.store.PkiStore;
|
import zeroecho.pki.spi.store.PkiStore;
|
||||||
@@ -203,9 +204,19 @@ public final class PkiBootstrap {
|
|||||||
* Opens a {@link SignatureWorkflow} using {@link SignatureWorkflowProvider}
|
* Opens a {@link SignatureWorkflow} using {@link SignatureWorkflowProvider}
|
||||||
* discovered via ServiceLoader.
|
* discovered via ServiceLoader.
|
||||||
*
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* @param dependencies explicit process-local runtime dependencies
|
||||||
* @return signature workflow (never {@code null})
|
* @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);
|
String requestedId = System.getProperty(PROP_CRYPTO_WORKFLOW_BACKEND);
|
||||||
|
|
||||||
SignatureWorkflowProvider provider = SpiSelector.select(SignatureWorkflowProvider.class, requestedId,
|
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() + ")");
|
LOG.info("Selected crypto workflow provider: " + provider.id() + " (keys: " + props.keySet() + ")");
|
||||||
}
|
}
|
||||||
|
|
||||||
return provider.allocate(config);
|
return provider.allocate(config, dependencies);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -33,11 +33,37 @@
|
|||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
package zeroecho.pki.spi.crypto;
|
package zeroecho.pki.spi.crypto;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import zeroecho.pki.spi.ProviderConfig;
|
||||||
import zeroecho.pki.spi.ConfigurableProvider;
|
import zeroecho.pki.spi.ConfigurableProvider;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ServiceLoader provider for {@link SignatureWorkflow}.
|
* ServiceLoader provider for {@link SignatureWorkflow}.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
*/
|
*/
|
||||||
public interface SignatureWorkflowProvider extends ConfigurableProvider<SignatureWorkflow> {
|
public interface SignatureWorkflowProvider extends ConfigurableProvider<SignatureWorkflow> {
|
||||||
// marker
|
/**
|
||||||
|
* Allocates a workflow using explicit process-local runtime dependencies.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* <p>Instances are immutable and safe for concurrent use. This object does not
|
||||||
|
* acquire or retain password material.</p>
|
||||||
|
*/
|
||||||
|
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> keyringUnlockProvider() {
|
||||||
|
return Optional.ofNullable(keyringUnlockProvider);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@ public final class ZeroEchoLibKeyRefParsingTest {
|
|||||||
System.out.println("signing_requires_prv_suffix_in_strict_mode_ok");
|
System.out.println("signing_requires_prv_suffix_in_strict_mode_ok");
|
||||||
|
|
||||||
try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", Path.of("nonexistent"),
|
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"),
|
AccessContext ctx = new AccessContext(new Principal("TEST", "unit"), new Purpose("UNIT_TEST"),
|
||||||
Optional.empty(), Optional.empty());
|
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");
|
System.out.println("verify_with_publicKeyEncoded_invalid_spki_fails_with_crypto_failure_ok");
|
||||||
|
|
||||||
try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", Path.of("nonexistent"),
|
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"),
|
AccessContext ctx = new AccessContext(new Principal("TEST", "unit"), new Purpose("UNIT_TEST"),
|
||||||
Optional.empty(), Optional.empty());
|
Optional.empty(), Optional.empty());
|
||||||
@@ -123,7 +123,7 @@ public final class ZeroEchoLibKeyRefParsingTest {
|
|||||||
System.out.println("status_unknown_operation_is_deterministic_ok");
|
System.out.println("status_unknown_operation_is_deterministic_ok");
|
||||||
|
|
||||||
try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", Path.of("nonexistent"),
|
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");
|
PkiId unknown = new PkiId("00000000-0000-0000-0000-000000000000");
|
||||||
SignatureWorkflow.OperationStatus st = wf.status(unknown);
|
SignatureWorkflow.OperationStatus st = wf.status(unknown);
|
||||||
|
|||||||
@@ -90,10 +90,12 @@ final class ZeroEchoLibSignatureWorkflowPersistenceTest {
|
|||||||
Instant now = Instant.parse("2026-02-03T04:05:06.789Z");
|
Instant now = Instant.parse("2026-02-03T04:05:06.789Z");
|
||||||
Path keyring = root.resolve("keyring.txt");
|
Path keyring = root.resolve("keyring.txt");
|
||||||
KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
|
KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
|
||||||
KeyringStore keyringStore = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
|
try (zeroecho.core.storage.KeyringPassword password =
|
||||||
keyringStore.putPrivate("test.prv", "RSA", new RsaPrivateKeySpec(pair.getPrivate().getEncoded()));
|
TestKeyringUnlocks.provider().acquire();
|
||||||
keyringStore.putPublic("test.pub", "RSA", new RsaPublicKeySpec(pair.getPublic().getEncoded()));
|
KeyringStore keyringStore = KeyringStore.create(keyring, password)) {
|
||||||
keyringStore.save(keyring);
|
keyringStore.putPrivate("test.prv", "RSA", pair.getPrivate());
|
||||||
|
keyringStore.putPublic("test.pub", "RSA", pair.getPublic());
|
||||||
|
}
|
||||||
|
|
||||||
List<ObservedBuffer> cleared = new ArrayList<>();
|
List<ObservedBuffer> cleared = new ArrayList<>();
|
||||||
List<LogRecord> records = new ArrayList<>();
|
List<LogRecord> records = new ArrayList<>();
|
||||||
@@ -120,7 +122,7 @@ final class ZeroEchoLibSignatureWorkflowPersistenceTest {
|
|||||||
logger.addHandler(handler);
|
logger.addHandler(handler);
|
||||||
try (ZeroEchoLibSignatureWorkflow workflow = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", keyring,
|
try (ZeroEchoLibSignatureWorkflow workflow = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", keyring,
|
||||||
root.resolve("cleanup-operations"), Clock.fixed(now, ZoneOffset.UTC), Duration.ofDays(90),
|
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) -> {
|
SignatureWorkflow.Registration registration = workflow.register((operationId, status) -> {
|
||||||
throw new IllegalStateException("DO_NOT_LOG_SIGNATURE_SENTINEL");
|
throw new IllegalStateException("DO_NOT_LOG_SIGNATURE_SENTINEL");
|
||||||
})) {
|
})) {
|
||||||
@@ -194,10 +196,12 @@ final class ZeroEchoLibSignatureWorkflowPersistenceTest {
|
|||||||
Instant base = Instant.parse("2026-02-03T04:05:06Z");
|
Instant base = Instant.parse("2026-02-03T04:05:06Z");
|
||||||
Path keyring = root.resolve("keyring.txt");
|
Path keyring = root.resolve("keyring.txt");
|
||||||
KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
|
KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
|
||||||
KeyringStore keyringStore = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
|
try (zeroecho.core.storage.KeyringPassword password =
|
||||||
keyringStore.putPrivate("test.prv", "RSA", new RsaPrivateKeySpec(pair.getPrivate().getEncoded()));
|
TestKeyringUnlocks.provider().acquire();
|
||||||
keyringStore.putPublic("test.pub", "RSA", new RsaPublicKeySpec(pair.getPublic().getEncoded()));
|
KeyringStore keyringStore = KeyringStore.create(keyring, password)) {
|
||||||
keyringStore.save(keyring);
|
keyringStore.putPrivate("test.prv", "RSA", pair.getPrivate());
|
||||||
|
keyringStore.putPublic("test.pub", "RSA", pair.getPublic());
|
||||||
|
}
|
||||||
|
|
||||||
PkiId onTimeId = SigningSubmissionId.create(NAMESPACE, base, new SecureRandom()).id();
|
PkiId onTimeId = SigningSubmissionId.create(NAMESPACE, base, new SecureRandom()).id();
|
||||||
Clock onTimeClock = Clock.fixed(base, ZoneOffset.UTC);
|
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) {
|
private static ZeroEchoLibSignatureWorkflow workflow(Path root, Path operations, Path keyring, Clock clock) {
|
||||||
return new ZeroEchoLibSignatureWorkflow("zeroecho-lib", keyring, operations, 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) {
|
private static SignatureWorkflow.SignRequest request(PkiId id, long fence, byte[] payload) {
|
||||||
|
|||||||
@@ -71,11 +71,14 @@ public final class ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest {
|
|||||||
System.out.println("verifyFromSpkiDerEcdsaSucceeds");
|
System.out.println("verifyFromSpkiDerEcdsaSucceeds");
|
||||||
|
|
||||||
Path keyring = tempDir.resolve("keyring.txt");
|
Path keyring = tempDir.resolve("keyring.txt");
|
||||||
KeyringStore ks = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
|
try (zeroecho.core.storage.KeyringPassword password =
|
||||||
ks.save(keyring);
|
TestKeyringUnlocks.provider().acquire();
|
||||||
|
KeyringStore ignored = KeyringStore.create(keyring, password)) {
|
||||||
|
// Empty keyring is sufficient for encoded-key verification.
|
||||||
|
}
|
||||||
|
|
||||||
try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("wf", keyring,
|
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");
|
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
|
||||||
kpg.initialize(new ECGenParameterSpec("secp256r1"));
|
kpg.initialize(new ECGenParameterSpec("secp256r1"));
|
||||||
KeyPair kp = kpg.generateKeyPair();
|
KeyPair kp = kpg.generateKeyPair();
|
||||||
|
|||||||
@@ -66,11 +66,14 @@ public final class ZeroEchoLibSignatureWorkflowVerifyEncodedTest {
|
|||||||
System.out.println("verifyFromSpkiDerSucceeds");
|
System.out.println("verifyFromSpkiDerSucceeds");
|
||||||
|
|
||||||
Path keyring = tempDir.resolve("keyring.txt");
|
Path keyring = tempDir.resolve("keyring.txt");
|
||||||
KeyringStore ks = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
|
try (zeroecho.core.storage.KeyringPassword password =
|
||||||
ks.save(keyring);
|
TestKeyringUnlocks.provider().acquire();
|
||||||
|
KeyringStore ignored = KeyringStore.create(keyring, password)) {
|
||||||
|
// Empty keyring is sufficient for encoded-key verification.
|
||||||
|
}
|
||||||
|
|
||||||
try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("wf", keyring,
|
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();
|
KeyPair kp = KeyPairGenerator.getInstance("RSA").generateKeyPair();
|
||||||
byte[] payload = "pqc-ready".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
byte[] payload = "pqc-ready".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ import org.bouncycastle.asn1.x500.X500Name;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
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.EncodedObject;
|
||||||
import zeroecho.pki.api.Encoding;
|
import zeroecho.pki.api.Encoding;
|
||||||
import zeroecho.pki.api.issuance.VerificationPolicy;
|
import zeroecho.pki.api.issuance.VerificationPolicy;
|
||||||
@@ -68,8 +70,12 @@ public final class WorkflowProofOfPossessionVerifierTest {
|
|||||||
public void verifyRsaCsrViaWorkflow_ok() throws Exception {
|
public void verifyRsaCsrViaWorkflow_ok() throws Exception {
|
||||||
System.out.println("verifyRsaCsrViaWorkflow_ok");
|
System.out.println("verifyRsaCsrViaWorkflow_ok");
|
||||||
|
|
||||||
Path keyringPath = tempDir.resolve("keyring.txt");
|
Path keyringPath = tempDir.resolve("keyring.zek");
|
||||||
java.nio.file.Files.writeString(keyringPath, "", java.nio.charset.StandardCharsets.UTF_8);
|
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());
|
System.out.println("...keyringPath=" + keyringPath.getFileName());
|
||||||
|
|
||||||
KeyPair kp = KeyPairGenerator.getInstance("RSA").generateKeyPair();
|
KeyPair kp = KeyPairGenerator.getInstance("RSA").generateKeyPair();
|
||||||
@@ -86,7 +92,9 @@ public final class WorkflowProofOfPossessionVerifierTest {
|
|||||||
|
|
||||||
ParsedCertificationRequest parsed = new BcX509CertificationRequestParser().parse(req);
|
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(),
|
ProviderConfig cfg = new ProviderConfig(provider.id(), Map.of("keyringPath", keyringPath.toString(),
|
||||||
"operationRoot", tempDir.resolve("signing-operations").toString()));
|
"operationRoot", tempDir.resolve("signing-operations").toString()));
|
||||||
SignatureWorkflow wf = provider.allocate(cfg);
|
SignatureWorkflow wf = provider.allocate(cfg);
|
||||||
|
|||||||
@@ -34,21 +34,52 @@
|
|||||||
package zeroecho.pki.spi.bootstrap;
|
package zeroecho.pki.spi.bootstrap;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
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.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
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.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.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.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
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.PkiId;
|
||||||
import zeroecho.pki.api.audit.Principal;
|
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.audit.AuditSink;
|
||||||
import zeroecho.pki.spi.crypto.SignatureWorkflow;
|
import zeroecho.pki.spi.crypto.SignatureWorkflow;
|
||||||
|
import zeroecho.pki.spi.crypto.SignatureWorkflowRuntimeDependencies;
|
||||||
import zeroecho.pki.spi.framework.CredentialFramework;
|
import zeroecho.pki.spi.framework.CredentialFramework;
|
||||||
import zeroecho.pki.spi.store.PkiStore;
|
import zeroecho.pki.spi.store.PkiStore;
|
||||||
import zeroecho.pki.util.async.AsyncBus;
|
import zeroecho.pki.util.async.AsyncBus;
|
||||||
@@ -68,6 +99,10 @@ import zeroecho.pki.util.async.AsyncBus;
|
|||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
public final class PkiBootstrapTest {
|
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
|
@TempDir
|
||||||
private Path tempDir;
|
private Path tempDir;
|
||||||
@@ -257,24 +292,192 @@ public final class PkiBootstrapTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void openSignatureWorkflow_zeroEchoLib_usesConfiguredKeyringPath() {
|
public void openSignatureWorkflow_zeroEchoLib_usesConfiguredKeyringPath()
|
||||||
|
throws Exception {
|
||||||
System.out.println("openSignatureWorkflow_zeroEchoLib_usesConfiguredKeyringPath");
|
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", "zeroecho-lib");
|
||||||
System.setProperty("zeroecho.pki.crypto.workflow.keyringPath",
|
System.setProperty("zeroecho.pki.crypto.workflow.keyringPath",
|
||||||
this.tempDir.resolve("workflow").resolve("keyring.zek").toString());
|
keyringPath.toString());
|
||||||
System.setProperty("zeroecho.pki.crypto.workflow.operationRoot",
|
System.setProperty("zeroecho.pki.crypto.workflow.operationRoot",
|
||||||
this.tempDir.resolve("workflow").resolve("operations").toString());
|
this.tempDir.resolve("workflow").resolve("operations").toString());
|
||||||
System.setProperty("zeroecho.pki.crypto.workflow.keyRefPrefix", "test-prefix:");
|
System.setProperty("zeroecho.pki.crypto.workflow.keyRefPrefix", "test-prefix:");
|
||||||
System.setProperty("zeroecho.pki.crypto.workflow.requireComponentSuffix", "false");
|
System.setProperty("zeroecho.pki.crypto.workflow.requireComponentSuffix", "false");
|
||||||
|
|
||||||
SignatureWorkflow workflow = PkiBootstrap.openSignatureWorkflow();
|
AtomicInteger acquisitions = new AtomicInteger();
|
||||||
assertNotNull(workflow);
|
AtomicReference<KeyringPassword> 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();
|
try (SignatureWorkflow workflow = PkiBootstrap.openSignatureWorkflow(dependencies)) {
|
||||||
System.out.println("...workflowClass=" + workflowClassName);
|
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<LogRecord> 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<KeyringPassword> 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");
|
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<LogRecord> 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.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import java.util.logging.Logger;
|
|||||||
|
|
||||||
import javax.crypto.SecretKey;
|
import javax.crypto.SecretKey;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import conflux.Ctx;
|
import conflux.Ctx;
|
||||||
@@ -62,6 +63,7 @@ import zeroecho.sdk.builders.core.PlainBytesBuilder;
|
|||||||
import zeroecho.sdk.content.api.DataContent;
|
import zeroecho.sdk.content.api.DataContent;
|
||||||
import zeroecho.sdk.ZeroEchoSession;
|
import zeroecho.sdk.ZeroEchoSession;
|
||||||
|
|
||||||
|
@Tag("sample")
|
||||||
class AesTest {
|
class AesTest {
|
||||||
private static final Logger LOG = Logger.getLogger(AesTest.class.getName());
|
private static final Logger LOG = Logger.getLogger(AesTest.class.getName());
|
||||||
private final ZeroEchoSession zeroEchoSession = new ZeroEchoSession();
|
private final ZeroEchoSession zeroEchoSession = new ZeroEchoSession();
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import java.util.logging.Level;
|
|||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import zeroecho.core.CryptoAlgorithm;
|
import zeroecho.core.CryptoAlgorithm;
|
||||||
@@ -107,6 +108,7 @@ import zeroecho.sdk.util.BouncyCastleActivator;
|
|||||||
* for didactic reasons.
|
* for didactic reasons.
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
|
@Tag("sample")
|
||||||
class AgreementVariantsTest {
|
class AgreementVariantsTest {
|
||||||
private final ZeroEchoSession session = new ZeroEchoSession();
|
private final ZeroEchoSession session = new ZeroEchoSession();
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import java.util.logging.Level;
|
|||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.TestInstance;
|
import org.junit.jupiter.api.TestInstance;
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ import zeroecho.sdk.Pbkdf2Limits;
|
|||||||
import zeroecho.sdk.ZeroEchoSession;
|
import zeroecho.sdk.ZeroEchoSession;
|
||||||
import zeroecho.sdk.util.BouncyCastleActivator;
|
import zeroecho.sdk.util.BouncyCastleActivator;
|
||||||
|
|
||||||
|
@Tag("sample")
|
||||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
class CombinedDeliveryTest {
|
class CombinedDeliveryTest {
|
||||||
private static final Logger LOG = Logger.getLogger(CombinedDeliveryTest.class.getName());
|
private static final Logger LOG = Logger.getLogger(CombinedDeliveryTest.class.getName());
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import java.util.Arrays;
|
|||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import zeroecho.core.alg.kyber.KyberKeyGenSpec;
|
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.
|
* HKDF salt) and injects key/IV/AAD into existing streaming builders.
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
|
@Tag("sample")
|
||||||
class HybridDerivedAesDemoTest {
|
class HybridDerivedAesDemoTest {
|
||||||
private final ZeroEchoSession session = new ZeroEchoSession();
|
private final ZeroEchoSession session = new ZeroEchoSession();
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import java.util.Arrays;
|
|||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import zeroecho.core.alg.common.agreement.KeyPairKey;
|
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.
|
* explicit close blocks keeps the handshake lifecycle visible to the reader.
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
|
@Tag("sample")
|
||||||
class HybridKexDemoTest {
|
class HybridKexDemoTest {
|
||||||
private final ZeroEchoSession session = new ZeroEchoSession();
|
private final ZeroEchoSession session = new ZeroEchoSession();
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import java.util.logging.Logger;
|
|||||||
import javax.crypto.SecretKey;
|
import javax.crypto.SecretKey;
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import zeroecho.core.alg.ed25519.Ed25519KeyGenSpec;
|
import zeroecho.core.alg.ed25519.Ed25519KeyGenSpec;
|
||||||
@@ -77,6 +78,7 @@ import zeroecho.sdk.util.BouncyCastleActivator;
|
|||||||
* with AND verification.
|
* with AND verification.
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
|
@Tag("sample")
|
||||||
class HybridSigningAesTest {
|
class HybridSigningAesTest {
|
||||||
|
|
||||||
private static final Logger LOG = Logger.getLogger(HybridSigningAesTest.class.getName());
|
private static final Logger LOG = Logger.getLogger(HybridSigningAesTest.class.getName());
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import java.util.logging.Level;
|
|||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.TestInstance;
|
import org.junit.jupiter.api.TestInstance;
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ import zeroecho.sdk.content.api.DataContent;
|
|||||||
import zeroecho.sdk.util.BouncyCastleActivator;
|
import zeroecho.sdk.util.BouncyCastleActivator;
|
||||||
import zeroecho.sdk.ZeroEchoSession;
|
import zeroecho.sdk.ZeroEchoSession;
|
||||||
|
|
||||||
|
@Tag("sample")
|
||||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
class PostQuantumTest {
|
class PostQuantumTest {
|
||||||
private final ZeroEchoSession session = new ZeroEchoSession();
|
private final ZeroEchoSession session = new ZeroEchoSession();
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import java.util.logging.Logger;
|
|||||||
|
|
||||||
import javax.crypto.SecretKey;
|
import javax.crypto.SecretKey;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import zeroecho.core.alg.rsa.RsaKeyGenSpec;
|
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.content.api.DataContent;
|
||||||
import zeroecho.sdk.ZeroEchoSession;
|
import zeroecho.sdk.ZeroEchoSession;
|
||||||
|
|
||||||
|
@Tag("sample")
|
||||||
class SigningAesTest {
|
class SigningAesTest {
|
||||||
private final ZeroEchoSession session = new ZeroEchoSession();
|
private final ZeroEchoSession session = new ZeroEchoSession();
|
||||||
private static final Logger LOG = Logger.getLogger(SigningAesTest.class.getName());
|
private static final Logger LOG = Logger.getLogger(SigningAesTest.class.getName());
|
||||||
|
|||||||
Reference in New Issue
Block a user