use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use did_git_sign::config::SigningConfig;
use did_git_sign::profiles::{Profile, Profiles};
use did_git_sign::{enable, init};
use ed25519_dalek_bip32::ed25519_dalek::SigningKey;
use secrecy::{ExposeSecret, SecretString};
use vta_sdk::client::{AutoConnect, CreateAclRequest, VtaClient};
use vta_sdk::error::VtaError;
use vta_sdk::provision_client::EphemeralSetupKey;
use crate::config::KeyBackend;
use crate::errors::OpenVTCError;
use crate::git_workspace::CheckoutFacts;
pub const SIGNER_CAPABILITIES: &[&str] = &["sign-sshsig"];
pub const LEGACY_SIGNER_CAPABILITIES: &[&str] = &["key-export"];
const SIGN_SSHSIG_SLUG: &str = "keys/sign-sshsig";
pub const MIN_BINARY: (u32, u32) = (0, 14);
const VTA_TIMEOUT: Duration = Duration::from_secs(30);
const LOCAL_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PersonaSigner {
pub did_key_id: String,
pub vta_key_id: String,
pub verifying_key: [u8; 32],
pub context: String,
pub label: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VtaEndpoint {
pub vta_did: String,
pub vta_url: String,
pub mediator_did: Option<String>,
}
impl VtaEndpoint {
#[must_use]
pub fn from_backend(backend: &KeyBackend) -> Option<Self> {
match backend {
KeyBackend::Vta {
vta_did,
vta_url,
mediator_did,
..
} => Some(Self {
vta_did: vta_did.clone(),
vta_url: vta_url.clone(),
mediator_did: mediator_did.clone(),
}),
_ => None,
}
}
}
pub struct SignerCredential {
pub did: String,
pub private_key_mb: SecretString,
}
impl SignerCredential {
pub fn generate() -> Result<Self, OpenVTCError> {
let key = EphemeralSetupKey::generate().map_err(|e| {
OpenVTCError::Config(format!("couldn't mint a signing credential: {e}"))
})?;
Ok(Self {
did: key.did.clone(),
private_key_mb: SecretString::from(key.private_key_multibase().to_string()),
})
}
}
#[must_use]
pub fn stored_credential(did_key_id: &str) -> Option<SignerCredential> {
did_git_sign::config::load_vta_credentials(did_key_id)
.ok()
.map(|c| SignerCredential {
did: c.credential_did,
private_key_mb: SecretString::from(c.private_key_multibase),
})
}
pub fn signer_grant(
did: &str,
context: &str,
label: &str,
capabilities: &[&str],
) -> CreateAclRequest {
CreateAclRequest::new(did, "admin")
.contexts(vec![context.to_string()])
.label(format!("did-git-sign · {label} (openvtc)"))
.capabilities(capabilities.iter().map(|c| (*c).to_string()).collect())
}
pub async fn vta_signs_sshsig(client: &VtaClient) -> bool {
match tokio::time::timeout(
VTA_TIMEOUT,
client.supported_trust_tasks(&[SIGN_SSHSIG_SLUG]),
)
.await
{
Ok(Ok(answer)) => answer
.supported_types
.iter()
.any(|t| t.contains(&format!("/{SIGN_SSHSIG_SLUG}/"))),
Ok(Err(e)) => {
tracing::debug!("trust-task-discovery failed; granting the legacy signer: {e}");
false
}
Err(_) => false,
}
}
pub fn check_context(context: &str, top_context_id: &str) -> Result<(), OpenVTCError> {
if context.is_empty() || context == top_context_id {
return Err(OpenVTCError::Config(
"this persona's keys are in the account's own VTA context, so a signing credential \
for it would reach every persona's keys. openvtc grants did-git-sign only a \
persona's own context; use a persona minted with its own context, or run \
`did-git-sign init` by hand if you accept that reach."
.into(),
));
}
Ok(())
}
async fn timed<T>(
what: &str,
fut: impl Future<Output = Result<T, VtaError>>,
) -> Result<T, OpenVTCError> {
match tokio::time::timeout(VTA_TIMEOUT, fut).await {
Ok(Ok(v)) => Ok(v),
Ok(Err(e)) => Err(OpenVTCError::Config(format!("{what}: {e}"))),
Err(_) => Err(OpenVTCError::Config(format!(
"{what}: the VTA did not answer within {} s",
VTA_TIMEOUT.as_secs()
))),
}
}
pub async fn verify_signer(
cred: &SignerCredential,
signer: &PersonaSigner,
endpoint: &VtaEndpoint,
) -> Result<(), OpenVTCError> {
let connected = timed(
"the signing credential could not connect to the VTA",
VtaClient::connect_auto(AutoConnect {
vta_url: &endpoint.vta_url,
vta_did: &endpoint.vta_did,
credential_did: &cred.did,
private_key_multibase: cred.private_key_mb.expose_secret(),
mediator_did: endpoint.mediator_did.as_deref(),
}),
)
.await?;
let remote = prove_remote_signing(&connected.client, signer).await;
if !matches!(remote, Ok(RemoteProof::NotOffered)) {
connected.client.shutdown().await;
return remote.map(|_| ());
}
let secret = timed(
"the VTA refused the signing credential the persona's key",
connected.client.get_key_secret(&signer.vta_key_id),
)
.await;
connected.client.shutdown().await;
let secret = secret?;
if secret.key_type != vta_sdk::keys::KeyType::Ed25519 {
return Err(OpenVTCError::Config(format!(
"the persona's signing key is {:?}; did-git-sign signs with Ed25519 only",
secret.key_type
)));
}
let seed = vta_sdk::did_key::decode_private_key_multibase(&secret.private_key_multibase)
.map_err(|e| OpenVTCError::Config(format!("the VTA's key could not be read: {e}")))?;
let public = SigningKey::from_bytes(&seed).verifying_key().to_bytes();
if public != signer.verifying_key {
return Err(OpenVTCError::Config(format!(
"the VTA's key {} is not the key {} publishes; refusing to sign with it",
signer.vta_key_id, signer.did_key_id
)));
}
Ok(())
}
enum RemoteProof {
Signed,
NotOffered,
}
const PROOF_MESSAGE: &[u8] = b"openvtc: proving did-git-sign's credential can sign";
async fn prove_remote_signing(
client: &VtaClient,
signer: &PersonaSigner,
) -> Result<RemoteProof, OpenVTCError> {
use did_git_sign::vta::{RemoteSignature, sign_sshsig};
let hash = vgi_core::sshsig_message_hash(PROOF_MESSAGE);
let answer = tokio::time::timeout(VTA_TIMEOUT, sign_sshsig(client, &signer.vta_key_id, &hash))
.await
.map_err(|_| {
OpenVTCError::Config(format!(
"the VTA did not sign within {} s",
VTA_TIMEOUT.as_secs()
))
})?
.map_err(|e| OpenVTCError::Config(format!("the VTA refused to sign: {e}")))?;
let raw = match answer {
RemoteSignature::Signed(raw) => raw,
RemoteSignature::Unsupported | RemoteSignature::NotPermitted(_) => {
return Ok(RemoteProof::NotOffered);
}
};
use ed25519_dalek_bip32::ed25519_dalek::{Signature, Verifier, VerifyingKey};
let key = VerifyingKey::from_bytes(&signer.verifying_key)
.map_err(|e| OpenVTCError::Config(format!("the persona's published key: {e}")))?;
let signature = Signature::from_slice(&raw)
.map_err(|e| OpenVTCError::Config(format!("the VTA's signature: {e}")))?;
key.verify(
&vgi_core::sshsig_signed_data(vgi_core::GIT_SSHSIG_NAMESPACE, &hash),
&signature,
)
.map_err(|_| {
OpenVTCError::Config(format!(
"the VTA's key {} is not the key {} publishes; refusing to sign with it",
signer.vta_key_id, signer.did_key_id
))
})?;
Ok(RemoteProof::Signed)
}
pub async fn grant_signer(
client: &VtaClient,
cred: &SignerCredential,
signer: &PersonaSigner,
endpoint: &VtaEndpoint,
top_context_id: &str,
) -> Result<(), OpenVTCError> {
check_context(&signer.context, top_context_id)?;
let capabilities = if vta_signs_sshsig(client).await {
SIGNER_CAPABILITIES
} else {
LEGACY_SIGNER_CAPABILITIES
};
timed(
"the VTA refused to grant did-git-sign the persona's context",
client.create_acl(signer_grant(
&cred.did,
&signer.context,
&signer.label,
capabilities,
)),
)
.await?;
if let Err(e) = verify_signer(cred, signer, endpoint).await {
if let Err(revoke) = timed("revoking it again", client.delete_acl(&cred.did)).await {
tracing::warn!(did = %cred.did, "unproven signer grant left in place: {revoke}");
}
return Err(e);
}
Ok(())
}
pub async fn revoke_signer(
client: &VtaClient,
credential_did: &str,
own_did: &str,
) -> Result<bool, OpenVTCError> {
if credential_did == own_did {
return Ok(false);
}
match tokio::time::timeout(VTA_TIMEOUT, client.delete_acl(credential_did)).await {
Ok(Ok(())) => Ok(true),
Ok(Err(VtaError::NotFound(_))) => Ok(false),
Ok(Err(e)) => Err(OpenVTCError::Config(format!(
"the VTA refused to revoke the signing credential: {e}"
))),
Err(_) => Err(OpenVTCError::Config(
"the VTA did not answer the revocation in time".into(),
)),
}
}
#[must_use]
pub fn profile_name(label: &str, did_key_id: &str, profiles: &Profiles) -> String {
if let Some(name) = profiles.name_of(did_key_id) {
return name.to_string();
}
let mut slug = String::new();
for c in label.chars() {
if c.is_ascii_alphanumeric() {
slug.push(c.to_ascii_lowercase());
} else if !slug.is_empty() && !slug.ends_with('-') {
slug.push('-');
}
}
let mut slug = slug.trim_matches('-').to_string();
slug.truncate(48);
let slug = slug.trim_end_matches('-');
let base = if slug.is_empty() {
"persona".to_string()
} else {
slug.to_string()
};
let base = if did_git_sign::profiles::validate_name(&base).is_ok() {
base
} else {
"persona".to_string()
};
if !profiles.profiles.contains_key(&base) {
return base;
}
(2..)
.map(|n| format!("{base}-{n}"))
.find(|n| !profiles.profiles.contains_key(n))
.unwrap_or(base)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InstalledIdentity {
pub profile: String,
pub include: PathBuf,
pub ssh_public_key: String,
pub replaced_credential: Option<String>,
}
pub fn install_identity(
signer: &PersonaSigner,
endpoint: &VtaEndpoint,
cred: &SignerCredential,
) -> Result<InstalledIdentity, OpenVTCError> {
let io = |what: &str, e: anyhow::Error| OpenVTCError::Config(format!("{what}: {e:#}"));
let mut profiles =
Profiles::load().map_err(|e| io("couldn't read did-git-sign's profiles", e))?;
let name = profile_name(&signer.label, &signer.did_key_id, &profiles);
let replaced_credential = did_git_sign::config::load_vta_credentials(&signer.did_key_id)
.ok()
.map(|c| c.credential_did)
.filter(|did| *did != cred.did);
let args = init::InstallArgs {
global: false,
did_key_id: signer.did_key_id.clone(),
vta_key_id: signer.vta_key_id.clone(),
credential_did: cred.did.clone(),
credential_private_key_mb: cred.private_key_mb.expose_secret().to_string(),
vta_did: endpoint.vta_did.clone(),
vta_url: endpoint.vta_url.clone(),
mediator_did: endpoint.mediator_did.clone(),
user_name: None,
verifying_key: &signer.verifying_key,
};
let default_exists = SigningConfig::default_global_path()
.map(|p| p.exists())
.unwrap_or(false);
let ssh_public_key = if default_exists {
init::add_identity(args).map_err(|e| io("couldn't store the signing identity", e))?
} else {
init::install(args)
.map_err(|e| io("couldn't store the signing identity", e))?
.ssh_public_key
};
profiles.profiles.insert(
name.clone(),
Profile {
did_key_id: signer.did_key_id.clone(),
vta_did: endpoint.vta_did.clone(),
context: Some(signer.context.clone()),
},
);
profiles
.save()
.map_err(|e| io("couldn't save did-git-sign's profiles", e))?;
let include = enable::write_include(&name, &signer.did_key_id)
.map_err(|e| io("couldn't write the signing settings", e))?;
Ok(InstalledIdentity {
profile: name,
include,
ssh_public_key,
replaced_credential,
})
}
#[derive(Debug, Default)]
pub struct RemovedIdentity {
pub credential_did: Option<String>,
pub profiles_removed: Vec<String>,
pub include_lines_removed: usize,
pub warnings: Vec<String>,
}
pub fn remove_identity(did_key_id: &str) -> Result<RemovedIdentity, OpenVTCError> {
let credential_did = did_git_sign::config::load_vta_credentials(did_key_id)
.ok()
.map(|c| c.credential_did);
let summary = init::uninstall(true, did_key_id)
.map_err(|e| OpenVTCError::Config(format!("couldn't remove the identity: {e:#}")))?;
let mut removed = RemovedIdentity {
credential_did,
include_lines_removed: summary.removed_include_lines,
warnings: summary.warnings,
..RemovedIdentity::default()
};
if let Ok(mut profiles) = Profiles::load() {
let names: Vec<String> = profiles
.profiles
.iter()
.filter(|(_, p)| p.did_key_id == did_key_id)
.map(|(n, _)| n.clone())
.collect();
for n in &names {
profiles.profiles.remove(n);
}
if !names.is_empty() {
if let Err(e) = profiles.save() {
removed
.warnings
.push(format!("couldn't update profiles.json: {e:#}"));
}
removed.profiles_removed = names;
}
}
Ok(removed)
}
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct IdentityStatus {
pub profile: Option<String>,
pub credential_did: Option<String>,
pub include: Option<PathBuf>,
pub default: bool,
}
impl IdentityStatus {
#[must_use]
pub fn ready(&self) -> bool {
self.profile.is_some() && self.credential_did.is_some() && self.include.is_some()
}
#[must_use]
pub fn any(&self) -> bool {
self.profile.is_some() || self.credential_did.is_some() || self.default
}
}
#[must_use]
pub fn identity_status(did_key_id: &str) -> IdentityStatus {
let profile = Profiles::load()
.ok()
.and_then(|p| p.name_of(did_key_id).map(str::to_string));
let include = profile
.as_deref()
.and_then(|n| enable::include_path(n).ok())
.filter(|p| enable::include_identity(p).as_deref() == Some(did_key_id));
IdentityStatus {
credential_did: did_git_sign::config::load_vta_credentials(did_key_id)
.ok()
.map(|c| c.credential_did),
default: SigningConfig::default_global_path()
.ok()
.and_then(|p| SigningConfig::load(&p).ok())
.is_some_and(|c| c.did_key_id == did_key_id),
profile,
include,
}
}
pub fn refresh_hooks() -> Result<(), OpenVTCError> {
init::install_hooks()
.map_err(|e| OpenVTCError::Config(format!("couldn't write did-git-sign's hooks: {e:#}")))
}
#[must_use]
pub fn hook_file() -> Option<PathBuf> {
enable::hooks_dir().ok().map(|d| d.join("commit-msg"))
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BinaryStatus {
Found {
version: String,
},
TooOld {
version: String,
},
Missing,
}
fn parse_version(out: &str) -> Option<(String, (u32, u32))> {
let version = out.split_whitespace().last()?.trim().to_string();
let mut parts = version.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
Some((version, (major, minor)))
}
#[must_use]
pub fn binary_status() -> BinaryStatus {
let Ok(out) = Command::new("did-git-sign")
.arg("--version")
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
else {
return BinaryStatus::Missing;
};
match parse_version(&String::from_utf8_lossy(&out.stdout)) {
Some((version, v)) if v >= MIN_BINARY => BinaryStatus::Found { version },
Some((version, _)) => BinaryStatus::TooOld { version },
None => BinaryStatus::Missing,
}
}
fn run_in(dir: &Path, args: &[&str]) -> Result<String, String> {
let mut child = Command::new("did-git-sign")
.args(args)
.current_dir(dir)
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("couldn't run did-git-sign: {e}"))?;
let started = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if started.elapsed() >= LOCAL_TIMEOUT => {
let _ = child.kill();
let _ = child.wait();
return Err("did-git-sign did not finish in time".into());
}
Ok(None) => std::thread::sleep(Duration::from_millis(50)),
Err(e) => return Err(format!("couldn't wait for did-git-sign: {e}")),
}
}
let out = child
.wait_with_output()
.map_err(|e| format!("couldn't read did-git-sign's output: {e}"))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
let err = String::from_utf8_lossy(&out.stderr);
let err = err.trim();
Err(err
.strip_prefix("Error: ")
.unwrap_or(err)
.lines()
.next()
.unwrap_or("did-git-sign failed")
.to_string())
}
}
pub fn enable_in(repo: &Path, profile: &str) -> Result<(), String> {
run_in(repo, &["enable", "--profile", profile]).map(|_| ())
}
pub fn disable_in(repo: &Path) -> Result<(), String> {
run_in(repo, &["disable"]).map(|_| ())
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CheckoutSigning {
Off,
On {
did_key_id: String,
profile: Option<String>,
here: bool,
},
NoClaim {
did_key_id: String,
hooks_path: String,
},
}
#[must_use]
pub fn checkout_signing(facts: &CheckoutFacts, profiles: &Profiles) -> CheckoutSigning {
let here = facts.includes.iter().any(|v| enable::is_our_include(v));
let signs = facts.ssh_program.as_deref() == Some("did-git-sign") && facts.gpgsign;
let Some(did_key_id) = facts.signing_key.clone().filter(|_| signs) else {
return CheckoutSigning::Off;
};
if let (Some(hooks_path), Ok(ours)) = (&facts.hooks_path, enable::hooks_dir()) {
let expanded = crate::git_workspace::expand_tilde(hooks_path);
if expanded != ours {
return CheckoutSigning::NoClaim {
did_key_id,
hooks_path: hooks_path.clone(),
};
}
}
CheckoutSigning::On {
profile: profiles.name_of(&did_key_id).map(str::to_string),
did_key_id,
here,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn profiles_with(name: &str, did: &str) -> Profiles {
let mut p = Profiles::default();
p.profiles.insert(
name.into(),
Profile {
did_key_id: did.into(),
vta_did: "did:webvh:vta".into(),
context: None,
},
);
p
}
#[test]
fn profile_names_are_slugs_and_unique() {
let none = Profiles::default();
assert_eq!(
profile_name("Alice @ Acme", "did:a#key-0", &none),
"alice-acme"
);
assert_eq!(profile_name(" ", "did:a#key-0", &none), "persona");
assert_eq!(profile_name("Ünïcode", "did:a#key-0", &none), "n-code");
let taken = profiles_with("alice", "did:other#key-0");
assert_eq!(profile_name("Alice", "did:a#key-0", &taken), "alice-2");
let mine = profiles_with("work", "did:a#key-0");
assert_eq!(profile_name("Alice", "did:a#key-0", &mine), "work");
for label in [
"Alice @ Acme",
"x",
"A very long persona label that keeps going on and on",
] {
let n = profile_name(label, "did:a#key-0", &none);
assert!(did_git_sign::profiles::validate_name(&n).is_ok(), "{n}");
}
}
#[test]
fn the_account_context_is_refused() {
assert!(check_context("openvtc", "openvtc").is_err());
assert!(check_context("", "openvtc").is_err());
assert!(check_context("openvtc/alice", "openvtc").is_ok());
}
#[test]
fn the_grant_is_narrowed_to_sshsig_signing_in_one_context() {
let req = signer_grant(
"did:key:z6Mk",
"openvtc/alice",
"Alice",
SIGNER_CAPABILITIES,
);
assert_eq!(req.role, "admin");
assert_eq!(req.allowed_contexts, vec!["openvtc/alice".to_string()]);
assert_eq!(req.capabilities, vec!["sign-sshsig".to_string()]);
assert!(
req.label
.as_deref()
.unwrap_or_default()
.contains("did-git-sign")
);
assert!(!req.handoff);
}
#[test]
fn an_older_vta_gets_the_key_export_grant() {
let req = signer_grant(
"did:key:z6Mk",
"openvtc/alice",
"Alice",
LEGACY_SIGNER_CAPABILITIES,
);
assert_eq!(req.capabilities, vec!["key-export".to_string()]);
}
#[test]
fn versions_are_read() {
assert_eq!(
parse_version("did-git-sign 0.15.1\n"),
Some(("0.15.1".into(), (0, 15)))
);
assert_eq!(parse_version("nonsense"), None);
assert!((0, 13) < MIN_BINARY && (0, 14) >= MIN_BINARY && (1, 0) >= MIN_BINARY);
}
fn facts(key: Option<&str>, program: Option<&str>, gpgsign: bool) -> CheckoutFacts {
CheckoutFacts {
is_repo: true,
signing_key: key.map(str::to_string),
ssh_program: program.map(str::to_string),
gpgsign,
..CheckoutFacts::default()
}
}
#[test]
fn a_checkout_signs_only_when_git_would_call_did_git_sign() {
let p = profiles_with("alice", "did:a#key-0");
assert_eq!(
checkout_signing(&facts(None, None, false), &p),
CheckoutSigning::Off
);
assert_eq!(
checkout_signing(&facts(Some("did:a#key-0"), Some("ssh-keygen"), true), &p),
CheckoutSigning::Off
);
assert_eq!(
checkout_signing(&facts(Some("did:a#key-0"), Some("did-git-sign"), false), &p),
CheckoutSigning::Off
);
let mut f = facts(Some("did:a#key-0"), Some("did-git-sign"), true);
f.hooks_path = enable::hooks_dir().ok().map(|d| d.display().to_string());
assert_eq!(
checkout_signing(&f, &p),
CheckoutSigning::On {
did_key_id: "did:a#key-0".into(),
profile: Some("alice".into()),
here: false
}
);
f.includes = vec![enable::include_path("alice").unwrap().display().to_string()];
assert!(matches!(
checkout_signing(&f, &p),
CheckoutSigning::On { here: true, .. }
));
f.hooks_path = Some(".husky".into());
assert!(matches!(
checkout_signing(&f, &p),
CheckoutSigning::NoClaim { .. }
));
}
}