use std::path::{Path, PathBuf};
use ed25519_dalek::SigningKey;
use crate::error::{AppError, Result};
pub(crate) const GGEN_SIGNING_KEY_ENV: &str = "GGEN_SIGNING_KEY";
const KEYS_DIR_REL: &str = ".ggen/keys";
const SIGNING_KEY_FILE: &str = "signing.key";
const VERIFYING_KEY_FILE: &str = "verifying.key";
fn keys_dir(project_root: &Path) -> PathBuf {
project_root.join(KEYS_DIR_REL)
}
fn decode_key_hex(source: &str, raw: &str) -> Result<[u8; 32]> {
let trimmed = raw.trim();
let bytes = hex::decode(trimmed).map_err(|e| {
AppError::fm_key(
1,
format!(
"{source}: not valid hex ({e}). \
Remediation: the key must be exactly 64 hex characters (32 bytes)."
),
)
})?;
bytes.try_into().map_err(|v: Vec<u8>| {
AppError::fm_key(
2,
format!(
"{source}: expected a 32-byte ed25519 key (64 hex chars), got {} bytes. \
Remediation: regenerate or re-copy the key -- it must be exactly 64 hex characters.",
v.len()
),
)
})
}
pub(crate) fn resolve_signing_key(project_root: &Path) -> Result<SigningKey> {
if let Ok(env_hex) = std::env::var(GGEN_SIGNING_KEY_ENV) {
let seed = decode_key_hex(&format!("{GGEN_SIGNING_KEY_ENV} env var"), &env_hex)?;
return Ok(SigningKey::from_bytes(&seed));
}
let signing_path = keys_dir(project_root).join(SIGNING_KEY_FILE);
match std::fs::read_to_string(&signing_path) {
Ok(raw) => match decode_key_hex(&signing_path.display().to_string(), &raw) {
Ok(seed) => Ok(SigningKey::from_bytes(&seed)),
Err(_) if raw.trim().is_empty() => {
let _ = std::fs::remove_file(&signing_path);
Err(AppError::fm_key(
2,
format!(
"signing key `{}` was present but empty/invalid (likely a partial \
write left behind by an earlier interrupted key generation). It has \
been deleted -- re-run to generate a fresh key.",
signing_path.display()
),
))
}
Err(e) => Err(e),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
generate_and_persist_keypair(project_root)
}
Err(e) => Err(AppError::fm_key(
3,
format!("signing key `{}` unreadable: {e}", signing_path.display()),
)),
}
}
pub(crate) fn resolve_verifying_key(project_root: &Path) -> Result<ed25519_dalek::VerifyingKey> {
if let Ok(env_hex) = std::env::var(GGEN_SIGNING_KEY_ENV) {
let seed = decode_key_hex(&format!("{GGEN_SIGNING_KEY_ENV} env var"), &env_hex)?;
return Ok(SigningKey::from_bytes(&seed).verifying_key());
}
let verifying_path = keys_dir(project_root).join(VERIFYING_KEY_FILE);
let raw = std::fs::read_to_string(&verifying_path).map_err(|e| {
AppError::fm_key(
4,
format!(
"verifying key `{}` unreadable: {e}. \
Remediation: run a real (non-dry-run) sync first to generate `.ggen/keys/`, \
or set GGEN_SIGNING_KEY.",
verifying_path.display()
),
)
})?;
let bytes = decode_key_hex(&verifying_path.display().to_string(), &raw)?;
ed25519_dalek::VerifyingKey::from_bytes(&bytes).map_err(|e| {
AppError::fm_key(
5,
format!(
"verifying key `{}` is not a valid ed25519 public key: {e}",
verifying_path.display()
),
)
})
}
fn generate_and_persist_keypair(project_root: &Path) -> Result<SigningKey> {
let mut seed = [0u8; 32];
getrandom::fill(&mut seed)
.map_err(|e| AppError::Config(format!("OS CSPRNG unavailable for ed25519 keygen: {e}")))?;
let signing_key = SigningKey::from_bytes(&seed);
persist_keypair(project_root, signing_key)
}
fn persist_keypair(project_root: &Path, signing_key: SigningKey) -> Result<SigningKey> {
let dir = keys_dir(project_root);
std::fs::create_dir_all(&dir)
.map_err(|e| AppError::fm_key(6, format!("cannot create `{}`: {e}", dir.display())))?;
ensure_keys_gitignore(&dir)?;
let signing_path = dir.join(SIGNING_KEY_FILE);
let verifying_path = dir.join(VERIFYING_KEY_FILE);
let signing_hex = hex::encode(signing_key.to_bytes());
let verifying_hex = hex::encode(signing_key.verifying_key().to_bytes());
match write_new_file(&signing_path, signing_hex.as_bytes()) {
Ok(()) => {
restrict_to_owner(&signing_path)?;
if let Err(e) = write_new_file(&verifying_path, verifying_hex.as_bytes()) {
if e.kind() != std::io::ErrorKind::AlreadyExists {
return Err(AppError::fm_key(
7,
format!("cannot write `{}`: {e}", verifying_path.display()),
));
}
if let Err(mismatch_err) =
confirm_verifying_key_matches(&verifying_path, &signing_key)
{
let _ = std::fs::remove_file(&signing_path);
return Err(mismatch_err);
}
}
Ok(signing_key)
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
let raw = std::fs::read_to_string(&signing_path).map_err(|e| {
AppError::fm_key(
3,
format!("signing key `{}` unreadable: {e}", signing_path.display()),
)
})?;
let seed = decode_key_hex(&signing_path.display().to_string(), &raw)?;
Ok(SigningKey::from_bytes(&seed))
}
Err(e) => Err(AppError::fm_key(
8,
format!("cannot write `{}`: {e}", signing_path.display()),
)),
}
}
fn confirm_verifying_key_matches(verifying_path: &Path, signing_key: &SigningKey) -> Result<()> {
let raw = std::fs::read_to_string(verifying_path).map_err(|e| {
AppError::fm_key(
10,
format!(
"a new signing key was generated, but the pre-existing `{}` could not be read \
back to confirm it matches ({e}). Remediation: inspect `{}` manually -- \
delete it if stale (a matching verifying.key will be written on the next \
resolve) or restore the original signing.key instead of letting a new one be \
generated.",
verifying_path.display(),
verifying_path.display(),
),
)
})?;
let existing_bytes = decode_key_hex(&verifying_path.display().to_string(), &raw)?;
let existing = ed25519_dalek::VerifyingKey::from_bytes(&existing_bytes).map_err(|e| {
AppError::fm_key(
10,
format!(
"a new signing key was generated, but the pre-existing `{}` is not a valid \
ed25519 public key ({e}), so it cannot be confirmed to match. Remediation: \
inspect `{}` manually -- delete it if stale or restore the original \
signing.key instead of letting a new one be generated.",
verifying_path.display(),
verifying_path.display(),
),
)
})?;
if existing != signing_key.verifying_key() {
return Err(AppError::fm_key(
11,
format!(
"a new signing key was generated, but the existing `{}` belongs to a \
DIFFERENT keypair -- its public key does not match the new signing key's \
derived public key. Left unresolved, every future `ggen receipt verify` \
would fail with a generic 'signature invalid' error, with no indication the \
real cause is this out-of-sync keypair. Remediation: either delete the stale \
`{}` if the original signing.key is truly gone (a fresh, matching \
verifying.key will be written automatically on the next resolve), or restore \
the original signing.key that matches this verifying.key instead of letting a \
new one be generated.",
verifying_path.display(),
verifying_path.display(),
),
));
}
Ok(())
}
fn write_new_file(path: &Path, contents: &[u8]) -> std::io::Result<()> {
use std::io::Write as _;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?;
match f.write_all(contents).and_then(|()| f.sync_all()) {
Ok(()) => Ok(()),
Err(e) => {
let _ = std::fs::remove_file(path);
Err(e)
}
}
}
const KEYS_GITIGNORE: &str =
"# Written by ggen: signing.key is this checkout's private ed25519 seed and must\n\
# never be committed (a committed seed signs receipts for anyone who can read the\n\
# repository). verifying.key is ignored too: every checkout owns its own pair, and a\n\
# tracked public half without its private half makes a fresh clone's first sync\n\
# refuse [FM-KEY-010]/[FM-KEY-011]. Publish the public key out of band.\n\
signing.key\n\
verifying.key\n";
fn ensure_keys_gitignore(dir: &Path) -> Result<()> {
let path = dir.join(".gitignore");
match write_new_file(&path, KEYS_GITIGNORE.as_bytes()) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
Err(e) => Err(AppError::fm_key(
12,
format!("cannot write `{}`: {e}", path.display()),
)),
}
}
#[cfg(unix)]
fn restrict_to_owner(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|e| {
AppError::fm_key(
9,
format!("cannot set permissions on `{}`: {e}", path.display()),
)
})
}
#[cfg(not(unix))]
fn restrict_to_owner(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn clear_env() {
std::env::remove_var(GGEN_SIGNING_KEY_ENV);
}
fn git(dir: &Path, args: &[&str]) -> std::process::Output {
std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("git runs")
}
#[test]
fn generated_keypair_is_ignored_by_git() {
let _guard = env_lock();
clear_env();
let dir = tempfile::tempdir().expect("tempdir");
assert!(git(dir.path(), &["init", "-q"]).status.success());
resolve_signing_key(dir.path()).expect("resolve generates a keypair");
let gitignore = dir.path().join(".ggen/keys/.gitignore");
let content = std::fs::read_to_string(&gitignore).expect(".gitignore written");
assert!(content.lines().any(|l| l == "signing.key"), "{content}");
assert!(content.lines().any(|l| l == "verifying.key"), "{content}");
for key in [".ggen/keys/signing.key", ".ggen/keys/verifying.key"] {
assert!(
git(dir.path(), &["check-ignore", "-q", key])
.status
.success(),
"{key} not ignored"
);
}
assert!(git(dir.path(), &["add", "-A"]).status.success());
let staged =
String::from_utf8(git(dir.path(), &["diff", "--cached", "--name-only"]).stdout)
.expect("utf8");
assert!(!staged.contains("signing.key"), "staged: {staged}");
assert!(!staged.contains("verifying.key"), "staged: {staged}");
assert!(staged.contains(".ggen/keys/.gitignore"), "staged: {staged}");
}
#[test]
fn a_fresh_clone_of_a_committed_project_generates_its_own_keypair() {
let _guard = env_lock();
clear_env();
let origin = tempfile::tempdir().expect("tempdir");
assert!(git(origin.path(), &["init", "-q"]).status.success());
let first = resolve_signing_key(origin.path()).expect("owner generates a keypair");
assert!(git(origin.path(), &["add", "-A"]).status.success());
assert!(git(
origin.path(),
&[
"-c",
"user.email=k@x",
"-c",
"user.name=k",
"commit",
"-q",
"-m",
"project"
]
)
.status
.success());
let clone_scratch = tempfile::tempdir().expect("tempdir");
let clone = clone_scratch.path().join("clone");
let clone_output = std::process::Command::new("git")
.args(["clone", "-q"])
.arg(origin.path())
.arg(&clone)
.output()
.expect("git clone runs");
assert!(clone_output.status.success(), "{clone_output:?}");
assert!(!clone.join(".ggen/keys/verifying.key").exists());
let second = resolve_signing_key(&clone).expect("a fresh clone generates its own keypair");
assert_ne!(first.to_bytes(), second.to_bytes());
resolve_verifying_key(&clone).expect("the clone's verifying key is its own");
}
#[test]
fn an_existing_keys_gitignore_is_never_overwritten() {
let _guard = env_lock();
clear_env();
let dir = tempfile::tempdir().expect("tempdir");
let keys = dir.path().join(".ggen/keys");
std::fs::create_dir_all(&keys).expect("mkdir");
std::fs::write(keys.join(".gitignore"), "owner-rule\n").expect("write owner rule");
resolve_signing_key(dir.path()).expect("resolve generates a keypair");
assert_eq!(
std::fs::read_to_string(keys.join(".gitignore")).expect("read"),
"owner-rule\n"
);
}
#[test]
fn generates_and_persists_a_fresh_keypair_on_first_resolve() {
let _guard = env_lock();
clear_env();
let dir = tempfile::tempdir().expect("tempdir");
let key = resolve_signing_key(dir.path()).expect("resolve");
let signing_path = dir.path().join(".ggen/keys/signing.key");
let verifying_path = dir.path().join(".ggen/keys/verifying.key");
assert!(signing_path.exists());
assert!(verifying_path.exists());
let persisted_hex = std::fs::read_to_string(&signing_path).expect("read");
assert_eq!(persisted_hex.trim(), hex::encode(key.to_bytes()));
let verifying_hex = std::fs::read_to_string(&verifying_path).expect("read");
assert_eq!(
verifying_hex.trim(),
hex::encode(key.verifying_key().to_bytes())
);
}
#[test]
fn never_overwrites_an_existing_signing_key_file() {
let _guard = env_lock();
clear_env();
let dir = tempfile::tempdir().expect("tempdir");
let keys_dir = dir.path().join(".ggen/keys");
std::fs::create_dir_all(&keys_dir).expect("mkdir");
let fixed_seed_hex = "ab".repeat(32);
std::fs::write(keys_dir.join("signing.key"), &fixed_seed_hex).expect("write");
let key = resolve_signing_key(dir.path()).expect("resolve");
assert_eq!(hex::encode(key.to_bytes()), fixed_seed_hex);
let after = std::fs::read_to_string(keys_dir.join("signing.key")).expect("read");
assert_eq!(after, fixed_seed_hex);
}
#[test]
fn env_var_takes_precedence_over_the_file() {
let _guard = env_lock();
let dir = tempfile::tempdir().expect("tempdir");
let keys_dir = dir.path().join(".ggen/keys");
std::fs::create_dir_all(&keys_dir).expect("mkdir");
std::fs::write(keys_dir.join("signing.key"), "cd".repeat(32)).expect("write");
let env_seed_hex = "ef".repeat(32);
std::env::set_var(GGEN_SIGNING_KEY_ENV, &env_seed_hex);
let result = resolve_signing_key(dir.path());
clear_env();
let key = result.expect("resolve");
assert_eq!(hex::encode(key.to_bytes()), env_seed_hex);
}
#[test]
fn malformed_env_var_is_a_hard_error_never_falls_back_to_file() {
let _guard = env_lock();
let dir = tempfile::tempdir().expect("tempdir");
let keys_dir = dir.path().join(".ggen/keys");
std::fs::create_dir_all(&keys_dir).expect("mkdir");
std::fs::write(keys_dir.join("signing.key"), "cd".repeat(32)).expect("write");
std::env::set_var(GGEN_SIGNING_KEY_ENV, "not-hex-and-wrong-length");
let result = resolve_signing_key(dir.path());
clear_env();
assert!(result.is_err(), "malformed env var must be a hard error");
}
#[test]
fn malformed_key_file_is_a_hard_error() {
let _guard = env_lock();
clear_env();
let dir = tempfile::tempdir().expect("tempdir");
let keys_dir = dir.path().join(".ggen/keys");
std::fs::create_dir_all(&keys_dir).expect("mkdir");
std::fs::write(keys_dir.join("signing.key"), "too-short").expect("write");
let result = resolve_signing_key(dir.path());
assert!(result.is_err(), "malformed key file must be a hard error");
}
#[test]
fn poisoned_zero_byte_signing_key_self_heals_on_next_resolve() {
let _guard = env_lock();
clear_env();
let dir = tempfile::tempdir().expect("tempdir");
let keys_dir = dir.path().join(".ggen/keys");
std::fs::create_dir_all(&keys_dir).expect("mkdir");
let signing_path = keys_dir.join("signing.key");
std::fs::write(&signing_path, b"").expect("write empty file");
assert_eq!(
std::fs::metadata(&signing_path).expect("meta").len(),
0,
"precondition: signing.key must be a real 0-byte file on disk"
);
let first = resolve_signing_key(dir.path());
let err_msg = first
.expect_err("0-byte signing.key must fail the call that finds it")
.to_string();
assert!(
err_msg.contains("[FM-KEY-002]"),
"expected [FM-KEY-002], got: {err_msg}"
);
assert!(
err_msg.contains("empty") && err_msg.contains("deleted"),
"error should explain the file was empty/invalid and has been deleted, got: {err_msg}"
);
assert!(
!signing_path.exists(),
"self-heal must actually remove the poisoned 0-byte file from disk"
);
let second =
resolve_signing_key(dir.path()).expect("second resolve must self-heal and succeed");
assert_eq!(second.to_bytes().len(), 32);
let persisted = std::fs::read_to_string(&signing_path).expect("read regenerated key");
assert_eq!(
persisted.trim(),
hex::encode(second.to_bytes()),
"regenerated signing.key on disk must match the key just resolved"
);
assert_eq!(
std::fs::metadata(&signing_path).expect("meta").len(),
64,
"regenerated signing.key must be a full 64-hex-char key, not another 0-byte file"
);
}
#[test]
fn non_empty_malformed_signing_key_is_not_auto_deleted() {
let _guard = env_lock();
clear_env();
let dir = tempfile::tempdir().expect("tempdir");
let keys_dir = dir.path().join(".ggen/keys");
std::fs::create_dir_all(&keys_dir).expect("mkdir");
let signing_path = keys_dir.join("signing.key");
std::fs::write(&signing_path, "too-short").expect("write");
let result = resolve_signing_key(dir.path());
assert!(
result.is_err(),
"malformed key file must still be a hard error"
);
assert!(
signing_path.exists(),
"a non-empty malformed key file must NOT be auto-deleted -- only the \
0-byte/write-failure case self-heals"
);
let after = std::fs::read_to_string(&signing_path).expect("read");
assert_eq!(after, "too-short", "file content must be untouched");
}
#[cfg(unix)]
#[test]
fn write_new_file_leaves_no_file_behind_when_open_fails() {
use std::os::unix::fs::PermissionsExt as _;
let _guard = env_lock();
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("signing.key");
let original_mode = std::fs::metadata(dir.path())
.expect("meta")
.permissions()
.mode();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555))
.expect("make dir read-only");
let result = write_new_file(&target, b"deadbeef");
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(original_mode))
.expect("restore dir permissions");
assert!(
result.is_err(),
"write into a read-only directory must fail"
);
assert!(
!target.exists(),
"no file must be left behind when write_new_file fails"
);
}
#[test]
fn resolve_verifying_key_never_generates_and_errors_when_absent() {
let _guard = env_lock();
clear_env();
let dir = tempfile::tempdir().expect("tempdir");
let result = resolve_verifying_key(dir.path());
assert!(result.is_err());
assert!(
!dir.path().join(".ggen/keys/verifying.key").exists(),
"verify must never generate a key as a side effect"
);
}
#[test]
fn signing_and_verifying_keys_from_the_same_resolve_match() {
let _guard = env_lock();
clear_env();
let dir = tempfile::tempdir().expect("tempdir");
let signing = resolve_signing_key(dir.path()).expect("resolve signing");
let verifying = resolve_verifying_key(dir.path()).expect("resolve verifying");
assert_eq!(verifying, signing.verifying_key());
}
#[test]
fn generate_and_persist_keypair_rejects_a_stale_verifying_key_from_a_different_keypair() {
let _guard = env_lock();
clear_env();
let dir = tempfile::tempdir().expect("tempdir");
let keys_dir = dir.path().join(".ggen/keys");
std::fs::create_dir_all(&keys_dir).expect("mkdir");
let stale_seed = [0xAAu8; 32];
let stale_signing_key = SigningKey::from_bytes(&stale_seed);
let verifying_path = keys_dir.join("verifying.key");
std::fs::write(
&verifying_path,
hex::encode(stale_signing_key.verifying_key().to_bytes()),
)
.expect("write stale verifying.key");
let result = resolve_signing_key(dir.path());
let err_msg = result
.expect_err(
"a stale, non-matching verifying.key must be a hard error, never a silent \
mismatched keypair",
)
.to_string();
assert!(
err_msg.contains("[FM-KEY-011]"),
"expected [FM-KEY-011], got: {err_msg}"
);
assert!(
err_msg.contains("DIFFERENT keypair"),
"error should explain the keypair mismatch, got: {err_msg}"
);
assert!(
!keys_dir.join("signing.key").exists(),
"the mismatched signing.key must be rolled back, not left on disk"
);
let after = std::fs::read_to_string(&verifying_path).expect("read");
assert_eq!(
after.trim(),
hex::encode(stale_signing_key.verifying_key().to_bytes()),
"the pre-existing verifying.key must not be modified"
);
}
#[test]
fn persist_keypair_succeeds_when_existing_verifying_key_matches_the_regenerated_signing_key() {
let _guard = env_lock();
clear_env();
let dir = tempfile::tempdir().expect("tempdir");
let fixed_seed = [0x11u8; 32];
let first = persist_keypair(dir.path(), SigningKey::from_bytes(&fixed_seed))
.expect("first persist");
assert_eq!(first.to_bytes(), fixed_seed);
std::fs::remove_file(dir.path().join(".ggen/keys/signing.key"))
.expect("remove signing.key");
let second = persist_keypair(dir.path(), SigningKey::from_bytes(&fixed_seed)).expect(
"second persist must succeed: the existing verifying.key matches the regenerated \
signing key",
);
assert_eq!(second.to_bytes(), fixed_seed);
let signing_path = dir.path().join(".ggen/keys/signing.key");
let verifying_path = dir.path().join(".ggen/keys/verifying.key");
assert!(signing_path.exists());
let persisted_verifying = std::fs::read_to_string(&verifying_path).expect("read");
assert_eq!(
persisted_verifying.trim(),
hex::encode(second.verifying_key().to_bytes())
);
}
#[cfg(unix)]
#[test]
fn signing_key_file_is_written_with_owner_only_permissions() {
use std::os::unix::fs::PermissionsExt as _;
let _guard = env_lock();
clear_env();
let dir = tempfile::tempdir().expect("tempdir");
resolve_signing_key(dir.path()).expect("resolve");
let meta = std::fs::metadata(dir.path().join(".ggen/keys/signing.key")).expect("meta");
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
}
}