ZeroEchoSigner.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.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.Signature;
import java.util.List;
import java.util.Locale;
import java.util.function.Supplier;
import java.util.logging.Level;
import java.util.logging.Logger;
import zeroecho.core.context.SignatureContext;
import zeroecho.core.spec.VoidSpec;
import zeroecho.core.storage.KeyringStore;
import zeroecho.core.tag.TagEngine;
import zeroecho.core.tag.TagEngineBuilder;
import zeroecho.sdk.builders.TagTrailerDataContentBuilder;
import zeroecho.sdk.content.api.DataContent;
import zeroecho.sdk.content.builtin.PlainFile;
import zeroecho.sdk.hybrid.signature.HybridSignatureContexts;
import zeroecho.sdk.hybrid.signature.HybridSignatureProfile;
import zeroecho.sdk.util.BouncyCastleActivator;
/**
* Produces a signed envelope of the evidence-pack manifest using the ZeroEcho
* cryptographic toolkit (version 1.1.1).
*
* <p>
* The signing key is read from a ZeroEcho {@link KeyringStore} — a plaintext
* UTF-8 keyring file, <em>not</em> a JDK PKCS12/JKS keystore and not anything
* produced by {@code keytool}. The keyring is loaded with
* {@link KeyringStore#load(Path)} and the private key is resolved by alias.
* Generate a keyring with MethodAtlas's {@code -gen-signing-key} mode (see
* {@link SigningKeyGenerator}) or with ZeroEcho's own {@code -K --generate}
* command-line tool.
* </p>
*
* <h2>Single-algorithm signing</h2>
* <p>
* When the signature algorithm contains no {@value #HYBRID_SEP} separator the
* manifest is signed with one algorithm. The algorithm defaults to
* {@value #DEFAULT_ALGO}; when no explicit algorithm is supplied the value
* stored alongside the key in the keyring is used. RSA keys are signed with
* RSA-PSS (SHA-256) and ECDSA keys with the P-256 curve; every other algorithm
* (Ed25519, Ed448, SPHINCS+, ML-DSA, SLH-DSA, …) is dispatched through
* {@link TagEngineBuilder#signature(String, java.security.Key, zeroecho.core.spec.ContextSpec)}.
* </p>
*
* <h2>Hybrid signing</h2>
* <p>
* A signature algorithm of the form {@code classic+pqc} (for example
* {@code Ed25519+SPHINCS+}) selects a hybrid composite that signs with both a
* classical and a post-quantum primitive. The key alias must then carry both
* component aliases separated by {@value #ALIAS_SEP}
* ({@code classicAlias/pqcAlias}). Verification of a hybrid envelope requires
* both component signatures (AND rule).
* </p>
*
* <h2>Thread-safety and lifecycle</h2>
* <p>
* Instances are <strong>not</strong> thread-safe and are single-use: create one
* signer per {@link #sign(Path, Path)} call. Hybrid signers in particular hold a
* stateful {@link SignatureContext} that must not be reused across streams. The
* signer is {@link AutoCloseable}; callers should use it in a
* try-with-resources block (or call {@link #close()}) so the owned hybrid
* context is released — close it even if {@link #sign(Path, Path)} is never
* invoked.
* </p>
*
* <p>
* Package-private because only {@link EvidencePackCommand} composes this signer.
* </p>
*
* @see SigningKeyGenerator
* @see <a href="https://gitea.egothor.org/Egothor/ZeroEcho">ZeroEcho</a>
* @since 4.0.0
*/
final class ZeroEchoSigner implements AutoCloseable {
private static final Logger LOG = Logger.getLogger(ZeroEchoSigner.class.getName());
/** Stream buffer size used when copying through the signing pipeline. */
/* default */ static final int BUFFER_SIZE = 8192;
/**
* Upper bound on the manifest body buffered by the hybrid DoS guard — 16 MiB
* is far more than any realistic SHA-256 manifest.
*/
/* default */ static final int MAX_BODY_BYTES = 16 * 1024 * 1024;
/** Default single-algorithm identifier when none is supplied and none is stored. */
/* default */ static final String DEFAULT_ALGO = "Ed25519";
/** Separator that distinguishes hybrid algorithm specifications ({@code classic+pqc}). */
/* default */ static final String HYBRID_SEP = "+";
/** Separator used inside the key-alias parameter to split classical and PQC aliases. */
/* default */ static final String ALIAS_SEP = "/";
/** Version of the ZeroEcho library recorded in {@code pack-meta.json}. */
/* default */ static final String ZEROECHO_LIB_VERSION = "1.1.1";
/** Factory that supplies a fresh signing engine for the manifest pipeline. */
private final Supplier<TagEngine<Signature>> engineFactory;
/** Resolved algorithm string, reported by callers in pack metadata. */
private final String algorithm;
/** Effective keystore alias actually used to load the signing key. */
private final String resolvedAlias;
/**
* Closeable cryptographic context owned by this signer, or {@code null}. The
* hybrid path eagerly creates a {@link SignatureContext} that this signer
* retains so {@link #close()} can release it; the single-algorithm path has
* no owned context (its per-stream engines are created and owned by the
* signing pipeline).
*/
private final SignatureContext ownedContext;
/**
* Private constructor; instances are obtained through
* {@link #fromKeyringFile(Path, String, String)} or
* {@link #fromKeyringText(String, String, String)}.
*
* @param engineFactory supplier of the signing engine; must not be {@code null}
* @param algorithm algorithm string (e.g. {@code "Ed25519"})
* @param resolvedAlias keyring alias actually used
* @param ownedContext closeable context this signer owns, or {@code null}
* when there is none to release
*/
private ZeroEchoSigner(Supplier<TagEngine<Signature>> engineFactory, String algorithm, String resolvedAlias,
SignatureContext ownedContext) {
this.engineFactory = engineFactory;
this.algorithm = algorithm;
this.resolvedAlias = resolvedAlias;
this.ownedContext = ownedContext;
}
/**
* Releases the cryptographic context this signer owns. A no-op for the
* single-algorithm path; for hybrid signers it closes the retained
* {@link SignatureContext}. Failures to close are logged at {@code FINE} and
* swallowed, since {@code close()} is best-effort cleanup and must not mask a
* signing result.
*/
@Override
public void close() {
if (ownedContext != null) {
try {
ownedContext.close();
} catch (IOException e) {
if (LOG.isLoggable(Level.FINE)) {
LOG.log(Level.FINE, "Failed to close hybrid signature context", e);
}
}
}
}
/**
* Returns the algorithm string that will be reported in pack metadata.
*
* @return algorithm identifier; never {@code null}
*/
/* default */ String algorithm() {
return algorithm;
}
/**
* Returns the keyring alias actually used to retrieve the signing key.
*
* @return alias; never {@code null} once construction succeeded
*/
/* default */ String resolvedAlias() {
return resolvedAlias;
}
/**
* Creates a signer from a ZeroEcho keyring file on disk. This is the path
* used for interactive CLI signing, where the keyring file is protected by
* file-system permissions or ACLs.
*
* @param keyringFile path to the ZeroEcho keyring; must not be {@code null}
* @param keyAlias alias to load; {@code null} means use the first
* alias in the keyring; for hybrid the format is
* {@code classicAlias/pqcAlias}
* @param signatureAlgorithm algorithm identifier; {@code null} means derive it
* from the keyring entry (single-algorithm only); a
* value of the form {@code classic+pqc} selects
* hybrid signing
* @return signer ready to sign; never {@code null}
* @throws IOException if reading the keyring or building the
* signing context fails
* @throws GeneralSecurityException if a key cannot be materialised
*/
/* default */ static ZeroEchoSigner fromKeyringFile(Path keyringFile, String keyAlias,
String signatureAlgorithm) throws IOException, GeneralSecurityException {
return build(KeyringStore.load(keyringFile), keyAlias, signatureAlgorithm);
}
/**
* Creates a signer from in-memory keyring text. This is the path used in
* CI/CD pipelines, where the keyring content is delivered through a platform
* secret (an environment variable) and is parsed in memory so the private
* key never touches the runner's disk.
*
* @param keyringText full keyring content (including the
* {@code # KeyringStore v1} header), typically the
* value of a CI secret variable; must not be
* {@code null} or blank
* @param keyAlias alias to load; {@code null} means use the first
* alias in the keyring; for hybrid the format is
* {@code classicAlias/pqcAlias}
* @param signatureAlgorithm algorithm identifier; {@code null} means derive it
* from the keyring entry; a value of the form
* {@code classic+pqc} selects hybrid signing
* @return signer ready to sign; never {@code null}
* @throws IOException if the keyring text is malformed or building
* the signing context fails
* @throws GeneralSecurityException if a key cannot be materialised
*/
/* default */ static ZeroEchoSigner fromKeyringText(String keyringText, String keyAlias,
String signatureAlgorithm) throws IOException, GeneralSecurityException {
if (keyringText == null || keyringText.isBlank()) {
throw new IOException("Keyring content is empty");
}
KeyringStore keyring = new KeyringStore();
keyring.importText(keyringText, true);
return build(keyring, keyAlias, signatureAlgorithm);
}
/**
* Builds a signer from an already-loaded keyring, dispatching to the single
* or hybrid construction path based on {@code signatureAlgorithm}.
*
* @param keyring loaded keyring store; must not be {@code null}
* @param keyAlias alias to load, or {@code null} for the first alias
* @param signatureAlgorithm algorithm identifier, or {@code null} to derive it
* @return configured signer
* @throws IOException if the alias/algorithm pairing is invalid
* @throws GeneralSecurityException if a key cannot be materialised
*/
private static ZeroEchoSigner build(KeyringStore keyring, String keyAlias, String signatureAlgorithm)
throws IOException, GeneralSecurityException {
// SPHINCS+ and the other PQC primitives are provided by Bouncy Castle;
// init is idempotent and harmless for classical algorithms.
BouncyCastleActivator.init();
if (signatureAlgorithm != null && signatureAlgorithm.contains(HYBRID_SEP)) {
return buildHybrid(keyring, keyAlias, signatureAlgorithm);
}
return buildSingle(keyring, keyAlias, signatureAlgorithm);
}
/**
* Signs {@code inputFile} and writes the resulting signed envelope to
* {@code outputFile}. The output contains the original bytes followed by a
* ZeroEcho signature trailer.
*
* @param inputFile manifest to sign
* @param outputFile destination for the signed envelope
* @return {@code outputFile} on success
* @throws IOException if reading, signing, or writing fails
* @throws GeneralSecurityException if the cryptographic step fails
*/
// ZeroEcho's builders surface configuration failures as unchecked exceptions
// (IllegalStateException from the engine factory, IllegalArgumentException for
// bad parameters); translating them to IOException keeps the CLI from crashing
// mid-pack with a raw stack trace.
@SuppressWarnings("PMD.AvoidCatchingGenericException")
/* default */ Path sign(Path inputFile, Path outputFile) throws IOException, GeneralSecurityException {
try {
DataContent tail = new TagTrailerDataContentBuilder<>(engineFactory)
.bufferSize(BUFFER_SIZE)
.build(true);
tail.setInput(new PlainFile(inputFile.toUri().toURL()));
try (InputStream in = tail.getStream();
OutputStream out = Files.newOutputStream(outputFile)) {
in.transferTo(out);
}
return outputFile;
} catch (RuntimeException e) {
throw new IOException("Failed to sign manifest with ZeroEcho (" + algorithm + ")", e);
}
}
// -------------------------------------------------------------------------
// Construction helpers
// -------------------------------------------------------------------------
/**
* Builds a single-algorithm signer, deriving the algorithm from the keyring
* entry when {@code explicitAlgorithm} is {@code null}.
*
* @param keyring loaded keyring store
* @param keyAlias requested alias, or {@code null} for the first alias
* @param explicitAlgorithm caller-supplied algorithm, or {@code null} to derive it
* @return configured signer
* @throws IOException if the keyring is empty
* @throws GeneralSecurityException if the private key cannot be materialised
*/
private static ZeroEchoSigner buildSingle(KeyringStore keyring, String keyAlias, String explicitAlgorithm)
throws IOException, GeneralSecurityException {
String alias = resolveAlias(keyring, keyAlias);
KeyringStore.PrivateWithId entry = keyring.getPrivateWithId(alias);
String algo = explicitAlgorithm != null ? explicitAlgorithm : entry.algorithm();
Supplier<TagEngine<Signature>> factory = engineFactoryFor(algo, entry.key());
return new ZeroEchoSigner(factory, algo, alias, null);
}
/**
* Builds a hybrid (classical + post-quantum) signer.
*
* @param keyring loaded keyring store
* @param keyAlias combined alias {@code classicAlias/pqcAlias}; must not be {@code null}
* @param algo combined algorithm {@code classic+pqc}
* @return configured signer
* @throws IOException if the alias is not a valid hybrid pair
* @throws GeneralSecurityException if either private key cannot be materialised
*/
private static ZeroEchoSigner buildHybrid(KeyringStore keyring, String keyAlias, String algo)
throws IOException, GeneralSecurityException {
String[] algoParts = splitPair(algo, HYBRID_SEP,
"Hybrid algorithm must be 'classic+pqc' (e.g. Ed25519+SPHINCS+): " + algo);
if (keyAlias == null || !keyAlias.contains(ALIAS_SEP)) {
throw new IOException("Hybrid signing requires a 'classicAlias/pqcAlias' key alias via "
+ "-evidence-pack-key-alias; got: " + keyAlias);
}
String[] aliasParts = splitPair(keyAlias, ALIAS_SEP,
"Hybrid key alias must be 'classicAlias/pqcAlias': " + keyAlias);
PrivateKey classicKey = keyring.getPrivate(aliasParts[0]);
PrivateKey pqcKey = keyring.getPrivate(aliasParts[1]);
HybridSignatureProfile profile = new HybridSignatureProfile(
algoParts[0], algoParts[1], null, null, HybridSignatureProfile.VerifyRule.AND);
SignatureContext context = HybridSignatureContexts.sign(profile, classicKey, pqcKey, MAX_BODY_BYTES);
// The hybrid context is stateful and single-use; the signer is documented as
// single-use, so reusing the same instance across the one sign() call is safe.
// The signer retains the context and releases it in close().
return new ZeroEchoSigner(() -> context, algo, keyAlias, context);
}
/**
* Resolves the per-algorithm ZeroEcho engine factory. RSA keys use RSA-PSS
* (SHA-256, 32-byte salt) and ECDSA keys use the P-256 curve, matching the
* dedicated {@link TagEngineBuilder} factories; every other identifier is
* dispatched through the generic
* {@link TagEngineBuilder#signature(String, java.security.Key, zeroecho.core.spec.ContextSpec)}
* factory, which covers Ed25519, Ed448, SPHINCS+, ML-DSA, and SLH-DSA.
*
* @param algo canonical algorithm string
* @param privateKey loaded private key
* @return supplier producing fresh signing engines
*/
private static Supplier<TagEngine<Signature>> engineFactoryFor(String algo, PrivateKey privateKey) {
String normalised = algo.toUpperCase(Locale.ROOT);
return switch (normalised) {
case "RSA", "RSA-PSS", "RSASSA-PSS" -> TagEngineBuilder.rsaSign(privateKey, null);
case "ECDSA" -> TagEngineBuilder.ecdsaSign(privateKey, null);
default -> TagEngineBuilder.signature(algo, privateKey, VoidSpec.INSTANCE);
};
}
/**
* Returns the alias to use: {@code keyAlias} when non-null, otherwise the
* first alias in the keyring.
*
* @param keyring keyring to query
* @param keyAlias requested alias, or {@code null}
* @return resolved alias
* @throws IOException if the keyring contains no aliases
*/
private static String resolveAlias(KeyringStore keyring, String keyAlias) throws IOException {
if (keyAlias != null) {
return keyAlias;
}
List<String> aliases = keyring.aliases();
if (aliases.isEmpty()) {
throw new IOException("Keyring contains no aliases");
}
return aliases.get(0);
}
/**
* Splits {@code value} into exactly two non-blank halves around {@code sep}.
*
* @param value value to split
* @param sep literal separator
* @param message error message used when the split does not yield two halves
* @return a two-element array of trimmed, non-blank parts
* @throws IOException if {@code value} does not split into two non-blank parts
*/
private static String[] splitPair(String value, String sep, String message) throws IOException {
int idx = value.indexOf(sep);
if (idx <= 0 || idx >= value.length() - sep.length()) {
throw new IOException(message);
}
String first = value.substring(0, idx).trim();
String second = value.substring(idx + sep.length()).trim();
if (first.isEmpty() || second.isEmpty()) {
throw new IOException(message);
}
return new String[] { first, second };
}
}