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.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.PublicKey;
import java.util.Locale;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.bouncycastle.openssl.jcajce.JcaPEMWriter;

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

/**
 * Generates an asymmetric signing key pair and stores it in a ZeroEcho
 * {@link KeyringStore} so that {@code -evidence-pack} can later sign manifests
 * with it.
 *
 * <p>
 * The keyring is a plaintext UTF-8 file (see {@link KeyringStore}); it is
 * <em>not</em> a JDK PKCS12/JKS keystore. Because the file stores the private
 * key in clear text, the generator restricts the file to owner read/write
 * (POSIX {@code 0600}) after writing it; on platforms without POSIX
 * permissions a warning is emitted instructing the operator to restrict access
 * manually.
 * </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+}. Storing a key requires an algorithm-specific
 * {@code AlgorithmKeySpec}; the spec class is discovered from the algorithm's
 * {@link CryptoAlgorithm#asymmetricBuildersInfo()} and instantiated from the
 * encoded key material by reflection — the same mechanism ZeroEcho's own
 * {@code KeyStoreManagement} tool uses.
 * </p>
 *
 * <p>
 * This type is a non-instantiable utility holder and is not thread-safe with
 * respect to concurrent writers of the same keyring file.
 * </p>
 *
 * @see ZeroEchoSigner
 * @see KeyringStore
 * @since 4.0.0
 */
final class SigningKeyGenerator {

    private static final Logger LOG = Logger.getLogger(SigningKeyGenerator.class.getName());

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

    /** Owner read/write only — the keyring holds a clear-text private key. */
    private static final String OWNER_ONLY_PERMISSIONS = "rw-------";

    /** 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
     * @since 4.0.0
     */
    /* default */ record GeneratedKey(String algorithm, String publicAlias, String privateAlias, Path keyringFile,
            Path publicKeyPem) {
    }

    /**
     * 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
     * keyring at {@code keyringFile}.
     *
     * <p>
     * When the keyring file already exists it is loaded and the new entries are
     * appended; otherwise a fresh keyring is created. The private key is written
     * in clear text, so a fresh keyring is created with owner-only permissions
     * (POSIX {@code 0600}) <em>before</em> it is written and re-verified afterwards
     * (see {@link #createOwnerOnlyFile(Path)} and {@link #restrictPermissions(Path)}),
     * leaving no window in which a newly created keyring is world-readable. 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       base 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
     * @return a {@link GeneratedKey} describing what was written
     * @throws IOException              if the keyring cannot be read or written
     * @throws GeneralSecurityException if key generation or spec construction 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)
            throws IOException, GeneralSecurityException {
        if (keyringFile == null) {
            throw new IllegalArgumentException("keyringFile must not be null");
        }
        if (alias == null || alias.isBlank()) {
            throw new IllegalArgumentException("alias must not be blank");
        }
        String canonical = canonicalAlgorithm(algorithm);

        BouncyCastleActivator.init();
        KeyringStore store = Files.exists(keyringFile) ? KeyringStore.load(keyringFile) : new KeyringStore();
        if (!overwrite && store.contains(alias)) {
            throw new IOException("Alias already exists in keyring (use -overwrite to replace): " + alias);
        }

        CryptoAlgorithm alg = requireAsymmetric(canonical);
        KeyPair keyPair = alg.generateKeyPair();

        Class<? extends AlgorithmKeySpec> publicSpecType = findSpecType(alg, "Public");
        Class<? extends AlgorithmKeySpec> privateSpecType = findSpecType(alg, "Private");

        AlgorithmKeySpec publicSpec = importSpec(publicSpecType, keyPair.getPublic().getEncoded(), canonical);
        AlgorithmKeySpec privateSpec = importSpec(privateSpecType, keyPair.getPrivate().getEncoded(), canonical);

        store.putPublic(alias, canonical, publicSpec);
        store.putPrivate(alias, canonical, privateSpec);
        createOwnerOnlyFile(keyringFile);
        store.save(keyringFile);
        restrictPermissions(keyringFile);

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

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

    /**
     * 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, so the file is left with
     * default permissions; 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 and verifies it exposes an
     * asymmetric builder.
     *
     * @param canonical canonical algorithm id
     * @return the catalog algorithm
     * @throws GeneralSecurityException if the algorithm is absent from the catalog
     *                                  or has no asymmetric builder
     */
    private static CryptoAlgorithm requireAsymmetric(String canonical) throws GeneralSecurityException {
        final CryptoAlgorithm alg;
        try {
            alg = CryptoAlgorithms.require(canonical);
        } catch (IllegalArgumentException e) {
            throw new GeneralSecurityException("Algorithm '" + canonical
                    + "' is not available in the ZeroEcho catalog", e);
        }
        if (alg.asymmetricBuildersInfo().isEmpty()) {
            throw new GeneralSecurityException("Algorithm '" + canonical + "' has no asymmetric key builder");
        }
        return alg;
    }

    /**
     * Finds the import-spec class for a public or private key by matching the
     * spec class simple name against {@code marker} (for example {@code "Public"}
     * or {@code "Private"}).
     *
     * @param alg    catalog algorithm to inspect
     * @param marker substring that identifies the key role in the spec class name
     * @return the matching spec class
     * @throws GeneralSecurityException if no matching spec class is registered
     */
    private static Class<? extends AlgorithmKeySpec> findSpecType(CryptoAlgorithm alg, String marker)
            throws GeneralSecurityException {
        for (CryptoAlgorithm.AsymBuilderInfo info : alg.asymmetricBuildersInfo()) {
            if (info.specType.getSimpleName().contains(marker)) {
                return info.specType;
            }
        }
        throw new GeneralSecurityException("Algorithm '" + alg.id() + "' exposes no "
                + marker.toLowerCase(Locale.ROOT) + "-key import spec");
    }

    /**
     * Constructs an {@link AlgorithmKeySpec} from encoded key material using the
     * conventional ZeroEcho factory methods, in order: {@code fromRaw(byte[])},
     * {@code of(byte[])}, then a single {@code byte[]} constructor.
     *
     * @param specType target spec class
     * @param material encoded key bytes (X.509 SPKI for public keys, PKCS#8 for
     *                 private keys)
     * @param algId    algorithm id, used only for diagnostics
     * @return the constructed spec
     * @throws GeneralSecurityException if none of the known factories accept the
     *                                  material
     */
    private static AlgorithmKeySpec importSpec(Class<? extends AlgorithmKeySpec> specType, byte[] material,
            String algId) throws GeneralSecurityException {
        AlgorithmKeySpec viaFactory = invokeStaticFactory(specType, "fromRaw", material);
        if (viaFactory == null) {
            viaFactory = invokeStaticFactory(specType, "of", material);
        }
        if (viaFactory != null) {
            return viaFactory;
        }
        AlgorithmKeySpec viaCtor = invokeByteArrayConstructor(specType, material);
        if (viaCtor != null) {
            return viaCtor;
        }
        throw new GeneralSecurityException("Cannot construct " + specType.getName() + " for algorithm '" + algId
                + "'; no fromRaw(byte[]), of(byte[]) or byte[] constructor accepted the key material");
    }

    /**
     * Invokes a static single-{@code byte[]} factory method named {@code name} on
     * {@code specType}, returning {@code null} when the method is absent.
     *
     * @param specType spec class to invoke on
     * @param name     factory method name
     * @param material encoded key bytes
     * @return the constructed spec, or {@code null} if no such method exists
     * @throws GeneralSecurityException if the method exists but fails to construct
     *                                  a spec
     */
    private static AlgorithmKeySpec invokeStaticFactory(Class<? extends AlgorithmKeySpec> specType, String name,
            byte[] material) throws GeneralSecurityException {
        try {
            Method method = specType.getMethod(name, byte[].class);
            return (AlgorithmKeySpec) method.invoke(null, (Object) material);
        } catch (NoSuchMethodException absent) {
            return null;
        } catch (IllegalAccessException | InvocationTargetException e) {
            throw new GeneralSecurityException("Factory " + specType.getName() + "." + name
                    + "(byte[]) failed", unwrap(e));
        }
    }

    /**
     * Invokes a single-{@code byte[]} constructor on {@code specType}, returning
     * {@code null} when no such constructor exists.
     *
     * @param specType spec class to instantiate
     * @param material encoded key bytes
     * @return the constructed spec, or {@code null} if no such constructor exists
     * @throws GeneralSecurityException if the constructor exists but fails
     */
    private static AlgorithmKeySpec invokeByteArrayConstructor(Class<? extends AlgorithmKeySpec> specType,
            byte[] material) throws GeneralSecurityException {
        try {
            Constructor<? extends AlgorithmKeySpec> ctor = specType.getConstructor(byte[].class);
            return ctor.newInstance((Object) material);
        } catch (NoSuchMethodException absent) {
            return null;
        } catch (ReflectiveOperationException e) {
            throw new GeneralSecurityException("Constructor " + specType.getName() + "(byte[]) failed", unwrap(e));
        }
    }

    /**
     * Unwraps an {@link InvocationTargetException} to its underlying cause so the
     * thrown {@link GeneralSecurityException} carries the real failure.
     *
     * @param e reflective exception
     * @return the underlying cause when present, otherwise {@code e}
     */
    private static Throwable unwrap(ReflectiveOperationException e) {
        return e instanceof InvocationTargetException && e.getCause() != null ? e.getCause() : e;
    }

    /**
     * Creates the keyring file with owner-only permissions before it is written,
     * so a freshly generated keyring is never momentarily world-readable while it
     * holds a clear-text private key (closing the create-then-{@code chmod} race).
     *
     * <p>
     * Applies only to a newly created file; when the keyring already exists (a key
     * is being added to it) the file keeps its current permissions and
     * {@link #restrictPermissions(Path)} tightens them after the write. On file
     * systems without POSIX support the file is created normally and
     * {@link #restrictPermissions(Path)} logs a warning.
     * </p>
     *
     * @param keyringFile keyring file to create
     * @throws IOException if the file cannot be created
     */
    private static void createOwnerOnlyFile(Path keyringFile) throws IOException {
        if (Files.exists(keyringFile)) {
            return;
        }
        try {
            Files.createFile(keyringFile,
                    PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString(OWNER_ONLY_PERMISSIONS)));
        } catch (UnsupportedOperationException notPosix) {
            Files.createFile(keyringFile);
        }
    }

    /**
     * Restricts the keyring file to owner read/write. On POSIX file systems this
     * applies {@code 0600}; on file systems without POSIX support (for example
     * Windows) a warning is logged because the private key is stored in clear
     * text and the operator must restrict access by other means.
     *
     * @param keyringFile file to lock down
     */
    private static void restrictPermissions(Path keyringFile) {
        try {
            Files.setPosixFilePermissions(keyringFile, PosixFilePermissions.fromString(OWNER_ONLY_PERMISSIONS));
        } catch (UnsupportedOperationException notPosix) {
            if (LOG.isLoggable(Level.WARNING)) {
                LOG.log(Level.WARNING, "Keyring {0} holds a clear-text private key but this file system does not "
                        + "support POSIX permissions; restrict access to your account manually (e.g. via NTFS ACLs).",
                        keyringFile);
            }
        } catch (IOException e) {
            if (LOG.isLoggable(Level.WARNING)) {
                LOG.log(Level.WARNING, "Failed to restrict permissions on keyring " + keyringFile
                        + "; verify it is readable only by you", e);
            }
        }
    }
}