SigningKeyGenerator.java

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas.evidence;

import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.nio.file.StandardCopyOption;
import java.security.PublicKey;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;

import org.bouncycastle.openssl.jcajce.JcaPEMWriter;

import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.KeyOperation;
import zeroecho.core.KeyOperationInfo;
import zeroecho.core.spec.AlgorithmKeySpec;
import zeroecho.core.storage.KeyringPassword;
import zeroecho.core.storage.KeyringStore;
import zeroecho.sdk.util.BouncyCastleActivator;

/**
 * Generates an asymmetric signing key pair and stores it in an encrypted
 * ZeroEcho {@link KeyringStore} so that {@code -evidence-pack} can later sign
 * manifests with it.
 *
 * <p>
 * The keyring is encrypted with a password (PBKDF2-HMAC-SHA-256 + AES-256-GCM);
 * it is <em>not</em> a JDK PKCS12/JKS keystore. ZeroEcho requires the keyring
 * directory and files to be owner-only (POSIX {@code 0700}/{@code 0600}, or an
 * owner-only ACL on Windows): a missing directory is created that way, and an
 * existing directory with broader access is rejected with a remediation hint
 * (see {@link KeyringFiles#explain(zeroecho.core.storage.KeyringException, Path)}).
 * </p>
 *
 * <p>
 * Only asymmetric signing algorithms are offered, and the offered set is
 * deliberately limited to the algorithms covered by round-trip tests: the
 * default {@value #DEFAULT_ALGORITHM}, plus {@code RSA}, {@code ECDSA}, and
 * {@code SPHINCS+}. Each key pair is generated with the algorithm's default
 * key-generation parameters as published by
 * {@link CryptoAlgorithm#keyOperations()}.
 * </p>
 *
 * <p>
 * This type is a non-instantiable utility holder. ZeroEcho's ownership lock
 * rejects concurrent writers of the same keyring.
 * </p>
 *
 * @see ZeroEchoSigner
 * @see KeyringStore
 * @since 4.0.0
 */
final class SigningKeyGenerator {

    /** Default signing algorithm when the caller supplies none. */
    /* default */ static final String DEFAULT_ALGORITHM = "Ed25519";

    /** Suffix of the temporary keyring backup taken while an alias is replaced. */
    private static final String BACKUP_SUFFIX = ".bak";

    /** Filename suffix for the exported X.509 public key in PEM form. */
    private static final String PUBLIC_PEM_SUFFIX = "-public.pem";

    /**
     * Canonical algorithm tokens accepted by the generator, keyed by their
     * upper-case form so lookups are case-insensitive while the stored id keeps
     * ZeroEcho's exact spelling.
     */
    private static final Map<String, String> SUPPORTED_ALGORITHMS = Map.of(
            "ED25519", "Ed25519",
            "RSA", "RSA",
            "ECDSA", "ECDSA",
            "SPHINCS+", "SPHINCS+");

    /** Prevents instantiation. */
    private SigningKeyGenerator() {
    }

    /**
     * Outcome of a successful key generation.
     *
     * @param algorithm     canonical algorithm id that was generated
     * @param publicAlias   alias under which the public key was stored
     * @param privateAlias  alias under which the private key was stored
     * @param keyringFile   keyring file the key pair was written to
     * @param publicKeyPem  companion file holding the public key in X.509 PEM
     *                      form, for verification with standard tools
     * @param created       {@code true} when the keyring was newly created,
     *                      {@code false} when the key was added to an existing one
     * @since 4.0.0
     */
    /* default */ record GeneratedKey(String algorithm, String publicAlias, String privateAlias, Path keyringFile,
            Path publicKeyPem, boolean created) {
    }

    /**
     * Returns the canonical algorithm ids this generator supports, in a stable,
     * human-readable order suitable for help text.
     *
     * @return comma-separated list of supported algorithm ids; never empty
     */
    /* default */ static String supportedAlgorithmsHint() {
        return "Ed25519 (default), RSA, ECDSA, SPHINCS+";
    }

    /**
     * Generates a signing key pair and stores it under {@code alias} in the
     * encrypted keyring at {@code keyringFile}.
     *
     * <p>
     * When the keyring already exists it is unlocked and the new pair is added;
     * otherwise a new keyring is created and protected with a new password
     * (at least {@value KeyringPasswordSource#MIN_NEW_PASSWORD_LENGTH}
     * characters). The public key is additionally exported next to the keyring as
     * an X.509 PEM file ({@code <alias>-public.pem}) so a manifest signature can
     * be verified with standard tooling such as {@code openssl}.
     * </p>
     *
     * @param keyringFile target keyring file; must not be {@code null}
     * @param alias       logical alias for the key pair; ZeroEcho stores it as
     *                    {@code alias.pub} and {@code alias.priv}; must not be
     *                    {@code null} or blank
     * @param algorithm   algorithm id ({@code null} selects
     *                    {@value #DEFAULT_ALGORITHM}); must be one of the
     *                    supported ids, case-insensitively
     * @param overwrite   when {@code true}, an existing entry under {@code alias}
     *                    is replaced; when {@code false}, a collision is an error
     * @param passwords   source of the keyring password; must not be {@code null}
     * @return a {@link GeneratedKey} describing what was written
     * @throws IOException              if the password is unavailable or wrong, the
     *                                  alias collides, or the keyring cannot be
     *                                  read or written
     * @throws GeneralSecurityException if key generation fails
     * @throws IllegalArgumentException if {@code alias} is blank or {@code algorithm}
     *                                  is not supported
     */
    /* default */ static GeneratedKey generate(Path keyringFile, String alias, String algorithm, boolean overwrite,
            KeyringPasswordSource passwords) throws IOException, GeneralSecurityException {
        if (keyringFile == null) {
            throw new IllegalArgumentException("keyringFile must not be null");
        }
        validateAlias(alias);
        String canonical = canonicalAlgorithm(algorithm);
        CryptoAlgorithm alg = requireAsymmetric(canonical);

        BouncyCastleActivator.init();
        // Generate first so a key-generation failure never leaves an empty new keyring behind.
        KeyPair keyPair = generateKeyPair(alg);
        boolean created = !Files.exists(keyringFile);
        boolean stored = false;
        try {
            store(keyringFile, alias, canonical, keyPair, overwrite, created, passwords);
            stored = true;
        } finally {
            if (created && !stored) {
                deleteKeyringFiles(keyringFile);
            }
        }

        Path publicKeyPem = keyringFile.resolveSibling(alias + PUBLIC_PEM_SUFFIX);
        writePublicKeyPem(keyPair.getPublic(), publicKeyPem);

        return new GeneratedKey(canonical, alias + ".pub", alias + ".priv", keyringFile, publicKeyPem, created);
    }

    /**
     * Rejects aliases that ZeroEcho or the hybrid alias syntax cannot represent.
     *
     * @param alias requested alias
     * @throws IllegalArgumentException if the alias is blank, carries a
     *                                  {@code .pub}/{@code .priv} suffix, or contains
     *                                  the hybrid separator {@value ZeroEchoSigner#ALIAS_SEP}
     */
    private static void validateAlias(String alias) {
        if (alias == null || alias.isBlank()) {
            throw new IllegalArgumentException("alias must not be blank");
        }
        if (alias.endsWith(".pub") || alias.endsWith(".priv") || alias.contains(ZeroEchoSigner.ALIAS_SEP)) {
            throw new IllegalArgumentException("alias must not end with .pub or .priv and must not contain '"
                    + ZeroEchoSigner.ALIAS_SEP + "': " + alias);
        }
    }

    /**
     * Opens or creates the keyring and stores the key pair. When an existing
     * alias is replaced, the previous keyring files are backed up first and the
     * backup is removed only after the new pair is durable, because ZeroEcho
     * removes the old pair and inserts the new one in two separate snapshots.
     *
     * @param keyringFile keyring store path
     * @param alias       logical alias
     * @param canonical   canonical algorithm id
     * @param keyPair     key pair to store
     * @param overwrite   whether an existing alias may be replaced
     * @param created     whether the keyring is created by this call
     * @param passwords   source of the keyring password
     * @throws IOException              if the keyring cannot be opened or written
     * @throws GeneralSecurityException if encryption fails
     */
    // ZeroEcho signals alias-namespace and limit violations with unchecked
    // exceptions; any of them must keep the backup and report how to restore it.
    @SuppressWarnings("PMD.AvoidCatchingGenericException")
    private static void store(Path keyringFile, String alias, String canonical, KeyPair keyPair, boolean overwrite,
            boolean created, KeyringPasswordSource passwords) throws IOException, GeneralSecurityException {
        try (KeyringPassword password = passwords.resolve(created);
                KeyringStore store = created
                        ? KeyringFiles.create(keyringFile, password)
                        : KeyringFiles.open(keyringFile, password)) {
            if (!store.contains(alias)) {
                store.putKeyPairIfAbsent(alias, canonical, keyPair);
                return;
            }
            if (!overwrite) {
                throw new IOException("Alias already exists in keyring (use -overwrite to replace): " + alias);
            }
            List<Path[]> backups = backup(keyringFile);
            try {
                store.removeAlias(alias);
                store.putKeyPairIfAbsent(alias, canonical, keyPair);
            } catch (IOException | GeneralSecurityException | RuntimeException e) {
                throw new IOException("Replacing alias '" + alias + "' failed; the previous keyring is preserved as "
                        + backups.get(0)[1] + " and " + backups.get(1)[1] + " — restore both by renaming them back",
                        e);
            }
            for (Path[] pair : backups) {
                Files.deleteIfExists(pair[1]);
            }
        }
    }

    /**
     * Copies the keyring store and its sidecar to {@code .bak} files in the same
     * owner-only directory.
     *
     * @param keyringFile keyring store path
     * @return {original, backup} path pairs for the store and the sidecar
     * @throws IOException if a copy fails
     */
    private static List<Path[]> backup(Path keyringFile) throws IOException {
        List<Path[]> pairs = new ArrayList<>(2);
        for (Path original : List.of(keyringFile, KeyringFiles.nonceFile(keyringFile))) {
            Path copy = original.resolveSibling(original.getFileName() + BACKUP_SUFFIX);
            Files.copy(original, copy, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES);
            pairs.add(new Path[] { original, copy });
        }
        return pairs;
    }

    /**
     * Removes the files of a keyring that was created by a failed generation, so
     * a retry starts from scratch (and asks for a new password again).
     *
     * @param keyringFile keyring store path
     * @throws IOException if a file cannot be deleted
     */
    private static void deleteKeyringFiles(Path keyringFile) throws IOException {
        Files.deleteIfExists(keyringFile);
        Files.deleteIfExists(KeyringFiles.nonceFile(keyringFile));
        Files.deleteIfExists(keyringFile.resolveSibling(keyringFile.getFileName() + ".lock"));
    }

    /**
     * Writes {@code publicKey} to {@code pemFile} as an X.509
     * {@code SubjectPublicKeyInfo} in PEM form ({@code -----BEGIN PUBLIC KEY-----}).
     *
     * <p>
     * The PEM is produced with Bouncy Castle's {@link JcaPEMWriter} — the same
     * mechanism ZeroEcho uses — so it is readable by standard tooling such as
     * {@code openssl}. The public key is not secret; it lets auditors verify a
     * signed manifest without trusting MethodAtlas or ZeroEcho.
     * </p>
     *
     * @param publicKey public key to export; must not be {@code null}
     * @param pemFile   destination PEM file
     * @throws IOException if the PEM cannot be written
     */
    private static void writePublicKeyPem(PublicKey publicKey, Path pemFile) throws IOException {
        try (BufferedWriter writer = Files.newBufferedWriter(pemFile, StandardCharsets.UTF_8);
                JcaPEMWriter pemWriter = new JcaPEMWriter(writer)) {
            pemWriter.writeObject(publicKey);
        }
    }

    /**
     * Resolves the requested algorithm to its canonical ZeroEcho id.
     *
     * @param algorithm requested id, or {@code null} for the default
     * @return canonical id
     * @throws IllegalArgumentException if the algorithm is not supported
     */
    private static String canonicalAlgorithm(String algorithm) {
        String requested = algorithm == null ? DEFAULT_ALGORITHM : algorithm;
        String canonical = SUPPORTED_ALGORITHMS.get(requested.toUpperCase(Locale.ROOT));
        if (canonical == null) {
            throw new IllegalArgumentException("Unsupported signing algorithm '" + requested
                    + "'. Supported: " + supportedAlgorithmsHint());
        }
        return canonical;
    }

    /**
     * Resolves the algorithm from the ZeroEcho catalog.
     *
     * @param canonical canonical algorithm id
     * @return the catalog algorithm
     * @throws GeneralSecurityException if the algorithm is absent from the catalog
     */
    private static CryptoAlgorithm requireAsymmetric(String canonical) throws GeneralSecurityException {
        BouncyCastleActivator.init();
        try {
            return CryptoAlgorithms.require(canonical);
        } catch (IllegalArgumentException e) {
            throw new GeneralSecurityException("Algorithm '" + canonical
                    + "' is not available in the ZeroEcho catalog", e);
        }
    }

    /**
     * Generates a key pair with the algorithm's default key-generation
     * specification.
     *
     * @param alg catalog algorithm
     * @return the generated key pair
     * @throws GeneralSecurityException if the algorithm publishes no default
     *                                  asymmetric key-generation specification
     */
    private static KeyPair generateKeyPair(CryptoAlgorithm alg) throws GeneralSecurityException {
        for (KeyOperationInfo info : alg.keyOperations()) {
            if (info.operation() == KeyOperation.ASYMMETRIC_KEY_PAIR_GENERATE && info.defaultSpec() != null) {
                return generateWith(alg, info.specType(), info.defaultSpec());
            }
        }
        throw new GeneralSecurityException("Algorithm '" + alg.id() + "' has no default asymmetric key generator");
    }

    private static <S extends AlgorithmKeySpec> KeyPair generateWith(CryptoAlgorithm alg, Class<S> specType,
            AlgorithmKeySpec spec) throws GeneralSecurityException {
        return alg.asymmetricKeyPairGenerator(specType).generateKeyPair(specType.cast(spec));
    }
}