use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub(crate) const KEYRING_SERVICE: &str = "did-git-sign";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SigningConfig {
pub did_key_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_name: Option<String>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct VtaCredentials {
pub vta_url: String,
pub vta_did: String,
pub credential_did: String,
pub private_key_multibase: String,
pub key_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mediator_did: Option<String>,
}
impl std::fmt::Debug for VtaCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VtaCredentials")
.field("vta_url", &self.vta_url)
.field("vta_did", &self.vta_did)
.field("credential_did", &self.credential_did)
.field("private_key_multibase", &"<redacted>")
.field("key_id", &self.key_id)
.field("mediator_did", &self.mediator_did)
.finish()
}
}
impl SigningConfig {
pub fn load(path: &Path) -> Result<Self> {
let data = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config: {}", path.display()))?;
serde_json::from_str(&data)
.with_context(|| format!("failed to parse config: {}", path.display()))
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("failed to create config directory: {}", parent.display())
})?;
}
let data = serde_json::to_string_pretty(self)?;
std::fs::write(path, data)
.with_context(|| format!("failed to write config: {}", path.display()))
}
pub fn default_global_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir().context("could not determine config directory")?;
Ok(config_dir.join("did-git-sign").join("config.json"))
}
pub fn repo_local_path() -> PathBuf {
PathBuf::from(".did-git-sign.json")
}
}
pub fn store_vta_credentials(did_key_id: &str, creds: &VtaCredentials) -> Result<()> {
let key = format!("{did_key_id}:vta");
let value = serde_json::to_string(creds)?;
let entry = keyring_core::Entry::new(KEYRING_SERVICE, &key)
.context("failed to create keyring entry for VTA credentials")?;
entry
.set_password(&value)
.context("failed to store VTA credentials in keyring")?;
Ok(())
}
pub fn load_vta_credentials(did_key_id: &str) -> Result<VtaCredentials> {
let key = format!("{did_key_id}:vta");
let entry = keyring_core::Entry::new(KEYRING_SERVICE, &key)
.context("failed to create keyring entry")?;
let data = entry
.get_password()
.context("VTA credentials not found in keyring — run `did-git-sign init` first")?;
serde_json::from_str(&data).context("failed to parse VTA credentials from keyring")
}
pub fn cache_token(did_key_id: &str, token: &str, expires_at: u64) -> Result<()> {
let key = format!("{did_key_id}:token");
let value = serde_json::json!({
"access_token": token,
"access_expires_at": expires_at,
});
let entry = keyring_core::Entry::new(KEYRING_SERVICE, &key)
.context("failed to create token cache entry")?;
entry
.set_password(&value.to_string())
.context("failed to cache token in keyring")?;
Ok(())
}
pub fn load_cached_token(did_key_id: &str) -> Option<String> {
let key = format!("{did_key_id}:token");
let entry = keyring_core::Entry::new(KEYRING_SERVICE, &key).ok()?;
let data = entry.get_password().ok()?;
let parsed: serde_json::Value = serde_json::from_str(&data).ok()?;
let expires_at = parsed["access_expires_at"].as_u64()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
if now + 30 < expires_at {
parsed["access_token"].as_str().map(|s| s.to_string())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_signing_config_roundtrip_serde() {
let cfg = SigningConfig {
did_key_id: "did:webvh:abc:example.com#key-0".to_string(),
user_name: Some("Alice".to_string()),
};
let json = serde_json::to_string(&cfg).unwrap();
let parsed: SigningConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.did_key_id, cfg.did_key_id);
assert_eq!(parsed.user_name, cfg.user_name);
}
#[test]
fn test_signing_config_user_name_none_omitted_in_json() {
let cfg = SigningConfig {
did_key_id: "did:webvh:abc:example.com#key-0".to_string(),
user_name: None,
};
let json = serde_json::to_string(&cfg).unwrap();
assert!(!json.contains("user_name"));
}
#[test]
fn test_signing_config_save_and_load() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
let cfg = SigningConfig {
did_key_id: "did:webvh:abc:example.com#key-0".to_string(),
user_name: Some("Bob".to_string()),
};
cfg.save(&path).unwrap();
let loaded = SigningConfig::load(&path).unwrap();
assert_eq!(loaded.did_key_id, cfg.did_key_id);
assert_eq!(loaded.user_name, cfg.user_name);
}
#[test]
fn test_signing_config_load_missing_file() {
let result = SigningConfig::load(Path::new("/nonexistent/config.json"));
assert!(result.is_err());
}
#[test]
fn test_signing_config_load_invalid_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.json");
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(b"not json").unwrap();
let result = SigningConfig::load(&path);
assert!(result.is_err());
}
#[test]
fn test_repo_local_path() {
let path = SigningConfig::repo_local_path();
assert_eq!(path, PathBuf::from(".did-git-sign.json"));
}
#[test]
fn test_default_global_path_ends_with_expected_suffix() {
if let Ok(path) = SigningConfig::default_global_path() {
assert!(path.ends_with("did-git-sign/config.json"));
}
}
#[test]
fn test_vta_credentials_roundtrip_serde() {
let creds = VtaCredentials {
vta_url: "https://vta.example.com".to_string(),
vta_did: "did:example:vta".to_string(),
credential_did: "did:key:z6Mk123".to_string(),
private_key_multibase: "z1234".to_string(),
key_id: "key-1".to_string(),
mediator_did: None,
};
let json = serde_json::to_string(&creds).unwrap();
let parsed: VtaCredentials = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.vta_url, creds.vta_url);
assert_eq!(parsed.key_id, creds.key_id);
assert_eq!(parsed.credential_did, creds.credential_did);
}
#[test]
fn test_vta_credentials_debug_redacts_private_key() {
let creds = VtaCredentials {
vta_url: "https://vta.example.com".to_string(),
vta_did: "did:example:vta".to_string(),
credential_did: "did:key:z6Mk123".to_string(),
private_key_multibase: "MARKER_MUST_NOT_LEAK".to_string(),
key_id: "key-1".to_string(),
mediator_did: Some("did:example:mediator".to_string()),
};
let debug = format!("{creds:?}");
assert!(
!debug.contains("MARKER_MUST_NOT_LEAK"),
"private key leaked into Debug output: {debug}"
);
assert!(debug.contains("VtaCredentials"));
assert!(debug.contains("<redacted>"));
assert!(debug.contains("https://vta.example.com"));
assert!(debug.contains("did:example:mediator"));
}
#[test]
fn test_signing_config_save_creates_parent_dirs() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("deep").join("config.json");
let cfg = SigningConfig {
did_key_id: "did:webvh:abc:example.com#key-0".to_string(),
user_name: None,
};
cfg.save(&path).unwrap();
assert!(path.exists());
}
}