use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use dialoguer::{Confirm, Select, theme::ColorfulTheme};
use did_git_sign::{config, init, names, profiles, sign, vta};
use ed25519_dalek::SigningKey;
use std::path::PathBuf;
use config::SigningConfig;
async fn run_provision(
vta_did: &str,
context: &str,
setup_key: &vta_sdk::provision_client::EphemeralSetupKey,
) -> Result<vta_sdk::provision_client::AdminCredentialReply> {
use vta_sdk::provision_client::{
AdminCredentialReply, DiagStatus, ProvisionAsk, VtaEvent, VtaIntent, VtaReply,
run_connection_test,
};
println!("Bootstrapping with the VTA…");
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<VtaEvent>();
let ask = ProvisionAsk::vta_admin_rotated(context.to_string()).with_label("did-git-sign");
let setup_did = setup_key.did.clone();
let setup_priv = setup_key.private_key_multibase().to_string();
let runner_vta_did = vta_did.to_string();
tokio::spawn(async move {
run_connection_test(
VtaIntent::AdminRotated,
runner_vta_did,
setup_did,
setup_priv,
ask,
None,
tx,
)
.await;
});
let mut admin_reply: Option<AdminCredentialReply> = None;
let mut failure: Option<String> = None;
while let Some(ev) = rx.recv().await {
match ev {
VtaEvent::CheckStart(check) => {
println!(" · {}…", check.label());
}
VtaEvent::CheckDone(check, status) => match status {
DiagStatus::Ok(detail) => println!(" ✓ {} — {detail}", check.label()),
DiagStatus::Skipped(detail) => {
println!(" · {} (skipped: {detail})", check.label())
}
DiagStatus::Failed(detail) => println!(" ✗ {} — {detail}", check.label()),
DiagStatus::Pending | DiagStatus::Running => {}
},
VtaEvent::Resolved(_)
| VtaEvent::AttemptCompleted { .. }
| VtaEvent::PreflightDone { .. } => {}
VtaEvent::Connected { reply, .. } => {
if let VtaReply::AdminOnly(adm) = reply {
admin_reply = Some(adm);
}
}
VtaEvent::Failed(reason) => {
failure = Some(reason);
}
}
}
if let Some(reason) = failure {
bail!("provisioning failed: {reason}");
}
admin_reply.context("provisioning ended without an admin credential")
}
fn init_default_keyring_store() -> Result<()> {
#[cfg(target_os = "macos")]
let store = apple_native_keyring_store::keychain::Store::new()
.map_err(|e| anyhow::anyhow!("init macOS keychain store: {e}"))?;
#[cfg(target_os = "linux")]
let store = linux_keyutils_keyring_store::Store::new()
.map_err(|e| anyhow::anyhow!("init linux keyutils store: {e}"))?;
#[cfg(target_os = "windows")]
let store = windows_native_keyring_store::Store::new()
.map_err(|e| anyhow::anyhow!("init Windows credential manager store: {e}"))?;
keyring_core::set_default_store(store);
Ok(())
}
#[derive(Parser)]
#[command(
name = "did-git-sign",
about = "Git commit signing using DID Ed25519 keys via VTA",
version
)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[arg(short = 'Y', hide = true)]
operation: Option<String>,
#[arg(short = 'f', hide = true)]
key_file: Option<PathBuf>,
#[arg(short = 'n', hide = true)]
namespace: Option<String>,
#[arg(hide = true)]
sign_file: Option<PathBuf>,
#[arg(short = 's', hide = true)]
sig_file: Option<PathBuf>,
#[arg(short = 'I', hide = true)]
identity: Option<String>,
#[arg(short = 'O', hide = true, action = clap::ArgAction::Append)]
sig_option: Vec<String>,
}
#[derive(Subcommand)]
enum Commands {
Init {
#[arg(long)]
global: bool,
#[arg(long)]
vta_did: String,
#[arg(long, default_value = "did-git-sign")]
context: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
vta_url: Option<String>,
#[arg(long)]
key_id: Option<String>,
#[arg(long)]
did_key_id: Option<String>,
#[arg(long)]
yes: bool,
#[arg(long)]
resolve_agent_names: bool,
#[arg(long)]
profile: Option<String>,
#[arg(long = "default", requires = "profile")]
make_default: bool,
},
Profiles,
Use {
name: String,
#[arg(long)]
global: bool,
},
Verify,
Health {
#[arg(long)]
resolve_agent_names: bool,
#[arg(long)]
did_jsonl: Option<std::path::PathBuf>,
#[arg(long)]
profile: Option<String>,
},
Uninstall {
#[arg(long)]
global: bool,
#[arg(long, conflicts_with = "global")]
local: bool,
#[arg(long)]
did_key_id: Option<String>,
},
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.init();
init_default_keyring_store()?;
let cli = Cli::parse();
if let Some(op) = &cli.operation {
match op.as_str() {
"sign" => {
let config_path = cli
.key_file
.as_ref()
.context("missing -f <config_path> argument")?;
let namespace = cli.namespace.as_deref().unwrap_or("git");
return sign::handle_sign(config_path, namespace, cli.sign_file.as_deref()).await;
}
_ => {
let code = delegate_to_ssh_keygen(op, &cli)?;
std::process::exit(code);
}
}
}
match cli.command {
Some(Commands::Init {
global,
vta_did,
context,
name,
vta_url,
key_id,
did_key_id,
yes,
resolve_agent_names,
profile,
make_default,
}) => {
cmd_init(
global,
vta_did,
context,
name,
vta_url,
key_id,
did_key_id,
yes,
resolve_agent_names,
profile,
make_default,
)
.await
}
Some(Commands::Profiles) => cmd_profiles(),
Some(Commands::Use { name, global }) => cmd_use(&name, global),
Some(Commands::Verify) => cmd_verify().await,
Some(Commands::Health {
resolve_agent_names,
did_jsonl,
profile,
}) => {
cmd_health(
resolve_agent_names,
did_jsonl.as_deref(),
profile.as_deref(),
)
.await
}
Some(Commands::Uninstall {
global,
local,
did_key_id,
}) => cmd_uninstall(global, local, did_key_id),
None => {
if let Some(f) = &cli.sign_file {
anyhow::bail!(
"unrecognised subcommand {:?}\n\nUsage: did-git-sign [COMMAND]\n\nRun 'did-git-sign --help' for available commands.",
f.display()
);
}
use clap::CommandFactory;
Cli::command().print_help()?;
println!();
Ok(())
}
}
}
fn pnm_grant_command(context: &str, setup_did: &str) -> String {
format!(
" pnm contexts create --id {context} --name \"did-git-sign\" \\\n \
--admin-did {setup_did} --admin-expires 1h --admin-handoff"
)
}
#[allow(clippy::too_many_arguments)]
async fn cmd_init(
global: bool,
vta_did: String,
context: String,
user_name: Option<String>,
vta_url_override: Option<String>,
key_id_override: Option<String>,
did_key_id_override: Option<String>,
yes: bool,
resolve_agent_names: bool,
profile: Option<String>,
make_default: bool,
) -> Result<()> {
if let Some(name) = &profile {
profiles::validate_name(name)?;
}
let target_config = if global {
SigningConfig::default_global_path()?
} else {
SigningConfig::repo_local_path()
};
let add_beside_default = profile.is_some() && !make_default && target_config.exists();
let vta_url = if let Some(url) = vta_url_override {
url
} else {
println!("Resolving VTA service endpoint from {vta_did}…");
vta_sdk::session::resolve_vta_url(&vta_did)
.await
.map_err(|e| anyhow::anyhow!("could not resolve VTA URL from {vta_did}: {e}"))?
};
if !vta::vta_url_is_secure(&vta_url) {
return Err(vta::insecure_vta_url(&vta_url));
}
println!("VTA URL: {vta_url}");
let mediator_did = vta_sdk::session::resolve_mediator_did(&vta_did)
.await
.map_err(|e| anyhow::anyhow!("could not resolve the VTA's mediator from {vta_did}: {e}"))?;
match &mediator_did {
Some(m) => println!("VTA mediator: {m}"),
None => println!("VTA mediator: none advertised; using REST"),
}
let setup_key = vta_sdk::provision_client::EphemeralSetupKey::generate()
.map_err(|e| anyhow::anyhow!("failed to generate setup did:key: {e}"))?;
println!();
println!("did-git-sign has minted a temporary admin DID for this setup session:");
println!(" {}", setup_key.did);
println!();
println!("Authorise it on the VTA via your Personal Network Manager (PNM):");
println!();
println!("{}", pnm_grant_command(&context, &setup_key.did));
println!();
if !yes {
println!("The admin grant is short-lived (1h) and can hand off once to a long-term");
println!("admin DID (--admin-handoff). Once the command above has run,");
print!("press Enter to continue (or Ctrl+C to abort)... ");
use std::io::Write;
std::io::stdout().flush().ok();
let mut buf = String::new();
std::io::stdin()
.read_line(&mut buf)
.context("failed to read confirmation from stdin")?;
}
let admin = run_provision(&vta_did, &context, &setup_key).await?;
println!();
println!("Authenticating as {}…", admin.admin_did);
let connected = vta::connect_with_retry_auto(vta_sdk::client::AutoConnect {
vta_url: &vta_url,
vta_did: &vta_did,
credential_did: &admin.admin_did,
private_key_multibase: &admin.admin_private_key_mb,
mediator_did: mediator_did.as_deref(),
})
.await
.map_err(|e| anyhow::anyhow!("VTA authentication failed: {e}"))?;
let client = connected.client;
let token = connected.rest_token;
println!("Authenticated.");
println!();
let outcome: Result<()> = async {
let mut book = names::book_from_vta(&client).await;
let (key_id, did_key_id) =
if let (Some(kid), Some(dkid)) = (key_id_override, did_key_id_override) {
(kid, dkid)
} else {
interactive_select(&client, &mut book, resolve_agent_names, yes).await?
};
names::resolve_agent_names_into(&mut book, [did_key_id.as_str()], resolve_agent_names)
.await;
let seed = vta::get_signing_key(&client, &key_id).await?;
let signing_key = SigningKey::from_bytes(seed.as_bytes());
let verifying_key = signing_key.verifying_key();
if let Some(token) = &token {
let _ = config::cache_token(&did_key_id, &token.access_token, token.access_expires_at);
}
let install_args = init::InstallArgs {
global,
did_key_id: did_key_id.clone(),
vta_key_id: key_id,
credential_did: admin.admin_did.clone(),
credential_private_key_mb: admin.admin_private_key_mb.clone(),
vta_did: vta_did.clone(),
vta_url,
mediator_did: mediator_did.clone(),
user_name,
verifying_key: verifying_key.as_bytes(),
};
if add_beside_default {
let name = profile.as_deref().unwrap_or_default();
let ssh_public_key = init::add_identity(install_args)?;
save_profile(name, &did_key_id, &vta_did, &context)?;
println!("VTA credentials stored in OS keyring");
println!("Allowed signers file updated");
println!();
println!("Profile '{name}' saved. It does not replace the default identity;");
println!("sign as it in a repository with:");
println!(" did-git-sign use {name}");
println!();
if let Some(n) = names::name_line(&book, &did_key_id) {
println!(" Name: {n}");
}
println!(" DID: {did_key_id}");
println!(" Key: {ssh_public_key}");
return Ok(());
}
let result = init::install(install_args)?;
if let Some(name) = &profile {
save_profile(name, &did_key_id, &vta_did, &context)?;
println!("Profile '{name}' saved (the default identity)");
}
println!("Config saved to: {}", result.config_path.display());
println!("VTA credentials stored in OS keyring");
println!("Git configured for DID signing");
println!("Allowed signers file updated");
if let Some(prev) = result.overridden_global_signing_key {
println!();
println!("Note: your global user.signingKey ({prev}) has been overridden locally");
println!(" for this repository. did-git-sign uses its JSON config file as the");
println!(" signing key path. Your global signing configuration is unchanged.");
}
if let Some(did) = &result.global_committer_email {
println!();
println!("Note: {did}");
println!(" now signs in every repository on this machine (did-git-sign.key in");
println!(" your global git config), and the commit-msg hook names it as the");
println!(" signer. user.email is unchanged.");
println!();
println!(" Signing as more than one identity? Give each a profile and choose");
println!(" one per repository:");
println!(" did-git-sign init --global --profile <name> --vta-did <VTA DID>");
println!(" did-git-sign use <name>");
println!();
println!(" or scope it per remote in ~/.gitconfig:");
println!(
" [includeIf \"hasconfig:remote.*.url:https://github.com/YourOrg/**\"]"
);
println!(" path = ~/.config/git/community-yourorg");
println!(" # ~/.config/git/community-yourorg");
println!(" [did-git-sign]");
println!(" key = {did}");
}
println!();
println!("Setup complete! Git commits will now be signed with:");
if let Some(name) = names::name_line(&book, &did_key_id) {
println!(" Name: {name}");
}
println!(" DID: {did_key_id}");
println!(" Key: {}", result.ssh_public_key);
println!();
println!("IMPORTANT — to make signatures show as 'Verified':");
println!(" 1. Copy the SSH public key above.");
println!(" 2. Add it to your account:");
println!(" User Settings → SSH Keys → Add new key");
println!(" Set Usage type to 'Signing' (or 'Authentication & Signing').");
println!(" 3. Ensure git user.email matches your account email:");
println!(" git config user.email");
println!();
println!("To sign a commit: git commit -S -m \"your message\"");
println!("To verify: git log --show-signature");
Ok(())
}
.await;
client.shutdown().await;
outcome
}
async fn interactive_select(
client: &vta_sdk::client::VtaClient,
book: &mut vta_sdk::display_name::NameBook,
resolve_agent_names: bool,
yes: bool,
) -> Result<(String, String)> {
let contexts = client
.list_contexts()
.await
.map_err(|e| anyhow::anyhow!("failed to list contexts: {e}"))?;
if contexts.contexts.is_empty() {
bail!("no contexts found in VTA — create a context first");
}
let context_labels: Vec<String> = contexts
.contexts
.iter()
.map(|c| {
let did_info = c
.did
.as_deref()
.map_or_else(|| "no DID".to_string(), |d| names::inline(book, d));
format!("{} — {} ({})", c.id, c.name, did_info)
})
.collect();
let ctx_idx = if contexts.contexts.len() == 1 {
println!("Using context: {}", context_labels[0]);
0
} else {
Select::with_theme(&ColorfulTheme::default())
.with_prompt("Select a context")
.items(&context_labels)
.default(0)
.interact()?
};
let context = &contexts.contexts[ctx_idx];
println!();
let dids = client
.list_dids_webvh(Some(&context.id), None)
.await
.map_err(|e| anyhow::anyhow!("failed to list DIDs: {e}"))?;
let dids = if dids.dids.is_empty() {
create_context_did(client, &context.id, yes).await?;
client
.list_dids_webvh(Some(&context.id), None)
.await
.map_err(|e| anyhow::anyhow!("failed to list DIDs: {e}"))?
} else {
dids
};
names::resolve_agent_names_into(
book,
dids.dids.iter().map(|d| d.did.as_str()),
resolve_agent_names,
)
.await;
let did_labels: Vec<String> = dids
.dids
.iter()
.map(|d| names::inline(book, &d.did))
.collect();
let did_idx = if dids.dids.len() == 1 {
println!("Using DID: {}", did_labels[0]);
0
} else {
Select::with_theme(&ColorfulTheme::default())
.with_prompt("Select a DID")
.items(&did_labels)
.default(0)
.interact()?
};
let selected_did = &dids.dids[did_idx].did;
println!();
let keys = client
.list_keys(0, 100, Some("active"), Some(&context.id))
.await
.map_err(|e| anyhow::anyhow!("failed to list keys: {e}"))?;
let ed25519_keys: Vec<_> = keys
.keys
.iter()
.filter(|k| k.key_type == vta_sdk::keys::KeyType::Ed25519)
.collect();
if ed25519_keys.is_empty() {
bail!(
"no active Ed25519 keys found in context '{}' — create signing keys first",
context.id
);
}
let key_labels: Vec<String> = ed25519_keys
.iter()
.map(|k| {
let label = k.label.as_deref().unwrap_or("unlabeled");
format!("{} ({})", label, k.key_id)
})
.collect();
let key_idx = if ed25519_keys.len() == 1 {
println!("Using key: {}", key_labels[0]);
0
} else {
Select::with_theme(&ColorfulTheme::default())
.with_prompt("Select a signing key")
.items(&key_labels)
.default(0)
.interact()?
};
let selected_key = ed25519_keys[key_idx];
println!();
let did_key_id = resolve_did_key_fragment(client, selected_did, selected_key).await?;
println!("Signing identity: {did_key_id}");
println!();
Ok((selected_key.key_id.clone(), did_key_id))
}
async fn create_context_did(
client: &vta_sdk::client::VtaClient,
context: &str,
yes: bool,
) -> Result<String> {
let servers = client
.list_webvh_servers()
.await
.map_err(|e| anyhow::anyhow!("failed to list DID-hosting servers: {e}"))?
.servers;
let label = |s: &vta_sdk::webvh::WebvhServerRecord| match &s.label {
Some(l) => format!("{l} ({})", s.id),
None => s.id.clone(),
};
let server = match servers.len() {
0 => bail!(
"context '{context}' has no DID, and the VTA has no DID-hosting server to \
create one on.\n\nRegister one (`pnm did-mgmt servers add …`), or create the \
DID yourself:\n pnm did-mgmt dids create --context {context} --did-url <url>\n\
then run `did-git-sign init` again."
),
1 => &servers[0],
n if yes => bail!(
"context '{context}' has no DID, and the VTA has {n} DID-hosting servers to \
create one on. Run without --yes to choose, or create it yourself:\n \
pnm did-mgmt dids create --context {context} --server <id>"
),
_ => {
let labels: Vec<String> = servers.iter().map(label).collect();
let idx = Select::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"Context '{context}' has no DID. Create one on which server?"
))
.items(&labels)
.default(0)
.interact()?;
&servers[idx]
}
};
if !yes
&& servers.len() == 1
&& !Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"Context '{context}' has no DID. Create a did:webvh for it on {}?",
label(server)
))
.default(true)
.interact()?
{
bail!(
"no DID to sign with. Create one with\n pnm did-mgmt dids create --context \
{context} --server {}\nand run `did-git-sign init` again.",
server.id
);
}
println!(
"Creating a did:webvh in context '{context}' on {}…",
label(server)
);
let req = vta_sdk::client::CreateDidWebvhRequest {
context_id: context.to_string(),
server_id: Some(server.id.clone()),
url: None,
path: None,
path_mode: None,
domain: None,
label: Some(context.to_string()),
portable: true,
add_mediator_service: false,
add_tsp_service: false,
additional_services: None,
pre_rotation_count: 0,
did_document: None,
did_log: None,
set_primary: true,
signing_key_id: None,
ka_key_id: None,
template: None,
template_context: None,
template_vars: std::collections::HashMap::new(),
};
let created = client
.create_did_webvh(req)
.await
.map_err(|e| anyhow::anyhow!("failed to create a DID in context '{context}': {e}"))?;
println!(
"Created {} (signing key {})",
created.did, created.signing_key_id
);
println!();
Ok(created.did)
}
async fn resolve_did_key_fragment(
client: &vta_sdk::client::VtaClient,
did: &str,
key: &vta_sdk::keys::KeyRecord,
) -> Result<String> {
let _did_record = client
.get_did_webvh(did)
.await
.map_err(|e| anyhow::anyhow!("failed to get DID record: {e}"))?;
let log_resp = client
.get_did_webvh_log(did)
.await
.map_err(|e| anyhow::anyhow!("failed to get DID log: {e}"))?;
if let Some(log) = &log_resp.log
&& let Some(last_line) = log.lines().last()
&& let Ok(entry) = serde_json::from_str::<serde_json::Value>(last_line)
&& let Some(state) = entry.get("state")
&& let Some(vms) = state.get("verificationMethod")
&& let Some(vms_arr) = vms.as_array()
{
for vm in vms_arr {
if let Some(pub_key_mb) = vm.get("publicKeyMultibase")
&& pub_key_mb.as_str() == Some(&key.public_key)
&& let Some(id) = vm.get("id").and_then(|v| v.as_str())
{
return Ok(id.to_string());
}
}
}
eprintln!("Warning: could not match key against DID document, using default fragment #key-0");
Ok(format!("{did}#key-0"))
}
fn load_config() -> Result<(PathBuf, SigningConfig)> {
let config_path = if SigningConfig::repo_local_path().exists() {
SigningConfig::repo_local_path()
} else {
SigningConfig::default_global_path()?
};
if !config_path.exists() {
anyhow::bail!("No did-git-sign configuration found. Run `did-git-sign init` first.");
}
let cfg = SigningConfig::load(&config_path)?;
Ok((config_path, cfg))
}
async fn cmd_verify() -> Result<()> {
let (config_path, cfg) = load_config()?;
println!("Config: {}", config_path.display());
println!("DID: {}", cfg.did_key_id);
print!("Keyring: ");
let creds = config::load_vta_credentials(&cfg.did_key_id)
.context("VTA credentials not found in keyring")?;
println!("OK (VTA: {})", creds.vta_url);
print!("VTA auth: ");
let (client, creds) = vta::authenticate(&cfg).await?;
println!("OK");
print!("Fetch key: ");
let seed = vta::get_signing_key(&client, &creds.key_id).await?;
println!("OK");
print!("Test sign: ");
let signing_key = SigningKey::from_bytes(seed.as_bytes());
let verifying_key = signing_key.verifying_key();
let test_data = b"did-git-sign verification test";
sign::test_sign(&signing_key, &verifying_key, test_data)?;
println!("OK");
println!();
println!("All checks passed. Signing is operational.");
Ok(())
}
fn print_commit_msg_hook_status() {
use init::CommitMsgHookStatus as Hook;
print!("Commit-msg hook: ");
match init::commit_msg_hook_status() {
Ok(Hook::Current { path }) => println!(
"OK (v{}, {})",
init::COMMIT_MSG_HOOK_VERSION,
path.display()
),
Ok(Hook::Outdated {
path,
installed,
current,
}) => {
println!(
"OUTDATED (v{installed}, current v{current}, {})",
path.display()
);
println!(
" Re-run `did-git-sign init` to replace it. Hooks before v2 put the \
Signed-by-DID trailer above any `---` line in a commit message, where \
verify-trust does not read it, and those commits fail as noSignerDid."
);
}
Ok(Hook::Newer {
path,
installed,
current,
}) => {
println!(
"NEWER (v{installed}, this binary writes v{current}, {})",
path.display()
);
println!(" Installed by a newer did-git-sign; upgrade this binary.");
}
Ok(Hook::Foreign { path }) => {
println!("NOT did-git-sign ({})", path.display());
println!(
" Nothing is known to write the Signed-by-DID trailer; commits will be \
refused at signing time. Re-run `did-git-sign init`."
);
}
Ok(Hook::Missing { path }) => {
println!("MISSING ({})", path.display());
println!(" Re-run `did-git-sign init` to install it.");
}
Ok(Hook::Unknown) => {
println!("not checked (not in a repository and no global core.hooksPath)");
}
Err(e) => println!("could not check: {e}"),
}
println!();
}
fn save_profile(name: &str, did_key_id: &str, vta_did: &str, context: &str) -> Result<()> {
let mut all = profiles::Profiles::load()?;
if let Some(old) = all.profiles.get(name)
&& old.did_key_id != did_key_id
{
println!(
"Profile '{name}' now names {did_key_id} (was {}).",
old.did_key_id
);
}
all.profiles.insert(
name.to_string(),
profiles::Profile {
did_key_id: did_key_id.to_string(),
vta_did: vta_did.to_string(),
context: Some(context.to_string()),
},
);
all.save()
}
fn git_config_get(key: &str) -> Option<String> {
let out = std::process::Command::new("git")
.args(["config", "--get", key])
.output()
.ok()?;
let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
(out.status.success() && !v.is_empty()).then_some(v)
}
fn cmd_profiles() -> Result<()> {
let all = profiles::Profiles::load()?;
if all.profiles.is_empty() {
println!("No profiles yet. Create one with:");
println!(" did-git-sign init --profile <name> --vta-did <VTA DID>");
return Ok(());
}
let default = [
Some(SigningConfig::repo_local_path()),
SigningConfig::default_global_path().ok(),
]
.into_iter()
.flatten()
.find(|p| p.exists())
.and_then(|p| SigningConfig::load(&p).ok())
.map(|c| c.did_key_id);
let here = git_config_get(sign::SIGNING_KEY_GIT_CONFIG);
for (name, p) in &all.profiles {
let mut marks = Vec::new();
if default.as_deref() == Some(p.did_key_id.as_str()) {
marks.push("default");
}
if here.as_deref() == Some(p.did_key_id.as_str()) {
marks.push("signs here");
}
let marks = if marks.is_empty() {
String::new()
} else {
format!(" [{}]", marks.join(", "))
};
println!("{name}{marks}");
println!(" DID: {}", p.did_key_id);
println!(" VTA: {}", p.vta_did);
if let Some(ctx) = &p.context {
println!(" Context: {ctx}");
}
if config::load_vta_credentials(&p.did_key_id).is_err() {
println!(
" Credentials: MISSING from the keyring; run `did-git-sign init --profile {name} …` again"
);
}
}
if let Some(sel) = &here
&& all.name_of(sel).is_none()
{
println!();
println!("This repository signs as {sel}, which is not a profile.");
}
Ok(())
}
fn cmd_use(name: &str, global: bool) -> Result<()> {
let all = profiles::Profiles::load()?;
let p = all.get(name)?;
config::load_vta_credentials(&p.did_key_id).with_context(|| {
format!(
"profile '{name}' has no credentials in the keyring; run \
`did-git-sign init --profile {name} …` again"
)
})?;
if !global {
let inside = std::process::Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.output()
.is_ok_and(|o| o.status.success());
if !inside {
bail!("not inside a git repository; run this in one, or pass --global");
}
}
init::set_signing_identity(global, &p.did_key_id)?;
if global {
println!("Every repository now signs as '{name}' unless it selects another:");
} else {
println!("This repository now signs as '{name}':");
}
println!(" {}", p.did_key_id);
if git_config_get("gpg.ssh.program").as_deref() != Some("did-git-sign") {
println!();
println!("Note: git here is not set up to sign with did-git-sign yet. Run");
println!("`did-git-sign init` in this repository (or with --global) first.");
}
Ok(())
}
async fn cmd_health(
resolve_agent_names: bool,
did_jsonl: Option<&std::path::Path>,
profile: Option<&str>,
) -> Result<()> {
let (config_path, mut cfg) = load_config()?;
if let Some(name) = profile {
cfg.did_key_id = profiles::Profiles::load()?.get(name)?.did_key_id.clone();
}
println!("did-git-sign health check");
println!("=========================");
println!();
let creds = config::load_vta_credentials(&cfg.did_key_id)
.context("VTA credentials not found in keyring — run `did-git-sign init` first")?;
let mut book = vta_sdk::display_name::NameBook::new();
names::resolve_agent_names_into(
&mut book,
[
cfg.did_key_id.as_str(),
creds.vta_did.as_str(),
creds.credential_did.as_str(),
],
resolve_agent_names,
)
.await;
let named = |label: &str, did: &str| {
if let Some(name) = names::name_line(&book, did) {
println!("{label:<16} {name}");
}
};
println!("Config: {}", config_path.display());
if let Some(name) = profile {
println!("Profile: {name}");
}
named("Signing name:", &cfg.did_key_id);
println!("DID: {}", cfg.did_key_id);
if let Some(name) = &cfg.user_name {
println!("User: {name}");
}
println!();
println!("VTA URL: {}", creds.vta_url);
named("VTA name:", &creds.vta_did);
println!("VTA DID: {}", creds.vta_did);
named("Credential name:", &creds.credential_did);
println!("Credential DID: {}", creds.credential_did);
println!("Signing Key ID: {}", creds.key_id);
let over_session = creds.mediator_did.is_some();
if over_session {
println!("Token cache: not used (a mediator session authenticates itself)");
} else {
match config::load_cached_token(&cfg.did_key_id) {
Some(_) => println!("Token cache: valid"),
None => println!("Token cache: empty or expired"),
}
}
println!();
print_commit_msg_hook_status();
if !over_session {
print!("VTA health: ");
let vta_client = vta_sdk::client::VtaClient::new(&creds.vta_url);
match vta_client.health().await {
Ok(health) => {
println!("OK (v{})", health.version.as_deref().unwrap_or("unknown"));
if let Some(mediator_did) = &health.mediator_did {
println!(" Mediator DID: {mediator_did}");
}
}
Err(e) => {
println!("FAILED");
println!(" Error: {e}");
}
}
}
print!("VTA auth: ");
match vta::authenticate(&cfg).await {
Ok((client, creds)) => {
println!("OK");
if over_session {
print_session_health(&client).await;
}
print!("Signing key: ");
match vta::get_signing_key(&client, &creds.key_id).await {
Ok(seed) => {
let signing_key = SigningKey::from_bytes(seed.as_bytes());
let verifying_key = signing_key.verifying_key();
println!("OK");
println!();
println!("SSH Public Key (for signature verification):");
println!(
" {}",
init::ssh_public_key_string(verifying_key.as_bytes())
);
println!();
println!("Allowed Signers Entry:");
println!(
" {}",
init::allowed_signers_entry(&cfg, verifying_key.as_bytes())
);
if let Some(path) = did_jsonl {
println!();
print!("DID doc check: ");
match check_key_in_did_log(path, verifying_key.as_bytes()) {
Ok(true) => {
println!("OK (key found in {})", path.display());
let local_mb = multibase::encode(
multibase::Base::Base58Btc,
[
vgi_core::ED25519_MULTICODEC_PREFIX.as_slice(),
verifying_key.as_bytes(),
]
.concat(),
);
println!(" publicKeyMultibase: {local_mb}");
}
Ok(false) => {
println!("MISMATCH");
println!(" {} does not contain the signing key.", path.display());
let local_mb = multibase::encode(
multibase::Base::Base58Btc,
[
vgi_core::ED25519_MULTICODEC_PREFIX.as_slice(),
verifying_key.as_bytes(),
]
.concat(),
);
println!(" Local key (multibase): {local_mb}");
println!(" Re-export or re-run onboarding.");
}
Err(e) => println!("FAILED ({e})"),
}
}
}
Err(e) => {
println!("FAILED");
println!(" Error: {e}");
}
}
client.shutdown().await;
}
Err(e) => {
println!("FAILED");
println!(" Error: {e}");
if over_session {
println!("VTA health: not checked (no session to ask it over)");
}
}
}
Ok(())
}
async fn print_session_health(client: &vta_sdk::client::VtaClient) {
print!("VTA health: ");
match client.health_details().await {
Ok(h) => {
println!(
"OK ({}, asked over the mediator; a VTA does not publish its version)",
h.status
);
if let Some(m) = &h.mediator_did {
println!(" Mediator DID: {}", m.as_str());
}
println!(
" TSP: {}",
if h.tsp_enabled {
"advertised"
} else {
"not advertised"
}
);
println!(" Sealed: {}", if h.sealed { "yes" } else { "no" });
println!(
" Key storage: {}",
if h.storage_encrypted {
"encrypted at rest"
} else {
"not encrypted at rest"
}
);
}
Err(e) => {
println!("FAILED");
println!(" Error: {e}");
}
}
}
fn check_key_in_did_log(path: &std::path::Path, local_key: &[u8; 32]) -> Result<bool> {
use vgi_core::ed25519_signing_keys_from_doc;
let content =
std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let first_line = content.lines().next().context("empty did.jsonl")?;
let entry: serde_json::Value =
serde_json::from_str(first_line).context("invalid JSON in did.jsonl")?;
let state = entry
.get("state")
.context("no 'state' field in did.jsonl entry")?;
let published_keys = ed25519_signing_keys_from_doc(state);
if published_keys.is_empty() {
anyhow::bail!("DID document lists no Ed25519 key under assertionMethod");
}
Ok(published_keys.iter().any(|k| k == local_key))
}
fn cmd_uninstall(
global_flag: bool,
local_flag: bool,
did_key_id_override: Option<String>,
) -> Result<()> {
let global = if global_flag {
true
} else {
!(local_flag || SigningConfig::repo_local_path().exists())
};
let config_path = if global {
SigningConfig::default_global_path()?
} else {
SigningConfig::repo_local_path()
};
let did_key_id = match did_key_id_override {
Some(id) => id,
None => match SigningConfig::load(&config_path) {
Ok(cfg) => cfg.did_key_id,
Err(e) => {
bail!(
"could not read {} to discover the principal — pass --did-key-id explicitly: {e}",
config_path.display()
);
}
},
};
let summary = init::uninstall(global, &did_key_id)?;
if let Some(path) = &summary.removed_config_file {
println!("Removed config: {}", path.display());
} else {
println!("Config file already absent");
}
if !summary.removed_keyring_entries.is_empty() {
for key in &summary.removed_keyring_entries {
println!("Removed keyring entry: {key}");
}
} else {
println!("Keyring entries already absent");
}
if summary.allowed_signers_entry_removed {
println!("Removed allowed_signers entry for {did_key_id}");
}
if !summary.git_config_keys_unset.is_empty() {
let scope = if global { "--global" } else { "--local" };
for key in &summary.git_config_keys_unset {
println!("Unset git config {scope} {key}");
}
}
for w in &summary.warnings {
eprintln!("warning: {w}");
}
println!();
println!("did-git-sign install removed.");
Ok(())
}
fn delegate_to_ssh_keygen(op: &str, cli: &Cli) -> Result<i32> {
let ssh_keygen =
std::env::var("DID_GIT_SIGN_SSH_KEYGEN").unwrap_or_else(|_| "ssh-keygen".to_string());
let mut cmd = std::process::Command::new(&ssh_keygen);
cmd.arg("-Y").arg(op);
if let Some(f) = &cli.key_file {
cmd.arg("-f").arg(f);
}
if let Some(i) = &cli.identity {
cmd.arg("-I").arg(i);
}
if let Some(n) = &cli.namespace {
cmd.arg("-n").arg(n);
}
if let Some(s) = &cli.sig_file {
cmd.arg("-s").arg(s);
}
for opt in &cli.sig_option {
cmd.arg("-O").arg(opt);
}
let status = cmd
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()
.context(format!(
"failed to invoke ssh-keygen at {:?} — is ssh-keygen installed? \
Set DID_GIT_SIGN_SSH_KEYGEN to override the path.",
ssh_keygen
))?;
Ok(status.code().unwrap_or(1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grant_command_carries_the_one_time_handoff() {
let cmd = pnm_grant_command("did-git-sign", "did:key:z6MkExample");
assert_eq!(
cmd,
" pnm contexts create --id did-git-sign --name \"did-git-sign\" \\\n \
--admin-did did:key:z6MkExample --admin-expires 1h --admin-handoff"
);
assert!(cmd.contains("--admin-handoff"));
assert!(cmd.contains("--admin-expires"));
}
struct EnvVarGuard(&'static str);
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe { std::env::remove_var(self.0) };
}
}
fn set_test_env(key: &'static str, value: &str) -> EnvVarGuard {
unsafe { std::env::set_var(key, value) };
EnvVarGuard(key)
}
#[test]
#[serial_test::serial]
#[cfg(not(windows))]
fn delegate_forwards_all_flags_to_ssh_keygen() {
let src =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/mock_ssh_keygen.sh");
let tmp_dir = tempfile::tempdir().unwrap();
let mock_path = tmp_dir.path().join("mock_ssh_keygen.sh");
std::fs::copy(&src, &mock_path).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&mock_path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&mock_path, perms).unwrap();
}
let out_file = tempfile::NamedTempFile::new().unwrap();
let _ssh_guard = set_test_env("DID_GIT_SIGN_SSH_KEYGEN", mock_path.to_str().unwrap());
let _out_guard = set_test_env(
"DID_GIT_SIGN_TEST_MOCK_OUT",
out_file.path().to_str().unwrap(),
);
let cli = Cli::try_parse_from([
"did-git-sign",
"-Y",
"verify",
"-f",
"allowed_signers",
"-I",
"did:webvh:test#key-0",
"-n",
"git",
"-s",
"buffer.diff.sig",
"-O",
"hashalg=sha512",
])
.expect("clap should parse these flags without error");
let code = delegate_to_ssh_keygen("verify", &cli).unwrap();
assert_eq!(code, 0, "mock ssh-keygen must exit 0");
let content = std::fs::read_to_string(out_file.path()).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert!(lines.contains(&"-Y"), "missing -Y flag");
assert!(lines.contains(&"verify"), "missing operation");
assert!(lines.contains(&"-f"), "missing -f flag");
assert!(lines.contains(&"allowed_signers"), "missing key_file value");
assert!(lines.contains(&"-I"), "missing -I flag");
assert!(lines.contains(&"did:webvh:test#key-0"), "missing identity");
assert!(lines.contains(&"-n"), "missing -n flag");
assert!(lines.contains(&"git"), "missing namespace");
assert!(lines.contains(&"-s"), "missing -s flag");
assert!(lines.contains(&"buffer.diff.sig"), "missing sig_file");
assert!(lines.contains(&"-O"), "missing -O flag");
assert!(lines.contains(&"hashalg=sha512"), "missing sig_option");
}
}