Initial commit (history reset)
This commit is contained in:
194
app/src/main/java/zeroecho/CovertCommand.java
Normal file
194
app/src/main/java/zeroecho/CovertCommand.java
Normal file
@@ -0,0 +1,194 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, 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;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.commons.cli.CommandLine;
|
||||
import org.apache.commons.cli.CommandLineParser;
|
||||
import org.apache.commons.cli.DefaultParser;
|
||||
import org.apache.commons.cli.Option;
|
||||
import org.apache.commons.cli.OptionGroup;
|
||||
import org.apache.commons.cli.Options;
|
||||
import org.apache.commons.cli.ParseException;
|
||||
|
||||
import zeroecho.sdk.integrations.covert.jpeg.JpegExifEmbedder;
|
||||
import zeroecho.sdk.integrations.covert.jpeg.Slot;
|
||||
|
||||
/**
|
||||
* Command-line extension of ZeroEcho for covert embedding and extraction of
|
||||
* binary payloads in JPEG files using EXIF metadata slots.
|
||||
*
|
||||
* <p>
|
||||
* This extension operates in two primary modes:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>Embedding a binary payload across one or more EXIF metadata fields</li>
|
||||
* <li>Extracting a previously embedded payload from those fields</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The embedding process preserves all original metadata except for the specific
|
||||
* slots being reused. Slot selection can be customized, or a default set of
|
||||
* predefined high-capacity EXIF fields will be used automatically.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* The following command-line options are supported:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>{@code --embed}: Activate embedding mode (requires
|
||||
* {@code --payload})</li>
|
||||
* <li>{@code --extract}: Activate extraction mode</li>
|
||||
* <li>{@code --jpeg <input.jpg>}: Input JPEG file to embed into or extract from
|
||||
* (required)</li>
|
||||
* <li>{@code --payload <file>}: File containing payload data to embed (required
|
||||
* for {@code --embed})</li>
|
||||
* <li>{@code --output <file>}: Output file (JPEG with embedded data, or raw
|
||||
* extracted payload) (required)</li>
|
||||
* <li>{@code --slots <s1;s2;...>}: Optional semicolon-separated list of EXIF
|
||||
* slot definitions, each in the form {@code [group.]name[:capacity]} or a fully
|
||||
* defined custom tag like
|
||||
* {@code [group.]name/tag=tagId,type,count,dir[:capacity]}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* If {@code --slots} is omitted, a default list of slots is used with realistic
|
||||
* capacities suitable for several kilobytes of covert data. Slots are defined
|
||||
* using EXIF field tags grouped by logical metadata regions (e.g., IFD0, Exif
|
||||
* IFD, GPS IFD).
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* Text-based EXIF slots (e.g., ASCII) will automatically encode binary payloads
|
||||
* using Base64. Extraction decodes them back transparently.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* This class is not meant to be instantiated.
|
||||
* </p>
|
||||
*
|
||||
* @author Leo Galambos
|
||||
*/
|
||||
public final class CovertCommand {
|
||||
private CovertCommand() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for command-line usage of the covert embedding and extraction
|
||||
* tool.
|
||||
* <p>
|
||||
* Parses command-line arguments to embed or extract binary payloads in JPEG
|
||||
* files using EXIF metadata slots. Embedding requires a payload file, a JPEG
|
||||
* input, and an output destination. Extraction requires only the JPEG input and
|
||||
* output file. Slot behavior can be customized using semicolon-separated slot
|
||||
* specifications.
|
||||
* </p>
|
||||
*
|
||||
* @param args Command-line arguments
|
||||
* @param options An {@link Options} instance to populate with supported CLI
|
||||
* options
|
||||
* @return {@code 0} on success, {@code 1} on I/O error
|
||||
* @throws ParseException if the arguments are invalid or incomplete
|
||||
*/
|
||||
public static int main(String[] args, Options options) throws ParseException {
|
||||
final Option EMBED_OPTION = Option.builder().longOpt("embed").desc("Embed a payload into a JPEG").build();
|
||||
final Option EXTRACT_OPTION = Option.builder().longOpt("extract").desc("Extract a payload from a JPEG").build();
|
||||
final Option JPEG_OPTION = Option.builder().longOpt("jpeg").hasArg().argName("input.jpg")
|
||||
.desc("Input JPEG file").required().build();
|
||||
final Option PAYLOAD_OPTION = Option.builder().longOpt("payload").hasArg().argName("payload.dat")
|
||||
.desc("Binary payload file to embed").build();
|
||||
final Option OUTPUT_OPTION = Option.builder().longOpt("output").hasArg().argName("outputFile")
|
||||
.desc("Output JPEG or payload file").required().build();
|
||||
final Option SLOTS_OPTION = Option.builder().longOpt("slots").hasArgs().valueSeparator(';')
|
||||
.argName("slot1;slot2;...")
|
||||
.desc("Custom EXIF slots (e.g. Exif.UserComment:4096;Exif.Custom/tag=700,ascii,64,exif:1024)").build();
|
||||
|
||||
OptionGroup modeGroup = new OptionGroup();
|
||||
modeGroup.addOption(EMBED_OPTION);
|
||||
modeGroup.addOption(EXTRACT_OPTION);
|
||||
modeGroup.setRequired(true);
|
||||
|
||||
options.addOptionGroup(modeGroup);
|
||||
options.addOption(JPEG_OPTION);
|
||||
options.addOption(PAYLOAD_OPTION);
|
||||
options.addOption(OUTPUT_OPTION);
|
||||
options.addOption(SLOTS_OPTION);
|
||||
|
||||
CommandLineParser parser = new DefaultParser();
|
||||
CommandLine cmd = parser.parse(options, args);
|
||||
|
||||
Path jpegPath = Path.of(cmd.getOptionValue("jpeg"));
|
||||
|
||||
List<Slot> slots;
|
||||
if (cmd.hasOption("slots")) {
|
||||
slots = Arrays.stream(cmd.getOptionValues("slots")).map(Slot::parse).collect(Collectors.toList());
|
||||
} else {
|
||||
slots = Slot.defaults();
|
||||
}
|
||||
|
||||
JpegExifEmbedder processor = new JpegExifEmbedder();
|
||||
processor.setSlots(slots);
|
||||
|
||||
try {
|
||||
if (cmd.hasOption("embed")) {
|
||||
if (!cmd.hasOption("payload")) {
|
||||
throw new ParseException("--payload is required for embedding");
|
||||
}
|
||||
try (InputStream payload = Files.newInputStream(Paths.get(cmd.getOptionValue("payload")));
|
||||
OutputStream output = Files.newOutputStream(Paths.get(cmd.getOptionValue("output")))) {
|
||||
processor.embed(jpegPath, payload, output);
|
||||
}
|
||||
} else if (cmd.hasOption("extract")) {
|
||||
try (OutputStream output = Files.newOutputStream(Paths.get(cmd.getOptionValue("output")))) {
|
||||
processor.extract(jpegPath, output);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
System.err.println("I/O error: " + e.getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
506
app/src/main/java/zeroecho/Guard.java
Normal file
506
app/src/main/java/zeroecho/Guard.java
Normal file
@@ -0,0 +1,506 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, 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;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.apache.commons.cli.CommandLine;
|
||||
import org.apache.commons.cli.CommandLineParser;
|
||||
import org.apache.commons.cli.DefaultParser;
|
||||
import org.apache.commons.cli.Option;
|
||||
import org.apache.commons.cli.OptionGroup;
|
||||
import org.apache.commons.cli.Options;
|
||||
import org.apache.commons.cli.ParseException;
|
||||
|
||||
import zeroecho.core.CryptoAlgorithms;
|
||||
import zeroecho.core.KeyUsage;
|
||||
import zeroecho.core.context.EncryptionContext;
|
||||
import zeroecho.core.context.KemContext;
|
||||
import zeroecho.core.storage.KeyringStore;
|
||||
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
|
||||
import zeroecho.sdk.builders.alg.ChaChaDataContentBuilder;
|
||||
import zeroecho.sdk.builders.core.PlainFileBuilder;
|
||||
import zeroecho.sdk.content.api.DataContent;
|
||||
import zeroecho.sdk.guard.MultiRecipientDataSourceBuilder;
|
||||
import zeroecho.sdk.guard.UnlockMaterial;
|
||||
|
||||
/**
|
||||
* Guard is a unified subcommand that encrypts and decrypts using a
|
||||
* multi-recipient envelope with AES or ChaCha payloads.
|
||||
*
|
||||
* <h2>Overview</h2> The class replaces the previous MultiRecipientAes and
|
||||
* PasswordBasedAes CLIs by exposing a single entry point. It builds an envelope
|
||||
* with a recipients table (real and decoy) via
|
||||
* {@code MultiRecipientDataSourceBuilder}, then delegates the payload to either
|
||||
* {@code AesDataContentBuilder} or {@code ChaChaDataContentBuilder}.
|
||||
*
|
||||
* <p>
|
||||
* Default behavior:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>Recipients are shuffled by default to harden traffic analysis. Disable
|
||||
* with {@code --no-shuffle}.</li>
|
||||
* <li>The symmetric stage writes and parses its own header by default.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Keyring resolution</h2> Recipient aliases and the unlocking alias are
|
||||
* resolved from {@code KeyringStore}. The algorithm id stored in the keyring
|
||||
* drives which recipient method to call, so the user does not have to specify
|
||||
* the algorithm again.
|
||||
*
|
||||
* <h2>Payload selection</h2> Use {@code --alg} to choose one of:
|
||||
* {@code aes-gcm}, {@code aes-ctr}, {@code aes-cbc-pkcs7},
|
||||
* {@code aes-cbc-nopad}, {@code chacha-aead}, {@code chacha-stream}.
|
||||
*
|
||||
* <h2>Examples</h2> <pre>{@code
|
||||
* # Encrypt with KEM + RSA recipients, 2 decoy passwords, AES-GCM
|
||||
* ZeroEcho -G --encrypt in.bin --ks keys.txt \
|
||||
* --to-alias alice --to-alias bob \
|
||||
* --decoy-psw-rand 2 \
|
||||
* --alg aes-gcm --tag-bits 128 --aad-hex 01ff
|
||||
*
|
||||
* # Decrypt with private key (payload is ChaCha20-Poly1305)
|
||||
* ZeroEcho -G --decrypt blob.bin --ks keys.txt \
|
||||
* --priv-alias alice \
|
||||
* --alg chacha-aead
|
||||
* }</pre>
|
||||
*/
|
||||
public final class Guard {
|
||||
|
||||
private Guard() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for the Guard subcommand. It preserves Apache Commons CLI usage
|
||||
* and the subcommand invocation pattern used by {@code ZeroEcho}.
|
||||
*
|
||||
* @param args command-line arguments
|
||||
* @param options options object provided by the dispatcher; this method
|
||||
* augments it
|
||||
* @return exit code (0 = success, non-zero = failure)
|
||||
* @throws ParseException on CLI parsing errors
|
||||
* @throws IOException on I/O errors
|
||||
* @throws GeneralSecurityException on cryptographic setup or keyring errors
|
||||
*/
|
||||
public static int main(final String[] args, final Options options) // NOPMD
|
||||
throws ParseException, IOException, GeneralSecurityException {
|
||||
|
||||
// ---- operation selection
|
||||
final Option OPT_ENCRYPT = Option.builder("e").longOpt("encrypt").hasArg().argName("in-file")
|
||||
.desc("Encrypt the given file").build();
|
||||
final Option OPT_DECRYPT = Option.builder("d").longOpt("decrypt").hasArg().argName("in-file")
|
||||
.desc("Decrypt the given file").build();
|
||||
final OptionGroup OP = new OptionGroup().addOption(OPT_ENCRYPT).addOption(OPT_DECRYPT);
|
||||
OP.setRequired(true);
|
||||
|
||||
// ---- common I/O
|
||||
final Option OPT_OUT = Option.builder("o").longOpt("output").hasArg().argName("out-file")
|
||||
.desc("Output file (default: <in>.enc for encrypt, <in>.dec for decrypt)").build();
|
||||
final Option OPT_KEYRING = Option.builder().longOpt("keyring").hasArg().argName("keyring.txt")
|
||||
.desc("KeyringStore file for aliases (required when aliases are used)").build();
|
||||
|
||||
// ---- payload selection and parameters
|
||||
final Option OPT_ALG = Option.builder().longOpt("alg").hasArg().argName("name")
|
||||
.desc("Payload: aes-gcm | aes-ctr | aes-cbc-pkcs7 | aes-cbc-nopad | chacha-aead | chacha-stream "
|
||||
+ "(default: aes-gcm)")
|
||||
.build();
|
||||
final Option OPT_AAD_HEX = Option.builder("a").longOpt("aad-hex").hasArg().argName("hex")
|
||||
.desc("Additional authenticated data as hex (AEAD modes)").build();
|
||||
final Option OPT_TAG_BITS = Option.builder().longOpt("tag-bits").hasArg().argName("96..128")
|
||||
.desc("AES-GCM tag length in bits (default 128)").build();
|
||||
final Option OPT_NONCE_HEX = Option.builder().longOpt("nonce-hex").hasArg().argName("hex")
|
||||
.desc("ChaCha nonce (12-byte hex)").build();
|
||||
final Option OPT_INIT_CTR = Option.builder().longOpt("init-ctr").hasArg().argName("int")
|
||||
.desc("ChaCha stream initial counter (default 1)").build();
|
||||
final Option OPT_CTR = Option.builder().longOpt("ctr").hasArg().argName("int")
|
||||
.desc("ChaCha stream counter override (propagated via context)").build();
|
||||
final Option OPT_NO_HDR = Option.builder().longOpt("no-header")
|
||||
.desc("Do not write or expect a symmetric header").build();
|
||||
|
||||
// ---- envelope parameters
|
||||
final Option OPT_CEK_BYTES = Option.builder().longOpt("cek-bytes").hasArg().argName("len")
|
||||
.desc("Payload key (CEK) length in bytes (default 32)").build();
|
||||
final Option OPT_MAX_RECIPS = Option.builder().longOpt("max-recipients").hasArg().argName("n")
|
||||
.desc("Max recipients in the envelope header (default 64)").build();
|
||||
final Option OPT_MAX_ENTRY = Option.builder().longOpt("max-entry-len").hasArg().argName("bytes")
|
||||
.desc("Max single recipient-entry length (default 1048576)").build();
|
||||
final Option OPT_NO_SHUFFLE = Option.builder().longOpt("no-shuffle")
|
||||
.desc("Disable shuffling of recipients (enabled by default)").build();
|
||||
|
||||
// ---- recipients (real)
|
||||
final Option OPT_TO_ALIAS = Option.builder().longOpt("to-alias").hasArg().argName("alias")
|
||||
.desc("Add recipient by alias from keyring (repeatable)").build();
|
||||
final Option OPT_TO_PSW = Option.builder().longOpt("to-psw").hasArg().argName("password")
|
||||
.desc("Add password recipient (repeatable)").build();
|
||||
final Option OPT_PSW_ITER = Option.builder().longOpt("to-iter").hasArg().argName("n")
|
||||
.desc("PBKDF2 iterations for password recipients (default 200000)").build();
|
||||
final Option OPT_PSW_SALT = Option.builder().longOpt("to-salt-len").hasArg().argName("bytes")
|
||||
.desc("PBKDF2 salt length for password recipients (default 16)").build();
|
||||
final Option OPT_PSW_KEK = Option.builder().longOpt("to-kek-bytes").hasArg().argName("bytes")
|
||||
.desc("Derived KEK length for password recipients (default 32)").build();
|
||||
|
||||
// ---- decoys (all types)
|
||||
final Option OPT_DECOY_ALIAS = Option.builder().longOpt("decoy-alias").hasArg().argName("alias")
|
||||
.desc("Add a decoy recipient from keyring (repeatable)").build();
|
||||
final Option OPT_DECOY_PSW = Option.builder().longOpt("decoy-psw").hasArg().argName("password")
|
||||
.desc("Add a decoy password recipient (repeatable)").build();
|
||||
final Option OPT_DECOY_PSW_RAND = Option.builder().longOpt("decoy-psw-rand").hasArg().argName("n")
|
||||
.desc("Add N random decoy password recipients").build();
|
||||
|
||||
// ---- unlock (decrypt)
|
||||
final Option OPT_PRIV_ALIAS = Option.builder().longOpt("priv-alias").hasArg().argName("alias")
|
||||
.desc("Unlock with private key from keyring").build();
|
||||
final Option OPT_PASSWORD = Option.builder("p").longOpt("password").hasArg().argName("password")
|
||||
.desc("Unlock with password").build();
|
||||
|
||||
options.addOptionGroup(OP);
|
||||
options.addOption(OPT_OUT);
|
||||
options.addOption(OPT_KEYRING);
|
||||
|
||||
options.addOption(OPT_ALG);
|
||||
options.addOption(OPT_AAD_HEX);
|
||||
options.addOption(OPT_TAG_BITS);
|
||||
options.addOption(OPT_NONCE_HEX);
|
||||
options.addOption(OPT_INIT_CTR);
|
||||
options.addOption(OPT_CTR);
|
||||
options.addOption(OPT_NO_HDR);
|
||||
|
||||
options.addOption(OPT_CEK_BYTES);
|
||||
options.addOption(OPT_MAX_RECIPS);
|
||||
options.addOption(OPT_MAX_ENTRY);
|
||||
options.addOption(OPT_NO_SHUFFLE);
|
||||
|
||||
options.addOption(OPT_TO_ALIAS);
|
||||
options.addOption(OPT_TO_PSW);
|
||||
options.addOption(OPT_PSW_ITER);
|
||||
options.addOption(OPT_PSW_SALT);
|
||||
options.addOption(OPT_PSW_KEK);
|
||||
|
||||
options.addOption(OPT_DECOY_ALIAS);
|
||||
options.addOption(OPT_DECOY_PSW);
|
||||
options.addOption(OPT_DECOY_PSW_RAND);
|
||||
|
||||
options.addOption(OPT_PRIV_ALIAS);
|
||||
options.addOption(OPT_PASSWORD);
|
||||
|
||||
final CommandLineParser parser = new DefaultParser();
|
||||
final CommandLine cmd = parser.parse(options, args);
|
||||
|
||||
final boolean encrypt = cmd.hasOption(OPT_ENCRYPT);
|
||||
final Path inPath = Paths.get(cmd.getOptionValue(encrypt ? OPT_ENCRYPT : OPT_DECRYPT));
|
||||
final Path outPath = computeOutPath(cmd, inPath, encrypt);
|
||||
|
||||
final String alg = cmd.getOptionValue(OPT_ALG, "aes-gcm").toLowerCase(Locale.ROOT);
|
||||
|
||||
final byte[] aad = cmd.hasOption(OPT_AAD_HEX) ? parseHex(cmd.getOptionValue(OPT_AAD_HEX)) : null;
|
||||
final int tagBits = Integer.parseInt(cmd.getOptionValue(OPT_TAG_BITS, "128"));
|
||||
final byte[] chachaNonce = cmd.hasOption(OPT_NONCE_HEX) ? parseHex(cmd.getOptionValue(OPT_NONCE_HEX)) : null;
|
||||
final Integer initCtr = cmd.hasOption(OPT_INIT_CTR) ? Integer.valueOf(cmd.getOptionValue(OPT_INIT_CTR)) : null;
|
||||
final Integer ctrOverride = cmd.hasOption(OPT_CTR) ? Integer.valueOf(cmd.getOptionValue(OPT_CTR)) : null;
|
||||
final boolean header = !cmd.hasOption(OPT_NO_HDR);
|
||||
|
||||
final int cekBytes = Integer.parseInt(cmd.getOptionValue(OPT_CEK_BYTES, "32"));
|
||||
final int maxRecipients = Integer.parseInt(cmd.getOptionValue(OPT_MAX_RECIPS, "64"));
|
||||
final int maxEntryLen = Integer.parseInt(cmd.getOptionValue(OPT_MAX_ENTRY, "1048576"));
|
||||
final boolean shuffle = !cmd.hasOption(OPT_NO_SHUFFLE); // default true
|
||||
|
||||
// configure symmetric builder
|
||||
final AesDataContentBuilder aes;
|
||||
final ChaChaDataContentBuilder chacha;
|
||||
switch (alg) {
|
||||
case "aes-gcm" -> {
|
||||
aes = AesDataContentBuilder.builder().modeGcm(tagBits);
|
||||
if (header) {
|
||||
aes.withHeader();
|
||||
}
|
||||
if (aad != null) {
|
||||
aes.withAad(aad);
|
||||
}
|
||||
chacha = null;
|
||||
}
|
||||
case "aes-ctr" -> {
|
||||
aes = AesDataContentBuilder.builder().modeCtr();
|
||||
if (header) {
|
||||
aes.withHeader();
|
||||
}
|
||||
chacha = null;
|
||||
}
|
||||
case "aes-cbc-pkcs7" -> {
|
||||
aes = AesDataContentBuilder.builder().modeCbcPkcs5();
|
||||
if (header) {
|
||||
aes.withHeader();
|
||||
}
|
||||
chacha = null;
|
||||
}
|
||||
case "aes-cbc-nopad" -> {
|
||||
aes = AesDataContentBuilder.builder().modeCbcNoPadding();
|
||||
if (header) {
|
||||
aes.withHeader();
|
||||
}
|
||||
chacha = null;
|
||||
}
|
||||
case "chacha-aead" -> {
|
||||
chacha = ChaChaDataContentBuilder.builder();
|
||||
// selecting AEAD: if the user did not supply AAD, pass empty to pick AEAD
|
||||
chacha.withAad(aad != null ? aad : new byte[0]);
|
||||
if (header) {
|
||||
chacha.withHeader();
|
||||
}
|
||||
if (chachaNonce != null) {
|
||||
chacha.withNonce(chachaNonce);
|
||||
}
|
||||
if (ctrOverride != null || initCtr != null) {
|
||||
// providing counters together with AAD would be conflicting; builder enforces
|
||||
// it
|
||||
if (initCtr != null) {
|
||||
chacha.initialCounter(initCtr);
|
||||
}
|
||||
if (ctrOverride != null) {
|
||||
chacha.withCounter(ctrOverride);
|
||||
}
|
||||
}
|
||||
aes = null;
|
||||
}
|
||||
case "chacha-stream" -> {
|
||||
chacha = ChaChaDataContentBuilder.builder();
|
||||
if (header) {
|
||||
chacha.withHeader();
|
||||
}
|
||||
if (chachaNonce != null) {
|
||||
chacha.withNonce(chachaNonce);
|
||||
}
|
||||
if (initCtr != null) {
|
||||
chacha.initialCounter(initCtr);
|
||||
}
|
||||
if (ctrOverride != null) {
|
||||
chacha.withCounter(ctrOverride);
|
||||
}
|
||||
aes = null;
|
||||
}
|
||||
default -> throw new ParseException("Unknown --alg: " + alg);
|
||||
}
|
||||
|
||||
// envelope builder (new API)
|
||||
final MultiRecipientDataSourceBuilder env = new MultiRecipientDataSourceBuilder().payloadKeyBytes(cekBytes)
|
||||
.headerLimits(maxRecipients, maxEntryLen);
|
||||
if (aes != null) {
|
||||
env.withAes(aes);
|
||||
} else {
|
||||
env.withChaCha(chacha);
|
||||
}
|
||||
// shuffle on by default
|
||||
if (shuffle) {
|
||||
env.shuffle();
|
||||
}
|
||||
// recipients and decoys only apply on encrypt
|
||||
if (encrypt) {
|
||||
final int iter = Integer.parseInt(cmd.getOptionValue(OPT_PSW_ITER, "200000"));
|
||||
final int saltLen = Integer.parseInt(cmd.getOptionValue(OPT_PSW_SALT, "16"));
|
||||
final int kekLen = Integer.parseInt(cmd.getOptionValue(OPT_PSW_KEK, "32"));
|
||||
|
||||
final KeyringStore ks = loadKeyringIfPresent(cmd, OPT_KEYRING);
|
||||
// real recipients by alias
|
||||
for (String alias : cmd.getOptionValues(OPT_TO_ALIAS) == null ? new String[0]
|
||||
: cmd.getOptionValues(OPT_TO_ALIAS)) {
|
||||
addRecipientFromAlias(env, ks, alias, kekLen, saltLen, false);
|
||||
}
|
||||
// real password recipients
|
||||
for (String psw : cmd.getOptionValues(OPT_TO_PSW) == null ? new String[0]
|
||||
: cmd.getOptionValues(OPT_TO_PSW)) {
|
||||
env.addPasswordRecipient(psw.toCharArray(), iter, saltLen, kekLen);
|
||||
}
|
||||
// decoys by alias (key types)
|
||||
for (String alias : cmd.getOptionValues(OPT_DECOY_ALIAS) == null ? new String[0]
|
||||
: cmd.getOptionValues(OPT_DECOY_ALIAS)) {
|
||||
addRecipientFromAlias(env, ks, alias, kekLen, saltLen, true);
|
||||
}
|
||||
// decoy passwords (explicit)
|
||||
for (String psw : cmd.getOptionValues(OPT_DECOY_PSW) == null ? new String[0]
|
||||
: cmd.getOptionValues(OPT_DECOY_PSW)) {
|
||||
env.addPasswordRecipientDecoy(psw.toCharArray(), iter, saltLen, kekLen);
|
||||
}
|
||||
// decoy passwords (random)
|
||||
final int rndCount = Integer.parseInt(cmd.getOptionValue(OPT_DECOY_PSW_RAND, "0"));
|
||||
for (int i = 0; i < rndCount; i++) {
|
||||
env.addPasswordRecipientDecoy(randomPassword(), iter, saltLen, kekLen);
|
||||
}
|
||||
} else {
|
||||
// unlock material for decrypt
|
||||
final String privAlias = cmd.getOptionValue(OPT_PRIV_ALIAS);
|
||||
final String password = cmd.getOptionValue(OPT_PASSWORD);
|
||||
|
||||
if ((privAlias == null && password == null) || (privAlias != null && password != null)) {
|
||||
throw new ParseException("Specify exactly one of --priv-alias or --password for decryption");
|
||||
}
|
||||
if (privAlias != null) {
|
||||
final KeyringStore ks = requireKeyring(cmd, OPT_KEYRING);
|
||||
final KeyringStore.PrivateWithId pr = ks.getPrivateWithId(privAlias);
|
||||
env.unlockWith(new UnlockMaterial.Private(pr.key()));
|
||||
} else {
|
||||
env.unlockWith(new UnlockMaterial.Password(password.toCharArray()));
|
||||
}
|
||||
}
|
||||
|
||||
// connect upstream and run
|
||||
final DataContent content = env.build(encrypt); // env installs default openers on decrypt if none were added
|
||||
content.setInput(PlainFileBuilder.builder().url(inPath.toUri().toURL()).build(encrypt));
|
||||
try (InputStream in = content.getStream(); OutputStream out = Files.newOutputStream(outPath)) {
|
||||
in.transferTo(out);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers (package-private/private)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static Path computeOutPath(CommandLine cmd, Path inPath, boolean encrypt) {
|
||||
if (cmd.hasOption("output")) {
|
||||
return Paths.get(cmd.getOptionValue("output"));
|
||||
}
|
||||
final String s = inPath.toString();
|
||||
final String suffix = encrypt ? ".enc" : ".dec";
|
||||
return Paths.get(s + suffix);
|
||||
}
|
||||
|
||||
private static byte[] parseHex(String s) throws ParseException {
|
||||
try {
|
||||
return HexFormat.of().parseHex(s);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new ParseException("Bad hex: " + ex.getMessage()); // NOPMD
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a recipient to the given multi-recipient encryption builder by resolving
|
||||
* a public key from the keyring.
|
||||
*
|
||||
* <p>
|
||||
* The method looks up the alias in the {@link KeyringStore}, extracts its
|
||||
* algorithm identifier and public key, and then attempts to create an
|
||||
* appropriate cryptographic context:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li><b>KEM path</b>: If the algorithm supports
|
||||
* {@link zeroecho.core.context.KemContext} under
|
||||
* {@link zeroecho.core.KeyUsage#ENCAPSULATE}, a key encapsulation recipient is
|
||||
* added with the provided key-encryption key (KEK) size and salt length.</li>
|
||||
* <li><b>Fallback path</b>: If KEM encapsulation is not supported, the method
|
||||
* falls back to {@link zeroecho.core.context.EncryptionContext} under
|
||||
* {@link zeroecho.core.KeyUsage#ENCRYPT} for classic public-key
|
||||
* encryption.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* In both cases, the created context is consumed by
|
||||
* {@link MultiRecipientDataSourceBuilder#addRecipient(Object)} and is closed
|
||||
* internally by the builder.
|
||||
* </p>
|
||||
*
|
||||
* @param env target builder to which the recipient is added
|
||||
* @param ks keyring store that provides public keys by alias
|
||||
* @param alias alias name of the recipient's public key in the keyring
|
||||
* @param kekBytes desired length in bytes of the key-encryption key when using
|
||||
* KEM
|
||||
* @param saltLen salt length in bytes when using KEM
|
||||
* @param decoy whether the recipient is a decoy
|
||||
* @throws GeneralSecurityException if the algorithm does not support the
|
||||
* requested usage or key material is invalid
|
||||
* @throws IOException if context creation or builder operations
|
||||
* require I/O and fail
|
||||
*/
|
||||
private static void addRecipientFromAlias(MultiRecipientDataSourceBuilder env, KeyringStore ks, String alias,
|
||||
int kekBytes, int saltLen, boolean decoy) throws GeneralSecurityException, IOException {
|
||||
KeyringStore.PublicWithId r = ks.getPublicWithId(alias);
|
||||
final String algId = r.algorithm();
|
||||
final java.security.PublicKey pub = r.key();
|
||||
|
||||
// Try KEM first
|
||||
try (KemContext kem = CryptoAlgorithms.create(algId, KeyUsage.ENCAPSULATE, pub)) {
|
||||
if (decoy) {
|
||||
env.addRecipientDecoy(kem, kekBytes, saltLen); // builder closes context
|
||||
} else {
|
||||
env.addRecipient(kem, kekBytes, saltLen); // builder closes context
|
||||
}
|
||||
return;
|
||||
} catch (Exception notKem) { // NOPMD
|
||||
// fall back to public-key encryption
|
||||
}
|
||||
|
||||
try (EncryptionContext enc = CryptoAlgorithms.create(algId, KeyUsage.ENCRYPT, pub)) {
|
||||
if (decoy) {
|
||||
env.addRecipientDecoy(enc); // builder closes context
|
||||
} else {
|
||||
env.addRecipient(enc); // builder closes context
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static char[] randomPassword() {
|
||||
// simple random alnum for decoy purposes only
|
||||
final String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
final SecureRandom rnd = new SecureRandom();
|
||||
final int len = 16 + rnd.nextInt(17); // 16..32
|
||||
final char[] out = new char[len];
|
||||
for (int i = 0; i < len; i++) {
|
||||
out[i] = alphabet.charAt(rnd.nextInt(alphabet.length()));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static KeyringStore loadKeyringIfPresent(CommandLine cmd, Option optKs) throws IOException {
|
||||
if (!cmd.hasOption(optKs)) {
|
||||
return new KeyringStore();
|
||||
}
|
||||
return KeyringStore.load(Paths.get(cmd.getOptionValue(optKs)));
|
||||
}
|
||||
|
||||
private static KeyringStore requireKeyring(CommandLine cmd, Option optKs) throws IOException, ParseException {
|
||||
if (!cmd.hasOption(optKs)) {
|
||||
throw new ParseException("--keyring <file> is required when aliases are used");
|
||||
}
|
||||
return KeyringStore.load(Paths.get(cmd.getOptionValue(optKs)));
|
||||
}
|
||||
}
|
||||
515
app/src/main/java/zeroecho/Kem.java
Normal file
515
app/src/main/java/zeroecho/Kem.java
Normal file
@@ -0,0 +1,515 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, 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;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.apache.commons.cli.CommandLine;
|
||||
import org.apache.commons.cli.CommandLineParser;
|
||||
import org.apache.commons.cli.DefaultParser;
|
||||
import org.apache.commons.cli.Option;
|
||||
import org.apache.commons.cli.OptionGroup;
|
||||
import org.apache.commons.cli.Options;
|
||||
import org.apache.commons.cli.ParseException;
|
||||
|
||||
import zeroecho.core.AlgorithmFamily;
|
||||
import zeroecho.core.CatalogSelector;
|
||||
import zeroecho.core.KeyUsage;
|
||||
import zeroecho.core.storage.KeyringStore;
|
||||
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
|
||||
import zeroecho.sdk.builders.alg.ChaChaDataContentBuilder;
|
||||
import zeroecho.sdk.builders.alg.KemDataContentBuilder;
|
||||
import zeroecho.sdk.builders.core.DataContentChainBuilder;
|
||||
import zeroecho.sdk.builders.core.PlainFileBuilder;
|
||||
import zeroecho.sdk.content.api.DataContent;
|
||||
|
||||
/**
|
||||
* Command-line utility that performs hybrid file encryption and decryption
|
||||
* using a KEM envelope and an AES or ChaCha payload.
|
||||
*
|
||||
* <h2>Overview</h2> This tool encapsulates a symmetric key with a selected KEM
|
||||
* (post-quantum friendly) and then applies a symmetric cipher to the payload.
|
||||
* The symmetric stage is configured on {@code KemDataContentBuilder} and
|
||||
* supports AES (GCM/CTR/CBC) and ChaCha (AEAD or stream) including AAD,
|
||||
* IV/nonce, counters, and an optional compact header. Keys are loaded from
|
||||
* {@code KeyringStore} by alias. Use {@code --list-kems} to discover valid KEM
|
||||
* identifiers.
|
||||
*
|
||||
* <h2>Examples</h2> <pre>{@code
|
||||
* # List available KEM identifiers
|
||||
* ZeroEcho -E --list-kems
|
||||
*
|
||||
* # Encrypt with Kyber768 + AES-GCM (128-bit tag), writing a header
|
||||
* ZeroEcho -E --encrypt in.bin -o out.bin --keyring keys.txt --pub alice-pub \
|
||||
* --kem ML-KEM --hkdf 5a45524f --key-bytes 32 --max-kem-ct 65536 \
|
||||
* --aes --aes-cipher gcm --aes-tag-bits 128 --aad DEADBEEF --header
|
||||
*
|
||||
* # Decrypt with the corresponding private key
|
||||
* ZeroEcho -E --decrypt out.bin -o plain.bin --keyring keys.txt --priv alice-priv \
|
||||
* --kem ML-KEM --aes --aes-cipher gcm --aes-tag-bits 128 --header
|
||||
*
|
||||
* # Encrypt with ChaCha20-Poly1305 (AEAD) and header
|
||||
* ZeroEcho -E --encrypt in.bin -o out.cc20p.bin --keyring keys.txt --pub alice-pub \
|
||||
* --kem ML-KEM --chacha --chacha-nonce 00112233445566778899AABB \
|
||||
* --aad 01020304 --header
|
||||
*
|
||||
* # Encrypt with ChaCha20 stream mode and explicit counters
|
||||
* ZeroEcho -E --encrypt in.bin -o out.cc20.bin --keyring keys.txt --pub alice-pub \
|
||||
* --kem ML-KEM --chacha --chacha-nonce 00112233445566778899AABB \
|
||||
* --chacha-initial 1 --chacha-counter 00000007
|
||||
* }</pre>
|
||||
*/
|
||||
public final class Kem { // NOPMD
|
||||
private static final Logger LOG = Logger.getLogger(Kem.class.getName());
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// CLI option constants
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Encrypt mode: -e|--encrypt <input> */
|
||||
public static final Option OPT_ENCRYPT = Option.builder("e").longOpt("encrypt").hasArg().argName("input")
|
||||
.desc("Encrypt the input file").build();
|
||||
|
||||
/** Decrypt mode: -d|--decrypt <input> */
|
||||
public static final Option OPT_DECRYPT = Option.builder("d").longOpt("decrypt").hasArg().argName("input")
|
||||
.desc("Decrypt the input file").build();
|
||||
|
||||
/** Output path: -o|--output <file> */
|
||||
public static final Option OPT_OUTPUT = Option.builder("o").longOpt("output").hasArg().argName("file")
|
||||
.desc("Output file path (default: <input>.enc for encrypt, <input>.dec for decrypt)").build();
|
||||
|
||||
/** Keyring path: -K|--keyring <file> */
|
||||
public static final Option OPT_KEYRING = Option.builder("K").longOpt("keyring").hasArg().argName("file")
|
||||
.desc("Path to KeyringStore file").build();
|
||||
|
||||
/** Recipient public alias (encrypt): --pub <alias> */
|
||||
public static final Option OPT_PUB = Option.builder().longOpt("pub").hasArg().argName("alias")
|
||||
.desc("Recipient public key alias (encryption)").build();
|
||||
|
||||
/** Recipient private alias (decrypt): --priv <alias> */
|
||||
public static final Option OPT_PRIV = Option.builder().longOpt("priv").hasArg().argName("alias")
|
||||
.desc("Recipient private key alias (decryption)").build();
|
||||
|
||||
/** KEM id: --kem <id> */
|
||||
public static final Option OPT_KEM = Option.builder().longOpt("kem").hasArg().argName("id")
|
||||
.desc("KEM algorithm id (see --list-kems)").build();
|
||||
|
||||
/** Discovery: --list-kems */
|
||||
public static final Option OPT_LIST_KEMS = Option.builder().longOpt("list-kems")
|
||||
.desc("List KEM algorithms that support ENCAPSULATE and DECAPSULATE and exit").build();
|
||||
|
||||
/** Payload switch: --aes */
|
||||
public static final Option OPT_AES = Option.builder().longOpt("aes")
|
||||
.desc("Use AES payload (select mode via --aes-cipher)").build();
|
||||
|
||||
/** Payload switch: --chacha */
|
||||
public static final Option OPT_CHACHA = Option.builder().longOpt("chacha")
|
||||
.desc("Use ChaCha payload (AEAD if --aad is provided, otherwise stream)").build();
|
||||
|
||||
/** AAD (hex): --aad <hex> */
|
||||
public static final Option OPT_AAD = Option.builder().longOpt("aad").hasArg().argName("hex")
|
||||
.desc("Additional Authenticated Data (hex)").build();
|
||||
|
||||
/** Header toggle: --header */
|
||||
public static final Option OPT_HEADER = Option.builder().longOpt("header")
|
||||
.desc("Write/read a compact symmetric header (IV/AAD/params) when supported").build();
|
||||
|
||||
/** HKDF: --hkdf [infoHex] */
|
||||
public static final Option OPT_HKDF = Option.builder().longOpt("hkdf").optionalArg(true).hasArg().argName("infoHex")
|
||||
.desc("Use HKDF-SHA256 for KEM secret; optional info as hex (default internal info)").build();
|
||||
|
||||
/** Direct secret: --direct */
|
||||
public static final Option OPT_DIRECT = Option.builder().longOpt("direct")
|
||||
.desc("Use the raw KEM shared secret directly (disable HKDF)").build();
|
||||
|
||||
/** Derived key bytes: --key-bytes <int> */
|
||||
public static final Option OPT_KEY_BYTES = Option.builder().longOpt("key-bytes").hasArg().argName("int")
|
||||
.type(Number.class).desc("Derived symmetric key length in bytes (default 32)").build();
|
||||
|
||||
/** Max KEM ciphertext len: --max-kem-ct <int> */
|
||||
public static final Option OPT_MAX_KEM_CT = Option.builder().longOpt("max-kem-ct").hasArg().argName("int")
|
||||
.type(Number.class).desc("Maximum accepted KEM ciphertext length in bytes (default 65536)").build();
|
||||
|
||||
/** AES mode: --aes-cipher gcm|ctr|cbc */
|
||||
public static final Option OPT_AES_CIPHER = Option.builder().longOpt("aes-cipher").hasArg().argName("gcm|ctr|cbc")
|
||||
.desc("AES cipher variant for payload (default gcm)").build();
|
||||
|
||||
/** AES IV: --aes-iv <hex> */
|
||||
public static final Option OPT_AES_IV = Option.builder().longOpt("aes-iv").hasArg().argName("hex")
|
||||
.desc("AES IV/nonce (hex)").build();
|
||||
|
||||
/** AES tag bits: --aes-tag-bits <int> */
|
||||
public static final Option OPT_AES_TAG_BITS = Option.builder().longOpt("aes-tag-bits").hasArg().argName("int")
|
||||
.type(Number.class).desc("AES-GCM authentication tag length in bits (default 128)").build();
|
||||
|
||||
/** ChaCha nonce: --chacha-nonce <hex> */
|
||||
public static final Option OPT_CHACHA_NONCE = Option.builder().longOpt("chacha-nonce").hasArg().argName("hex")
|
||||
.desc("ChaCha nonce (hex, usually 12 bytes)").build();
|
||||
|
||||
/** ChaCha counter value: --chacha-counter <int> */
|
||||
public static final Option OPT_CHACHA_COUNTER = Option.builder().longOpt("chacha-counter").hasArg().argName("int")
|
||||
.type(Number.class).desc("ChaCha counter value for stream mode (integer)").build();
|
||||
|
||||
/** ChaCha initial counter: --chacha-initial <int> */
|
||||
public static final Option OPT_CHACHA_INITIAL = Option.builder().longOpt("chacha-initial").hasArg().argName("int")
|
||||
.type(Number.class).desc("ChaCha initial counter (integer)").build();
|
||||
|
||||
private Kem() {
|
||||
// no instances
|
||||
}
|
||||
|
||||
public static int main(String[] args, Options opts) throws ParseException, IOException, GeneralSecurityException { // NOPMD
|
||||
defineOptions(opts);
|
||||
CommandLineParser parser = new DefaultParser();
|
||||
CommandLine cmd = parser.parse(opts, args);
|
||||
|
||||
// Fast path: listing does not require any other arguments (keyring, mode, etc.)
|
||||
if (cmd.hasOption(OPT_LIST_KEMS.getLongOpt())) {
|
||||
listKems();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Validate mode
|
||||
boolean encrypt = cmd.hasOption(OPT_ENCRYPT.getLongOpt()) || cmd.hasOption(OPT_ENCRYPT.getOpt());
|
||||
boolean decrypt = cmd.hasOption(OPT_DECRYPT.getLongOpt()) || cmd.hasOption(OPT_DECRYPT.getOpt());
|
||||
if (encrypt == decrypt) {
|
||||
throw new ParseException("Choose exactly one of --encrypt or --decrypt");
|
||||
}
|
||||
|
||||
// Validate payload selection
|
||||
boolean wantAes = cmd.hasOption(OPT_AES.getLongOpt());
|
||||
boolean wantChaCha = cmd.hasOption(OPT_CHACHA.getLongOpt());
|
||||
if (wantAes == wantChaCha) {
|
||||
throw new ParseException("Choose exactly one payload cipher: --aes or --chacha");
|
||||
}
|
||||
|
||||
// Required arguments for actual work
|
||||
require(cmd, OPT_KEM, "Missing required option: --kem");
|
||||
require(cmd, OPT_KEYRING, "Missing required option: --keyring");
|
||||
|
||||
final String input = encrypt ? cmd.getOptionValue(OPT_ENCRYPT) : cmd.getOptionValue(OPT_DECRYPT);
|
||||
if (input == null || input.isBlank()) {
|
||||
throw new ParseException("Missing input file for the selected mode");
|
||||
}
|
||||
|
||||
final Path output = Path.of(cmd.getOptionValue(OPT_OUTPUT.getLongOpt(), input + (encrypt ? ".enc" : ".dec")));
|
||||
|
||||
final String kemId = cmd.getOptionValue(OPT_KEM.getLongOpt());
|
||||
final Path keyringPath = Path.of(cmd.getOptionValue(OPT_KEYRING.getLongOpt()));
|
||||
final KeyringStore keyring = KeyringStore.load(keyringPath);
|
||||
|
||||
// Configure KEM envelope
|
||||
KemDataContentBuilder kem = KemDataContentBuilder.builder().kem(kemId);
|
||||
if (cmd.hasOption(OPT_DIRECT.getLongOpt())) {
|
||||
kem = kem.directSecret();
|
||||
} else {
|
||||
byte[] info = parseOptionalHex(cmd, OPT_HKDF, "ZeroEcho-KEM".getBytes());
|
||||
kem = kem.hkdfSha256(info);
|
||||
}
|
||||
// typed numeric options
|
||||
Integer keyBytes = parsedIntOpt(cmd, OPT_KEY_BYTES);
|
||||
if (keyBytes != null) {
|
||||
kem = kem.derivedKeyBytes(keyBytes);
|
||||
}
|
||||
Integer maxKemCt = parsedIntOpt(cmd, OPT_MAX_KEM_CT);
|
||||
if (maxKemCt != null) {
|
||||
kem = kem.maxKemCiphertextLen(maxKemCt);
|
||||
}
|
||||
|
||||
// Common symmetric knobs
|
||||
final byte[] aad = parseHexOpt(cmd, OPT_AAD);
|
||||
final boolean wantHeader = cmd.hasOption(OPT_HEADER.getLongOpt());
|
||||
|
||||
// AES payload
|
||||
if (wantAes) {
|
||||
String mode = cmd.getOptionValue(OPT_AES_CIPHER.getLongOpt(), "gcm").toLowerCase(Locale.ROOT);
|
||||
AesDataContentBuilder aes = AesDataContentBuilder.builder();
|
||||
switch (mode) {
|
||||
case "gcm" -> {
|
||||
Integer tagBitsOpt = parsedIntOpt(cmd, OPT_AES_TAG_BITS);
|
||||
int tagBits = tagBitsOpt == null ? 128 : tagBitsOpt;
|
||||
|
||||
aes = aes.modeGcm(tagBits);
|
||||
}
|
||||
case "ctr" -> aes = aes.modeCtr();
|
||||
case "cbc" -> aes = aes.modeCbcPkcs5();
|
||||
default -> throw new ParseException("Unsupported --aes-cipher: " + mode);
|
||||
}
|
||||
byte[] iv = parseHexOpt(cmd, OPT_AES_IV);
|
||||
if (iv != null) {
|
||||
aes = aes.withIv(iv);
|
||||
}
|
||||
if (aad != null && aad.length > 0) {
|
||||
aes = aes.withAad(aad);
|
||||
}
|
||||
if (wantHeader) {
|
||||
aes = aes.withHeader();
|
||||
}
|
||||
kem = kem.withAes(aes);
|
||||
}
|
||||
|
||||
// ChaCha payload
|
||||
if (wantChaCha) {
|
||||
ChaChaDataContentBuilder cc = ChaChaDataContentBuilder.builder();
|
||||
byte[] nonce = parseHexOpt(cmd, OPT_CHACHA_NONCE);
|
||||
if (nonce != null) {
|
||||
cc = cc.withNonce(nonce);
|
||||
}
|
||||
// counter is an integer, not bytes; use typed parsed option
|
||||
Integer counter = parsedIntOpt(cmd, OPT_CHACHA_COUNTER);
|
||||
if (counter != null) {
|
||||
cc = cc.withCounter(counter);
|
||||
}
|
||||
Integer initial = parsedIntOpt(cmd, OPT_CHACHA_INITIAL);
|
||||
if (initial != null) {
|
||||
cc = cc.initialCounter(initial);
|
||||
}
|
||||
if (aad != null && aad.length > 0) {
|
||||
cc = cc.withAad(aad); // selects AEAD
|
||||
}
|
||||
if (wantHeader) {
|
||||
cc = cc.withHeader();
|
||||
}
|
||||
kem = kem.withChaCha(cc);
|
||||
}
|
||||
|
||||
// Pipeline: source -> kem payload stage
|
||||
DataContent chain;
|
||||
if (encrypt) {
|
||||
String alias = require(cmd, OPT_PUB, "Missing --pub for encryption");
|
||||
PublicKey recipient = keyring.getPublic(alias);
|
||||
chain = DataContentChainBuilder.encrypt()
|
||||
.add(PlainFileBuilder.builder().url(Path.of(input).toUri().toURL()))
|
||||
.add(kem.recipientPublic(recipient)).build();
|
||||
} else {
|
||||
String alias = require(cmd, OPT_PRIV, "Missing --priv for decryption");
|
||||
PrivateKey recipient = keyring.getPrivate(alias);
|
||||
chain = DataContentChainBuilder.decrypt()
|
||||
.add(PlainFileBuilder.builder().url(Path.of(input).toUri().toURL()))
|
||||
.add(kem.recipientPrivate(recipient)).build();
|
||||
}
|
||||
|
||||
try (InputStream in = chain.getStream(); OutputStream out = Files.newOutputStream(output)) {
|
||||
in.transferTo(out);
|
||||
} catch (IOException ex) {
|
||||
if (LOG.isLoggable(Level.SEVERE)) {
|
||||
LOG.log(Level.SEVERE, "I/O error", ex);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the CLI option set.
|
||||
*
|
||||
* @return populated {@link Options}
|
||||
*/
|
||||
private static Options defineOptions(Options opts) {
|
||||
// Mode group (not required up-front; validated after --list-kems short-circuit)
|
||||
OptionGroup mode = new OptionGroup();
|
||||
mode.addOption(OPT_ENCRYPT);
|
||||
mode.addOption(OPT_DECRYPT);
|
||||
opts.addOptionGroup(mode);
|
||||
|
||||
// Payload selection group
|
||||
OptionGroup payload = new OptionGroup();
|
||||
payload.addOption(OPT_AES);
|
||||
payload.addOption(OPT_CHACHA);
|
||||
opts.addOptionGroup(payload);
|
||||
|
||||
// General and catalog options
|
||||
opts.addOption(OPT_OUTPUT);
|
||||
opts.addOption(OPT_KEYRING);
|
||||
opts.addOption(OPT_PUB);
|
||||
opts.addOption(OPT_PRIV);
|
||||
opts.addOption(OPT_KEM);
|
||||
opts.addOption(OPT_LIST_KEMS);
|
||||
|
||||
// Symmetric common knobs
|
||||
opts.addOption(OPT_AAD);
|
||||
opts.addOption(OPT_HEADER);
|
||||
|
||||
// AES knobs
|
||||
opts.addOption(OPT_AES_CIPHER);
|
||||
opts.addOption(OPT_AES_IV);
|
||||
opts.addOption(OPT_AES_TAG_BITS);
|
||||
|
||||
// ChaCha knobs
|
||||
opts.addOption(OPT_CHACHA_NONCE);
|
||||
opts.addOption(OPT_CHACHA_COUNTER);
|
||||
opts.addOption(OPT_CHACHA_INITIAL);
|
||||
|
||||
// KEM knobs
|
||||
opts.addOption(OPT_HKDF);
|
||||
opts.addOption(OPT_DIRECT);
|
||||
opts.addOption(OPT_KEY_BYTES);
|
||||
opts.addOption(OPT_MAX_KEM_CT);
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints KEM algorithm identifiers that belong to family {@code KEM} and
|
||||
* support both {@code ENCAPSULATE} and {@code DECAPSULATE}.
|
||||
*/
|
||||
private static void listKems() {
|
||||
List<String> ids = CatalogSelector.selectByFamilyAndRoles(AlgorithmFamily.KEM,
|
||||
EnumSet.of(KeyUsage.ENCAPSULATE, KeyUsage.DECAPSULATE));
|
||||
if (ids.isEmpty()) {
|
||||
System.out.println("(no KEM algorithms found)");
|
||||
return;
|
||||
}
|
||||
for (String id : ids) {
|
||||
System.out.println(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the non-empty value of the given option or throws a
|
||||
* {@link ParseException}.
|
||||
*
|
||||
* @param cmd parsed command line
|
||||
* @param opt option definition
|
||||
* @param message error message when missing
|
||||
* @return option value string
|
||||
* @throws ParseException if missing or empty
|
||||
*/
|
||||
private static String require(CommandLine cmd, Option opt, String message) throws ParseException {
|
||||
String v = cmd.getOptionValue(opt.getLongOpt());
|
||||
if (v == null || v.isBlank()) {
|
||||
throw new ParseException(message);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an optional integer using Commons CLI typed parsing.
|
||||
*
|
||||
* <p>
|
||||
* Requires the {@link Option} to declare {@code .type(Number.class)}. If the
|
||||
* option is present and convertible, returns its {@code intValue()}, otherwise
|
||||
* {@code null}.
|
||||
* </p>
|
||||
*
|
||||
* @param cmd parsed command line
|
||||
* @param opt the option to parse
|
||||
* @return {@code Integer} value or {@code null} if not present
|
||||
* @throws ParseException if present but not a valid integer
|
||||
*/
|
||||
private static Integer parsedIntOpt(CommandLine cmd, Option opt) throws ParseException {
|
||||
// getParsedOptionValue returns null if absent; otherwise a Number if
|
||||
// .type(Number.class) is set
|
||||
Object v = cmd.getParsedOptionValue(opt.getLongOpt());
|
||||
if (v == null) {
|
||||
return null;
|
||||
}
|
||||
if (v instanceof Number) {
|
||||
return ((Number) v).intValue();
|
||||
}
|
||||
// Fallback: attempt manual parse when type not respected by the parser
|
||||
String s = cmd.getOptionValue(opt.getLongOpt());
|
||||
if (s == null || s.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(s);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new ParseException("Invalid integer for --" + opt.getLongOpt() + ": " + s); // NOPMD
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an optional hex string for the given option.
|
||||
*
|
||||
* @param cmd parsed command line
|
||||
* @param opt option definition
|
||||
* @return byte array or {@code null} if absent
|
||||
* @throws ParseException if the hex value is invalid
|
||||
*/
|
||||
private static byte[] parseHexOpt(CommandLine cmd, Option opt) throws ParseException {
|
||||
if (!cmd.hasOption(opt.getLongOpt())) {
|
||||
return null; // NOPMD
|
||||
}
|
||||
String v = cmd.getOptionValue(opt.getLongOpt());
|
||||
if (v == null || v.isBlank()) {
|
||||
return null; // NOPMD
|
||||
}
|
||||
try {
|
||||
return HexFormat.of().parseHex(v);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ParseException("Invalid hex for --" + opt.getLongOpt() + ": " + e.getMessage()); // NOPMD
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the hex-decoded bytes from an optional value; if the option is
|
||||
* absent, returns the provided default.
|
||||
*
|
||||
* @param cmd parsed command line
|
||||
* @param opt option definition
|
||||
* @param def default bytes to use when option is not present
|
||||
* @return decoded bytes or default when absent
|
||||
* @throws ParseException if the option is present but the value is not valid
|
||||
* hex
|
||||
*/
|
||||
private static byte[] parseOptionalHex(CommandLine cmd, Option opt, byte[] def) throws ParseException {
|
||||
if (!cmd.hasOption(opt.getLongOpt())) {
|
||||
return def;
|
||||
}
|
||||
String v = cmd.getOptionValue(opt.getLongOpt());
|
||||
if (v == null || v.isBlank()) {
|
||||
return def;
|
||||
}
|
||||
try {
|
||||
return HexFormat.of().parseHex(v);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ParseException("Invalid hex for --" + opt.getLongOpt() + ": " + e.getMessage()); // NOPMD
|
||||
}
|
||||
}
|
||||
}
|
||||
673
app/src/main/java/zeroecho/KeyStoreManagement.java
Normal file
673
app/src/main/java/zeroecho/KeyStoreManagement.java
Normal file
@@ -0,0 +1,673 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, 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;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.PrintWriter;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyPair;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
|
||||
import org.apache.commons.cli.CommandLine;
|
||||
import org.apache.commons.cli.CommandLineParser;
|
||||
import org.apache.commons.cli.DefaultParser;
|
||||
import org.apache.commons.cli.Option;
|
||||
import org.apache.commons.cli.OptionGroup;
|
||||
import org.apache.commons.cli.Options;
|
||||
import org.apache.commons.cli.ParseException;
|
||||
|
||||
import zeroecho.core.CryptoAlgorithm;
|
||||
import zeroecho.core.CryptoAlgorithms;
|
||||
import zeroecho.core.spec.AlgorithmKeySpec;
|
||||
import zeroecho.core.spi.AsymmetricKeyBuilder;
|
||||
import zeroecho.core.spi.SymmetricKeyBuilder;
|
||||
import zeroecho.core.storage.KeyringStore;
|
||||
|
||||
/**
|
||||
* Command-line utility for managing key material in a text-based keyring store.
|
||||
*
|
||||
* <h2>Overview</h2> The {@code KeyStoreManagement} subcommand provides
|
||||
* lifecycle operations on key material stored in
|
||||
* {@link zeroecho.core.storage.KeyringStore}. It supports listing algorithms
|
||||
* and aliases, generating key pairs or symmetric keys, exporting/importing
|
||||
* versioned text snippets, and overwriting existing entries.
|
||||
*
|
||||
* The keystore is a plain-text file with aliases mapped to keys. Import/export
|
||||
* uses line-oriented snippets with format-version headers, suitable for
|
||||
* exchanging public keys or migrating key material between systems.
|
||||
*
|
||||
* <h2>Usage</h2> Invoked as: <pre>{@code
|
||||
* ZeroEcho -K [options]
|
||||
* }</pre>
|
||||
*
|
||||
* <h2>Modes</h2> Exactly one action must be chosen:
|
||||
* <ul>
|
||||
* <li>{@code --list-algorithms} - list catalog algorithms and whether they
|
||||
* support symmetric/asymmetric builders.</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
|
||||
* 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>
|
||||
*
|
||||
* <h2>General options</h2>
|
||||
* <ul>
|
||||
* <li>{@code -k | --keystore <file>} - path to keystore file (required).</li>
|
||||
* <li>{@code --overwrite} - overwrite existing aliases on conflict.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Generate options</h2>
|
||||
* <ul>
|
||||
* <li>{@code --alg <id>} - algorithm id (e.g., RSA, Ed25519, AES, Frodo).</li>
|
||||
* <li>{@code --alias <name>} - base alias; for asymmetric, both public and
|
||||
* private entries will be created.</li>
|
||||
* <li>{@code --kind sym|asym} - force symmetric or asymmetric if the algorithm
|
||||
* supports both (optional).</li>
|
||||
* <li>{@code --pub-suffix <sfx>} - suffix for public alias (default:
|
||||
* .pub).</li>
|
||||
* <li>{@code --prv-suffix <sfx>} - suffix for private alias (default:
|
||||
* .prv).</li>
|
||||
* </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
|
||||
* # List available algorithms
|
||||
* ZeroEcho -K --list-algorithms
|
||||
*
|
||||
* # Generate a new RSA key pair and store as alice.pub / alice.prv
|
||||
* ZeroEcho -K --generate --alg RSA --alias alice --keystore keys.txt
|
||||
*
|
||||
* # Generate a new AES secret key and store as "backup-key"
|
||||
* ZeroEcho -K --generate --alg AES --alias backup-key --kind sym --keystore keys.txt
|
||||
*
|
||||
* # List aliases in the keystore
|
||||
* ZeroEcho -K --list-aliases --keystore keys.txt
|
||||
*
|
||||
* # Export selected aliases to a file
|
||||
* ZeroEcho -K --export --aliases alice.pub,alice.prv --out alice-keys.txt --keystore keys.txt
|
||||
*
|
||||
* # Import aliases from stdin, overwriting if necessary
|
||||
* ZeroEcho -K --import --in - --overwrite --keystore keys.txt < alice-keys.txt
|
||||
* }</pre>
|
||||
*
|
||||
* <h2>Exit codes</h2>
|
||||
* <ul>
|
||||
* <li>0 - operation succeeded</li>
|
||||
* <li>non-zero - error occurred (parse error, I/O failure, or invalid
|
||||
* arguments)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
public final class KeyStoreManagement { // NOPMD
|
||||
|
||||
private final static String STD_IN_OUT = "-";
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Option constants (centralized for maintainability)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private static final Option KEYSTORE_OPTION = Option.builder("k").longOpt("keystore").hasArg().argName("file")
|
||||
.desc("Path to keyring store").build();
|
||||
|
||||
private static final Option LIST_ALGORITHMS_OPTION = Option.builder().longOpt("list-algorithms")
|
||||
.desc("List catalog algorithms with symmetric/asymmetric support").build();
|
||||
|
||||
private static final Option LIST_ALIASES_OPTION = Option.builder().longOpt("list-aliases")
|
||||
.desc("List aliases present in the keyring").build();
|
||||
|
||||
private static final Option GENERATE_OPTION = Option.builder().longOpt("generate")
|
||||
.desc("Generate a keypair or a secret").build();
|
||||
|
||||
private static final Option ALG_OPTION = Option.builder().longOpt("alg").hasArg().argName("id")
|
||||
.desc("Algorithm id (e.g., RSA, Ed25519, AES, Frodo)").build();
|
||||
|
||||
private static final Option ALIAS_OPTION = Option.builder().longOpt("alias").hasArg().argName("name")
|
||||
.desc("Alias base; for asymmetric, two entries will be written").build();
|
||||
|
||||
private static final Option KIND_OPTION = Option.builder().longOpt("kind").hasArg().argName("sym|asym")
|
||||
.desc("Force symmetric or asymmetric when algorithm supports both").build();
|
||||
|
||||
private static final Option PUB_SUFFIX_OPTION = Option.builder().longOpt("pub-suffix").hasArg().argName("sfx")
|
||||
.desc("Suffix for public alias (default .pub)").build();
|
||||
|
||||
private static final Option PRV_SUFFIX_OPTION = Option.builder().longOpt("prv-suffix").hasArg().argName("sfx")
|
||||
.desc("Suffix for private alias (default .prv)").build();
|
||||
|
||||
private static final Option OVERWRITE_OPTION = Option.builder().longOpt("overwrite")
|
||||
.desc("Overwrite existing aliases on conflict").build();
|
||||
|
||||
private static final Option EXPORT_OPTION = Option.builder().longOpt("export")
|
||||
.desc("Export selected aliases as a versioned text snippet").build();
|
||||
|
||||
private static final Option IMPORT_OPTION = Option.builder().longOpt("import")
|
||||
.desc("Import a versioned text snippet into the keyring").build();
|
||||
|
||||
private static final Option ALIASES_OPTION = Option.builder().longOpt("aliases").hasArg().argName("a,b,c")
|
||||
.desc("Comma-separated aliases to export; empty means all").build();
|
||||
|
||||
private static final Option OUTFILE_OPTION = Option.builder().longOpt("out").hasArg().argName("file|-")
|
||||
.desc("Output file for export (default '-' for stdout)").build();
|
||||
|
||||
private static final Option INFILE_OPTION = Option.builder().longOpt("in").hasArg().argName("file|-")
|
||||
.desc("Input file for import (default '-' for stdin)").build();
|
||||
|
||||
/** Prevents instantiation. */
|
||||
private KeyStoreManagement() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Entry point - lets exceptions bubble to the caller (ZeroEcho)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parses arguments, executes the requested action, and returns an exit code.
|
||||
* Parser and IO exceptions are intentionally propagated for the central CLI to
|
||||
* handle.
|
||||
*
|
||||
* @param args arguments passed by the application dispatcher
|
||||
* @param dispatcherOptions an existing {@code Options} instance used by the
|
||||
* dispatcher; this method only adds its own options
|
||||
* @return process exit code (0 for success; non-zero for semantic errors)
|
||||
* @throws ParseException if the parser fails
|
||||
* @throws IOException if I/O fails
|
||||
*/
|
||||
public static int main(final String[] args, final Options dispatcherOptions) throws ParseException, IOException {
|
||||
defineOptions(dispatcherOptions);
|
||||
CommandLineParser parser = new DefaultParser();
|
||||
CommandLine cmd = parser.parse(dispatcherOptions, args);
|
||||
|
||||
if (cmd.hasOption(LIST_ALGORITHMS_OPTION.getLongOpt())) {
|
||||
listAlgorithms();
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!cmd.hasOption(KEYSTORE_OPTION.getLongOpt())) {
|
||||
throw new ParseException("Missing required option: k/keystore");
|
||||
}
|
||||
|
||||
Path keyringPath = Path.of(cmd.getOptionValue(KEYSTORE_OPTION.getLongOpt()));
|
||||
KeyringStore store = Files.exists(keyringPath) ? KeyringStore.load(keyringPath) : new KeyringStore();
|
||||
|
||||
if (cmd.hasOption(LIST_ALIASES_OPTION.getLongOpt())) {
|
||||
listAliases(store);
|
||||
return 0;
|
||||
}
|
||||
if (cmd.hasOption(GENERATE_OPTION.getLongOpt())) {
|
||||
doGenerate(store, cmd);
|
||||
store.save(keyringPath);
|
||||
return 0;
|
||||
}
|
||||
if (cmd.hasOption(EXPORT_OPTION.getLongOpt())) {
|
||||
doExportSnippet(store, cmd);
|
||||
return 0;
|
||||
}
|
||||
if (cmd.hasOption(IMPORT_OPTION.getLongOpt())) {
|
||||
doImportSnippet(store, cmd);
|
||||
store.save(keyringPath);
|
||||
return 0;
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("No operation selected");
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds this subcommand's options to the provided {@code Options} instance.
|
||||
*
|
||||
* @param options an existing {@code Options} instance from the central
|
||||
* dispatcher
|
||||
*/
|
||||
public static void defineOptions(final Options options) {
|
||||
options.addOption(KEYSTORE_OPTION);
|
||||
|
||||
OptionGroup actions = new OptionGroup();
|
||||
actions.setRequired(true);
|
||||
actions.addOption(LIST_ALGORITHMS_OPTION);
|
||||
actions.addOption(LIST_ALIASES_OPTION);
|
||||
actions.addOption(GENERATE_OPTION);
|
||||
actions.addOption(EXPORT_OPTION);
|
||||
actions.addOption(IMPORT_OPTION);
|
||||
options.addOptionGroup(actions);
|
||||
|
||||
options.addOption(ALG_OPTION);
|
||||
options.addOption(ALIAS_OPTION);
|
||||
options.addOption(KIND_OPTION);
|
||||
options.addOption(PUB_SUFFIX_OPTION);
|
||||
options.addOption(PRV_SUFFIX_OPTION);
|
||||
options.addOption(OVERWRITE_OPTION);
|
||||
|
||||
options.addOption(ALIASES_OPTION);
|
||||
options.addOption(OUTFILE_OPTION);
|
||||
options.addOption(INFILE_OPTION);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Lists available algorithms with builder availability to stdout.
|
||||
*/
|
||||
public static void listAlgorithms() {
|
||||
PrintWriter out = new PrintWriter(System.out, true, StandardCharsets.UTF_8); // NOPMD
|
||||
Set<String> ids = CryptoAlgorithms.available();
|
||||
for (String id : ids) {
|
||||
CryptoAlgorithm a = CryptoAlgorithms.require(id);
|
||||
boolean hasAsym = !a.asymmetricBuildersInfo().isEmpty();
|
||||
boolean hasSym = !a.symmetricBuildersInfo().isEmpty();
|
||||
out.printf(Locale.ROOT, "%-12s asym:%s sym:%s%n", id, hasAsym, hasSym);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists aliases present in the store to stdout.
|
||||
*
|
||||
* @param store loaded keyring store
|
||||
*/
|
||||
public static void listAliases(final KeyringStore store) {
|
||||
PrintWriter out = new PrintWriter(System.out, true, StandardCharsets.UTF_8); // NOPMD
|
||||
List<String> aliases = store.aliases();
|
||||
if (aliases.isEmpty()) {
|
||||
out.println("(empty)");
|
||||
return;
|
||||
}
|
||||
for (String alias : aliases) {
|
||||
out.println(alias);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a keypair or secret and stores entries under the chosen aliases.
|
||||
*
|
||||
* <p>
|
||||
* This reuses the same import-spec construction heuristic as the project's
|
||||
* dynamic tests: find plausible import-spec classes and build specs from
|
||||
* SPKI/PKCS8/RAW bytes.
|
||||
* </p>
|
||||
*
|
||||
* @param store keyring store to mutate
|
||||
* @param cmd parsed command line
|
||||
*/
|
||||
public static void doGenerate(final KeyringStore store, final CommandLine cmd) { // NOPMD
|
||||
String algId = required(cmd, ALG_OPTION, "--alg is required for --generate");
|
||||
String aliasBase = required(cmd, ALIAS_OPTION, "--alias is required for --generate");
|
||||
String kind = cmd.getOptionValue(KIND_OPTION.getLongOpt());
|
||||
String pubSfx = cmd.getOptionValue(PUB_SUFFIX_OPTION.getLongOpt(), ".pub");
|
||||
String prvSfx = cmd.getOptionValue(PRV_SUFFIX_OPTION.getLongOpt(), ".prv");
|
||||
boolean overwrite = cmd.hasOption(OVERWRITE_OPTION.getLongOpt());
|
||||
|
||||
CryptoAlgorithm alg = CryptoAlgorithms.require(algId);
|
||||
boolean canAsym = !alg.asymmetricBuildersInfo().isEmpty();
|
||||
boolean canSym = !alg.symmetricBuildersInfo().isEmpty();
|
||||
|
||||
boolean doAsym = "asym".equalsIgnoreCase(kind) || (kind == null && canAsym && !canSym);
|
||||
boolean doSym = "sym".equalsIgnoreCase(kind) || (kind == null && canSym && !canAsym);
|
||||
|
||||
if (!doAsym && !doSym && canAsym && canSym) {
|
||||
throw new IllegalArgumentException("Algorithm supports both; specify --kind sym|asym");
|
||||
}
|
||||
|
||||
if (doAsym) {
|
||||
KeyPair kp = null;
|
||||
CryptoAlgorithm.AsymBuilderInfo used = null;
|
||||
for (CryptoAlgorithm.AsymBuilderInfo bi : alg.asymmetricBuildersInfo()) {
|
||||
if (bi.defaultKeySpec == null) {
|
||||
continue;
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
Class<AlgorithmKeySpec> st = (Class<AlgorithmKeySpec>) bi.specType;
|
||||
AsymmetricKeyBuilder<AlgorithmKeySpec> b = alg.asymmetricKeyBuilder(st);
|
||||
try {
|
||||
kp = b.generateKeyPair((AlgorithmKeySpec) bi.defaultKeySpec);
|
||||
if (kp != null) {
|
||||
used = bi;
|
||||
break;
|
||||
}
|
||||
} catch (Throwable ignore) { // NOPMD
|
||||
}
|
||||
}
|
||||
if (kp == null || used == null) {
|
||||
throw new IllegalStateException("No asymmetric builder with default spec worked for " + algId);
|
||||
}
|
||||
|
||||
Class<?> pubImp = null;
|
||||
Class<?> prvImp = null;
|
||||
for (CryptoAlgorithm.AsymBuilderInfo x : alg.asymmetricBuildersInfo()) {
|
||||
if (looksLikeImportSpecForPublic(x.specType)) {
|
||||
pubImp = x.specType;
|
||||
}
|
||||
if (looksLikeImportSpecForPrivate(x.specType)) {
|
||||
prvImp = x.specType;
|
||||
}
|
||||
}
|
||||
if (pubImp == null && prvImp == null) {
|
||||
throw new IllegalStateException("No import spec class found for " + algId + " (asymmetric)");
|
||||
}
|
||||
|
||||
byte[] spki = kp.getPublic() != null ? kp.getPublic().getEncoded() : null;
|
||||
byte[] pkcs8 = kp.getPrivate() != null ? kp.getPrivate().getEncoded() : null;
|
||||
|
||||
AlgorithmKeySpec pubSpec = pubImp != null ? makeImportSpec(pubImp, spki, algId, used.defaultKeySpec) : null;
|
||||
AlgorithmKeySpec prvSpec = prvImp != null ? makeImportSpec(prvImp, pkcs8, algId, used.defaultKeySpec)
|
||||
: null;
|
||||
|
||||
if (pubImp != null && pubSpec == null) {
|
||||
throw new IllegalStateException("Cannot construct public import spec for " + algId);
|
||||
}
|
||||
if (prvImp != null && prvSpec == null) {
|
||||
throw new IllegalStateException("Cannot construct private import spec for " + algId);
|
||||
}
|
||||
|
||||
String pubAlias = aliasBase + pubSfx;
|
||||
String prvAlias = aliasBase + prvSfx;
|
||||
ensureWritable(store, pubAlias, overwrite);
|
||||
ensureWritable(store, prvAlias, overwrite);
|
||||
|
||||
store.putPublic(pubAlias, algId, pubSpec);
|
||||
store.putPrivate(prvAlias, algId, prvSpec);
|
||||
|
||||
PrintWriter out = new PrintWriter(System.out, true, StandardCharsets.UTF_8); // NOPMD
|
||||
out.printf("Generated %s -> %s, %s%n", algId, pubAlias, prvAlias);
|
||||
}
|
||||
|
||||
if (doSym) {
|
||||
SecretKey sk = null;
|
||||
CryptoAlgorithm.SymBuilderInfo used = null;
|
||||
for (CryptoAlgorithm.SymBuilderInfo bi : alg.symmetricBuildersInfo()) {
|
||||
if (bi.defaultKeySpec() == null) {
|
||||
continue;
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
Class<AlgorithmKeySpec> st = (Class<AlgorithmKeySpec>) bi.specType();
|
||||
SymmetricKeyBuilder<AlgorithmKeySpec> b = alg.symmetricKeyBuilder(st);
|
||||
try {
|
||||
sk = b.generateSecret((AlgorithmKeySpec) bi.defaultKeySpec());
|
||||
if (sk != null) {
|
||||
used = bi;
|
||||
break;
|
||||
}
|
||||
} catch (Throwable ignore) { // NOPMD
|
||||
}
|
||||
}
|
||||
if (sk == null || used == null) {
|
||||
throw new IllegalStateException("No symmetric builder with default spec worked for " + algId);
|
||||
}
|
||||
|
||||
Class<?> impSym = findSymmetricImportSpecClass(alg);
|
||||
if (impSym == null) {
|
||||
throw new IllegalStateException("No symmetric import spec class for " + algId);
|
||||
}
|
||||
|
||||
byte[] raw = sk.getEncoded();
|
||||
AlgorithmKeySpec secSpec = makeImportSpec(impSym, raw, algId, used.defaultKeySpec());
|
||||
if (secSpec == null) {
|
||||
throw new IllegalStateException("Cannot construct symmetric import spec for " + algId);
|
||||
}
|
||||
|
||||
ensureWritable(store, aliasBase, overwrite);
|
||||
store.putSecret(aliasBase, algId, secSpec);
|
||||
|
||||
PrintWriter out = new PrintWriter(System.out, true, StandardCharsets.UTF_8); // NOPMD
|
||||
out.printf("Generated %s -> %s%n", algId, aliasBase);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (CryptoAlgorithm.SymBuilderInfo x : alg.symmetricBuildersInfo()) {
|
||||
String n = x.specType().getSimpleName();
|
||||
if (n.contains("Import") || n.endsWith("KeyImportSpec") || n.endsWith("SecretSpec")) {
|
||||
return x.specType();
|
||||
}
|
||||
}
|
||||
for (CryptoAlgorithm.SymBuilderInfo x : alg.symmetricBuildersInfo()) {
|
||||
try {
|
||||
x.specType().getConstructor(byte[].class);
|
||||
return x.specType();
|
||||
} catch (NoSuchMethodException ignored) {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs an AlgorithmKeySpec from encoded bytes using conventional
|
||||
* factories/ctors. Mirrors the makeImportSpec approach used in the dynamic
|
||||
* test.
|
||||
*
|
||||
* @param specType target spec class
|
||||
* @param material encoded bytes (SPKI, PKCS#8, or RAW)
|
||||
* @param algId algorithm id (used for variant name heuristics)
|
||||
* @param defaultSpec the default spec used by the builder (for optional variant
|
||||
* hints)
|
||||
* @return constructed AlgorithmKeySpec or null if none matched
|
||||
*/
|
||||
private static AlgorithmKeySpec makeImportSpec(Class<?> specType, byte[] material, String algId,
|
||||
Object defaultSpec) {
|
||||
if (material == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Method m = specType.getMethod("fromRaw", byte[].class);
|
||||
Object spec = m.invoke(null, material);
|
||||
return (AlgorithmKeySpec) spec;
|
||||
} catch (NoSuchMethodException ignored) {
|
||||
} catch (Throwable t) { // NOPMD
|
||||
}
|
||||
try {
|
||||
Method m = specType.getMethod("fromRaw", String.class, byte[].class);
|
||||
String name = deriveVariantNameForImport(algId, defaultSpec);
|
||||
Object spec = m.invoke(null, name, material);
|
||||
return (AlgorithmKeySpec) spec;
|
||||
} catch (NoSuchMethodException ignored) {
|
||||
} catch (Throwable t) { // NOPMD
|
||||
}
|
||||
try {
|
||||
Method m = specType.getMethod("of", byte[].class);
|
||||
Object spec = m.invoke(null, material);
|
||||
return (AlgorithmKeySpec) spec;
|
||||
} catch (NoSuchMethodException ignored) {
|
||||
} catch (Throwable t) { // NOPMD
|
||||
}
|
||||
try {
|
||||
Constructor<?> c = specType.getConstructor(byte[].class);
|
||||
Object spec = c.newInstance(material);
|
||||
return (AlgorithmKeySpec) spec;
|
||||
} catch (NoSuchMethodException ignored) {
|
||||
} catch (Throwable t) { // NOPMD
|
||||
}
|
||||
try {
|
||||
Constructor<?> c = specType.getConstructor(String.class);
|
||||
String b64 = Base64.getEncoder().encodeToString(material);
|
||||
Object spec = c.newInstance(b64);
|
||||
return (AlgorithmKeySpec) spec;
|
||||
} catch (NoSuchMethodException ignored) {
|
||||
} catch (Throwable t) { // NOPMD
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String deriveVariantNameForImport(String algId, Object defaultSpec) {
|
||||
if (defaultSpec != null) {
|
||||
try {
|
||||
Method m = defaultSpec.getClass().getMethod("macName");
|
||||
Object v = m.invoke(defaultSpec);
|
||||
if (v instanceof String) {
|
||||
return (String) v;
|
||||
}
|
||||
} catch (Throwable ignored) { // NOPMD
|
||||
}
|
||||
}
|
||||
if ("HMAC".equalsIgnoreCase(algId)) { // NOPMD
|
||||
return "HmacSHA256";
|
||||
}
|
||||
return algId;
|
||||
}
|
||||
|
||||
private static String required(CommandLine cmd, Option opt, String message) {
|
||||
if (!cmd.hasOption(opt.getLongOpt())) {
|
||||
throw new IllegalArgumentException(message);
|
||||
}
|
||||
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.
|
||||
*
|
||||
* @param store keyring store
|
||||
* @param alias alias to check
|
||||
* @param overwrite whether collisions are allowed
|
||||
* @throws IllegalArgumentException if alias exists and overwrite is false
|
||||
*/
|
||||
private static void ensureWritable(KeyringStore store, String alias, boolean overwrite) {
|
||||
if (store.contains(alias) && !overwrite) {
|
||||
throw new IllegalArgumentException("Alias already exists: " + alias + " (use --overwrite)");
|
||||
}
|
||||
}
|
||||
}
|
||||
330
app/src/main/java/zeroecho/Tag.java
Normal file
330
app/src/main/java/zeroecho/Tag.java
Normal file
@@ -0,0 +1,330 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, 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;
|
||||
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.apache.commons.cli.CommandLine;
|
||||
import org.apache.commons.cli.DefaultParser;
|
||||
import org.apache.commons.cli.HelpFormatter;
|
||||
import org.apache.commons.cli.Option;
|
||||
import org.apache.commons.cli.Options;
|
||||
import org.apache.commons.cli.ParseException;
|
||||
|
||||
import zeroecho.core.alg.digest.DigestSpec;
|
||||
import zeroecho.core.err.VerificationException;
|
||||
import zeroecho.core.spec.ContextSpec;
|
||||
import zeroecho.core.spec.VoidSpec;
|
||||
import zeroecho.core.storage.KeyringStore;
|
||||
import zeroecho.core.tag.TagEngineBuilder;
|
||||
import zeroecho.sdk.builders.TagTrailerDataContentBuilder;
|
||||
import zeroecho.sdk.content.api.DataContent;
|
||||
import zeroecho.sdk.content.api.PlainContent;
|
||||
import zeroecho.sdk.content.builtin.PlainFile;
|
||||
|
||||
/**
|
||||
* CLI entry point for producing and verifying trailer tags based on digital
|
||||
* signatures or digests.
|
||||
*
|
||||
* <h2>Overview</h2> This class wires command line options to content-processing
|
||||
* pipelines that either append a tag (produce mode) or validate and strip a tag
|
||||
* (verify mode). The tag can be a digital signature or a message digest,
|
||||
* depending on the selected type and algorithm.
|
||||
*
|
||||
* <h2>Usage</h2> <pre>{@code
|
||||
* ZeroEcho -T --type signature --mode produce --alg Ed25519 \
|
||||
* --ks keys.txt --priv my-signing-key --in file.bin --out file.tagged
|
||||
*
|
||||
* ZeroEcho -T --type signature --mode verify --alg Ed25519 \
|
||||
* --ks keys.txt --pub my-verify-key --in file.tagged --out file.bin
|
||||
*
|
||||
* ZeroEcho -T --type digest --mode produce --alg SHA-256 \
|
||||
* --in file.bin --out file.tagged
|
||||
*
|
||||
* ZeroEcho -T --type digest --mode verify --alg SHA-256 \
|
||||
* --in file.tagged --out file.bin
|
||||
*
|
||||
* # Use "-" to read from STDIN or write to STDOUT:
|
||||
* ZeroEcho -T --type digest --mode produce --alg SHA-256 --in - --out -
|
||||
* }</pre>
|
||||
*
|
||||
* <h2>Notes</h2> The signature mode requires a keyring file. In produce mode a
|
||||
* private key is required, while in verify mode a public key is required.
|
||||
* Digest mode does not require keys. A non-zero exit status indicates a
|
||||
* verification mismatch or input errors.
|
||||
*/
|
||||
public final class Tag { // NOPMD
|
||||
/**
|
||||
* Conventional marker for standard input or output stream.
|
||||
*/
|
||||
private final static String STD_IN_OUT = "-";
|
||||
|
||||
// ---- All options as constants
|
||||
private static final Option TYPE_OPT = Option.builder().longOpt("type").hasArg().argName("signature|digest")
|
||||
.desc("tag primitive type").build();
|
||||
|
||||
private static final Option MODE_OPT = Option.builder().longOpt("mode").hasArg().argName("produce|verify")
|
||||
.desc("operation mode").build();
|
||||
|
||||
private static final Option ALG_OPT = Option.builder().longOpt("alg").hasArg().argName("id")
|
||||
.desc("algorithm id (signature: Ed25519/Ed448/ECDSA/RSA; digest: SHA-256/.../SHAKE256:N)").build();
|
||||
|
||||
private static final Option KS_OPT = Option.builder().longOpt("ks").hasArg().argName("file")
|
||||
.desc("keyring file (required for signature)").build();
|
||||
|
||||
private static final Option PRIV_OPT = Option.builder().longOpt("priv").hasArg().argName("alias")
|
||||
.desc("private key alias (signature + produce)").build();
|
||||
|
||||
private static final Option PUB_OPT = Option.builder().longOpt("pub").hasArg().argName("alias")
|
||||
.desc("public key alias (signature + verify)").build();
|
||||
|
||||
private static final Option IN_OPT = Option.builder().longOpt("in").hasArg().argName("file|-")
|
||||
.desc("input file or - for STDIN").build();
|
||||
|
||||
private static final Option OUT_OPT = Option.builder().longOpt("out").hasArg().argName("file|-")
|
||||
.desc("output file or - for STDOUT").build();
|
||||
|
||||
// ---- Allowed values and defaults (no enums)
|
||||
private static final String TYPE_SIGNATURE = "signature";
|
||||
private static final String TYPE_DIGEST = "digest";
|
||||
private static final String MODE_PRODUCE = "produce";
|
||||
private static final String MODE_VERIFY = "verify";
|
||||
|
||||
private Tag() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses CLI arguments and executes produce or verify operation for signature
|
||||
* or digest tags.
|
||||
*
|
||||
* <h4>Behavior</h4> The method validates required options, constructs the
|
||||
* appropriate processing pipeline, and streams data from the input to the
|
||||
* output while producing or verifying the trailing tag. On verification
|
||||
* mismatch, it returns a non-zero exit code and attempts to remove a corrupted
|
||||
* output file when applicable.
|
||||
*
|
||||
* @param args the command line arguments that follow {@code -T}
|
||||
* @param root the options container to which this method appends its own
|
||||
* options before parsing
|
||||
* @return process exit code: {@code 0} on success, {@code 1} on verification
|
||||
* mismatch or I/O error during verification, {@code 2} on invalid
|
||||
* arguments or unsupported options
|
||||
* @throws ParseException if CLI parsing fails
|
||||
* @throws IOException if an I/O error occurs while reading or
|
||||
* writing streams
|
||||
* @throws GeneralSecurityException if a cryptographic error occurs during
|
||||
* signature or digest processing
|
||||
*/
|
||||
public static int main(String[] args, Options root) throws ParseException, IOException, GeneralSecurityException {
|
||||
Options opts = root;
|
||||
opts.addOption(TYPE_OPT);
|
||||
opts.addOption(MODE_OPT);
|
||||
opts.addOption(ALG_OPT);
|
||||
opts.addOption(KS_OPT);
|
||||
opts.addOption(PRIV_OPT);
|
||||
opts.addOption(PUB_OPT);
|
||||
opts.addOption(IN_OPT);
|
||||
opts.addOption(OUT_OPT);
|
||||
|
||||
CommandLine cli = new DefaultParser().parse(opts, args);
|
||||
|
||||
if (!has(cli, TYPE_OPT) || !has(cli, MODE_OPT) || !has(cli, ALG_OPT) || !has(cli, IN_OPT)
|
||||
|| !has(cli, OUT_OPT)) {
|
||||
new HelpFormatter().printHelp("zeroecho -T [options]", opts);
|
||||
return 2;
|
||||
}
|
||||
|
||||
String type = opt(cli, TYPE_OPT).trim().toLowerCase(Locale.ROOT);
|
||||
String mode = opt(cli, MODE_OPT).trim().toLowerCase(Locale.ROOT);
|
||||
if (!isOneOf(type, TYPE_SIGNATURE, TYPE_DIGEST)) {
|
||||
System.err.println("Unsupported --type: " + type);
|
||||
return 2;
|
||||
}
|
||||
if (!isOneOf(mode, MODE_PRODUCE, MODE_VERIFY)) {
|
||||
System.err.println("Unsupported --mode: " + mode);
|
||||
return 2;
|
||||
}
|
||||
|
||||
String alg = opt(cli, ALG_OPT);
|
||||
String inPath = opt(cli, IN_OPT);
|
||||
String outPath = opt(cli, OUT_OPT);
|
||||
|
||||
PlainContent source = source(inPath);
|
||||
boolean produce = MODE_PRODUCE.equals(mode);
|
||||
DataContent tail;
|
||||
|
||||
if (TYPE_SIGNATURE.equals(type)) {
|
||||
String ksPath = require(cli, KS_OPT, "--ks <file> is required for --type signature");
|
||||
KeyringStore keyring = KeyringStore.load(Path.of(ksPath));
|
||||
ContextSpec spec = VoidSpec.INSTANCE;
|
||||
|
||||
if (produce) {
|
||||
String privAlias = require(cli, PRIV_OPT, "signature produce requires --priv <alias>");
|
||||
PrivateKey priv = keyring.getPrivate(privAlias);
|
||||
tail = new TagTrailerDataContentBuilder<>(TagEngineBuilder.signature(alg, priv, spec)).build(true);
|
||||
} else {
|
||||
String pubAlias = require(cli, PUB_OPT, "signature verify requires --pub <alias>");
|
||||
PublicKey pub = keyring.getPublic(pubAlias);
|
||||
tail = new TagTrailerDataContentBuilder<>(TagEngineBuilder.signature(alg, pub, spec)).build(false);
|
||||
}
|
||||
} else { // digest
|
||||
DigestSpec spec = parseDigest(alg);
|
||||
tail = new TagTrailerDataContentBuilder<>(TagEngineBuilder.digest(spec)).build(produce);
|
||||
}
|
||||
|
||||
tail.setInput(source);
|
||||
|
||||
try (InputStream in = tail.getStream(); OutputStream out = sink(outPath)) {
|
||||
in.transferTo(out);
|
||||
} catch (IOException verifyFail) {
|
||||
System.out.println("ERROR: " + verifyFail.getMessage());
|
||||
if (verifyFail.getCause() instanceof VerificationException) {
|
||||
try {
|
||||
// remove the file if it is corrupted
|
||||
Files.deleteIfExists(Path.of(outPath));
|
||||
} catch (IOException e1) { // NOPMD
|
||||
// ignore any errors
|
||||
}
|
||||
}
|
||||
return 1; // non-zero exit for verify mismatch
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private static boolean has(CommandLine cli, Option opt) {
|
||||
return cli.hasOption(opt.getLongOpt());
|
||||
}
|
||||
|
||||
private static String opt(CommandLine cli, Option opt) {
|
||||
return cli.getOptionValue(opt.getLongOpt());
|
||||
}
|
||||
|
||||
private static String require(CommandLine cli, Option opt, String msg) {
|
||||
String v = cli.getOptionValue(opt.getLongOpt());
|
||||
if (v == null) {
|
||||
throw new IllegalArgumentException(msg);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
private static boolean isOneOf(String s, String a, String b) {
|
||||
return a.equalsIgnoreCase(s) || b.equalsIgnoreCase(s);
|
||||
}
|
||||
|
||||
private static PlainContent source(String path) throws IOException {
|
||||
if (STD_IN_OUT.equals(path)) {
|
||||
return new StdinContent(System.in);
|
||||
}
|
||||
return new PlainFile(Path.of(path).toUri().toURL());
|
||||
}
|
||||
|
||||
private static OutputStream sink(String path) throws IOException {
|
||||
if (STD_IN_OUT.equals(path)) {
|
||||
return new NonClosingBufferedOutputStream(System.out, 64 * 1024);
|
||||
}
|
||||
return new BufferedOutputStream(Files.newOutputStream(Path.of(path)), 64 * 1024);
|
||||
}
|
||||
|
||||
private static DigestSpec parseDigest(String s) {
|
||||
String u = s.trim().toUpperCase(Locale.ROOT);
|
||||
if ("SHA-256".equals(u) || "SHA256".equals(u)) {
|
||||
return DigestSpec.sha256();
|
||||
}
|
||||
if ("SHA-512".equals(u) || "SHA512".equals(u)) {
|
||||
return DigestSpec.sha512();
|
||||
}
|
||||
if ("SHA3-256".equals(u) || "SHA3_256".equals(u)) {
|
||||
return DigestSpec.sha3_256();
|
||||
}
|
||||
if ("SHA3-512".equals(u) || "SHA3_512".equals(u)) {
|
||||
return DigestSpec.sha3_512();
|
||||
}
|
||||
if (u.startsWith("SHAKE128:")) {
|
||||
int len = Integer.parseInt(u.substring("SHAKE128:".length()));
|
||||
return DigestSpec.shake128(len);
|
||||
}
|
||||
if (u.startsWith("SHAKE256:")) {
|
||||
int len = Integer.parseInt(u.substring("SHAKE256:".length()));
|
||||
return DigestSpec.shake256(len);
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported digest: " + s);
|
||||
}
|
||||
|
||||
/** STDIN wrapper that implements PlainContent. */
|
||||
private static final class StdinContent implements PlainContent {
|
||||
private final InputStream in;
|
||||
|
||||
private StdinContent(InputStream in) {
|
||||
this.in = Objects.requireNonNull(in, "stdin");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInput(DataContent upstream) {
|
||||
if (upstream != null) {
|
||||
throw new IllegalArgumentException("stdin is a source; no input allowed");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getStream() {
|
||||
return in;
|
||||
}
|
||||
}
|
||||
|
||||
/** Buffered stream that never closes the underlying stream (for STDOUT). */
|
||||
private static final class NonClosingBufferedOutputStream extends BufferedOutputStream {
|
||||
private NonClosingBufferedOutputStream(OutputStream out, int size) {
|
||||
super(out, size);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
flush();
|
||||
} // do not close underlying System.out
|
||||
}
|
||||
}
|
||||
198
app/src/main/java/zeroecho/ZeroEcho.java
Normal file
198
app/src/main/java/zeroecho/ZeroEcho.java
Normal file
@@ -0,0 +1,198 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, 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;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.apache.commons.cli.CommandLineParser;
|
||||
import org.apache.commons.cli.DefaultParser;
|
||||
import org.apache.commons.cli.HelpFormatter;
|
||||
import org.apache.commons.cli.MissingOptionException;
|
||||
import org.apache.commons.cli.Option;
|
||||
import org.apache.commons.cli.OptionGroup;
|
||||
import org.apache.commons.cli.Options;
|
||||
import org.apache.commons.cli.ParseException;
|
||||
|
||||
import zeroecho.sdk.util.BouncyCastleActivator;
|
||||
|
||||
/**
|
||||
* ZeroEcho is a command-line utility for managing asymmetric keys and
|
||||
* certificates, primarily focusing on Certificate Authority (CA) operations
|
||||
* such as issuing, revoking, and listing certificates.
|
||||
* <p>
|
||||
* It supports command-line options for asymmetric key management, including
|
||||
* issuing certificates from CSRs or subject names, revoking certificates, and
|
||||
* retrieving all certificates for a user.
|
||||
* </p>
|
||||
* <p>
|
||||
* This class initializes Bouncy Castle security provider and uses Apache
|
||||
* Commons CLI for command-line parsing.
|
||||
* </p>
|
||||
*
|
||||
* @author Leo Galambos
|
||||
*/
|
||||
public final class ZeroEcho {
|
||||
/**
|
||||
* Logger instance for the {@code ZeroEcho} class used to log messages and
|
||||
* events.
|
||||
* <p>
|
||||
* This logger is configured with the name of the {@code ZeroEcho} class,
|
||||
* allowing for fine-grained logging control specific to this class.
|
||||
* </p>
|
||||
*/
|
||||
public static final Logger LOG = Logger.getLogger(ZeroEcho.class.getName());
|
||||
|
||||
static {
|
||||
BouncyCastleActivator.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Default constructor for ZeroEcho.
|
||||
* <p>
|
||||
* This constructor does not perform any initialization since all operations are
|
||||
* handled via static methods and blocks.
|
||||
* </p>
|
||||
*/
|
||||
private ZeroEcho() {
|
||||
// No initialization needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for the ZeroEcho application. Parses command-line arguments
|
||||
* and dispatches to the appropriate subcommand for asymmetric key management or
|
||||
* prints the help message.
|
||||
*
|
||||
* @param args command-line arguments passed to the program
|
||||
*/
|
||||
public static void main(final String[] args) {
|
||||
final int errorCode = mainProcess(args);
|
||||
|
||||
if (errorCode == 0) {
|
||||
System.out.println("OK");
|
||||
} else {
|
||||
System.out.println("ERR: " + errorCode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for the ZeroEcho application. Parses command-line arguments and
|
||||
* dispatches to the appropriate subcommand for asymmetric key management or
|
||||
* prints the help message.
|
||||
*
|
||||
* @param args command-line arguments passed to the program
|
||||
* @return error-code
|
||||
*/
|
||||
public static int mainProcess(final String... args) {
|
||||
final Option KEM_OPTION = Option.builder("E").longOpt("kem").desc("KEM encryption/decryption").build();
|
||||
final Option GUARD_OPTION = Option.builder("G").longOpt("guard")
|
||||
.desc("multi-recipient encryption/decryption (keys+passwords), AES/ChaCha").build();
|
||||
final Option KEYSTORE_OPTION = Option.builder("K").longOpt("ksm").desc("key store management").build();
|
||||
final Option COVERT_OPTION = Option.builder("C").longOpt("covert").desc("covert channel processing").build();
|
||||
final Option TAG_OPTION = Option.builder("T").longOpt("tag")
|
||||
.desc("tag subcommand (signature/digest; produce/verify)").build();
|
||||
|
||||
final OptionGroup OPERATION_GROUP = new OptionGroup();
|
||||
OPERATION_GROUP.addOption(GUARD_OPTION);
|
||||
OPERATION_GROUP.addOption(KEYSTORE_OPTION);
|
||||
OPERATION_GROUP.addOption(KEM_OPTION);
|
||||
OPERATION_GROUP.addOption(COVERT_OPTION);
|
||||
OPERATION_GROUP.addOption(TAG_OPTION);
|
||||
OPERATION_GROUP.setRequired(true); // At least one required
|
||||
|
||||
Options options = new Options();
|
||||
options.addOptionGroup(OPERATION_GROUP);
|
||||
|
||||
final CommandLineParser parser = new DefaultParser();
|
||||
|
||||
try {
|
||||
// parse the command line arguments (allow remaining arguments for subcommands)
|
||||
parser.parse(options, args, true);
|
||||
|
||||
return switch (OPERATION_GROUP.getSelected()) {
|
||||
case "E" -> Kem.main(args, options = new Options().addOption(KEM_OPTION));
|
||||
case "G" -> Guard.main(args, options = new Options().addOption(GUARD_OPTION));
|
||||
case "K" -> KeyStoreManagement.main(args, options = new Options().addOption(KEYSTORE_OPTION));
|
||||
case "C" -> CovertCommand.main(args, options = new Options().addOption(COVERT_OPTION));
|
||||
case "T" -> Tag.main(args, options = new Options().addOption(TAG_OPTION));
|
||||
default -> 1;
|
||||
|
||||
};
|
||||
|
||||
} catch (MissingOptionException ex) {
|
||||
if (LOG.isLoggable(Level.SEVERE)) {
|
||||
LOG.log(Level.SEVERE, ex.getMessage());
|
||||
}
|
||||
return help(options);
|
||||
|
||||
} catch (ParseException | GeneralSecurityException ex) {
|
||||
if (LOG.isLoggable(Level.WARNING)) {
|
||||
LOG.log(Level.WARNING, "Unexpected exception", ex.getMessage());
|
||||
}
|
||||
return help(options);
|
||||
|
||||
} catch (IOException e) {
|
||||
LOG.logp(Level.WARNING, "ZeroEcho", "mainProcess", e.getMessage(), e);
|
||||
return -1;
|
||||
|
||||
} finally {
|
||||
LOG.log(Level.INFO, "Completed.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints the usage help message for the command-line interface of the ZeroEcho
|
||||
* application.
|
||||
*
|
||||
* <p>
|
||||
* This method automatically generates and displays a help statement based on
|
||||
* the provided command-line {@link Options}. It then terminates the program
|
||||
* with exit status {@code 1}.
|
||||
*
|
||||
* @param options The {@link Options} instance defining the available
|
||||
* command-line options.
|
||||
* @return always {@code 1}
|
||||
*/
|
||||
private static int help(final Options options) {
|
||||
// automatically generate the help statement
|
||||
final HelpFormatter formatter = new HelpFormatter();
|
||||
formatter.setWidth(80);
|
||||
formatter.printHelp(ZeroEcho.class.getName(), options);
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
134
app/src/main/java/zeroecho/package-info.java
Normal file
134
app/src/main/java/zeroecho/package-info.java
Normal file
@@ -0,0 +1,134 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, 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.
|
||||
******************************************************************************/
|
||||
/**
|
||||
* Command-line tooling built around streaming cryptographic pipelines for
|
||||
* encryption, tagging, key management, and covert transport.
|
||||
*
|
||||
* <p>
|
||||
* This package provides a small set of composable subcommands exposed through a
|
||||
* single dispatcher and implemented on top of builder-style SDK components. The
|
||||
* design favors streaming I/O so large inputs are never fully buffered in
|
||||
* memory, and it emphasizes strong defaults with explicit opt-ins for advanced
|
||||
* parameters.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Design goals</h2>
|
||||
* <ul>
|
||||
* <li><b>Single front end, multiple tools:</b> the {@link ZeroEcho} dispatcher
|
||||
* selects a subcommand and passes through the remaining CLI arguments to it,
|
||||
* allowing each tool to define its own options without global conflicts.</li>
|
||||
* <li><b>Streaming composition:</b> subcommands assemble {@code DataContent}
|
||||
* chains so input is transformed on the fly (encrypt, tag, verify, embed) and
|
||||
* written to the destination stream. This keeps memory usage low and makes
|
||||
* stdin/stdout a first-class I/O mode.</li>
|
||||
* <li><b>Safe, explicit defaults:</b> symmetric modes default to authenticated
|
||||
* variants when available, headers are on by default when they carry required
|
||||
* parameters, and verification errors cause clear exit codes and cleanup of
|
||||
* incomplete outputs.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Core ideas and algorithms</h2>
|
||||
* <p>
|
||||
* The tools build on two families of primitives:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li><b>Asymmetric/KEM envelopes:</b> hybrid encryption encapsulates a
|
||||
* content-encryption key with a chosen KEM, then applies a symmetric cipher
|
||||
* (AES-GCM/CTR/CBC or ChaCha AEAD/stream). HKDF-SHA256 is supported for
|
||||
* deriving the payload key from the KEM secret, with limits on accepted
|
||||
* KEM-ciphertext sizes.</li>
|
||||
* <li><b>Trailer tags:</b> a trailer at the end of the stream carries either a
|
||||
* digital signature or a digest. Produce mode computes and appends the tag;
|
||||
* verify mode validates and strips it, returning a non-zero status on
|
||||
* mismatch.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Key material is loaded from a simple, text-based keyring that supports
|
||||
* listing, generation, and import/export of versioned snippets for both
|
||||
* symmetric and asymmetric algorithms.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Subcommands overview</h2>
|
||||
* <ul>
|
||||
* <li>{@link Guard} - multi-recipient envelope that mixes public-key recipients
|
||||
* and passwords, optionally shuffling recipients; payload is AES or ChaCha with
|
||||
* optional header and AAD.</li>
|
||||
* <li>{@link Kem} - KEM-based hybrid encryption and decryption with AES or
|
||||
* ChaCha payloads, HKDF support, counters/nonces, and compact symmetric
|
||||
* headers.</li>
|
||||
* <li>{@link KeyStoreManagement} - maintenance of a text keyring: list
|
||||
* algorithms and aliases, generate keys, and import/export versioned
|
||||
* snippets.</li>
|
||||
* <li>{@link Tag} - produce or verify trailer tags using digital signatures or
|
||||
* message digests.</li>
|
||||
* <li>{@link CovertCommand} - embed or extract a binary payload in JPEG files
|
||||
* via configurable EXIF slots.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>I/O conventions and exit codes</h2>
|
||||
* <ul>
|
||||
* <li>All tools accept "-" for stdin/stdout when appropriate, enabling shell
|
||||
* pipelines.</li>
|
||||
* <li>Exit code {@code 0} indicates success; non-zero codes signal parse
|
||||
* errors, verification failures, or I/O problems. Verification failures attempt
|
||||
* to remove partially written outputs.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Typical usage</h2> <pre>{@code
|
||||
* # Multi-recipient envelope (AES-GCM) with shuffled recipients
|
||||
* ZeroEcho -G --encrypt in.bin --output out.enc \
|
||||
* --keyring keyring.txt --to-alias alice --to-psw s3cret \
|
||||
* --alg aes-gcm --tag-bits 128 --aad-hex DEADBEEF
|
||||
*
|
||||
* # KEM + ChaCha AEAD with header
|
||||
* ZeroEcho -E --encrypt in.bin -o out.enc \
|
||||
* --keyring keyring.txt --pub alice-pub \
|
||||
* --kem Kyber-768 --chacha --chacha-nonce 00112233445566778899AABB \
|
||||
* --aad 01020304 --header
|
||||
*
|
||||
* # Generate a key into the keyring
|
||||
* ZeroEcho -K --keystore keyring.txt --generate --alg Ed25519 --alias signing
|
||||
*
|
||||
* # Trailer signature (produce/verify)
|
||||
* ZeroEcho -T --type signature --mode produce --alg Ed25519 \
|
||||
* --ks keyring.txt --priv signing.prv --in file.bin --out file.tagged
|
||||
*
|
||||
* # Covert EXIF embedding
|
||||
* ZeroEcho -C --embed --jpeg in.jpg --payload secret.bin --output out.jpg
|
||||
* }</pre>
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
package zeroecho;
|
||||
78
app/src/main/javadoc/overview.html
Normal file
78
app/src/main/javadoc/overview.html
Normal file
@@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>ZeroEcho App Overview</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>ZeroEcho Command-Line App</h1>
|
||||
|
||||
<p>
|
||||
The ZeroEcho CLI is a streaming, security-first front end built on the <code>lib</code> module.
|
||||
It exposes practical workflows for key management, hybrid/KEM envelopes, multi-recipient
|
||||
protection, and covert payload embedding in JPEG EXIF metadata. The app favors explicit
|
||||
configuration, safe defaults, and pipelines that avoid materializing large payloads.
|
||||
</p>
|
||||
|
||||
<h2>Commands</h2>
|
||||
<ul>
|
||||
<li><b>guard</b> - multi-recipient envelopes (public keys and/or passwords) with AES or ChaCha payloads.</li>
|
||||
<li><b>kem</b> - hybrid encryption: derive a content key via a KEM (e.g., Kyber), then encrypt the payload (AES/ChaCha).</li>
|
||||
<li><b>keystore</b> - manage a human-editable text keyring: list, generate, import, export.</li>
|
||||
<li><b>covert</b> - embed or extract a binary payload in JPEG EXIF fields using configurable slots.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Global usage</h2>
|
||||
<p>
|
||||
Each command supports <code>--help</code> for exact flags and examples. Inputs and outputs are streamed;
|
||||
large files do not need to be fully loaded in memory.
|
||||
</p>
|
||||
|
||||
<h2>I/O conventions</h2>
|
||||
<ul>
|
||||
<li>Streams are processed lazily; errors in verification surface at end of stream.</li>
|
||||
<li>Authenticated modes (AES-GCM, ChaCha20-Poly1305) are the default where applicable.</li>
|
||||
<li>For hybrid flows, shared secrets from agreement/KEM are fed through a KDF before use.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Keyring format</h2>
|
||||
<p>
|
||||
The keyring is a compact UTF-8 text file of entries with algorithm id, spec class, and encoded material.
|
||||
It is intended to be versionable by humans but must be treated as sensitive data.
|
||||
</p>
|
||||
|
||||
<h2>Security notes</h2>
|
||||
<ul>
|
||||
<li>Prefer authenticated encryption and strong KEM parameter sets.</li>
|
||||
<li>Protect keyrings with OS permissions; avoid committing them to VCS.</li>
|
||||
<li>Export encrypted content when targeting untrusted destinations; do not embed secrets in cleartext scripts.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Exit codes and logging</h2>
|
||||
<ul>
|
||||
<li>Commands return 0 on success; non-zero indicates failure.</li>
|
||||
<li>Errors go to STDERR; enable verbose logging for diagnostics as needed.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Examples (illustrative)</h2>
|
||||
<pre>
|
||||
# Generate a signing key into a text keyring
|
||||
zeroecho keystore --keyring keyring.txt --generate --alg Ed25519 --alias signing
|
||||
|
||||
# Hybrid envelope with a KEM-derived content key and AES-GCM payload
|
||||
zeroecho kem --encrypt --keyring keyring.txt --recipient alice --kem Kyber-768 --symmetric aes-gcm --tag-bits 128
|
||||
|
||||
# Multi-recipient envelope (password + public key)
|
||||
zeroecho guard --encrypt --keyring keyring.txt --to-password s3cret --to-alias bob
|
||||
|
||||
# Covert EXIF embedding
|
||||
zeroecho covert --embed --jpeg in.jpg --payload secret.bin --slots exif.usercomment --output out.jpg
|
||||
</pre>
|
||||
|
||||
<h2>System requirements</h2>
|
||||
<ul>
|
||||
<li>Java 21 or newer.</li>
|
||||
<li>At least one JCA provider supplying the selected algorithms (e.g., JDK defaults, Bouncy Castle, a PQC provider).</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
148
app/src/test/java/zeroecho/CovertCommandTest.java
Normal file
148
app/src/test/java/zeroecho/CovertCommandTest.java
Normal file
@@ -0,0 +1,148 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, 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;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
|
||||
import org.apache.commons.cli.Options;
|
||||
import org.apache.commons.cli.ParseException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
public class CovertCommandTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private Path copyTestJpeg(String name) throws IOException {
|
||||
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(name)) {
|
||||
if (inputStream == null) {
|
||||
throw new IllegalArgumentException("Missing resource: " + name);
|
||||
}
|
||||
|
||||
Path target = tempDir.resolve(name);
|
||||
Files.copy(inputStream, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
return target;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEmbedAndExtractDefaultSlots() throws Exception {
|
||||
System.out.println("testEmbedAndExtractDefaultSlots");
|
||||
|
||||
// Prepare input JPEG and payload
|
||||
Path jpeg = copyTestJpeg("test.jpg");
|
||||
Path payload = tempDir.resolve("secret.txt");
|
||||
Path stegoOutput = tempDir.resolve("stego.jpg");
|
||||
Path extractedOutput = tempDir.resolve("extracted.dat");
|
||||
|
||||
String message = "SecretMessage123!";
|
||||
Files.write(payload, message.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
// --- Embed ---
|
||||
Options embedOptions = new Options();
|
||||
int embedCode = CovertCommand.main(new String[] { "--embed", "--jpeg", jpeg.toString(), "--payload",
|
||||
payload.toString(), "--output", stegoOutput.toString() }, embedOptions);
|
||||
assertEquals(0, embedCode);
|
||||
assertTrue(Files.exists(stegoOutput));
|
||||
assertTrue(Files.size(stegoOutput) > Files.size(jpeg));
|
||||
|
||||
// --- Extract ---
|
||||
Options extractOptions = new Options();
|
||||
int extractCode = CovertCommand.main(
|
||||
new String[] { "--extract", "--jpeg", stegoOutput.toString(), "--output", extractedOutput.toString() },
|
||||
extractOptions);
|
||||
assertEquals(0, extractCode);
|
||||
assertTrue(Files.exists(extractedOutput));
|
||||
|
||||
// Verify content
|
||||
String extracted = Files.readString(extractedOutput);
|
||||
assertEquals(message, extracted);
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEmbedFailsWithoutPayload() {
|
||||
System.out.println("testEmbedFailsWithoutPayload");
|
||||
|
||||
Exception thrown = assertThrows(ParseException.class, () -> {
|
||||
Options options = new Options();
|
||||
CovertCommand.main(new String[] { "--embed", "--jpeg", "input.jpg", "--output", "out.jpg" }, options);
|
||||
});
|
||||
|
||||
assertTrue(thrown.getMessage().contains("--payload is required"));
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCustomSlotsEmbedding() throws Exception {
|
||||
System.out.println("testCustomSlotsEmbedding");
|
||||
|
||||
Path jpeg = copyTestJpeg("test.jpg");
|
||||
Path payload = tempDir.resolve("msg.bin");
|
||||
Path stego = tempDir.resolve("custom_stego.jpg");
|
||||
Path extracted = tempDir.resolve("custom_extracted.bin");
|
||||
|
||||
String secret = "This uses custom slots!";
|
||||
Files.write(payload, secret.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
// Correctly defined custom slot string
|
||||
String slotString = "Exif.UserComment:2048;Exif.CustomDesc/tag=700,ascii,64,exif:1024";
|
||||
|
||||
Options embedOptions = new Options();
|
||||
int code = CovertCommand.main(new String[] { "--embed", "--jpeg", jpeg.toString(), "--payload",
|
||||
payload.toString(), "--output", stego.toString(), "--slots", slotString }, embedOptions);
|
||||
assertEquals(0, code);
|
||||
assertTrue(Files.exists(stego));
|
||||
|
||||
Options extractOptions = new Options();
|
||||
code = CovertCommand.main(new String[] { "--extract", "--jpeg", stego.toString(), "--output",
|
||||
extracted.toString(), "--slots", slotString }, extractOptions);
|
||||
assertEquals(0, code);
|
||||
|
||||
String extractedMsg = Files.readString(extracted);
|
||||
assertEquals(secret, extractedMsg);
|
||||
System.out.println("...ok");
|
||||
}
|
||||
}
|
||||
354
app/src/test/java/zeroecho/GuardTest.java
Normal file
354
app/src/test/java/zeroecho/GuardTest.java
Normal file
@@ -0,0 +1,354 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, 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;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.io.PrintStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Random;
|
||||
|
||||
import org.apache.commons.cli.Options;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import zeroecho.sdk.util.BouncyCastleActivator;
|
||||
|
||||
/**
|
||||
* CLI-level round-trip tests for the Guard subcommand.
|
||||
*
|
||||
* <h2>Scope</h2> These tests drive
|
||||
* {@link Guard#main(String[], org.apache.commons.cli.Options)} with only the
|
||||
* options implemented by Guard. They exercise:
|
||||
* <ul>
|
||||
* <li>Password-only encryption and decryption,</li>
|
||||
* <li>RSA recipients with private-key based decryption,</li>
|
||||
* <li>Mixed recipients (RSA + ElGamal + password) with decoys and default
|
||||
* shuffling,</li>
|
||||
* <li>AES-GCM (with tag bits and AAD) and ChaCha20-Poly1305 (with AAD and
|
||||
* explicit nonce).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Keys are generated using the existing KeyStoreManagement CLI to populate a
|
||||
* real KeyringStore file, as done in KemTest. The CLI persists aliases as
|
||||
* {@code <alias>.pub} and {@code <alias>.prv}.
|
||||
* </p>
|
||||
*/
|
||||
public class GuardTest {
|
||||
|
||||
/** All temporary files live here and are auto-cleaned by JUnit. */
|
||||
@TempDir
|
||||
Path tmp;
|
||||
|
||||
private PrintStream savedOut;
|
||||
|
||||
@BeforeAll
|
||||
static void bootBouncyCastle() {
|
||||
System.out.println("bootBouncyCastle()");
|
||||
// The project initializes BC explicitly where needed.
|
||||
BouncyCastleActivator.init();
|
||||
System.out.println("bootBouncyCastle...ok");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void restoreStdout() {
|
||||
if (savedOut != null) {
|
||||
System.setOut(savedOut);
|
||||
savedOut = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Password-only round trip using AES-GCM with header and AAD.
|
||||
*
|
||||
* <p>
|
||||
* This does not require a keyring.
|
||||
* </p>
|
||||
*/
|
||||
@Test
|
||||
void password_aesGcm_roundTrip_ok() throws Exception {
|
||||
final String method = "password_aesGcm_roundTrip_ok()";
|
||||
final String password = "Tr0ub4dor&3";
|
||||
final int size = 4096;
|
||||
final String aadHex = "A1B2C3D4";
|
||||
final int tagBits = 128;
|
||||
|
||||
System.out.println(method);
|
||||
System.out.println("...params: size=" + size + " tagBits=" + tagBits + " aadHex=" + aadHex);
|
||||
|
||||
Path in = writeRandom(tmp.resolve("pt.bin"), size, 0xA11CE01);
|
||||
Path enc = tmp.resolve("pt.bin.enc");
|
||||
Path dec = tmp.resolve("pt.bin.dec");
|
||||
|
||||
// Encrypt
|
||||
String[] encArgs = { "--encrypt", in.toString(), "--output", enc.toString(), "--to-psw", password, "--alg",
|
||||
"aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex", aadHex };
|
||||
System.out.println("...encrypt: " + Arrays.toString(encArgs));
|
||||
int e = Guard.main(encArgs, new Options());
|
||||
assertEquals(0, e, "... encrypt expected exit code 0");
|
||||
|
||||
// Decrypt (using password)
|
||||
String[] decArgs = { "--decrypt", enc.toString(), "--output", dec.toString(), "--password", password, "--alg",
|
||||
"aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex", aadHex };
|
||||
System.out.println("...decrypt: " + Arrays.toString(decArgs));
|
||||
int d = Guard.main(decArgs, new Options());
|
||||
assertEquals(0, d, "... decrypt expected exit code 0");
|
||||
|
||||
assertArrayEquals(Files.readAllBytes(in), Files.readAllBytes(dec), "AES-GCM password round-trip mismatch");
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA recipient round trips with both AES-GCM and ChaCha20-Poly1305 payloads.
|
||||
*
|
||||
* <p>
|
||||
* Keys are generated through the real KeyStoreManagement CLI and read via Guard
|
||||
* with --to-alias and --priv-alias.
|
||||
* </p>
|
||||
*/
|
||||
@Test
|
||||
void rsa_alias_aesGcm_and_chacha_roundTrip_ok() throws Exception {
|
||||
final String method = "rsa_alias_aesGcm_and_chacha_roundTrip_ok()";
|
||||
final String base = "alice";
|
||||
final String rsaId = "RSA";
|
||||
final int sizeAes = 8192;
|
||||
final int sizeCha = 3072;
|
||||
final int tagBits = 128;
|
||||
final String aadAes = "010203";
|
||||
final String aadCha = "D00DFEED";
|
||||
final String chNonce = "00112233445566778899AABB";
|
||||
|
||||
System.out.println(method);
|
||||
System.out.println("...params: sizeAes=" + sizeAes + " sizeCha=" + sizeCha + " tagBits=" + tagBits + " aadAes="
|
||||
+ aadAes + " aadCha=" + aadCha + " chNonce=" + chNonce);
|
||||
|
||||
// Prepare keyring with RSA pair
|
||||
Path ring = tmp.resolve("ring-rsa.txt");
|
||||
KeyAliases rsa = generateIntoKeyStore(ring, rsaId, base);
|
||||
|
||||
// AES-GCM round-trip
|
||||
{
|
||||
Path in = writeRandom(tmp.resolve("rsa-pt-aes.bin"), sizeAes, 0x5157A11);
|
||||
Path enc = tmp.resolve("rsa-pt-aes.bin.enc");
|
||||
Path dec = tmp.resolve("rsa-pt-aes.bin.dec");
|
||||
|
||||
String[] encArgs = { "--encrypt", in.toString(), "--output", enc.toString(), "--keyring", ring.toString(),
|
||||
"--to-alias", rsa.pub, "--alg", "aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex",
|
||||
aadAes };
|
||||
System.out.println("...AES encrypt: " + Arrays.toString(encArgs));
|
||||
int e = Guard.main(encArgs, new Options());
|
||||
assertEquals(0, e, "... AES encrypt rc");
|
||||
|
||||
String[] decArgs = { "--decrypt", enc.toString(), "--output", dec.toString(), "--keyring", ring.toString(),
|
||||
"--priv-alias", rsa.prv, "--alg", "aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex",
|
||||
aadAes };
|
||||
System.out.println("...AES decrypt: " + Arrays.toString(decArgs));
|
||||
int d = Guard.main(decArgs, new Options());
|
||||
assertEquals(0, d, "... AES decrypt rc");
|
||||
|
||||
assertArrayEquals(Files.readAllBytes(in), Files.readAllBytes(dec), "RSA AES-GCM round-trip mismatch");
|
||||
System.out.println("...AES round-trip ok");
|
||||
}
|
||||
|
||||
// ChaCha20-Poly1305 round-trip
|
||||
{
|
||||
Path in = writeRandom(tmp.resolve("rsa-pt-ch.bin"), sizeCha, 0xC0FFEE1);
|
||||
Path enc = tmp.resolve("rsa-pt-ch.bin.enc");
|
||||
Path dec = tmp.resolve("rsa-pt-ch.bin.dec");
|
||||
|
||||
String[] encArgs = { "--encrypt", in.toString(), "--output", enc.toString(), "--keyring", ring.toString(),
|
||||
"--to-alias", rsa.pub, "--alg", "chacha-aead", "--aad-hex", aadCha, "--nonce-hex", chNonce };
|
||||
System.out.println("...ChaCha encrypt: " + Arrays.toString(encArgs));
|
||||
int e = Guard.main(encArgs, new Options());
|
||||
assertEquals(0, e, "... ChaCha encrypt rc");
|
||||
|
||||
String[] decArgs = { "--decrypt", enc.toString(), "--output", dec.toString(), "--keyring", ring.toString(),
|
||||
"--priv-alias", rsa.prv, "--alg", "chacha-aead", "--aad-hex", aadCha, "--nonce-hex", chNonce };
|
||||
System.out.println("...ChaCha decrypt: " + Arrays.toString(decArgs));
|
||||
int d = Guard.main(decArgs, new Options());
|
||||
assertEquals(0, d, "... ChaCha decrypt rc");
|
||||
|
||||
assertArrayEquals(Files.readAllBytes(in), Files.readAllBytes(dec),
|
||||
"RSA ChaCha20-Poly1305 round-trip mismatch");
|
||||
System.out.println("...ChaCha round-trip ok");
|
||||
}
|
||||
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
/**
|
||||
* Mixed recipients with decoys: RSA + password recipients plus an ElGamal decoy
|
||||
* alias.
|
||||
*
|
||||
* <p>
|
||||
* Verifies that default shuffling does not prevent decryption and that both
|
||||
* password and private-key paths can unlock.
|
||||
* </p>
|
||||
*/
|
||||
@Test
|
||||
void mixed_recipients_with_decoys_roundTrip_ok() throws Exception {
|
||||
final String method = "mixed_recipients_with_decoys_roundTrip_ok()";
|
||||
final int size = 4096;
|
||||
|
||||
System.out.println(method);
|
||||
|
||||
// Prepare keyring with RSA and ElGamal
|
||||
Path ring = tmp.resolve("ring-mixed.txt");
|
||||
KeyAliases rsa = generateIntoKeyStore(ring, "RSA", "bob");
|
||||
KeyAliases elg = generateIntoKeyStore(ring, "ElGamal", "carol"); // used as decoy alias
|
||||
|
||||
Path in = writeRandom(tmp.resolve("pt-mixed.bin"), size, 0xBADC0DE);
|
||||
Path enc = tmp.resolve("pt-mixed.bin.enc");
|
||||
Path dec1 = tmp.resolve("pt-mixed.bin.dec1");
|
||||
Path dec2 = tmp.resolve("pt-mixed.bin.dec2");
|
||||
|
||||
final String password = "correct horse battery staple";
|
||||
final String aad = "FEEDFACE";
|
||||
final int tagBits = 128;
|
||||
|
||||
// Encrypt with: RSA real recipient, password real recipient, ElGamal decoy
|
||||
// alias,
|
||||
// plus 2 random password decoys. Recipients are shuffled by default.
|
||||
String[] encArgs = { "--encrypt", in.toString(), "--output", enc.toString(), "--keyring", ring.toString(),
|
||||
"--to-alias", rsa.pub, "--to-psw", password, "--decoy-alias", elg.pub, "--decoy-psw-rand", "2", "--alg",
|
||||
"aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex", aad };
|
||||
System.out.println("...encrypt: " + Arrays.toString(encArgs));
|
||||
int e = Guard.main(encArgs, new Options());
|
||||
assertEquals(0, e, "... encrypt rc");
|
||||
|
||||
// Decrypt via private RSA key
|
||||
String[] decPriv = { "--decrypt", enc.toString(), "--output", dec1.toString(), "--keyring", ring.toString(),
|
||||
"--priv-alias", rsa.prv, "--alg", "aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex",
|
||||
aad };
|
||||
System.out.println("...decrypt(private): " + Arrays.toString(decPriv));
|
||||
int d1 = Guard.main(decPriv, new Options());
|
||||
assertEquals(0, d1, "... decrypt(private) rc");
|
||||
assertArrayEquals(Files.readAllBytes(in), Files.readAllBytes(dec1),
|
||||
"mixed recipients decrypt(private) mismatch");
|
||||
|
||||
// Decrypt via password instead of key
|
||||
String[] decPwd = { "--decrypt", enc.toString(), "--output", dec2.toString(), "--password", password, "--alg",
|
||||
"aes-gcm", "--tag-bits", Integer.toString(tagBits), "--aad-hex", aad };
|
||||
System.out.println("...decrypt(password): " + Arrays.toString(decPwd));
|
||||
int d2 = Guard.main(decPwd, new Options());
|
||||
assertEquals(0, d2, "... decrypt(password) rc");
|
||||
assertArrayEquals(Files.readAllBytes(in), Files.readAllBytes(dec2),
|
||||
"mixed recipients decrypt(password) mismatch");
|
||||
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
/**
|
||||
* Negative: decryption should fail to parse when both --priv-alias and
|
||||
* --password are given.
|
||||
*/
|
||||
@Test
|
||||
void decrypt_with_both_unlock_material_rejected() throws Exception {
|
||||
final String method = "decrypt_with_both_unlock_material_rejected()";
|
||||
System.out.println(method);
|
||||
|
||||
// Minimal valid blob: encrypt with password, then try to decrypt specifying
|
||||
// both unlock options
|
||||
Path in = writeRandom(tmp.resolve("pt-neg.bin"), 256, 0xABCD);
|
||||
Path enc = tmp.resolve("pt-neg.bin.enc");
|
||||
String pwd = "x";
|
||||
|
||||
String[] encArgs = { "--encrypt", in.toString(), "--output", enc.toString(), "--to-psw", pwd, "--alg",
|
||||
"aes-gcm", "--tag-bits", "128" };
|
||||
int e = Guard.main(encArgs, new Options());
|
||||
assertEquals(0, e, "... encrypt rc");
|
||||
|
||||
// Supply both options on purpose
|
||||
Exception ex = assertThrows(Exception.class, () -> {
|
||||
String[] bad = { "--decrypt", enc.toString(), "--output", tmp.resolve("out-neg.bin").toString(),
|
||||
"--password", pwd, "--priv-alias", "whatever", "--alg", "aes-gcm" };
|
||||
Guard.main(bad, new Options());
|
||||
});
|
||||
System.out.println("...got expected exception: " + ex);
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private static Path writeRandom(Path p, int size, long seed) throws Exception {
|
||||
byte[] b = new byte[size];
|
||||
new Random(seed).nextBytes(b);
|
||||
Files.write(p, b);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an asymmetric keypair using the KeyStoreManagement CLI.
|
||||
*
|
||||
* <p>
|
||||
* The CLI stores aliases as {@code <alias>.pub} and {@code <alias>.prv}.
|
||||
* </p>
|
||||
*
|
||||
* @param ring keyring file path
|
||||
* @param algId algorithm id, e.g. "RSA", "ElGamal", "ML-KEM"
|
||||
* @param baseAlias base alias without suffix
|
||||
* @return public/private aliases to be used with Guard
|
||||
*/
|
||||
private static KeyAliases generateIntoKeyStore(Path ring, String algId, String baseAlias) throws Exception {
|
||||
String[] genArgs = { "--keystore", ring.toString(), "--generate", "--alg", algId, "--alias", baseAlias,
|
||||
"--kind", "asym" };
|
||||
System.out.println("...KeyStoreManagement generate: " + Arrays.toString(genArgs));
|
||||
int rc = KeyStoreManagement.main(genArgs, new Options());
|
||||
if (rc != 0) {
|
||||
throw new GeneralSecurityException("KeyStoreManagement failed with rc=" + rc + " for " + algId);
|
||||
}
|
||||
return new KeyAliases(baseAlias + ".pub", baseAlias + ".prv");
|
||||
}
|
||||
|
||||
private static final class KeyAliases {
|
||||
final String pub;
|
||||
final String prv;
|
||||
|
||||
KeyAliases(String pub, String prv) {
|
||||
this.pub = pub;
|
||||
this.prv = prv;
|
||||
}
|
||||
}
|
||||
}
|
||||
319
app/src/test/java/zeroecho/KemTest.java
Normal file
319
app/src/test/java/zeroecho/KemTest.java
Normal file
@@ -0,0 +1,319 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, 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;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
|
||||
import org.apache.commons.cli.Options;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import zeroecho.core.storage.KeyringStore;
|
||||
import zeroecho.sdk.util.BouncyCastleActivator;
|
||||
|
||||
/**
|
||||
* CLI-level tests for KEMAes that:
|
||||
* <ul>
|
||||
* <li>Verify {@code --list-kems} runs without a keyring.</li>
|
||||
* <li>Iterate over <em>all</em> available KEM ids discovered via
|
||||
* {@code --list-kems} and for each id perform hybrid round-trips:
|
||||
* <ul>
|
||||
* <li>AES-GCM with header and AAD,</li>
|
||||
* <li>ChaCha20-Poly1305 with header and AAD.</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The tests:
|
||||
* <ul>
|
||||
* <li>use the real KeyStore format by calling {@link KeyStoreManagement} to
|
||||
* generate a keypair,</li>
|
||||
* <li>pass the resulting aliases into
|
||||
* {@code KEMAes.main(String[], Options)},</li>
|
||||
* <li>print the method name and parameters first, progress lines prefixed by
|
||||
* {@code "..."},</li>
|
||||
* <li>end with {@code "...ok"} on success.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class KemTest {
|
||||
|
||||
/** All temporary files live here and are auto-cleaned by JUnit. */
|
||||
@TempDir
|
||||
Path tmp;
|
||||
|
||||
private PrintStream savedOut;
|
||||
|
||||
@BeforeAll
|
||||
static void bootBouncyCastle() {
|
||||
System.out.println("bootBouncyCastle()");
|
||||
// The project initializes BC explicitly for KEM implementations.
|
||||
BouncyCastleActivator.init();
|
||||
System.out.println("bootBouncyCastle...ok");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void restoreStdout() {
|
||||
if (savedOut != null) {
|
||||
System.setOut(savedOut);
|
||||
savedOut = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms that {@code --list-kems} short-circuits and exits 0 without
|
||||
* requiring a keyring.
|
||||
*/
|
||||
@Test
|
||||
void listKems_runs() throws Exception {
|
||||
System.out.println("listKems_runs()");
|
||||
Options opts = new Options();
|
||||
String[] args = { "--list-kems" };
|
||||
|
||||
System.out.println("...invoking: " + Arrays.toString(args));
|
||||
int rc = Kem.main(args, opts);
|
||||
assertEquals(0, rc, "... expected exit code 0");
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs hybrid round-trips for <em>every</em> KEM listed by {@code --list-kems}:
|
||||
* <ol>
|
||||
* <li>Generate a real KeyStore with a fresh keypair for the KEM,</li>
|
||||
* <li>Encrypt+decrypt using AES-GCM (header+AAD),</li>
|
||||
* <li>Encrypt+decrypt using ChaCha20-Poly1305 (header+AAD).</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>
|
||||
* If any KEM fails at any step, the test records it and continues. At the end,
|
||||
* it fails with a concise summary of all failures.
|
||||
* </p>
|
||||
*/
|
||||
@Test
|
||||
void allKems_encryptDecrypt_aesAndChacha() throws Exception {
|
||||
final String method = "allKems_encryptDecrypt_aesAndChacha()";
|
||||
final int aesSize = 8192;
|
||||
final int chachaSize = 4096;
|
||||
final int gcmTagBits = 128;
|
||||
final String aadAes = "A1B2C3";
|
||||
final String aadChaCha = "DEADBEEF";
|
||||
final String nonceChaCha = "00112233445566778899AABB";
|
||||
|
||||
System.out.println(method);
|
||||
System.out.println("...params: aesSize=" + aesSize + " chachaSize=" + chachaSize + " gcmTagBits=" + gcmTagBits
|
||||
+ " aesAAD=" + aadAes + " chachaAAD=" + aadChaCha + " chachaNonce=" + nonceChaCha);
|
||||
|
||||
// Discover KEM ids via the CLI (ensures we use exactly the ids users will see).
|
||||
List<String> kemIds = listKemsViaCli();
|
||||
System.out.println("...discovered " + kemIds.size() + " KEM ids");
|
||||
if (kemIds.isEmpty()) {
|
||||
throw new GeneralSecurityException("No KEM algorithms reported by --list-kems");
|
||||
}
|
||||
|
||||
List<String> failures = new ArrayList<>();
|
||||
|
||||
for (String kemId : kemIds) {
|
||||
System.out.println("...KEM " + kemId + " begin");
|
||||
|
||||
try {
|
||||
// Real keystore for this KEM
|
||||
Path ring = tmp.resolve("ring-" + kemId.replace('/', '_') + ".txt");
|
||||
KeyAliases aliases = generateKemIntoKeyStore(ring, kemId, "alias-" + shortId(kemId));
|
||||
|
||||
// Sanity: re-open to ensure the file is valid
|
||||
KeyringStore ks = KeyringStore.load(ring);
|
||||
if (!(ks.contains(aliases.pub) && ks.contains(aliases.prv))) {
|
||||
throw new IllegalStateException("Keyring does not contain expected aliases for " + kemId);
|
||||
}
|
||||
|
||||
// AES-GCM round-trip
|
||||
{
|
||||
byte[] content = randomBytes(aesSize);
|
||||
Path plain = tmp.resolve("plain-aes-" + shortId(kemId) + ".bin");
|
||||
Path enc = tmp.resolve("enc-aes-" + shortId(kemId) + ".bin");
|
||||
Path dec = tmp.resolve("dec-aes-" + shortId(kemId) + ".bin");
|
||||
Files.write(plain, content);
|
||||
System.out.println("...[" + kemId + "] AES encrypt");
|
||||
int e = Kem.main(new String[] { "--encrypt", plain.toString(), "--output", enc.toString(),
|
||||
"--keyring", ring.toString(), "--pub", aliases.pub, "--kem", kemId, "--aes", "--aes-cipher",
|
||||
"gcm", "--aes-tag-bits", Integer.toString(gcmTagBits), "--header", "--aad", aadAes },
|
||||
new Options());
|
||||
if (e != 0) {
|
||||
throw new IllegalStateException("AES encrypt rc=" + e);
|
||||
}
|
||||
System.out.println("...[" + kemId + "] AES decrypt");
|
||||
int d = Kem.main(new String[] { "--decrypt", enc.toString(), "--output", dec.toString(),
|
||||
"--keyring", ring.toString(), "--priv", aliases.prv, "--kem", kemId, "--aes",
|
||||
"--aes-cipher", "gcm", "--aes-tag-bits", Integer.toString(gcmTagBits), "--header", "--aad",
|
||||
aadAes }, new Options());
|
||||
if (d != 0) {
|
||||
throw new IllegalStateException("AES decrypt rc=" + d);
|
||||
}
|
||||
byte[] back = Files.readAllBytes(dec);
|
||||
assertArrayEquals(content, back, "[" + kemId + "] AES-GCM round-trip mismatch");
|
||||
System.out.println("...[" + kemId + "] AES round-trip ok");
|
||||
}
|
||||
|
||||
// ChaCha20-Poly1305 round-trip (AEAD implied by AAD)
|
||||
{
|
||||
byte[] content = randomBytes(chachaSize);
|
||||
Path plain = tmp.resolve("plain-cc20-" + shortId(kemId) + ".bin");
|
||||
Path enc = tmp.resolve("enc-cc20-" + shortId(kemId) + ".bin");
|
||||
Path dec = tmp.resolve("dec-cc20-" + shortId(kemId) + ".bin");
|
||||
Files.write(plain, content);
|
||||
System.out.println("...[" + kemId + "] ChaCha encrypt");
|
||||
int e = Kem.main(new String[] { "--encrypt", plain.toString(), "--output", enc.toString(),
|
||||
"--keyring", ring.toString(), "--pub", aliases.pub, "--kem", kemId, "--chacha",
|
||||
"--chacha-nonce", nonceChaCha, "--aad", aadChaCha, "--header" }, new Options());
|
||||
if (e != 0) {
|
||||
throw new IllegalStateException("ChaCha encrypt rc=" + e);
|
||||
}
|
||||
System.out.println("...[" + kemId + "] ChaCha decrypt");
|
||||
int d = Kem.main(new String[] { "--decrypt", enc.toString(), "--output", dec.toString(),
|
||||
"--keyring", ring.toString(), "--priv", aliases.prv, "--kem", kemId, "--chacha",
|
||||
"--chacha-nonce", nonceChaCha, "--aad", aadChaCha, "--header" }, new Options());
|
||||
if (d != 0) {
|
||||
throw new IllegalStateException("ChaCha decrypt rc=" + d);
|
||||
}
|
||||
byte[] back = Files.readAllBytes(dec);
|
||||
assertArrayEquals(content, back, "[" + kemId + "] ChaCha20-Poly1305 round-trip mismatch");
|
||||
System.out.println("...[" + kemId + "] ChaCha round-trip ok");
|
||||
}
|
||||
|
||||
System.out.println("...KEM " + kemId + " ok");
|
||||
} catch (Throwable t) {
|
||||
System.out.println("...KEM " + kemId + " FAILED: " + t);
|
||||
failures.add(kemId + " -> " + t.getClass().getSimpleName() + ": " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (!failures.isEmpty()) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("Some KEM(s) failed:\n");
|
||||
for (String f : failures) {
|
||||
sb.append(" - ").append(f).append('\n');
|
||||
}
|
||||
throw new AssertionError(sb.toString());
|
||||
}
|
||||
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Calls the CLI entrypoint with {@code --list-kems} and parses stdout lines to
|
||||
* a list of ids.
|
||||
*/
|
||||
private List<String> listKemsViaCli() throws Exception {
|
||||
savedOut = System.out;
|
||||
ByteArrayOutputStream sink = new ByteArrayOutputStream();
|
||||
System.setOut(new PrintStream(sink, true, StandardCharsets.UTF_8));
|
||||
try {
|
||||
int rc = Kem.main(new String[] { "--list-kems" }, new Options());
|
||||
if (rc != 0) {
|
||||
throw new IllegalStateException("--list-kems rc=" + rc);
|
||||
}
|
||||
} finally {
|
||||
System.setOut(savedOut);
|
||||
savedOut = null;
|
||||
}
|
||||
String out = sink.toString(StandardCharsets.UTF_8);
|
||||
List<String> ids = new ArrayList<>();
|
||||
for (String line : out.split("\\R")) {
|
||||
String id = line.trim();
|
||||
if (!id.isEmpty() && !id.startsWith("(")) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a KEM keypair using the real KeyStoreManagement CLI and returns the
|
||||
* public/private aliases. The CLI stores aliases as {@code <alias>.pub} and
|
||||
* {@code <alias>.prv}.
|
||||
*/
|
||||
private static KeyAliases generateKemIntoKeyStore(Path ring, String kemId, String baseAlias) throws Exception {
|
||||
String[] genArgs = { "--keystore", ring.toString(), "--generate", "--alg", kemId, "--alias", baseAlias,
|
||||
"--kind", "asym" };
|
||||
System.out.println("...KeyStoreManagement generate: " + Arrays.toString(genArgs));
|
||||
int rc = KeyStoreManagement.main(genArgs, new Options());
|
||||
if (rc != 0) {
|
||||
throw new GeneralSecurityException("KeyStoreManagement failed with rc=" + rc + " for " + kemId);
|
||||
}
|
||||
return new KeyAliases(baseAlias + ".pub", baseAlias + ".prv");
|
||||
}
|
||||
|
||||
private static String shortId(String kemId) {
|
||||
String s = kemId.replaceAll("[^A-Za-z0-9]+", "");
|
||||
if (s.length() > 16) {
|
||||
s = s.substring(0, 16);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private static byte[] randomBytes(int n) {
|
||||
byte[] b = new byte[n];
|
||||
new Random(0x5EEDC0DEL).nextBytes(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
private static final class KeyAliases {
|
||||
final String pub;
|
||||
final String prv;
|
||||
|
||||
KeyAliases(String pub, String prv) {
|
||||
this.pub = pub;
|
||||
this.prv = prv;
|
||||
}
|
||||
}
|
||||
}
|
||||
241
app/src/test/java/zeroecho/KeyStoreManagementTest.java
Normal file
241
app/src/test/java/zeroecho/KeyStoreManagementTest.java
Normal file
@@ -0,0 +1,241 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, 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;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
|
||||
import org.apache.commons.cli.Options;
|
||||
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.spec.AlgorithmKeySpec;
|
||||
import zeroecho.core.spi.AsymmetricKeyBuilder;
|
||||
import zeroecho.core.spi.SymmetricKeyBuilder;
|
||||
import zeroecho.core.storage.KeyringStore;
|
||||
import zeroecho.sdk.util.BouncyCastleActivator;
|
||||
|
||||
/**
|
||||
* KeyStoreManagementTest drives the KeyStoreManagement CLI across all available
|
||||
* algorithms, printing progress and verifying that generated entries can be
|
||||
* materialized via KeyringStore.
|
||||
*
|
||||
* <h2>What it does</h2>
|
||||
* <ul>
|
||||
* <li>For each algorithm that exposes a default-capable asymmetric builder,
|
||||
* generates a keypair.</li>
|
||||
* <li>For each algorithm that exposes a default-capable symmetric builder,
|
||||
* generates a secret.</li>
|
||||
* <li>Reloads the keyring and materializes keys using KeyringStore.</li>
|
||||
* <li>Prints progress and brief details to stdout during the run.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class KeyStoreManagementTest {
|
||||
|
||||
@TempDir
|
||||
Path tmp;
|
||||
|
||||
@BeforeAll
|
||||
static void setupProviders() {
|
||||
BouncyCastleActivator.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates keypairs and secrets where supported and verifies materialization.
|
||||
*
|
||||
* @throws Exception on unexpected failure
|
||||
*/
|
||||
@Test
|
||||
public void generateAndVerifyAllAlgorithms() throws Exception {
|
||||
Path ring = tmp.resolve("ring.txt");
|
||||
|
||||
Set<String> algIds = CryptoAlgorithms.available();
|
||||
System.out.println("Algorithms: " + algIds);
|
||||
assertTrue(!algIds.isEmpty(), "Catalog should not be empty");
|
||||
|
||||
int attempted = 0;
|
||||
for (String id : algIds) {
|
||||
Options dispatcher = new Options();
|
||||
CryptoAlgorithm alg = CryptoAlgorithms.require(id);
|
||||
boolean doAsym = hasAsymmetricDefault(alg);
|
||||
boolean doSym = hasSymmetricDefault(alg);
|
||||
|
||||
if (doAsym) {
|
||||
String alias = "asym-" + sanitize(id) + "-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
System.out.println("Generating asymmetric: " + id + " -> " + alias + ".pub/.prv");
|
||||
String[] argv = new String[] { "--keystore", ring.toString(), "--generate", "--alg", id, "--alias",
|
||||
alias, "--kind", "asym" };
|
||||
try {
|
||||
int rc = KeyStoreManagement.main(argv, dispatcher);
|
||||
System.out.println(" rc=" + rc);
|
||||
assertTrue(rc == 0, "asymmetric generation failed for " + id);
|
||||
attempted++;
|
||||
} catch (Throwable t) {
|
||||
System.out.println(
|
||||
" SKIP asym " + id + " due to " + t.getClass().getSimpleName() + ": " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (doSym) {
|
||||
String alias = "sym-" + sanitize(id) + "-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
System.out.println("Generating symmetric: " + id + " -> " + alias);
|
||||
String[] argv = new String[] { "--keystore", ring.toString(), "--generate", "--alg", id, "--alias",
|
||||
alias, "--kind", "sym" };
|
||||
try {
|
||||
int rc = KeyStoreManagement.main(argv, dispatcher);
|
||||
System.out.println(" rc=" + rc);
|
||||
assertTrue(rc == 0, "symmetric generation failed for " + id);
|
||||
attempted++;
|
||||
} catch (Throwable t) {
|
||||
System.out.println(
|
||||
" SKIP sym " + id + " due to " + t.getClass().getSimpleName() + ": " + t.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(attempted > 0, "No generation attempts were successful");
|
||||
|
||||
// Verify by reloading and materializing.
|
||||
KeyringStore store = KeyringStore.load(ring);
|
||||
List<String> aliases = store.aliases();
|
||||
System.out.println("Reloaded aliases (" + aliases.size() + "): " + aliases);
|
||||
|
||||
int ok = 0;
|
||||
for (int i = 0; i < aliases.size(); i++) {
|
||||
String a = aliases.get(i);
|
||||
boolean good = false;
|
||||
try {
|
||||
PublicKey pub = store.getPublic(a);
|
||||
if (pub != null) {
|
||||
System.out.println(" OK public: " + a + " alg=" + pub.getAlgorithm() + " encLen="
|
||||
+ encLen(pub.getEncoded()));
|
||||
good = true;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
if (!good) {
|
||||
try {
|
||||
PrivateKey prv = store.getPrivate(a);
|
||||
if (prv != null) {
|
||||
System.out.println(" OK private: " + a + " alg=" + prv.getAlgorithm() + " encLen="
|
||||
+ encLen(prv.getEncoded()));
|
||||
good = true;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
if (!good) {
|
||||
try {
|
||||
SecretKey sk = store.getSecret(a);
|
||||
if (sk != null) {
|
||||
byte[] raw = sk.getEncoded();
|
||||
System.out.println(" OK secret: " + a + " alg=" + sk.getAlgorithm() + " len="
|
||||
+ (raw == null ? 0 : raw.length));
|
||||
good = true;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
if (good) {
|
||||
ok++;
|
||||
}
|
||||
}
|
||||
assertTrue(ok > 0, "No entries could be materialized back");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static boolean hasAsymmetricDefault(CryptoAlgorithm alg) {
|
||||
try {
|
||||
List<CryptoAlgorithm.AsymBuilderInfo> infos = alg.asymmetricBuildersInfo();
|
||||
for (int i = 0; i < infos.size(); i++) {
|
||||
CryptoAlgorithm.AsymBuilderInfo bi = infos.get(i);
|
||||
if (bi.defaultKeySpec == null) {
|
||||
continue;
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
Class<AlgorithmKeySpec> st = (Class<AlgorithmKeySpec>) bi.specType;
|
||||
AsymmetricKeyBuilder<AlgorithmKeySpec> b = alg.asymmetricKeyBuilder(st);
|
||||
if (b != null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean hasSymmetricDefault(CryptoAlgorithm alg) {
|
||||
try {
|
||||
List<CryptoAlgorithm.SymBuilderInfo> infos = alg.symmetricBuildersInfo();
|
||||
for (int i = 0; i < infos.size(); i++) {
|
||||
CryptoAlgorithm.SymBuilderInfo bi = infos.get(i);
|
||||
if (bi.defaultKeySpec() == null) {
|
||||
continue;
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
Class<AlgorithmKeySpec> st = (Class<AlgorithmKeySpec>) bi.specType();
|
||||
SymmetricKeyBuilder<AlgorithmKeySpec> b = alg.symmetricKeyBuilder(st);
|
||||
if (b != null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String sanitize(String id) {
|
||||
return id.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "-");
|
||||
}
|
||||
|
||||
private static String encLen(byte[] der) {
|
||||
return der == null ? "0" : Integer.toString(der.length);
|
||||
}
|
||||
}
|
||||
314
app/src/test/java/zeroecho/TagTest.java
Normal file
314
app/src/test/java/zeroecho/TagTest.java
Normal file
@@ -0,0 +1,314 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, 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;
|
||||
|
||||
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.ByteArrayOutputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Random;
|
||||
|
||||
import org.apache.commons.cli.Options;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import zeroecho.core.storage.KeyringStore;
|
||||
import zeroecho.sdk.util.BouncyCastleActivator;
|
||||
|
||||
/**
|
||||
* CLI-level tests for the Tag subcommand (signature + digest).
|
||||
*
|
||||
* <h2>Scope</h2>
|
||||
* <ul>
|
||||
* <li>Signature produce+verify with Ed25519 via a real keyring file,</li>
|
||||
* <li>Signature verify failure appends marker text and returns rc=1,</li>
|
||||
* <li>Digest produce+verify (SHA-256) round trip,</li>
|
||||
* <li>Digest verify failure appends marker text and returns rc=1,</li>
|
||||
* <li>Digest round trip over STDIN/STDOUT.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class TagTest {
|
||||
|
||||
@TempDir
|
||||
Path tmp;
|
||||
|
||||
private PrintStream savedOut;
|
||||
private PrintStream savedErr;
|
||||
private java.io.InputStream savedIn;
|
||||
|
||||
@BeforeAll
|
||||
static void bootBouncyCastle() {
|
||||
BouncyCastleActivator.init();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void restoreStd() {
|
||||
if (savedOut != null) {
|
||||
System.setOut(savedOut);
|
||||
savedOut = null;
|
||||
}
|
||||
if (savedErr != null) {
|
||||
System.setErr(savedErr);
|
||||
savedErr = null;
|
||||
}
|
||||
if (savedIn != null) {
|
||||
System.setIn(savedIn);
|
||||
savedIn = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- boilerplate logging ----------
|
||||
private static void logBegin(Object... params) {
|
||||
String thisClass = TagTest.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 = TagTest.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");
|
||||
System.out.println();
|
||||
}
|
||||
|
||||
/** Ed25519 signature round-trip using files. */
|
||||
@Test
|
||||
void signature_ed25519_roundTrip_files_ok() throws Exception {
|
||||
logBegin("signature_ed25519_roundTrip_files_ok");
|
||||
|
||||
Path ring = tmp.resolve("ring-ed25519.txt");
|
||||
KeyAliases ed = generateIntoKeyStore(ring, "Ed25519", "ed");
|
||||
// sanity
|
||||
KeyringStore ks = KeyringStore.load(ring);
|
||||
assertTrue(ks.contains(ed.pub) && ks.contains(ed.prv), "missing expected aliases");
|
||||
|
||||
byte[] pt = randomBytes(4096);
|
||||
Path plain = tmp.resolve("plain.bin");
|
||||
Path signed = tmp.resolve("signed.bin");
|
||||
Path recovered = tmp.resolve("recovered.bin");
|
||||
Files.write(plain, pt);
|
||||
|
||||
// produce
|
||||
String[] produce = { "--type", "signature", "--mode", "produce", "--alg", "Ed25519", "--ks", ring.toString(),
|
||||
"--priv", ed.prv, "--in", plain.toString(), "--out", signed.toString() };
|
||||
assertEquals(0, Tag.main(produce, new Options()), "produce rc");
|
||||
|
||||
// verify (match)
|
||||
String[] verify = { "--type", "signature", "--mode", "verify", "--alg", "Ed25519", "--ks", ring.toString(),
|
||||
"--pub", ed.pub, "--in", signed.toString(), "--out", recovered.toString() };
|
||||
assertEquals(0, Tag.main(verify, new Options()), "verify rc");
|
||||
|
||||
assertArrayEquals(pt, Files.readAllBytes(recovered), "round-trip mismatch");
|
||||
|
||||
logEnd();
|
||||
}
|
||||
|
||||
@Test
|
||||
void signature_verify_mismatch_throws_and_appends_text() throws Exception {
|
||||
logBegin("signature_verify_mismatch_throws_and_appends_text");
|
||||
|
||||
Path ring = tmp.resolve("ring-ed25519-neg.txt");
|
||||
KeyAliases ed = generateIntoKeyStore(ring, "Ed25519", "ed-neg");
|
||||
|
||||
byte[] pt = randomBytes(1024);
|
||||
Path plain = tmp.resolve("plain-neg.bin");
|
||||
Path signed = tmp.resolve("signed-neg.bin");
|
||||
Path out = tmp.resolve("out-neg.bin");
|
||||
Files.write(plain, pt);
|
||||
|
||||
// produce
|
||||
assertEquals(0,
|
||||
Tag.main(new String[] { "--type", "signature", "--mode", "produce", "--alg", "Ed25519", "--ks",
|
||||
ring.toString(), "--priv", ed.prv, "--in", plain.toString(), "--out", signed.toString() },
|
||||
new Options()));
|
||||
|
||||
// corrupt last byte -> break signature
|
||||
flipLastByte(signed);
|
||||
|
||||
// verify (mismatch): expect throw + marker appended
|
||||
assertEquals(1,
|
||||
Tag.main(
|
||||
new String[] { "--type", "signature", "--mode", "verify", "--alg", "Ed25519", "--ks",
|
||||
ring.toString(), "--pub", ed.pub, "--in", signed.toString(), "--out", out.toString() },
|
||||
new Options()));
|
||||
|
||||
assertTrue(Files.notExists(out, LinkOption.NOFOLLOW_LINKS));
|
||||
|
||||
logEnd();
|
||||
}
|
||||
|
||||
/** SHA-256 digest round-trip using files. */
|
||||
@Test
|
||||
void digest_sha256_roundTrip_files_ok() throws Exception {
|
||||
logBegin("digest_sha256_roundTrip_files_ok");
|
||||
|
||||
byte[] pt = randomBytes(5000);
|
||||
Path plain = tmp.resolve("plain-d.bin");
|
||||
Path tagged = tmp.resolve("tagged-d.bin");
|
||||
Path recovered = tmp.resolve("recovered-d.bin");
|
||||
Files.write(plain, pt);
|
||||
|
||||
// produce
|
||||
assertEquals(0, Tag.main(new String[] { "--type", "digest", "--mode", "produce", "--alg", "SHA-256", "--in",
|
||||
plain.toString(), "--out", tagged.toString() }, new Options()));
|
||||
|
||||
// verify (match)
|
||||
assertEquals(0, Tag.main(new String[] { "--type", "digest", "--mode", "verify", "--alg", "SHA-256", "--in",
|
||||
tagged.toString(), "--out", recovered.toString() }, new Options()));
|
||||
|
||||
assertArrayEquals(pt, Files.readAllBytes(recovered), "digest round-trip mismatch");
|
||||
|
||||
logEnd();
|
||||
}
|
||||
|
||||
@Test
|
||||
void digest_verify_mismatch_throws_and_appends_text() throws Exception {
|
||||
logBegin("digest_verify_mismatch_throws_and_appends_text");
|
||||
|
||||
byte[] pt = randomBytes(2048);
|
||||
Path plain = tmp.resolve("plain-d-neg.bin");
|
||||
Path tagged = tmp.resolve("tagged-d-neg.bin");
|
||||
Path out = tmp.resolve("out-d-neg.bin");
|
||||
Files.write(plain, pt);
|
||||
|
||||
// produce
|
||||
assertEquals(0, Tag.main(new String[] { "--type", "digest", "--mode", "produce", "--alg", "SHA-256", "--in",
|
||||
plain.toString(), "--out", tagged.toString() }, new Options()));
|
||||
|
||||
// corrupt last byte -> break digest
|
||||
flipLastByte(tagged);
|
||||
|
||||
// verify (mismatch): expect throw + default marker ("digest invalid")
|
||||
assertEquals(1, Tag.main(new String[] { "--type", "digest", "--mode", "verify", "--alg", "SHA-256", "--in",
|
||||
tagged.toString(), "--out", out.toString() }, new Options()));
|
||||
|
||||
assertTrue(Files.notExists(out, LinkOption.NOFOLLOW_LINKS));
|
||||
|
||||
logEnd();
|
||||
}
|
||||
|
||||
/** Digest round trip over STDIN/STDOUT. */
|
||||
@Test
|
||||
void digest_roundTrip_stdin_stdout_ok() throws Exception {
|
||||
logBegin("digest_roundTrip_stdin_stdout_ok");
|
||||
|
||||
byte[] pt = randomBytes(3000);
|
||||
|
||||
// produce (stdin -> stdout)
|
||||
savedIn = System.in;
|
||||
savedOut = System.out;
|
||||
ByteArrayInputStream src = new ByteArrayInputStream(pt);
|
||||
ByteArrayOutputStream producedSink = new ByteArrayOutputStream();
|
||||
System.setIn(src);
|
||||
System.setOut(new PrintStream(producedSink, true, StandardCharsets.UTF_8));
|
||||
|
||||
assertEquals(0, Tag.main(
|
||||
new String[] { "--type", "digest", "--mode", "produce", "--alg", "SHA-256", "--in", "-", "--out", "-" },
|
||||
new Options()));
|
||||
|
||||
// save produced bytes
|
||||
Path tagged = tmp.resolve("stdio-tagged.bin");
|
||||
Files.write(tagged, producedSink.toByteArray());
|
||||
|
||||
// verify (file -> stdout)
|
||||
ByteArrayOutputStream verifiedSink = new ByteArrayOutputStream();
|
||||
System.setIn(savedIn); // not used in this step
|
||||
System.setOut(new PrintStream(verifiedSink, true, StandardCharsets.UTF_8));
|
||||
|
||||
assertEquals(0, Tag.main(new String[] { "--type", "digest", "--mode", "verify", "--alg", "SHA-256", "--in",
|
||||
tagged.toString(), "--out", "-" }, new Options()));
|
||||
|
||||
assertArrayEquals(pt, verifiedSink.toByteArray(), "stdio round-trip mismatch");
|
||||
|
||||
logEnd();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Generates an asymmetric keypair using the real KeyStoreManagement CLI. */
|
||||
private static KeyAliases generateIntoKeyStore(Path ring, String algId, String baseAlias) throws Exception {
|
||||
String[] genArgs = { "--keystore", ring.toString(), "--generate", "--alg", algId, "--alias", baseAlias,
|
||||
"--kind", "asym" };
|
||||
int rc = KeyStoreManagement.main(genArgs, new Options());
|
||||
if (rc != 0) {
|
||||
throw new GeneralSecurityException("KeyStoreManagement failed with rc=" + rc + " for " + algId);
|
||||
}
|
||||
return new KeyAliases(baseAlias + ".pub", baseAlias + ".prv");
|
||||
}
|
||||
|
||||
private static void flipLastByte(Path file) throws Exception {
|
||||
byte[] all = Files.readAllBytes(file);
|
||||
if (all.length == 0) {
|
||||
throw new IllegalStateException("cannot flip byte in empty file");
|
||||
}
|
||||
all[all.length - 1] ^= 0xFF;
|
||||
Files.write(file, all);
|
||||
}
|
||||
|
||||
private static byte[] randomBytes(int n) {
|
||||
byte[] b = new byte[n];
|
||||
new Random(0xBADC0FFEL).nextBytes(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
private static final class KeyAliases {
|
||||
final String pub;
|
||||
final String prv;
|
||||
|
||||
KeyAliases(String pub, String prv) {
|
||||
this.pub = pub;
|
||||
this.prv = prv;
|
||||
}
|
||||
}
|
||||
}
|
||||
74
app/src/test/java/zeroecho/ZeroEchoTest.java
Normal file
74
app/src/test/java/zeroecho/ZeroEchoTest.java
Normal file
@@ -0,0 +1,74 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, 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;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ZeroEchoTest {
|
||||
|
||||
@Test
|
||||
void testAsymetricOptionWithoutParamsReturnsOne() {
|
||||
System.out.println("testAsymetricOptionWithoutParamsReturnsOne");
|
||||
int result = ZeroEcho.mainProcess(new String[] { "-A" });
|
||||
assertEquals(1, result, "Asymetric option without parameters should return 1 (error/help)");
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAesPswOptionWithoutParamsReturnsOne() {
|
||||
System.out.println("testAesPswOptionWithoutParamsReturnsOne");
|
||||
int result = ZeroEcho.mainProcess(new String[] { "-P" });
|
||||
assertEquals(1, result, "AES-PSW option without parameters should return 1 (error/help)");
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testNoOptionReturnsOne() {
|
||||
System.out.println("testNoOptionReturnsOne");
|
||||
int result = ZeroEcho.mainProcess(new String[] {});
|
||||
assertEquals(1, result, "No options should return 1 (error/help)");
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInvalidOptionReturnsOne() {
|
||||
System.out.println("testInvalidOptionReturnsOne");
|
||||
int result = ZeroEcho.mainProcess(new String[] { "-X" });
|
||||
assertEquals(1, result, "Invalid option should return 1 (error/help)");
|
||||
System.out.println("...ok");
|
||||
}
|
||||
}
|
||||
BIN
app/src/test/resources/test.jpg
Normal file
BIN
app/src/test/resources/test.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
Reference in New Issue
Block a user