Initial commit (history reset)

This commit is contained in:
2025-09-16 23:14:24 +02:00
commit 2cc988925a
396 changed files with 71058 additions and 0 deletions

26
app/.classpath Normal file
View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="bin/main" path="src/main/java">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="bin/test" path="src/test/java">
<attributes>
<attribute name="gradle_scope" value="test"/>
<attribute name="gradle_used_by_scope" value="test"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="bin/test" path="src/test/resources">
<attributes>
<attribute name="gradle_scope" value="test"/>
<attribute name="gradle_used_by_scope" value="test"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
<classpathentry kind="output" path="bin/default"/>
</classpath>

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/app/

29
app/.project Normal file
View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>app</name>
<comment>Project app created by Buildship.</comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>net.sourceforge.pmd.eclipse.plugin.pmdBuilder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
<nature>net.sourceforge.pmd.eclipse.plugin.pmdNature</nature>
</natures>
</projectDescription>

31
app/LICENSE Normal file
View File

@@ -0,0 +1,31 @@
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.

59
app/build.gradle Normal file
View File

@@ -0,0 +1,59 @@
plugins {
id 'buildlogic.java-application-conventions'
id 'com.palantir.git-version'
}
group 'org.egothor'
dependencies {
implementation 'org.apache.commons:commons-text'
implementation 'commons-cli:commons-cli'
implementation project(':lib')
// might be removed if I move BC ops to the lib
testImplementation 'org.bouncycastle:bcpkix-jdk18on'
}
application {
// Define the main class for the application.
mainClass = 'zeroecho.ZeroEcho'
}
jar {
manifest {
attributes(
'Main-Class': application.mainClass,
'Implementation-Title': rootProject.name,
'Implementation-Version': "${version}"
)
}
from sourceSets.main.output
dependsOn configurations.runtimeClasspath
// Include each JAR dependency
configurations.runtimeClasspath.findAll { it.exists() && it.name.endsWith('.jar') }.each { jarFile ->
def jarName = jarFile.name.replaceAll(/\.jar$/, '')
from(zipTree(jarFile)) {
// Exclude signature-related files
exclude 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA'
// Rename license/notice files to avoid conflicts
eachFile { file ->
if (file.path ==~ /META-INF\/(LICENSE|NOTICE)(\..*)?/) {
file.path = "META-INF/licenses-from-${jarName}/${file.name}"
}
}
includeEmptyDirs = false
}
}
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
javadoc {
options.links("https://www.egothor.org/javadoc/zeroecho/lib")
}

View 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;
}
}

View 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)));
}
}

View 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 &lt;input&gt; */
public static final Option OPT_ENCRYPT = Option.builder("e").longOpt("encrypt").hasArg().argName("input")
.desc("Encrypt the input file").build();
/** Decrypt mode: -d|--decrypt &lt;input&gt; */
public static final Option OPT_DECRYPT = Option.builder("d").longOpt("decrypt").hasArg().argName("input")
.desc("Decrypt the input file").build();
/** Output path: -o|--output &lt;file&gt; */
public static final Option OPT_OUTPUT = Option.builder("o").longOpt("output").hasArg().argName("file")
.desc("Output file path (default: &lt;input&gt;.enc for encrypt, &lt;input&gt;.dec for decrypt)").build();
/** Keyring path: -K|--keyring &lt;file&gt; */
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 &lt;alias&gt; */
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 &lt;alias&gt; */
public static final Option OPT_PRIV = Option.builder().longOpt("priv").hasArg().argName("alias")
.desc("Recipient private key alias (decryption)").build();
/** KEM id: --kem &lt;id&gt; */
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 &lt;hex&gt; */
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 &lt;int&gt; */
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 &lt;int&gt; */
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 &lt;hex&gt; */
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 &lt;int&gt; */
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 &lt;hex&gt; */
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 &lt;int&gt; */
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 &lt;int&gt; */
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
}
}
}

View 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)");
}
}
}

View 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
}
}

View 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;
}
}

View 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;

View 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>

View 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");
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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);
}
}

View 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;
}
}
}

View 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");
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB