GenSigningKeyCommand.java
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas.evidence;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.GeneralSecurityException;
/**
* CLI command handler for the {@code -gen-signing-key} mode, which creates (or
* extends) an encrypted ZeroEcho keyring containing a fresh signing key pair for
* evidence-pack signing.
*
* <p>
* The mode is recognised and dispatched by {@code MethodAtlasApp} before the
* normal scan-argument parsing, mirroring how {@code -diff} is handled. It is
* self-contained: it parses its own small set of options and never participates
* in the scan pipeline.
* </p>
*
* <h2>Usage</h2>
* <pre>{@code
* methodatlas -gen-signing-key <keyring-file> [-key-alias <alias>] \
* [-key-algo <algorithm>] [-overwrite] \
* [-key-password-file <file> | -key-password-env <VAR>]
* }</pre>
*
* <p>
* The keyring is encrypted with a password resolved by
* {@link KeyringPasswordSource}: a password file, a named environment variable,
* {@code METHODATLAS_KEYRING_PASSWORD}, or an interactive prompt. A password is
* never accepted as a command-line value. The keyring lives in an owner-only
* directory together with its {@code .nonce} sidecar; keep both out of version
* control and out of any distributed evidence pack. See {@link SigningKeyGenerator}.
* </p>
*
* @see SigningKeyGenerator
* @see ZeroEchoSigner
* @since 4.0.0
*/
public final class GenSigningKeyCommand {
/** Mode flag that selects this command. */
public static final String FLAG_GEN_SIGNING_KEY = "-gen-signing-key";
private static final String FLAG_KEY_ALIAS = "-key-alias";
private static final String FLAG_KEY_ALGO = "-key-algo";
private static final String FLAG_OVERWRITE = "-overwrite";
private static final String FLAG_KEY_PASSWORD_FILE = "-key-password-file";
private static final String FLAG_KEY_PASSWORD_ENV = "-key-password-env";
/** Usage line printed when the arguments are invalid. */
private static final String USAGE = "Usage: -gen-signing-key <keyring-file> [-key-alias <alias>] "
+ "[-key-algo <algorithm>] [-overwrite] [-key-password-file <file> | -key-password-env <VAR>]";
/** Default alias used when {@code -key-alias} is omitted. */
private static final String DEFAULT_ALIAS = "methodatlas-signing";
/** Exit code returned on success. */
private static final int EXIT_OK = 0;
/** Exit code returned when the arguments are invalid. */
private static final int EXIT_BAD_ARGS = 2;
private final Path keyringFile;
private final String alias;
private final String algorithm;
private final boolean overwrite;
private final KeyringPasswordSource passwords;
/**
* Creates a new command.
*
* @param keyringFile target keyring file; must not be {@code null}
* @param alias base alias for the generated key pair
* @param algorithm algorithm id, or {@code null} for the default
* @param overwrite whether to replace an existing alias
* @param passwords source of the keyring password
*/
private GenSigningKeyCommand(Path keyringFile, String alias, String algorithm, boolean overwrite,
KeyringPasswordSource passwords) {
this.keyringFile = keyringFile;
this.alias = alias;
this.algorithm = algorithm;
this.overwrite = overwrite;
this.passwords = passwords;
}
/**
* Parses {@code -gen-signing-key} arguments and runs the command.
*
* @param args full command-line arguments, including the
* {@code -gen-signing-key} flag and its value
* @param out writer that receives the success summary
* @return {@code 0} on success, {@code 2} when the arguments are invalid
* @throws IOException if no password is available, the keyring cannot be
* written, an alias collides without {@code -overwrite},
* or key generation fails
*/
public static int run(String[] args, PrintWriter out) throws IOException {
final GenSigningKeyCommand command;
try {
command = parse(args);
} catch (IllegalArgumentException e) {
System.err.println("gen-signing-key: " + e.getMessage());
System.err.println(USAGE);
return EXIT_BAD_ARGS;
}
return command.execute(out);
}
/**
* Parses the command-line arguments into a command instance.
*
* @param args full command-line arguments
* @return parsed command
* @throws IllegalArgumentException if the keyring value is missing or a flag
* lacks its required value
*/
@SuppressWarnings("PMD.AvoidReassigningLoopVariables") // ++i consumes a flag's value, matching CliArgs
private static GenSigningKeyCommand parse(String... args) {
Path keyringFile = null;
String alias = DEFAULT_ALIAS;
String algorithm = null;
boolean overwrite = false;
Path passwordFile = null;
String passwordEnv = null;
for (int i = 0; i < args.length; i++) {
switch (args[i]) {
case FLAG_GEN_SIGNING_KEY -> keyringFile = Paths.get(value(args, ++i, FLAG_GEN_SIGNING_KEY));
case FLAG_KEY_ALIAS -> alias = value(args, ++i, FLAG_KEY_ALIAS);
case FLAG_KEY_ALGO -> algorithm = value(args, ++i, FLAG_KEY_ALGO);
case FLAG_OVERWRITE -> overwrite = true;
case FLAG_KEY_PASSWORD_FILE -> passwordFile = Paths.get(value(args, ++i, FLAG_KEY_PASSWORD_FILE));
case FLAG_KEY_PASSWORD_ENV -> passwordEnv = value(args, ++i, FLAG_KEY_PASSWORD_ENV);
default -> {
// Ignore unrelated tokens so the mode can be combined with
// a leading program name or stray scan arguments.
}
}
}
if (keyringFile == null) {
throw new IllegalArgumentException("missing keyring file after " + FLAG_GEN_SIGNING_KEY);
}
return new GenSigningKeyCommand(keyringFile, alias, algorithm, overwrite,
new KeyringPasswordSource(passwordFile, passwordEnv));
}
/**
* Reads the value following a flag at index {@code i}.
*
* @param args command-line arguments
* @param i index of the value (already advanced past the flag)
* @param flag the flag whose value is being read, for diagnostics
* @return the value token
* @throws IllegalArgumentException if the value is missing
*/
private static String value(String[] args, int i, String flag) {
if (i >= args.length) {
throw new IllegalArgumentException("missing value after " + flag);
}
return args[i];
}
/**
* Generates the key pair and prints a summary plus the next steps for local
* and CI/CD signing.
*
* @param out writer that receives the success summary
* @return {@code 0} on success
* @throws IOException if no password is available, the keyring cannot be
* written, an alias collides without {@code -overwrite},
* or key generation fails
*/
private int execute(PrintWriter out) throws IOException {
final SigningKeyGenerator.GeneratedKey generated;
try {
generated = SigningKeyGenerator.generate(keyringFile, alias, algorithm, overwrite, passwords);
} catch (IllegalArgumentException e) {
System.err.println("gen-signing-key: " + e.getMessage());
return EXIT_BAD_ARGS;
} catch (GeneralSecurityException e) {
throw new IOException("Signing-key generation failed: " + e.getMessage(), e);
} catch (KeyringPasswordSource.MissingPasswordException e) {
throw new IOException(e.getMessage() + "; " + KeyringPasswordSource.howToSupply(
FLAG_KEY_PASSWORD_FILE, FLAG_KEY_PASSWORD_ENV), e);
}
Path keyring = generated.keyringFile().toAbsolutePath();
out.println((generated.created() ? "Created encrypted keyring " : "Added key to encrypted keyring ")
+ keyring);
out.println(" algorithm: " + generated.algorithm());
out.println(" public alias: " + generated.publicAlias());
out.println(" private alias: " + generated.privateAlias());
out.println(" sidecar: " + KeyringFiles.nonceFile(keyring) + " (keep it together with the keyring)");
out.println(" public key: " + generated.publicKeyPem().toAbsolutePath() + " (X.509 PEM, for verifiers)");
out.println("Keep the keyring, its .nonce file and the password private and out of version control.");
out.println("Sign an evidence pack locally with:");
out.println(" -evidence-pack <framework> -evidence-pack-keyring " + generated.keyringFile()
+ " -evidence-pack-key-alias " + alias);
out.println("Prepare a CI/CD secret with:");
out.println(" " + ExportKeyringSecretCommand.FLAG_EXPORT_KEYRING_SECRET + " " + generated.keyringFile());
return EXIT_OK;
}
}