KeyringFiles.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.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.attribute.AclEntry;
import java.nio.file.attribute.AclEntryFlag;
import java.nio.file.attribute.AclEntryPermission;
import java.nio.file.attribute.AclEntryType;
import java.nio.file.attribute.AclFileAttributeView;
import java.nio.file.attribute.PosixFileAttributeView;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.GeneralSecurityException;
import java.util.Arrays;
import java.util.Base64;
import java.util.EnumSet;
import java.util.List;
import java.util.Locale;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Stream;
import zeroecho.core.storage.KeyringException;
import zeroecho.core.storage.KeyringPassword;
import zeroecho.core.storage.KeyringStore;
/**
* File-level helpers for encrypted ZeroEcho 1.2 keyrings: opening and creating
* them with actionable error messages, detecting the retired plaintext format,
* and moving a keyring through a CI secret as a text bundle.
*
* <h2>On-disk layout</h2>
* <p>
* A keyring at {@code dir/name} consists of the encrypted store {@code name},
* its authenticated nonce-reservation sidecar {@code name.nonce}, and a
* {@code name.lock} file that ZeroEcho holds while the keyring is open. The
* directory and both data files must be accessible only by their owner (POSIX
* {@code 0700}/{@code 0600}, or an owner-only ACL on Windows); ZeroEcho refuses
* anything else.
* </p>
*
* <h2>Secret bundle</h2>
* <p>
* A CI platform secret carries one text value, so {@link #exportBundle(Path)}
* encodes the store and its sidecar as Base64 lines under a
* {@value #BUNDLE_HEADER} header. {@link #restoreBundle(String)} writes them
* into a fresh owner-only temporary directory that is deleted again when the
* returned {@link RestoredKeyring} is closed. The bundle is still encrypted with
* the keyring password; it is not a plaintext key.
* </p>
*
* @since 6.2.0
*/
final class KeyringFiles {
private static final Logger LOG = Logger.getLogger(KeyringFiles.class.getName());
/** First line of a keyring secret bundle. */
/* default */ static final String BUNDLE_HEADER = "# MethodAtlas keyring secret v1";
/** Bundle line prefix for the encrypted keyring store. */
private static final String KEYRING_PREFIX = "keyring=";
/** Bundle line prefix for the nonce-reservation sidecar. */
private static final String NONCE_PREFIX = "nonce=";
/** Suffix of ZeroEcho's nonce-reservation sidecar file. */
private static final String NONCE_SUFFIX = ".nonce";
/** Header line of the plaintext keyring format used by ZeroEcho 1.1. */
private static final String LEGACY_HEADER = "# KeyringStore";
/** Magic bytes that open an encrypted ZeroEcho 1.2 keyring. */
private static final byte[] KEYRING_MAGIC = "ZEKRING2".getBytes(StandardCharsets.US_ASCII);
/** Upper bound on a restored keyring image, matching ZeroEcho's own limit. */
private static final int MAX_IMAGE_BYTES = 128 * 1024 * 1024;
/** File name of a keyring restored from a secret bundle. */
private static final String RESTORED_NAME = "keyring";
/** Name prefix of the temporary directory a secret bundle is restored into. */
private static final String TEMP_PREFIX = "methodatlas-keyring-";
private static final String OWNER_ONLY_DIR = "rwx------";
private static final String OWNER_ONLY_FILE = "rw-------";
/** Prevents instantiation. */
private KeyringFiles() {
}
/**
* A keyring materialised from a secret bundle in a private temporary
* directory. Closing it deletes the directory and everything in it; a
* deletion failure is logged rather than thrown, so cleanup can never
* discard a signer that was already built from the restored keyring.
*
* @param directory owner-only temporary directory
* @param keyring path of the restored keyring store inside {@code directory}
*/
/* default */ record RestoredKeyring(Path directory, Path keyring) implements AutoCloseable {
@Override
public void close() {
deleteTree(directory);
}
}
/**
* Deletes {@code directory} and its direct children (the restored store, its
* sidecar and lock file), logging instead of throwing on failure.
*
* @param directory directory to delete
*/
private static void deleteTree(Path directory) {
try {
try (Stream<Path> entries = Files.list(directory)) {
for (Path entry : entries.toList()) {
Files.deleteIfExists(entry);
}
}
Files.deleteIfExists(directory);
} catch (IOException e) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Could not delete the temporary keyring copy in {0}; remove it manually "
+ "(it is still encrypted): {1}", new Object[] { directory, e.getMessage() });
}
}
}
/**
* Opens an existing encrypted keyring.
*
* @param keyringFile keyring store path
* @param password unlock password
* @return the open keyring; the caller must close it
* @throws IOException with an actionable message when the file is
* missing, uses the retired plaintext format,
* has unsafe permissions, or the password is wrong
* @throws GeneralSecurityException if cryptographic initialisation fails
*/
/* default */ static KeyringStore open(Path keyringFile, KeyringPassword password)
throws IOException, GeneralSecurityException {
if (!Files.isRegularFile(keyringFile)) {
throw new IOException("Keyring file not found: " + keyringFile);
}
rejectLegacy(keyringFile);
try {
return KeyringStore.open(keyringFile, password);
} catch (KeyringException e) {
throw explain(e, keyringFile);
}
}
/**
* Creates a new encrypted keyring. A missing parent directory is created
* owner-only.
*
* @param keyringFile keyring store path; must not exist yet
* @param password password that will protect the keyring
* @return the open, empty keyring; the caller must close it
* @throws IOException with an actionable message when the
* location is unsuitable
* @throws GeneralSecurityException if cryptographic initialisation fails
*/
/* default */ static KeyringStore create(Path keyringFile, KeyringPassword password)
throws IOException, GeneralSecurityException {
try {
return KeyringStore.create(keyringFile, password);
} catch (KeyringException e) {
throw explain(e, keyringFile);
}
}
/**
* Encodes a keyring and its nonce sidecar as a single text value suitable for
* a CI platform secret.
*
* @param keyringFile keyring store path
* @return the bundle text, ending with a line break
* @throws IOException if either file is missing, the store is not an encrypted
* ZeroEcho 1.2 keyring, or reading fails
*/
/* default */ static String exportBundle(Path keyringFile) throws IOException {
Path nonce = sibling(keyringFile, NONCE_SUFFIX);
if (!Files.isRegularFile(keyringFile)) {
throw new IOException("Keyring file not found: " + keyringFile);
}
rejectLegacy(keyringFile);
byte[] image = Files.readAllBytes(keyringFile);
if (!startsWith(image, KEYRING_MAGIC)) {
throw new IOException(keyringFile + " is not an encrypted ZeroEcho keyring");
}
if (!Files.isRegularFile(nonce)) {
throw new IOException("Keyring sidecar " + nonce + " is missing; a keyring is only usable together "
+ "with its .nonce file");
}
Base64.Encoder encoder = Base64.getEncoder();
String keyringLine = KEYRING_PREFIX + encoder.encodeToString(image);
String nonceLine = NONCE_PREFIX + encoder.encodeToString(Files.readAllBytes(nonce));
return BUNDLE_HEADER + '\n' + keyringLine + '\n' + nonceLine + '\n';
}
/**
* Restores a keyring secret bundle into a fresh owner-only temporary
* directory.
*
* @param bundle bundle text produced by {@link #exportBundle(Path)}
* @return the restored keyring; close it to delete the temporary copy
* @throws IOException if the bundle is malformed or the files cannot be written
*/
// The restored keyring is returned open on success, so try-with-resources does
// not fit; the finally block deletes it on every failure path instead.
@SuppressWarnings("PMD.UseTryWithResources")
/* default */ static RestoredKeyring restoreBundle(String bundle) throws IOException {
if (bundle == null || bundle.isBlank()) {
throw new IOException("Keyring secret is empty");
}
String trimmed = bundle.strip();
if (trimmed.startsWith(LEGACY_HEADER)) {
throw legacyFormat("the keyring secret");
}
List<String> lines = trimmed.lines().map(String::strip).filter(line -> !line.isEmpty()).toList();
if (lines.size() != 3 || !BUNDLE_HEADER.equals(lines.get(0)) || !lines.get(1).startsWith(KEYRING_PREFIX)
|| !lines.get(2).startsWith(NONCE_PREFIX)) {
throw new IOException("Keyring secret is not a MethodAtlas keyring bundle; create it with "
+ ExportKeyringSecretCommand.FLAG_EXPORT_KEYRING_SECRET + " <keyring-file>");
}
byte[] image = decode(lines.get(1).substring(KEYRING_PREFIX.length()));
byte[] nonce = decode(lines.get(2).substring(NONCE_PREFIX.length()));
if (image.length > MAX_IMAGE_BYTES || !startsWith(image, KEYRING_MAGIC)) {
throw new IOException("Keyring secret does not contain an encrypted ZeroEcho keyring");
}
boolean posix = Files.getFileStore(Path.of(System.getProperty("java.io.tmpdir")))
.supportsFileAttributeView(PosixFileAttributeView.class);
Path directory = posix
? Files.createTempDirectory(TEMP_PREFIX,
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString(OWNER_ONLY_DIR)))
: Files.createTempDirectory(TEMP_PREFIX);
RestoredKeyring restored = new RestoredKeyring(directory, directory.resolve(RESTORED_NAME));
boolean complete = false;
try {
if (!posix) {
restrictAclToOwner(directory, true);
}
writeOwnerOnly(restored.keyring(), image, posix);
writeOwnerOnly(sibling(restored.keyring(), NONCE_SUFFIX), nonce, posix);
complete = true;
return restored;
} finally {
if (!complete) {
restored.close();
}
}
}
// -------------------------------------------------------------------------
// Diagnostics
// -------------------------------------------------------------------------
/**
* Fails fast with a migration hint when {@code file} uses the plaintext
* keyring format of ZeroEcho 1.1 (MethodAtlas 6.1 and earlier).
*
* @param file candidate keyring file
* @throws IOException if the file is a legacy plaintext keyring or unreadable
*/
private static void rejectLegacy(Path file) throws IOException {
byte[] head = new byte[LEGACY_HEADER.length()];
int read;
try (InputStream in = Files.newInputStream(file)) {
read = in.readNBytes(head, 0, head.length);
}
if (read == head.length && LEGACY_HEADER.equals(new String(head, StandardCharsets.US_ASCII))) {
throw legacyFormat(file.toString());
}
}
private static IOException legacyFormat(String what) {
return new IOException(what + " uses the plaintext keyring format of MethodAtlas 6.1 and earlier, which "
+ "ZeroEcho 1.2 no longer reads. Generate a new encrypted keyring with "
+ GenSigningKeyCommand.FLAG_GEN_SIGNING_KEY + ", publish its new public key to your auditors, "
+ "and destroy the old plaintext keyring.");
}
/**
* Translates ZeroEcho's redacted keyring error codes into actionable messages.
*
* @param e ZeroEcho failure
* @param keyringFile keyring the operation targeted
* @return an {@link IOException} carrying {@code e} as its cause
*/
/* default */ static IOException explain(KeyringException e, Path keyringFile) {
Path dir = keyringFile.toAbsolutePath().normalize().getParent();
String message = switch (e.code()) {
case KEYRING_UNLOCK_FAILED -> "Wrong keyring password for " + keyringFile;
case KEYRING_FILESYSTEM_UNSUPPORTED -> "Keyring location " + keyringFile + " is not private: the "
+ "directory " + dir + " and the keyring files must be accessible only by you, and must not be "
+ "symbolic or hard links. On Linux/macOS run 'chmod 700 " + dir + "' and 'chmod 600' on the "
+ "keyring and its .nonce file, or keep keyrings in a dedicated directory such as "
+ "~/.methodatlas/keys/.";
case KEYRING_ALREADY_OPEN -> "Keyring " + keyringFile + " is in use by another process";
case KEYRING_FORMAT_INVALID -> "Keyring " + keyringFile + " is corrupted or does not match its .nonce "
+ "file; restore both files together from the same backup";
case KEYRING_IO_FAILED -> "Keyring " + keyringFile + " cannot be accessed; it must not exist yet when "
+ "a new keyring is created, and its directory must exist when an existing keyring is opened";
default -> "Keyring operation failed on " + keyringFile + ": "
+ e.code().name().toLowerCase(Locale.ROOT).replace('_', ' ');
};
return new IOException(message, e);
}
// -------------------------------------------------------------------------
// Owner-only file creation
// -------------------------------------------------------------------------
private static void writeOwnerOnly(Path file, byte[] content, boolean posix) throws IOException {
if (posix) {
Files.createFile(file,
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString(OWNER_ONLY_FILE)));
} else {
Files.createFile(file);
restrictAclToOwner(file, false);
}
Files.write(file, content);
}
/**
* Replaces the ACL of {@code path} with a single owner ALLOW entry, the same
* protection ZeroEcho verifies on Windows. On a directory the entry is
* inheritable so files created inside it are owner-only too.
*/
private static void restrictAclToOwner(Path path, boolean directory) throws IOException {
AclFileAttributeView view = Files.getFileAttributeView(path, AclFileAttributeView.class,
LinkOption.NOFOLLOW_LINKS);
if (view == null) {
throw new IOException("File system of " + path + " supports neither POSIX permissions nor ACLs");
}
AclEntry.Builder builder = AclEntry.newBuilder()
.setType(AclEntryType.ALLOW)
.setPrincipal(view.getOwner())
.setPermissions(EnumSet.allOf(AclEntryPermission.class));
if (directory) {
builder.setFlags(EnumSet.of(AclEntryFlag.DIRECTORY_INHERIT, AclEntryFlag.FILE_INHERIT));
}
view.setAcl(List.of(builder.build()));
}
// -------------------------------------------------------------------------
// Small utilities
// -------------------------------------------------------------------------
private static Path sibling(Path keyringFile, String suffix) {
return keyringFile.resolveSibling(keyringFile.getFileName() + suffix);
}
/**
* Returns the nonce-reservation sidecar that must accompany a keyring.
*
* @param keyringFile keyring store path
* @return the {@code .nonce} sidecar path
*/
/* default */ static Path nonceFile(Path keyringFile) {
return sibling(keyringFile, NONCE_SUFFIX);
}
private static byte[] decode(String base64) throws IOException {
try {
return Base64.getDecoder().decode(base64);
} catch (IllegalArgumentException e) {
throw new IOException("Keyring secret contains invalid Base64 data", e);
}
}
private static boolean startsWith(byte[] data, byte[] prefix) {
return data.length >= prefix.length && Arrays.equals(data, 0, prefix.length, prefix, 0, prefix.length);
}
}