KeyringPasswordSource.java
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas.evidence;
import java.io.Console;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFileAttributeView;
import java.nio.file.attribute.PosixFilePermission;
import java.util.Arrays;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.function.UnaryOperator;
import java.util.logging.Level;
import java.util.logging.Logger;
import zeroecho.core.storage.KeyringPassword;
/**
* Resolves the password that unlocks an encrypted ZeroEcho keyring.
*
* <p>
* ZeroEcho 1.2 keyrings are encrypted (PBKDF2-HMAC-SHA-256 + AES-256-GCM), so
* every keyring operation needs a password. A password is never accepted as a
* command-line value, because argument vectors are visible to other local users
* and end up in shell history and CI logs. The sources are consulted in this
* order, and the first one configured wins:
* </p>
* <ol>
* <li>a password file named on the command line (first line, trailing line
* break removed) — suits container and CI secret files;</li>
* <li>an environment variable named on the command line — it must be set;</li>
* <li>the {@value #DEFAULT_ENV} environment variable, when set;</li>
* <li>an interactive, non-echoing prompt when an interactive terminal is attached.</li>
* </ol>
*
* <p>
* When creating a new keyring the password must be at least
* {@value #MIN_NEW_PASSWORD_LENGTH} characters long, and an interactive prompt
* asks for it twice. Instances are immutable and safe to reuse; every
* {@link #resolve(boolean)} call returns a fresh {@link KeyringPassword} that
* the caller must close.
* </p>
*
* @since 6.2.0
*/
final class KeyringPasswordSource {
private static final Logger LOG = Logger.getLogger(KeyringPasswordSource.class.getName());
/** Environment variable consulted when no explicit source is configured. */
/* default */ static final String DEFAULT_ENV = "METHODATLAS_KEYRING_PASSWORD";
/** Minimum length enforced for the password of a newly created keyring. */
/* default */ static final int MIN_NEW_PASSWORD_LENGTH = 12;
private final Path passwordFile;
private final String passwordEnv;
private final UnaryOperator<String> environment;
/** Non-echoing prompt: maps prompt text to the entered characters (empty or {@code null} = no input). */
private final Function<String, char[]> prompt;
/** Signals that no password source is configured and no console is attached. */
/* default */ static final class MissingPasswordException extends IOException {
private static final long serialVersionUID = 1L;
/** Creates the exception with a fixed message. */
/* default */ MissingPasswordException() {
super("No keyring password available");
}
}
/**
* Creates a source backed by the process environment and the system console.
*
* @param passwordFile file holding the password, or {@code null}
* @param passwordEnv name of an environment variable holding the password,
* or {@code null}
*/
/* default */ KeyringPasswordSource(Path passwordFile, String passwordEnv) {
this(passwordFile, passwordEnv, System::getenv, KeyringPasswordSource::readFromConsole);
}
/**
* Creates a source with injectable environment and prompt, for tests.
*
* @param passwordFile file holding the password, or {@code null}
* @param passwordEnv name of an environment variable holding the password,
* or {@code null}
* @param environment environment lookup; must not be {@code null}
* @param prompt interactive prompt, or {@code null} when none is available;
* a prompt returning {@code null} or an empty array means no input
*/
/* default */ KeyringPasswordSource(Path passwordFile, String passwordEnv, UnaryOperator<String> environment,
Function<String, char[]> prompt) {
this.passwordFile = passwordFile;
this.passwordEnv = passwordEnv;
this.environment = environment;
this.prompt = prompt;
}
/**
* Resolves the keyring password from the first configured source.
*
* @param newKeyring {@code true} when the password protects a keyring that
* is about to be created (length check and confirmation)
* @return the password; the caller must close it
* @throws MissingPasswordException if no source is configured and no
* interactive console is available
* @throws IOException if a configured source is empty or unreadable, the confirmation does not match, or
* a new password is too short
*/
/* default */ KeyringPassword resolve(boolean newKeyring) throws IOException {
char[] chars = resolveChars(newKeyring);
try {
if (newKeyring && chars.length < MIN_NEW_PASSWORD_LENGTH) {
throw new IOException("The keyring password must be at least " + MIN_NEW_PASSWORD_LENGTH
+ " characters long");
}
return new KeyringPassword(chars);
} finally {
Arrays.fill(chars, '\0');
}
}
/**
* Returns a human-readable list of the accepted password sources, used in
* error messages.
*
* @param fileFlag flag that names a password file
* @param envFlag flag that names a password environment variable
* @return guidance text; never {@code null}
*/
/* default */ static String howToSupply(String fileFlag, String envFlag) {
return "supply the keyring password with " + fileFlag + " <file>, " + envFlag + " <VAR>, the "
+ DEFAULT_ENV + " environment variable, or run interactively in a terminal";
}
private char[] resolveChars(boolean newKeyring) throws IOException {
if (passwordFile != null) {
return fromFile();
}
if (passwordEnv != null) {
String value = environment.apply(passwordEnv);
if (value == null || value.isEmpty()) {
throw new IOException("Keyring password environment variable " + passwordEnv + " is unset or empty");
}
return value.toCharArray();
}
String fallback = environment.apply(DEFAULT_ENV);
if (fallback != null && !fallback.isEmpty()) {
return fallback.toCharArray();
}
return fromPrompt(newKeyring);
}
private char[] fromFile() throws IOException {
warnIfShared(passwordFile);
String content = Files.readString(passwordFile, StandardCharsets.UTF_8);
int end = content.indexOf('\n');
String line = end < 0 ? content : content.substring(0, end);
if (line.endsWith("\r")) {
line = line.substring(0, line.length() - 1);
}
if (line.isEmpty()) {
throw new IOException("Keyring password file " + passwordFile + " is empty");
}
return line.toCharArray();
}
/**
* Warns when a password file is readable by other users on POSIX systems.
*
* @param file password file
*/
private static void warnIfShared(Path file) {
try {
PosixFileAttributeView view = Files.getFileAttributeView(file, PosixFileAttributeView.class);
if (view == null) {
return;
}
Set<PosixFilePermission> perms = view.readAttributes().permissions();
if ((perms.contains(PosixFilePermission.GROUP_READ) || perms.contains(PosixFilePermission.OTHERS_READ))
&& LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Keyring password file {0} is readable by other users; restrict it with "
+ "''chmod 600 {0}''", file);
}
} catch (IOException e) {
if (LOG.isLoggable(Level.FINE)) {
LOG.log(Level.FINE, "Cannot inspect permissions of " + file, e);
}
}
}
private char[] fromPrompt(boolean newKeyring) throws IOException {
char[] first = prompt == null ? new char[0]
: prompt.apply(newKeyring ? "New keyring password: " : "Keyring password: ");
if (first == null || first.length == 0) {
throw new MissingPasswordException();
}
if (newKeyring) {
char[] confirm = prompt.apply("Repeat keyring password: ");
boolean matches = confirm != null && Arrays.equals(first, confirm);
if (confirm != null) {
Arrays.fill(confirm, '\0');
}
if (!matches) {
Arrays.fill(first, '\0');
throw new IOException("The keyring passwords do not match");
}
}
return first;
}
private static char[] readFromConsole(String text) {
// Since JDK 22 System.console() returns a Console even without a terminal
// (redirected stdin, CI); only an interactive terminal may be prompted.
char[] entered = Optional.ofNullable(System.console())
.filter(Console::isTerminal)
.map(console -> console.readPassword("%s", text))
.orElse(null);
return entered == null ? new char[0] : entered;
}
}