Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,9 @@ test/java/sqlite: generate/fixtures verify/java-migrations
$(java_mvn) -f java/pom.xml verify -Driver.test.database=sqlite -DexcludedGroups=postgres

.PHONY: test/java/tools
test/java/tools: ## Test Java maintenance tools and Maven warning enforcement
test/java/tools: ## Test Java maintenance tools, credential handoff, and Maven warning enforcement
bash java/bin/check-maven-warnings_test.sh
python3 -m unittest discover -s java/bin -p '*_test.py'
go test ./java/bin/check-packages/*.go
go test ./java/bin/sync-migrations/*.go

Expand Down
1 change: 1 addition & 0 deletions java/.mvn/maven.config
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-Daether.remoteRepositoryFilter.prefixes.resolvePrefixFiles.central=false
212 changes: 212 additions & 0 deletions java/bin/release-credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""Keep release credentials in a private local handoff, outside the checkout."""

import argparse
import base64
import getpass
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import warnings


FILES = {"credentials.json", "private-key.asc"}
PREFIX = "river-java-release-credentials-"
ROOT = Path(__file__).resolve().parents[2]


def check_permissions(path, directory=False):
info = path.lstat()
expected_type = stat.S_ISDIR if directory else stat.S_ISREG
if not expected_type(info.st_mode) or info.st_uid != os.getuid():
raise ValueError(f"Expected an owned {'directory' if directory else 'regular file'}: {path}")
expected_mode = 0o700 if directory else 0o600
if stat.S_IMODE(info.st_mode) != expected_mode:
raise ValueError(f"Expected permissions {expected_mode:o}: {path}")


def cleanup(directory):
load(directory)
if {path.name for path in directory.iterdir()} != FILES:
raise ValueError("Handoff contains unexpected files; refusing to remove it.")
for name in sorted(FILES):
(directory / name).unlink()
directory.rmdir()
print("Removed the temporary credential handoff.")


def load(directory):
if not directory.name.startswith(PREFIX):
raise ValueError("Expected a directory created by the prepare command.")
check_permissions(directory, directory=True)
if directory.resolve().is_relative_to(ROOT):
raise ValueError("Credential handoffs must be outside the checkout.")
for name in FILES:
check_permissions(directory / name)
credentials = json.loads((directory / "credentials.json").read_text())
fields = ("CENTRAL_USERNAME", "CENTRAL_PASSWORD", "MAVEN_GPG_PASSPHRASE", "MAVEN_GPG_KEY_FINGERPRINT")
if credentials.get("schema_version") != 1 or any(not isinstance(credentials.get(name), str) for name in fields):
raise ValueError("Unrecognized credential handoff format.")
if not credentials["CENTRAL_USERNAME"] or not credentials["CENTRAL_PASSWORD"]:
raise ValueError("Both Central publishing-token fields are required.")
return credentials


def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="action", required=True)
prepare_parser = commands.add_parser("prepare", help="Prompt locally and create a private handoff")
source = prepare_parser.add_mutually_exclusive_group(required=True)
source.add_argument("--key-file", type=Path, help="Existing ASCII-armored private-key export")
source.add_argument("--gpg-homedir", type=Path, help="Export once from an existing GnuPG home")
prepare_parser.add_argument("--fingerprint", default="", help="Full primary-key fingerprint; required with --gpg-homedir")
for action, help_text in (
("cleanup", "Remove the temporary handoff after release or abandonment"),
("run", "Run a command with credentials in its environment and redacted output"),
("update-token", "Replace only the Central token using hidden prompts"),
):
command = commands.add_parser(action, help=help_text)
command.add_argument("directory", type=Path)
if action == "run":
command.add_argument("command", nargs=argparse.REMAINDER)
args = parser.parse_args()
os.umask(0o077)
warnings.simplefilter("error", getpass.GetPassWarning)
if args.action == "prepare":
if args.fingerprint and not re.fullmatch(r"[0-9a-fA-F]{40}|[0-9a-fA-F]{64}", args.fingerprint):
parser.error("--fingerprint must be a full hexadecimal fingerprint without spaces")
if args.gpg_homedir and not args.fingerprint:
parser.error("--gpg-homedir requires --fingerprint")
prepare(args)
elif args.action == "cleanup":
cleanup(args.directory)
elif args.action == "run":
command = args.command[1:] if args.command[:1] == ["--"] else args.command
if not command:
parser.error("run requires a command after --")
return run(args.directory, command)
else:
update_token(args.directory)
return 0


def prepare(args):
if Path(tempfile.gettempdir()).resolve().is_relative_to(ROOT):
raise ValueError("TMPDIR must be outside the checkout.")
key = args.key_file.expanduser().read_text() if args.key_file else None
if key is not None and "-----BEGIN PGP PRIVATE KEY BLOCK-----" not in key:
raise ValueError("--key-file must contain an ASCII-armored OpenPGP private key.")
credentials = {
"schema_version": 1,
"MAVEN_GPG_KEY_FINGERPRINT": args.fingerprint.upper(),
"MAVEN_GPG_PASSPHRASE": getpass.getpass("OpenPGP key passphrase (empty if unencrypted): "),
}
credentials.update(prompt_token())
directory = Path(tempfile.mkdtemp(prefix=PREFIX))
try:
private_key = directory / "private-key.asc"
if key is not None:
private_key.write_text(key)
else:
result = subprocess.run([
"gpg", "--homedir", str(args.gpg_homedir.expanduser()),
"--batch", "--pinentry-mode", "loopback", "--passphrase-fd", "0",
"--armor", "--output", str(private_key),
"--export-secret-keys", args.fingerprint,
], input=credentials["MAVEN_GPG_PASSPHRASE"] + "\n", text=True, capture_output=True)
if result.returncode:
raise ValueError("GnuPG export failed: " + redactor(credentials)(result.stderr).strip())
if not private_key.is_file() or "-----BEGIN PGP PRIVATE KEY BLOCK-----" not in private_key.read_text():
raise ValueError("GnuPG did not export an OpenPGP private key.")
private_key.chmod(0o600)
write_credentials(directory, credentials)
except BaseException:
shutil.rmtree(directory)
raise
# stdout contains only the path, so callers may capture it in a shell variable.
print(directory)


def prompt_token():
credentials = {
"CENTRAL_USERNAME": getpass.getpass("Central publishing-token username: ").strip(),
"CENTRAL_PASSWORD": getpass.getpass("Central publishing-token password: ").strip(),
}
if not all(credentials.values()):
raise ValueError("Both Central publishing-token fields are required.")
return credentials


def redactor(credentials, key=""):
values = [credentials.get(name, "") for name in ("CENTRAL_USERNAME", "CENTRAL_PASSWORD", "MAVEN_GPG_PASSPHRASE")]
values.extend(key.splitlines())
values.append(key)
if credentials.get("CENTRAL_USERNAME") and credentials.get("CENTRAL_PASSWORD"):
values.append(base64.b64encode((credentials["CENTRAL_USERNAME"] + ":" + credentials["CENTRAL_PASSWORD"]).encode()).decode())
values.extend(json.dumps(value)[1:-1] for value in list(values))
pattern = re.compile("|".join(re.escape(value) for value in sorted(set(values), key=len, reverse=True) if value))
return lambda text: pattern.sub("[REDACTED]", text) if pattern.pattern else text


def run(directory, command):
credentials = load(directory)
key = (directory / "private-key.asc").read_text()
environment = os.environ.copy()
for name in ("CENTRAL_USERNAME", "CENTRAL_PASSWORD", "MAVEN_GPG_PASSPHRASE", "MAVEN_GPG_KEY_FINGERPRINT"):
environment[name] = credentials[name]
environment["MAVEN_GPG_KEY"] = key
if not credentials["MAVEN_GPG_KEY_FINGERPRINT"]:
environment.pop("MAVEN_GPG_KEY_FINGERPRINT", None)
redact = redactor(credentials, key)
with subprocess.Popen(command, env=environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace") as process:
try:
for line in process.stdout:
print(redact(line), end="", flush=True)
code = process.wait()
except BaseException:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
raise
return code if code >= 0 else 128 - code


def update_token(directory):
credentials = load(directory)
credentials.update(prompt_token())
write_credentials(directory, credentials)
print("Updated the Central token; the signing key and passphrase are unchanged.")


def write_credentials(directory, credentials):
temporary = None
try:
with tempfile.NamedTemporaryFile(mode="w", dir=directory, delete=False) as output:
temporary = Path(output.name)
json.dump(credentials, output)
output.write("\n")
temporary.chmod(0o600)
temporary.replace(directory / "credentials.json")
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)


if __name__ == "__main__":
try:
sys.exit(main())
except (EOFError, getpass.GetPassWarning, OSError, ValueError) as error:
print(f"Release credentials: {error}", file=sys.stderr)
sys.exit(1)
except KeyboardInterrupt:
print("Cancelled.", file=sys.stderr)
sys.exit(130)
171 changes: 171 additions & 0 deletions java/bin/release_credentials_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import argparse
import base64
import contextlib
import importlib.util
import io
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch


SPEC = importlib.util.spec_from_file_location("release_credentials", Path(__file__).with_name("release-credentials.py"))
release = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(release)
KEY = "-----BEGIN PGP PRIVATE KEY BLOCK-----\nTEST-PRIVATE-MATERIAL\n-----END PGP PRIVATE KEY BLOCK-----\n"


class ReleaseCredentialsTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name).resolve()
self.key_file = self.root / "original.asc"
self.key_file.write_text(KEY)
self.umask = os.umask(0o077)
self.addCleanup(os.umask, self.umask)

def prepare(self, fingerprint=""):
args = argparse.Namespace(key_file=self.key_file, gpg_homedir=None, fingerprint=fingerprint)
output = io.StringIO()
with patch.object(release.tempfile, "tempdir", str(self.root)), patch.object(release.getpass, "getpass", side_effect=["key-passphrase", "token-user", "token-password"]), contextlib.redirect_stdout(output):
release.prepare(args)
return Path(output.getvalue().strip())

def test_cleanup_preserves_original_key_and_refuses_unexpected_files(self):
directory = self.prepare()
extra = directory / "unrelated"
extra.write_text("keep")
with self.assertRaisesRegex(ValueError, "unexpected files"):
release.cleanup(directory)
self.assertTrue((directory / "credentials.json").exists())
extra.unlink()
with contextlib.redirect_stdout(io.StringIO()):
release.cleanup(directory)
self.assertFalse(directory.exists())
self.assertEqual(self.key_file.read_text(), KEY)

def test_cli_runs_command_after_separator(self):
directory = self.prepare()
result = subprocess.run([
sys.executable, str(Path(__file__).with_name("release-credentials.py")),
"run", str(directory), "--", sys.executable, "-c",
"import os; print(os.environ['CENTRAL_PASSWORD']); print('finished')",
], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertNotIn("token-password", result.stdout + result.stderr)
self.assertIn("[REDACTED]", result.stdout)
self.assertIn("finished", result.stdout)

def test_failed_export_removes_partial_handoff(self):
args = argparse.Namespace(key_file=None, gpg_homedir=self.root, fingerprint="A" * 40)
before = set(self.root.iterdir())
failure = subprocess.CompletedProcess([], 1, stdout="", stderr="bad key-passphrase")
with patch.object(release.tempfile, "tempdir", str(self.root)), patch.object(release.getpass, "getpass", side_effect=["key-passphrase", "token-user", "token-password"]), patch.object(release.subprocess, "run", return_value=failure):
with self.assertRaises(ValueError) as error:
release.prepare(args)
self.assertNotIn("key-passphrase", str(error.exception))
self.assertEqual(set(self.root.iterdir()), before)

def test_gpg_export_passes_passphrase_over_stdin(self):
args = argparse.Namespace(key_file=None, gpg_homedir=self.root, fingerprint="A" * 40)
def export(command, **kwargs):
self.assertNotIn("key-passphrase", command)
self.assertEqual(kwargs["input"], "key-passphrase\n")
self.assertIn("--passphrase-fd", command)
Path(command[command.index("--output") + 1]).write_text(KEY)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
output = io.StringIO()
with patch.object(release.tempfile, "tempdir", str(self.root)), patch.object(release.getpass, "getpass", side_effect=["key-passphrase", "token-user", "token-password"]), patch.object(release.subprocess, "run", side_effect=export), contextlib.redirect_stdout(output):
release.prepare(args)
directory = Path(output.getvalue().strip())
self.assertEqual(release.load(directory)["MAVEN_GPG_KEY_FINGERPRINT"], "A" * 40)

def test_handoff_permissions_and_no_gpg_for_exported_key(self):
with patch.object(release.subprocess, "run", side_effect=AssertionError("GnuPG must not run")):
directory = self.prepare()
self.assertEqual(directory.stat().st_mode & 0o777, 0o700)
for name in release.FILES:
self.assertEqual((directory / name).stat().st_mode & 0o777, 0o600)
self.assertEqual((directory / "private-key.asc").read_text(), KEY)
self.assertEqual(release.load(directory)["CENTRAL_USERNAME"], "token-user")

def test_missing_hidden_input_does_not_save_credentials(self):
args = argparse.Namespace(key_file=self.key_file, gpg_homedir=None, fingerprint="")
before = set(self.root.iterdir())
with patch.object(release.getpass, "getpass", side_effect=release.getpass.GetPassWarning("No hidden input")):
with self.assertRaises(release.getpass.GetPassWarning):
release.prepare(args)
self.assertEqual(set(self.root.iterdir()), before)

def test_rejects_handoffs_inside_checkout(self):
directory = self.prepare()
with patch.object(release, "ROOT", self.root):
with self.assertRaisesRegex(ValueError, "outside the checkout"):
release.load(directory)

def test_rejects_loose_permissions_and_symlinks(self):
directory = self.prepare()
credentials = directory / "credentials.json"
credentials.chmod(0o644)
with self.assertRaisesRegex(ValueError, "permissions 600"):
release.load(directory)
credentials.chmod(0o600)
external = self.root / "external.json"
credentials.rename(external)
credentials.symlink_to(external)
with self.assertRaisesRegex(ValueError, "regular file"):
release.load(directory)

def test_rejects_public_key_and_empty_token(self):
self.key_file.write_text("-----BEGIN PGP PUBLIC KEY BLOCK-----")
args = argparse.Namespace(key_file=self.key_file, gpg_homedir=None, fingerprint="")
with self.assertRaisesRegex(ValueError, "private key"):
release.prepare(args)
self.key_file.write_text(KEY)
with patch.object(release.getpass, "getpass", side_effect=["pass", "", "token"]):
with self.assertRaisesRegex(ValueError, "Both Central"):
release.prepare(args)

def test_run_injects_credentials_redacts_output_and_preserves_exit_status(self):
directory = self.prepare()
code = '''import base64, json, os, sys
assert os.environ['MAVEN_GPG_KEY'].startswith('-----BEGIN PGP PRIVATE KEY BLOCK-----')
assert 'MAVEN_GPG_KEY_FINGERPRINT' not in os.environ
assert os.environ['MAVEN_GPG_PASSPHRASE'] == 'key-passphrase'
for name in ('CENTRAL_USERNAME', 'CENTRAL_PASSWORD', 'MAVEN_GPG_KEY', 'MAVEN_GPG_PASSPHRASE'):
print(os.environ[name], flush=True)
print(json.dumps(os.environ['MAVEN_GPG_KEY']), file=sys.stderr, flush=True)
print(base64.b64encode((os.environ['CENTRAL_USERNAME'] + ':' + os.environ['CENTRAL_PASSWORD']).encode()).decode(), flush=True)
print('nonsecret build output', flush=True)
sys.exit(7)
'''
output = io.StringIO()
with patch.dict(os.environ, {"MAVEN_GPG_KEY_FINGERPRINT": "inherited-wrong-key"}), contextlib.redirect_stdout(output):
result = release.run(directory, [sys.executable, "-c", code])
self.assertEqual(result, 7)
text = output.getvalue()
for value in ("token-user", "token-password", "key-passphrase", "TEST-PRIVATE-MATERIAL", base64.b64encode(b"token-user:token-password").decode()):
self.assertNotIn(value, text)
self.assertIn("nonsecret build output", text)
self.assertIn("[REDACTED]", text)

def test_update_token_preserves_signing_credentials(self):
directory = self.prepare()
before = release.load(directory)
with patch.object(release.getpass, "getpass", side_effect=["new-user", "new-password"]), contextlib.redirect_stdout(io.StringIO()):
release.update_token(directory)
after = release.load(directory)
self.assertEqual(after["CENTRAL_USERNAME"], "new-user")
self.assertEqual(after["CENTRAL_PASSWORD"], "new-password")
self.assertEqual(after["MAVEN_GPG_PASSPHRASE"], before["MAVEN_GPG_PASSPHRASE"])
self.assertEqual((directory / "private-key.asc").read_text(), KEY)
self.assertEqual({path.name for path in directory.iterdir()}, release.FILES)


if __name__ == "__main__":
unittest.main()
Loading
Loading