use anyhow::{bail, Context, Result};
use base64::Engine;
use colored::*;
use ed25519_dalek::pkcs8::{spki::der::pem::LineEnding, EncodePrivateKey};
use ed25519_dalek::SigningKey;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use uuid::Uuid;
#[derive(Debug, Serialize)]
struct CreatePublicKeyRequest<'a> {
algorithm: &'a str,
#[serde(rename = "publicKeyB64")]
public_key_b64: &'a str,
label: &'a str,
}
#[derive(Debug, Deserialize)]
struct PublicKeyResponse {
id: String,
algorithm: String,
#[serde(rename = "publicKeyB64")]
public_key_b64: String,
label: String,
#[serde(rename = "createdAt")]
created_at: String,
#[serde(rename = "revokedAt", default)]
revoked_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ListKeysResponse {
keys: Vec<PublicKeyResponse>,
}
pub async fn list_keys(registry: &str, token: &str) -> Result<()> {
let client = reqwest::Client::new();
let url = format!("{}/api/v1/users/me/public-keys", registry.trim_end_matches('/'));
let resp = client
.get(&url)
.bearer_auth(token)
.send()
.await
.context("sending list-keys request")?;
if !resp.status().is_success() {
bail!("registry returned {}: {}", resp.status(), resp.text().await.unwrap_or_default());
}
let body: ListKeysResponse = resp.json().await.context("decoding list-keys response")?;
if body.keys.is_empty() {
println!("{}", "No public keys registered on this account.".yellow());
println!(
"Add one with: {}",
"mockforge-plugin key add --label <name> --file <path>".cyan()
);
return Ok(());
}
println!("{}", "Registered public keys:".bold());
for key in body.keys {
let fingerprint = fingerprint_short(&key.public_key_b64);
println!(" {} {}", "•".cyan(), key.label.bold());
println!(" id: {}", key.id);
println!(" algorithm: {}", key.algorithm);
println!(" fingerprint: {}", fingerprint);
println!(" created: {}", key.created_at);
if let Some(rev) = key.revoked_at {
println!(" revoked: {} {}", rev, "(inactive)".red());
}
}
Ok(())
}
pub async fn add_key(
registry: &str,
token: &str,
label: &str,
file: Option<&Path>,
public_key_b64: Option<&str>,
) -> Result<()> {
if label.trim().is_empty() {
bail!("--label must not be empty");
}
let key_b64 = match (file, public_key_b64) {
(Some(p), None) => read_key_file(p)?,
(None, Some(b)) => b.trim().to_string(),
(None, None) => {
bail!("pass either --file <path> or --public-key <base64>");
}
(Some(_), Some(_)) => {
bail!("pass only one of --file / --public-key, not both");
}
};
let decoded = base64::engine::general_purpose::STANDARD
.decode(key_b64.trim())
.or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(key_b64.trim()))
.context("public key is not valid base64")?;
if decoded.len() != 32 {
bail!("ed25519 public key must be 32 bytes; got {}", decoded.len());
}
let body = CreatePublicKeyRequest {
algorithm: "ed25519",
public_key_b64: key_b64.trim(),
label,
};
let client = reqwest::Client::new();
let url = format!("{}/api/v1/users/me/public-keys", registry.trim_end_matches('/'));
let resp = client
.post(&url)
.bearer_auth(token)
.json(&body)
.send()
.await
.context("sending add-key request")?;
if !resp.status().is_success() {
bail!("registry returned {}: {}", resp.status(), resp.text().await.unwrap_or_default());
}
let created: PublicKeyResponse = resp.json().await.context("decoding add-key response")?;
println!(
"{} Registered key {} ({})",
"✅".green().bold(),
created.id.cyan(),
created.label
);
println!(" fingerprint: {}", fingerprint_short(&created.public_key_b64));
Ok(())
}
pub async fn revoke_key(registry: &str, token: &str, id: &str) -> Result<()> {
let uuid = Uuid::parse_str(id).context("key id is not a valid UUID")?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/users/me/public-keys/{}", registry.trim_end_matches('/'), uuid);
let resp = client
.delete(&url)
.bearer_auth(token)
.send()
.await
.context("sending revoke request")?;
if !resp.status().is_success() {
bail!("registry returned {}: {}", resp.status(), resp.text().await.unwrap_or_default());
}
println!("{} Revoked key {}", "✅".green().bold(), uuid);
Ok(())
}
pub async fn generate_key(out: &Path, force: bool) -> Result<()> {
if out.exists() && !force {
bail!(
"{} already exists — refusing to overwrite. Pass --force to replace it.",
out.display()
);
}
#[allow(deprecated)]
let mut thread_rng = rand::thread_rng();
use rand::RngCore;
let mut secret = [0u8; 32];
thread_rng.fill_bytes(&mut secret);
let signing = SigningKey::from_bytes(&secret);
let public_b64 =
base64::engine::general_purpose::STANDARD.encode(signing.verifying_key().to_bytes());
let pem = signing
.to_pkcs8_pem(LineEnding::LF)
.context("encoding private key as PKCS#8 PEM")?;
if let Some(parent) = out.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating parent directory {}", parent.display()))?;
}
}
write_private_key_securely(out, pem.as_bytes())
.with_context(|| format!("writing key to {}", out.display()))?;
println!("{} Generated Ed25519 keypair.", "✅".green().bold());
println!(" private key: {}", out.display());
println!(" public key: {}", public_b64.bold());
println!();
println!(
"Register it on the registry with:\n {} {}",
"mockforge-plugin key add --label <name> --public-key".cyan(),
public_b64.cyan()
);
Ok(())
}
fn write_private_key_securely(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(bytes)?;
file.sync_all()?;
Ok(())
}
#[cfg(windows)]
{
std::fs::write(path, bytes)?;
tighten_acl_windows(path);
Ok(())
}
#[cfg(not(any(unix, windows)))]
{
std::fs::write(path, bytes)
}
}
#[cfg(windows)]
fn tighten_acl_windows(path: &Path) {
match resolve_windows_user() {
Some(user) => run_icacls(path, &user),
None => {
eprintln!(
"{}",
"warning: could not determine current Windows user; ACL \
tightening skipped. Review the key file's permissions manually."
.yellow()
);
}
}
}
#[cfg_attr(not(windows), allow(dead_code))]
fn resolve_windows_user() -> Option<String> {
let username = std::env::var("USERNAME").ok().filter(|s| !s.trim().is_empty());
match username {
Some(u) => {
if let Ok(domain) = std::env::var("USERDOMAIN") {
if !domain.trim().is_empty() {
return Some(format!("{}\\{}", domain, u));
}
}
Some(u)
}
None => None,
}
}
#[cfg_attr(not(windows), allow(dead_code))]
fn run_icacls(path: &Path, user: &str) {
use std::process::Command;
let status = Command::new("icacls")
.arg(path)
.arg("/inheritance:r")
.arg("/grant:r")
.arg(format!("{}:F", user))
.status();
match status {
Ok(s) if s.success() => {}
Ok(s) => eprintln!(
"{} icacls exited with {}. The key file exists but its ACL \
may still inherit permissions from the parent directory — \
review manually.",
"warning:".yellow(),
s
),
Err(e) => eprintln!(
"{} could not run icacls ({}). Tighten the key file's ACL \
manually so only your account can read it.",
"warning:".yellow(),
e
),
}
}
pub(crate) fn load_signing_key(path: &Path) -> Result<SigningKey> {
use ed25519_dalek::pkcs8::DecodePrivateKey;
let pem = std::fs::read_to_string(path)
.with_context(|| format!("reading private key from {}", path.display()))?;
let signing = SigningKey::from_pkcs8_pem(&pem).with_context(|| {
format!(
"{} is not a PKCS#8 PEM Ed25519 private key (try `openssl genpkey -algorithm ed25519`)",
path.display()
)
})?;
Ok(signing)
}
pub(crate) fn attestation_message(
artifact_checksum_hex: &str,
sbom_canonical: &[u8],
) -> Result<[u8; 32]> {
use sha2::{Digest, Sha256};
let checksum = hex::decode(artifact_checksum_hex.trim())
.with_context(|| format!("artifact checksum is not hex: {}", artifact_checksum_hex))?;
let mut hasher = Sha256::new();
hasher.update(&checksum);
hasher.update(sbom_canonical);
Ok(hasher.finalize().into())
}
pub(crate) fn read_and_canonicalize_sbom(path: &Path) -> Result<Vec<u8>> {
let raw =
std::fs::read(path).with_context(|| format!("reading SBOM from {}", path.display()))?;
let value: serde_json::Value =
serde_json::from_slice(&raw).context("SBOM is not valid JSON")?;
serde_jcs::to_vec(&value).context("canonicalizing SBOM with RFC 8785 (JCS)")
}
pub async fn rotate_key(
registry: &str,
token: &str,
out: &Path,
force: bool,
label: &str,
revoke_previous_id: Option<&str>,
) -> Result<()> {
generate_key(out, force).await?;
let signing = load_signing_key(out)?;
let public_b64 =
base64::engine::general_purpose::STANDARD.encode(signing.verifying_key().to_bytes());
println!();
println!("{} Registering new key…", "→".cyan());
add_key(registry, token, label, None, Some(&public_b64)).await?;
if let Some(id) = revoke_previous_id {
println!();
println!("{} Revoking previous key {}…", "→".cyan(), id);
revoke_key(registry, token, id).await?;
}
println!();
println!("{}", "Rotation complete.".green().bold());
Ok(())
}
pub async fn sign_sbom(
key_file: &Path,
artifact_checksum_hex: &str,
sbom_path: &Path,
) -> Result<()> {
let signing = load_signing_key(key_file)?;
let sbom_bytes = read_and_canonicalize_sbom(sbom_path)?;
let msg = attestation_message(artifact_checksum_hex, &sbom_bytes)?;
let sig = ed25519_dalek::Signer::sign(&signing, &msg);
let b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
println!("{}", b64);
Ok(())
}
pub async fn generate_key_cli(out: Option<PathBuf>, force: bool) -> Result<()> {
let path = out.unwrap_or_else(|| PathBuf::from("mockforge_publisher_key.pem"));
generate_key(&path, force).await
}
fn read_key_file(path: &Path) -> Result<String> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("reading key file {}", path.display()))?;
let trimmed = raw.trim();
if trimmed.starts_with('{') {
let v: serde_json::Value =
serde_json::from_str(trimmed).context("parsing key file as JWK JSON")?;
if let Some(x) = v.get("x").and_then(|v| v.as_str()) {
return Ok(x.to_string());
}
bail!("JWK key file has no `x` field");
}
if trimmed.starts_with("-----BEGIN") {
let body: String = trimmed
.lines()
.filter(|l| !l.starts_with("-----"))
.collect::<String>()
.split_whitespace()
.collect();
let der = base64::engine::general_purpose::STANDARD
.decode(&body)
.context("PEM body is not valid base64")?;
if der.len() < 32 {
bail!("PEM-encoded key is too short to contain an ed25519 public key");
}
let raw_key = &der[der.len() - 32..];
return Ok(base64::engine::general_purpose::STANDARD.encode(raw_key));
}
Ok(trimmed.to_string())
}
fn fingerprint_short(public_key_b64: &str) -> String {
use sha2::{Digest, Sha256};
let bytes = base64::engine::general_purpose::STANDARD
.decode(public_key_b64)
.or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(public_key_b64))
.unwrap_or_default();
let digest = Sha256::digest(&bytes);
let hex_str: String = digest.iter().take(8).map(|b| format!("{:02x}", b)).collect();
hex_str
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_key_file_accepts_bare_base64() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=").unwrap();
assert_eq!(
read_key_file(tmp.path()).unwrap(),
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
);
}
#[test]
fn read_key_file_accepts_jwk() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
r#"{"kty":"OKP","crv":"Ed25519","x":"CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC="}"#,
)
.unwrap();
assert_eq!(
read_key_file(tmp.path()).unwrap(),
"CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC="
);
}
#[tokio::test]
async fn generate_key_writes_pkcs8_pem_and_loads_back() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("k.pem");
generate_key(&path, false).await.unwrap();
let bytes = std::fs::read_to_string(&path).unwrap();
assert!(bytes.starts_with("-----BEGIN PRIVATE KEY-----"));
let signing = load_signing_key(&path).unwrap();
let msg = b"hi";
let sig = ed25519_dalek::Signer::sign(&signing, msg);
ed25519_dalek::Verifier::verify(&signing.verifying_key(), msg, &sig).unwrap();
let err = generate_key(&path, false).await.unwrap_err().to_string();
assert!(err.contains("refusing to overwrite"), "got: {}", err);
let before = std::fs::read_to_string(&path).unwrap();
generate_key(&path, true).await.unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert_ne!(before, after, "expected a fresh key on overwrite");
}
#[cfg(unix)]
#[tokio::test]
async fn generate_key_sets_0600_on_unix() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("perm.pem");
generate_key(&path, false).await.unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(
mode & 0o077,
0,
"private key should not be group/world readable (got {:o})",
mode
);
}
#[test]
fn resolve_windows_user_prefers_domain_prefix() {
std::env::set_var("USERNAME", "alice");
std::env::remove_var("USERDOMAIN");
assert_eq!(resolve_windows_user().as_deref(), Some("alice"));
std::env::set_var("USERDOMAIN", "CORP");
assert_eq!(resolve_windows_user().as_deref(), Some("CORP\\alice"));
std::env::set_var("USERDOMAIN", " ");
assert_eq!(resolve_windows_user().as_deref(), Some("alice"));
std::env::remove_var("USERNAME");
std::env::set_var("USERDOMAIN", "CORP");
assert!(resolve_windows_user().is_none());
std::env::set_var("USERNAME", " ");
assert!(resolve_windows_user().is_none());
}
#[cfg(windows)]
#[tokio::test]
async fn generate_key_tightens_acl_on_windows() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("win-key.pem");
generate_key(&path, false).await.unwrap();
let contents = std::fs::read_to_string(&path).expect("key readable after ACL tighten");
assert!(
contents.starts_with("-----BEGIN PRIVATE KEY-----"),
"unexpected key contents: {}",
&contents[..contents.len().min(64)]
);
let path_str = path.to_str().expect("tempdir path is utf-8");
let ps_script = format!(
"(Get-Acl -LiteralPath '{}') | \
Select-Object @{{Name='AreAccessRulesProtected';Expression={{$_.AreAccessRulesProtected}}}}, \
@{{Name='Access';Expression={{$_.Access | ForEach-Object {{ \
@{{ IdentityReference = $_.IdentityReference.Value; \
FileSystemRights = $_.FileSystemRights.ToString(); \
AccessControlType = $_.AccessControlType.ToString(); \
IsInherited = $_.IsInherited }} \
}} }}}} | \
ConvertTo-Json -Depth 4 -Compress",
path_str.replace('\'', "''")
);
let output = std::process::Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &ps_script])
.output()
.expect("powershell present on windows-latest runners");
assert!(
output.status.success(),
"Get-Acl failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let json_str = String::from_utf8(output.stdout).expect("Get-Acl output is utf-8");
let acl: serde_json::Value = serde_json::from_str(json_str.trim())
.unwrap_or_else(|e| panic!("Get-Acl JSON did not parse: {e}; raw={json_str}"));
assert_eq!(
acl.get("AreAccessRulesProtected").and_then(|v| v.as_bool()),
Some(true),
"expected inheritance stripped; full ACL: {acl}"
);
let access_rules: Vec<&serde_json::Value> = match acl.get("Access") {
Some(serde_json::Value::Array(items)) => items.iter().collect(),
Some(obj @ serde_json::Value::Object(_)) => vec![obj],
other => panic!("unexpected Access shape: {other:?}; full ACL: {acl}"),
};
assert!(!access_rules.is_empty(), "DACL is empty; full ACL: {acl}");
let rule_identity = |r: &serde_json::Value| -> String {
r.get("IdentityReference").and_then(|v| v.as_str()).unwrap_or("").to_string()
};
let rule_rights = |r: &serde_json::Value| -> String {
r.get("FileSystemRights").and_then(|v| v.as_str()).unwrap_or("").to_string()
};
let rule_is_allow = |r: &serde_json::Value| -> bool {
r.get("AccessControlType").and_then(|v| v.as_str()) == Some("Allow")
};
let expected_user = resolve_windows_user().expect("USERNAME set on the runner");
let user_has_full = access_rules.iter().copied().any(|r| {
rule_is_allow(r)
&& rule_identity(r).eq_ignore_ascii_case(&expected_user)
&& rule_rights(r).contains("FullControl")
});
assert!(
user_has_full,
"expected FullControl Allow for {expected_user:?}; full ACL: {acl}"
);
const FORBIDDEN: &[&str] = &[
"Everyone",
"NT AUTHORITY\\Authenticated Users",
"BUILTIN\\Users",
];
for rule in &access_rules {
if !rule_is_allow(rule) {
continue;
}
let ident = rule_identity(rule);
for bad in FORBIDDEN {
assert!(
!ident.eq_ignore_ascii_case(bad),
"forbidden principal {bad:?} still has allow rule {rule}; full ACL: {acl}"
);
}
}
}
#[test]
fn fingerprint_is_stable_and_short() {
let f = fingerprint_short("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
assert_eq!(f.len(), 16);
assert_eq!(f, fingerprint_short("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="));
}
}