From 0d8139617491b89a5bf900817738d3390b7c1d90 Mon Sep 17 00:00:00 2001 From: bordumb Date: Fri, 24 Jul 2026 04:09:09 +0100 Subject: [PATCH] Refactor git shellouts to libgit2 Auths-Id: did:keri:EMN-WRXNAkLfavKsaFHS0ehP7eB1s8a1alktBJoDhI7b Auths-Device: did:keri:EAswoxxXY6-kXqYcc3mUngY8GOiwhDwXxFfjWXzCvuW6 Auths-Anchor-Seq: 1 --- Cargo.lock | 1 + crates/auths-cli/src/commands/org.rs | 20 +- crates/auths-cli/src/commands/sign.rs | 369 ++---------------- .../auths-cli/src/commands/verify_commit.rs | 20 +- .../auths-cli/tests/cases/e2e_auths_sign.rs | 111 ++++++ crates/auths-cli/tests/cases/mod.rs | 1 + crates/auths-mcp-gateway/Cargo.toml | 1 + crates/auths-mcp-gateway/src/chain.rs | 272 ++++++------- crates/auths-sdk/Cargo.toml | 3 + .../src/workflows/agent_provision.rs | 82 ++-- .../auths-sdk/src/workflows/commit_signing.rs | 270 +++++++++++++ crates/auths-sdk/src/workflows/mod.rs | 2 + 12 files changed, 595 insertions(+), 557 deletions(-) create mode 100644 crates/auths-cli/tests/cases/e2e_auths_sign.rs create mode 100644 crates/auths-sdk/src/workflows/commit_signing.rs diff --git a/Cargo.lock b/Cargo.lock index 2f88049e..9a290f9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -768,6 +768,7 @@ dependencies = [ "base64", "chrono", "clap", + "git2", "hex", "metrics", "reqwest", diff --git a/crates/auths-cli/src/commands/org.rs b/crates/auths-cli/src/commands/org.rs index 680d2a7e..bd349b92 100644 --- a/crates/auths-cli/src/commands/org.rs +++ b/crates/auths-cli/src/commands/org.rs @@ -1274,19 +1274,15 @@ pub fn handle_org( } } -/// Read a raw git commit object (`git cat-file commit `). +/// Read a raw git commit object using git2. fn read_commit_object(sha: &str) -> Result { - let out = std::process::Command::new("git") - .args(["cat-file", "commit", sha]) - .output() - .context("failed to run git cat-file")?; - if !out.status.success() { - return Err(anyhow!( - "git cat-file commit {sha} failed: {}", - String::from_utf8_lossy(&out.stderr).trim() - )); - } - String::from_utf8(out.stdout).context("commit object is not valid UTF-8") + let repo = git2::Repository::discover(".").context("failed to discover git repository")?; + let oid = git2::Oid::from_str(sha).context("invalid SHA format")?; + let odb = repo.odb().context("failed to get git ODB")?; + let obj = odb + .read(oid) + .context("failed to read commit object from database")?; + String::from_utf8(obj.data().to_vec()).context("commit object is not valid UTF-8") } /// Parse the `Auths-Anchor-Seq` trailer value from a raw commit, if present. diff --git a/crates/auths-cli/src/commands/sign.rs b/crates/auths-cli/src/commands/sign.rs index c9c296a0..70849c17 100644 --- a/crates/auths-cli/src/commands/sign.rs +++ b/crates/auths-cli/src/commands/sign.rs @@ -62,101 +62,6 @@ const ARTIFACT_EXTENSIONS: &[&str] = &[ ".pkg", ".nupkg", ]; -/// Reject capability scope values that carry control characters. -/// -/// A scope value rides in a single-line `Auths-Scope` commit trailer; a newline -/// (or other control character) would split it into an attacker-chosen extra -/// trailer — for example a second `Auths-Id` — which a verifier would then -/// resolve instead of the real signer. -/// -/// Args: -/// * `scope`: The capability tokens supplied via `--scope`. -/// -/// Usage: -/// ```ignore -/// validate_scope(&scope)?; -/// ``` -fn validate_scope(scope: &[String]) -> Result<()> { - for value in scope { - if value.chars().any(char::is_control) { - anyhow::bail!( - "Invalid --scope value {value:?}: control characters (including newlines) are not allowed" - ); - } - } - Ok(()) -} - -/// Build the in-band signer trailers for the local machine's signing identity: -/// `Auths-Id` = root identity, `Auths-Device` = signing device, and (when the root -/// KEL tip is known) `Auths-Anchor-Seq` = the delegator-anchoring position at -/// signing, so a verifier can order this commit against a later revocation by KEL -/// position. The trailers ride in the commit message body, covered by the signature. -fn commit_trailer_args(signer: &LocalSigner, scope: &[String]) -> Vec { - let mut trailers = vec![ - format!("Auths-Id: {}", signer.root_did), - format!("Auths-Device: {}", signer.signer_did), - ]; - if let Some(seq) = signer.anchor_seq { - trailers.push(auths_verifier::anchor_seq_trailer(seq)); - } - // The capabilities this commit claims it exercises. A verifier rejects a claim - // outside the signer's delegator-anchored grant (`CommitVerdict::OutsideAgentScope`). - if !scope.is_empty() { - trailers.push(auths_verifier::scope_trailer(scope)); - } - trailers -} - -/// Resolve the local signing identity → the trailer values to embed in-band. -/// -/// Resolution reads identity + registry only (no key decryption), so it needs no -/// passphrase. Fails clearly when this machine has no resolvable signing identity. -fn resolve_signer_trailer( - repo_opt: Option<&Path>, - env_config: &EnvironmentConfig, -) -> Result { - let repo_path = - auths_sdk::storage_layout::resolve_repo_path(repo_opt.map(|p| p.to_path_buf()))?; - let ctx = crate::factories::storage::build_auths_context(&repo_path, env_config, None) - .context("Failed to build auths context for commit signing")?; - resolve_local_signer(&ctx).map_err(anyhow::Error::from).context( - "Could not resolve the local signing identity. Run `auths init`, or pair this device with `auths pair --join`.", - ) -} - -/// Execute `git rebase --exec` to re-sign a range, embedding the signer trailers -/// per commit (the amend re-signs over the trailered message). -/// -/// Args: -/// * `base` - The exclusive base ref (commits after this ref will be re-signed). -/// * `trailers` - The `Auths-Id` / `Auths-Device` trailer strings. -fn execute_git_rebase(base: &str, trailers: &[String]) -> Result<()> { - // did:keri values and integer sequences are `[A-Za-z0-9_:.\- ]`, safe to - // single-quote in the exec shell. - let trailer_flags: String = trailers - .iter() - .map(|t| format!(" --trailer '{}'", t)) - .collect(); - // `-c trailer.ifexists=replace`: re-signing a commit that already carries an - // Auths-* trailer replaces it in place rather than appending a second copy, so - // a re-signed commit (the recovery rewrite) keeps exactly one trailer per token. - let exec_cmd = format!( - "git -c trailer.ifexists=replace commit --amend -C HEAD --no-verify{trailer_flags}" - ); - let output = crate::subprocess::git_command(&["rebase", "--exec", &exec_cmd, base]) - .output() - .context("Failed to spawn git rebase")?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow!( - "Failed to re-sign commits. Check for uncommitted changes or rebase conflicts.\n\nGit reported: {}", - stderr.trim() - )); - } - Ok(()) -} - /// Ensure the signer's root is pinned in the repo's committed `.auths/roots` and /// staged, so the pin (the trust declaration teammates and CI inherit) lands with /// the next commit. Idempotent and best-effort — a pin failure never fails the @@ -184,162 +89,21 @@ fn ensure_repo_root_pin(signer: &LocalSigner) { } } -/// Resolve a git ref/range into the list of commit SHAs the amend rewrote, so we -/// can confirm each one actually carries a signature. -/// -/// `HEAD`-style single refs resolve to that one commit; `base..tip` ranges resolve -/// to every commit the rebase re-signed. -fn resolve_signed_range_shas(range: &str) -> Result> { - let rev_arg = if range.contains("..") { - range.to_string() - } else { - format!("{range}^!") - }; - let output = crate::subprocess::git_command(&["rev-list", &rev_arg]) - .output() - .context("Failed to list commits to confirm signing")?; - if !output.status.success() { - return Err(anyhow!( - "Could not resolve '{}' to confirm the signature landed.\n\nGit reported: {}", - range, - String::from_utf8_lossy(&output.stderr).trim() - )); - } - Ok(String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::to_string) - .collect()) -} - -/// Confirm every commit in `range` actually carries an SSH signature after the amend. -/// -/// The amend embeds the trailers and *asks* git to sign, but git only signs when a -/// signing program is configured (`gpg.format ssh` + `gpg.ssh.program`). When it is -/// not, the rewrite lands unsigned — and `auths verify` would then call the commit -/// `No signature found`. We use the verifier's own `gpgsig` detection as the single -/// source of truth so success is never claimed for a commit the verifier rejects. -fn ensure_commits_signed(range: &str) -> Result<()> { - let shas = resolve_signed_range_shas(range)?; - for sha in &shas { - let raw = read_raw_commit_object(sha)?; - if !auths_verifier::commit_object_is_signed(&raw) { - return Err(anyhow!( - "Commit {} was amended but no signature was attached, so `auths verify` would \ - call it unsigned. Configure git SSH signing first — run `auths doctor --fix` \ - (sets gpg.format=ssh, gpg.ssh.program=auths-sign, commit.gpgsign=true).", - short_sha(sha) - )); - } - } - Ok(()) -} - -/// The raw git commit object (`git cat-file commit `) — the bytes a verifier -/// reads to decide whether a `gpgsig` SSH block is present. -fn read_raw_commit_object(sha: &str) -> Result { - let output = crate::subprocess::git_command(&["cat-file", "commit", sha]) - .output() - .context("Failed to read commit object to confirm signing")?; - if !output.status.success() { - return Err(anyhow!( - "git cat-file commit {} failed: {}", - short_sha(sha), - String::from_utf8_lossy(&output.stderr).trim() - )); - } - String::from_utf8(output.stdout).context("Commit object is not valid UTF-8") -} - -/// First 8 chars of a SHA for human-readable messages. -fn short_sha(sha: &str) -> &str { - sha.get(..8).unwrap_or(sha) -} - -/// Sign a Git commit range, embedding the `Auths-Id` / `Auths-Device` trailers -/// in-band so a verifier knows which KEL to replay. Amending triggers auths-sign -/// via git's signing program; the trailers (one per token — re-signing replaces -/// rather than appends, via `trailer.ifexists=replace`) are part of the signed -/// message body. +/// Resolve the local signing identity → the trailer values to embed in-band. /// -/// Args: -/// * `range` - A git ref or range (e.g., "HEAD", "main..HEAD"). -/// * `signer` - The resolved local signing identity (root + device DIDs). -/// * `scope` - Capabilities this commit claims (emitted as an `Auths-Scope` trailer). -fn sign_commit_range( - range: &str, - signer: &LocalSigner, - scope: &[String], - autostash: bool, -) -> Result<()> { - ensure_repo_root_pin(signer); - validate_scope(scope)?; - - let mut stashed = false; - if autostash - && let Ok(out) = crate::subprocess::git_command(&["status", "--porcelain"]).output() - && !out.stdout.is_empty() - { - let _ = - crate::subprocess::git_command(&["stash", "push", "-m", "auths-autostash"]).output(); - stashed = true; - } - - let trailers = commit_trailer_args(signer, scope); - let is_range = range.contains(".."); - let res = if is_range { - let parts: Vec<&str> = range.splitn(2, "..").collect(); - let base = parts[0]; - execute_git_rebase(base, &trailers) - } else { - // `-c trailer.ifexists=replace`: amending a commit that already carries an - // Auths-* trailer (a re-sign) replaces that trailer in place instead of - // appending a duplicate, so the message keeps exactly one trailer per token. - let mut args: Vec<&str> = vec![ - "-c", - "trailer.ifexists=replace", - "commit", - "--amend", - "--no-edit", - "--no-verify", - ]; - for trailer in &trailers { - args.push("--trailer"); - args.push(trailer); - } - let output = crate::subprocess::git_command(&args) - .output() - .context("Failed to spawn git commit --amend")?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(anyhow!( - "Failed to amend commit with signature. Ensure you have a commit to amend and no conflicting changes.\n\nGit reported: {}", - stderr.trim() - )) - } else { - Ok(()) - } - }; - - if stashed { - let _ = crate::subprocess::git_command(&["stash", "pop"]).output(); - } - - res?; - - // The amend succeeded, but git only attaches a signature when a signing program - // is configured. Confirm one actually landed before claiming success — otherwise - // `auths verify` would call this commit unsigned and the success line would lie. - ensure_commits_signed(range)?; - if crate::ux::format::is_json_mode() { - crate::ux::format::JsonResponse::success( - "sign", - &serde_json::json!({ "target": range, "type": "commit" }), - ) - .print()?; - } else { - println!("✔ Signed: {}", range); - } - Ok(()) +/// Resolution reads identity + registry only (no key decryption), so it needs no +/// passphrase. Fails clearly when this machine has no resolvable signing identity. +fn resolve_signer_trailer( + repo_opt: Option<&Path>, + env_config: &EnvironmentConfig, +) -> Result { + let repo_path = + auths_sdk::storage_layout::resolve_repo_path(repo_opt.map(|p| p.to_path_buf()))?; + let ctx = crate::factories::storage::build_auths_context(&repo_path, env_config, None) + .context("Failed to build auths context for commit signing")?; + resolve_local_signer(&ctx).map_err(anyhow::Error::from).context( + "Could not resolve the local signing identity. Run `auths init`, or pair this device with `auths pair --join`.", + ) } /// Sign a Git commit or artifact file. @@ -448,7 +212,24 @@ pub fn handle_sign_unified( } SignTarget::CommitRange(range) => { let signer = resolve_signer_trailer(repo_opt.as_deref(), env_config)?; - sign_commit_range(&range, &signer, &cmd.scope, cmd.autostash) + ensure_repo_root_pin(&signer); + auths_sdk::workflows::commit_signing::sign_commit_range( + &range, + &signer, + &cmd.scope, + cmd.autostash, + )?; + + if crate::ux::format::is_json_mode() { + crate::ux::format::JsonResponse::success( + "sign", + &serde_json::json!({ "target": range, "type": "commit" }), + ) + .print()?; + } else { + println!("✔ Signed: {}", range); + } + Ok(()) } } } @@ -468,92 +249,6 @@ impl crate::commands::executable::ExecutableCommand for SignCommand { mod tests { use super::*; - #[test] - fn commit_trailer_args_emit_auths_id_and_device() { - let signer = LocalSigner { - signer_did: "did:keri:Edevice".to_string(), - root_did: "did:keri:Eroot".to_string(), - anchor_seq: None, - }; - let trailers = commit_trailer_args(&signer, &[]); - assert_eq!(trailers[0], "Auths-Id: did:keri:Eroot"); - assert_eq!(trailers[1], "Auths-Device: did:keri:Edevice"); - assert_eq!( - trailers.len(), - 2, - "no anchor seq + no scope → only Auths-Id/Auths-Device" - ); - } - - #[test] - fn trailer_carries_signing_sequence() { - let signer = LocalSigner { - signer_did: "did:keri:Edevice".to_string(), - root_did: "did:keri:Eroot".to_string(), - anchor_seq: Some(7), - }; - let trailers = commit_trailer_args(&signer, &[]); - assert_eq!(trailers.len(), 3); - assert_eq!(trailers[2], "Auths-Anchor-Seq: 7"); - } - - #[test] - fn commit_trailer_args_emit_scope_claim() { - let signer = LocalSigner { - signer_did: "did:keri:Eagent".to_string(), - root_did: "did:keri:Eroot".to_string(), - anchor_seq: Some(3), - }; - let trailers = - commit_trailer_args(&signer, &["sign_commit".to_string(), "open-PR".to_string()]); - // Auths-Id, Auths-Device, Auths-Anchor-Seq, Auths-Scope (last). - assert_eq!(trailers.len(), 4); - assert_eq!(trailers[3], "Auths-Scope: sign_commit,open-PR"); - // Round-trips through the verifier's own formatter. - assert_eq!( - trailers[3], - auths_verifier::scope_trailer(&["sign_commit".to_string(), "open-PR".to_string()]) - ); - } - - #[test] - fn commit_trailer_args_no_scope_omits_trailer() { - let signer = LocalSigner { - signer_did: "did:keri:Eagent".to_string(), - root_did: "did:keri:Eroot".to_string(), - anchor_seq: None, - }; - let trailers = commit_trailer_args(&signer, &[]); - assert!( - !trailers.iter().any(|t| t.starts_with("Auths-Scope")), - "no scope claim → no Auths-Scope trailer (backward compatible)" - ); - } - - #[test] - fn validate_scope_rejects_control_chars() { - // A newline would split the single-line Auths-Scope trailer, injecting an - // attacker-chosen trailer (e.g. a forged Auths-Id) into the signed body. - assert!(validate_scope(&["legit\nAuths-Id: did:keri:Eattacker".to_string()]).is_err()); - assert!(validate_scope(&["carriage\rreturn".to_string()]).is_err()); - assert!(validate_scope(&["tab\there".to_string()]).is_err()); - assert!(validate_scope(&["sign_commit".to_string(), "open-PR".to_string()]).is_ok()); - assert!(validate_scope(&[]).is_ok()); - } - - #[test] - fn commit_trailer_args_root_machine_signs_directly() { - // On the root machine signer == root → both trailers carry the same DID. - let signer = LocalSigner { - signer_did: "did:keri:Eroot".to_string(), - root_did: "did:keri:Eroot".to_string(), - anchor_seq: None, - }; - let trailers = commit_trailer_args(&signer, &[]); - assert_eq!(trailers[0], "Auths-Id: did:keri:Eroot"); - assert_eq!(trailers[1], "Auths-Device: did:keri:Eroot"); - } - #[test] fn test_parse_sign_target_commit_ref() { let target = parse_sign_target("HEAD"); diff --git a/crates/auths-cli/src/commands/verify_commit.rs b/crates/auths-cli/src/commands/verify_commit.rs index 1fbe35e5..39202f22 100644 --- a/crates/auths-cli/src/commands/verify_commit.rs +++ b/crates/auths-cli/src/commands/verify_commit.rs @@ -653,19 +653,15 @@ async fn verify_one_commit( result } -/// The raw git commit object (headers + message + `gpgsig`), exactly as produced by -/// `git cat-file commit ` — the bytes the SSH signature is computed over. +/// The raw git commit object (headers + message + `gpgsig`), read using `git2` — the bytes the SSH signature is computed over. fn raw_commit_object(sha: &str) -> Result { - let output = git_command(&["cat-file", "commit", sha]) - .output() - .context("Failed to run git cat-file")?; - if !output.status.success() { - return Err(anyhow!( - "git cat-file commit {sha} failed: {}", - String::from_utf8_lossy(&output.stderr) - )); - } - String::from_utf8(output.stdout).context("Commit object is not valid UTF-8") + let repo = git2::Repository::discover(".").context("failed to discover git repository")?; + let oid = git2::Oid::from_str(sha).context("invalid SHA format")?; + let odb = repo.odb().context("failed to get git ODB")?; + let obj = odb + .read(oid) + .context("Failed to read commit object from database")?; + String::from_utf8(obj.data().to_vec()).context("Commit object is not valid UTF-8") } /// Map a [`CommitVerdict`] onto a CLI result row: the valid flag, the verified signer, diff --git a/crates/auths-cli/tests/cases/e2e_auths_sign.rs b/crates/auths-cli/tests/cases/e2e_auths_sign.rs new file mode 100644 index 00000000..e7b609e6 --- /dev/null +++ b/crates/auths-cli/tests/cases/e2e_auths_sign.rs @@ -0,0 +1,111 @@ +use crate::cases::helpers::TestEnv; + +#[test] +fn test_e2e_auths_sign_preserves_ssh_signature() { + let env = TestEnv::new(); + + // 1. Create a raw commit BEFORE provisioning ANY identity or agent. + // This ensures no hooks run, so the commit has no trailers. + std::fs::write(env.repo_path.join("dummy.txt"), "hello e2e auths sign test").unwrap(); + let output = env.git_cmd().args(["add", "dummy.txt"]).output().unwrap(); + assert!(output.status.success(), "Git add failed"); + + let output = env + .git_cmd() + .args([ + "commit", + "--no-verify", + "-m", + "Initial commit without trailers", + ]) + .output() + .unwrap(); + assert!(output.status.success(), "Git commit failed"); + + // Initialize the identity AFTER the raw commit is made + env.init_identity(); + + // Verify the commit has NO trailers and NO signature yet + let output = env + .git_cmd() + .args(["cat-file", "commit", "HEAD"]) + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(!stdout.contains("Auths-Id: did:keri:")); + assert!(!stdout.contains("-----BEGIN SSH SIGNATURE-----")); + + // 2. Provision the agent, which configures `gpg.ssh.program` and hooks + let output = env + .cmd("auths") + .args([ + "agent", + "provision", + "--label", + "ci-agent", + "--profile", + "ci", + ]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "Agent provision failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // 3. Run `auths sign` to retroactively sign HEAD. + // We must execute it inside bash sourcing `env.sh` to have access to auths-sign in PATH + let mut bash_cmd = std::process::Command::new("bash"); + bash_cmd.current_dir(&env.repo_path); + + let path = std::env::var("PATH").unwrap_or_default(); + let target_dir = assert_cmd::cargo::cargo_bin("auths-sign"); + let bin_dir = target_dir.parent().unwrap().to_path_buf(); + // Also include the auths binary directory so `auths` command is found + let auths_dir = assert_cmd::cargo::cargo_bin("auths"); + let auths_bin_dir = auths_dir.parent().unwrap().to_path_buf(); + let env_path = format!("{}:{}:{}", bin_dir.display(), auths_bin_dir.display(), path); + + bash_cmd + .env("HOME", env.home.path()) + .env("PATH", env_path) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", env.home.path().join(".gitconfig")) + .arg("-c") + .arg("source ~/.auths-agents/ci-agent/env.sh && auths sign HEAD"); + + let output = bash_cmd.output().unwrap(); + assert!( + output.status.success(), + "Bash auths sign failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + // 4. Verify the rewritten commit has trailers AND an SSH signature + let output = env + .git_cmd() + .args(["cat-file", "commit", "HEAD"]) + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + + // If `auths sign` accidentally gets migrated to `libgit2` in the future, + // this test will FAIL because libgit2 does not automatically invoke gpg.ssh.program + assert!( + stdout.contains("Auths-Id: did:keri:"), + "Missing Auths-Id trailer in rewritten commit" + ); + assert!( + stdout.contains("Auths-Device: did:keri:"), + "Missing Auths-Device trailer in rewritten commit" + ); + assert!( + stdout.contains("-----BEGIN SSH SIGNATURE-----"), + "Missing SSH signature block in rewritten commit. (Did someone replace `git commit --amend` with libgit2?)" + ); +} diff --git a/crates/auths-cli/tests/cases/mod.rs b/crates/auths-cli/tests/cases/mod.rs index c8c388c4..f55815fd 100644 --- a/crates/auths-cli/tests/cases/mod.rs +++ b/crates/auths-cli/tests/cases/mod.rs @@ -1,6 +1,7 @@ mod clap_collision; mod config_repo; mod doctor; +mod e2e_auths_sign; mod e2e_git_hook; mod expand_rotate; mod forgery_resistance; diff --git a/crates/auths-mcp-gateway/Cargo.toml b/crates/auths-mcp-gateway/Cargo.toml index 9a1ddf41..f21ad2e5 100644 --- a/crates/auths-mcp-gateway/Cargo.toml +++ b/crates/auths-mcp-gateway/Cargo.toml @@ -58,6 +58,7 @@ anyhow = "1" # Payment-path observability (#7): Prometheus metrics behind an opt-in /metrics endpoint. auths-telemetry = { workspace = true } metrics = "0.24" +git2.workspace = true [dev-dependencies] # The wrap↔gate cross-rail budget parity test drives the durable verifier-held diff --git a/crates/auths-mcp-gateway/src/chain.rs b/crates/auths-mcp-gateway/src/chain.rs index a55d02c9..720e049b 100644 --- a/crates/auths-mcp-gateway/src/chain.rs +++ b/crates/auths-mcp-gateway/src/chain.rs @@ -397,45 +397,37 @@ impl Chain { std::fs::write(work.join("call.json"), canonical)?; // Fresh repo so each call is its own signed commit. - must( - Command::new("git").arg("init").arg("-q").current_dir(&work), - "git init (per-call work repo)", - )?; + let repo = git2::Repository::init(&work)?; + // Configure git to sign as the agent through `auths-sign` (git's SSH signing // program). `auths sign HEAD` amends the commit, which triggers this signer. let sign_prog = locate_auths_sign(&self.auths_bin)?; - for (k, v) in [ - ("gpg.format", "ssh".to_string()), - ("gpg.ssh.program", sign_prog.to_string_lossy().to_string()), - ("user.signingkey", format!("auths:{}", self.agent_alias)), - ("commit.gpgsign", "true".to_string()), - ("user.name", self.agent_alias.clone()), - ("user.email", format!("{}@auths.local", self.agent_alias)), - ] { - must( - Command::new("git") - .args(["config", k, &v]) - .current_dir(&work), - "git config (agent signer)", - )?; + { + let mut config = repo.config()?; + config.set_str("gpg.format", "ssh")?; + config.set_str("gpg.ssh.program", &sign_prog.to_string_lossy())?; + config.set_str("user.signingkey", &format!("auths:{}", self.agent_alias))?; + config.set_bool("commit.gpgsign", true)?; + config.set_str("user.name", &self.agent_alias)?; + config.set_str("user.email", &format!("{}@auths.local", self.agent_alias))?; } - must( - Command::new("git") - .args(["add", "call.json"]) - .current_dir(&work), - "git add", - )?; + + let mut index = repo.index()?; + index.add_path(std::path::Path::new("call.json"))?; + let tree_id = index.write_tree()?; + let tree = repo.find_tree(tree_id)?; + // The signed `Auths-Prev` trailer links this call to the prior spend-log record (the hash of // its commit), or a fixed genesis sentinel for the first — so the offline audit can verify // the log is a continuous chain and catch a DROPPED or reordered record, not only an edited // one. The SSH signature applied below covers it. - must( - Command::new("git") - .args(["commit", "-qm", "tools/call", "--no-gpg-sign"]) - .args(["--trailer", &format!("Auths-Prev:{prev_binding}")]) - .current_dir(&work), - "git commit", + let sig = git2::Signature::now( + &self.agent_alias, + &format!("{}@auths.local", self.agent_alias), )?; + let msg = format!("tools/call\n\nAuths-Prev:{}", prev_binding); + repo.commit(Some("HEAD"), &sig, &sig, &msg, &tree, &[])?; + // Sign as the agent against its delegate-machine registry, claiming the // capability the call exercises (the verifier checks it ⊆ anchored scope). must( @@ -445,23 +437,17 @@ impl Chain { .current_dir(&work), "auths sign HEAD --scope", )?; - let sha = must( - Command::new("git") - .args(["rev-parse", "HEAD"]) - .current_dir(&work), - "git rev-parse HEAD", - )?; - let sha = String::from_utf8_lossy(&sha.stdout).trim().to_string(); - // The raw commit object text the verifier parses (gpgsig + trailers). - let raw = must( - Command::new("git") - .args(["cat-file", "commit", &sha]) - .current_dir(&work), - "git cat-file commit", - )?; - self.inproc.learn_call(capability, &raw.stdout); + + // Read back the amended, signed commit + let head_commit = repo.head()?.peel_to_commit()?; + let sha = head_commit.id().to_string(); + let odb = repo.odb()?; + let obj = odb.read(head_commit.id())?; + let raw_stdout = obj.data().to_vec(); + + self.inproc.learn_call(capability, &raw_stdout); metrics::counter!(crate::metrics_http::SIGN_TOTAL, "path" => "subprocess").increment(1); - Ok((raw.stdout, sha)) + Ok((raw_stdout, sha)) } /// Sign a SETTLEMENT commit: the agent attests its OWN settled cost under the dedicated @@ -497,52 +483,42 @@ impl Chain { std::fs::create_dir_all(&work)?; // A minimal payload so the commit has a tree; the cost lives in the SIGNED trailers below. std::fs::write(work.join("settle.json"), b"{}")?; - must( - Command::new("git").arg("init").arg("-q").current_dir(&work), - "git init (per-settlement work repo)", - )?; + // Fresh repo so each settlement is its own signed commit. + let repo = git2::Repository::init(&work)?; + let sign_prog = locate_auths_sign(&self.auths_bin)?; - for (k, v) in [ - ("gpg.format", "ssh".to_string()), - ("gpg.ssh.program", sign_prog.to_string_lossy().to_string()), - ("user.signingkey", format!("auths:{}", self.agent_alias)), - ("commit.gpgsign", "true".to_string()), - ("user.name", self.agent_alias.clone()), - ("user.email", format!("{}@auths.local", self.agent_alias)), - ] { - must( - Command::new("git") - .args(["config", k, &v]) - .current_dir(&work), - "git config (agent signer)", - )?; + { + let mut config = repo.config()?; + config.set_str("gpg.format", "ssh")?; + config.set_str("gpg.ssh.program", &sign_prog.to_string_lossy())?; + config.set_str("user.signingkey", &format!("auths:{}", self.agent_alias))?; + config.set_bool("commit.gpgsign", true)?; + config.set_str("user.name", &self.agent_alias)?; + config.set_str("user.email", &format!("{}@auths.local", self.agent_alias))?; } - must( - Command::new("git") - .args(["add", "settle.json"]) - .current_dir(&work), - "git add", - )?; + + let mut index = repo.index()?; + index.add_path(std::path::Path::new("settle.json"))?; + let tree_id = index.write_tree()?; + let tree = repo.find_tree(tree_id)?; + // The settled cost as SIGNED trailers — the SSH signature covers the whole message. - must( - Command::new("git") - .args(["commit", "-qm", "tools/settle", "--no-gpg-sign"]) - .args(["--trailer", &format!("Auths-Settle-Call:{call_binding}")]) - .args(["--trailer", &format!("Auths-Settle-Rail:{rail}")]) - // The settled cost/cumulative are stamped as raw cent integers (the audit parses - // them back with `parse::()`) — unwrap Cents at this trailer-format boundary. - .args([ - "--trailer", - &format!("Auths-Settle-Cents:{}", actual.get().get()), - ]) - .args(["--trailer", &format!("Auths-Settle-Ref:{rail_ref}")]) - .args([ - "--trailer", - &format!("Auths-Settle-Cumulative:{}", cumulative_cents.get()), - ]) - .current_dir(&work), - "git commit (settlement, signed cost trailers)", + let sig = git2::Signature::now( + &self.agent_alias, + &format!("{}@auths.local", self.agent_alias), )?; + let msg = format!( + "tools/settle\n\n\ + Auths-Settle-Call:{call_binding}\n\ + Auths-Settle-Rail:{rail}\n\ + Auths-Settle-Cents:{}\n\ + Auths-Settle-Ref:{rail_ref}\n\ + Auths-Settle-Cumulative:{}", + actual.get().get(), + cumulative_cents.get() + ); + repo.commit(Some("HEAD"), &sig, &sig, &msg, &tree, &[])?; + must( Command::new(&self.auths_bin) .args(["--repo", &self.agent_repo.to_string_lossy(), "sign", "HEAD"]) @@ -550,21 +526,15 @@ impl Chain { .current_dir(&work), "auths sign HEAD --scope settle", )?; - let sha = must( - Command::new("git") - .args(["rev-parse", "HEAD"]) - .current_dir(&work), - "git rev-parse HEAD", - )?; - let sha = String::from_utf8_lossy(&sha.stdout).trim().to_string(); - let raw = must( - Command::new("git") - .args(["cat-file", "commit", &sha]) - .current_dir(&work), - "git cat-file commit", - )?; - self.inproc.learn_settlement(&raw.stdout); - Ok((raw.stdout, sha)) + + let head_commit = repo.head()?.peel_to_commit()?; + let sha = head_commit.id().to_string(); + let odb = repo.odb()?; + let obj = odb.read(head_commit.id())?; + let raw_stdout = obj.data().to_vec(); + + self.inproc.learn_settlement(&raw_stdout); + Ok((raw_stdout, sha)) } } @@ -616,76 +586,52 @@ fn materialize_agent_machine(org: &Path, agent: &Path, root_did: &str) -> anyhow Command::new("cp").arg("-R").arg(org).arg(agent), "cp -R org registry → agent machine", )?; - let git_dir = agent.join(".git"); - let gd = git_dir.to_string_lossy().to_string(); - let idx = git_dir.join("tmp-index"); - let idx_s = idx.to_string_lossy().to_string(); - must( - Command::new("git") - .args(["--git-dir", &gd, "read-tree", "refs/auths/registry"]) - .env("GIT_INDEX_FILE", &idx_s), - "read-tree refs/auths/registry", - )?; - let listed = must( - Command::new("git") - .args(["--git-dir", &gd, "ls-files"]) - .env("GIT_INDEX_FILE", &idx_s), - "ls-files (agent index)", - )?; + let repo = git2::Repository::open(agent)?; + let reference = repo.find_reference("refs/auths/registry")?; + let commit = reference.peel_to_commit()?; + let tree = commit.tree()?; + + let mut index = repo.index()?; + index.read_tree(&tree)?; + let subtree = format!( "identities/{}/{}/{}/", &root_pfx[0..2.min(root_pfx.len())], &root_pfx[2..4.min(root_pfx.len())], root_pfx ); - for line in String::from_utf8_lossy(&listed.stdout).lines() { - if line.contains(&subtree) { - must( - Command::new("git") - .args(["--git-dir", &gd, "rm", "--cached", "-q", "--", line]) - .env("GIT_INDEX_FILE", &idx_s), - "rm --cached (drop org icp subtree)", - )?; + + let mut paths_to_remove = Vec::new(); + for entry in index.iter() { + if let Some(path_str) = String::from_utf8_lossy(&entry.path).to_string().into() { + if path_str.contains(&subtree) { + paths_to_remove.push(entry.path.clone()); + } } } - let tree = must( - Command::new("git") - .args(["--git-dir", &gd, "write-tree"]) - .env("GIT_INDEX_FILE", &idx_s), - "write-tree (agent-only)", - )?; - let tree = String::from_utf8_lossy(&tree.stdout).trim().to_string(); - let parent = must( - Command::new("git").args(["--git-dir", &gd, "rev-parse", "refs/auths/registry"]), - "rev-parse refs/auths/registry", - )?; - let parent = String::from_utf8_lossy(&parent.stdout).trim().to_string(); - let commit = must( - Command::new("git").args([ - "--git-dir", - &gd, - "commit-tree", - &tree, - "-p", - &parent, - "-m", - "agent-only", - ]), - "commit-tree (agent-only)", - )?; - let commit = String::from_utf8_lossy(&commit.stdout).trim().to_string(); - must( - Command::new("git").args([ - "--git-dir", - &gd, - "update-ref", - "refs/auths/registry", - &commit, - ]), - "update-ref refs/auths/registry", + + for path_bytes in paths_to_remove { + if let Ok(path_str) = std::str::from_utf8(&path_bytes) { + let path = std::path::Path::new(path_str); + index.remove_dir(path, 0).ok(); + index.remove(path, 0).ok(); + } + } + + let new_tree_oid = index.write_tree()?; + let new_tree = repo.find_tree(new_tree_oid)?; + + let sig = git2::Signature::now("Auths Agent Provision", "agent@auths.local")?; + repo.commit( + Some("refs/auths/registry"), + &sig, + &sig, + "agent-only", + &new_tree, + &[&commit], )?; - std::fs::remove_file(&idx).ok(); + Ok(()) } diff --git a/crates/auths-sdk/Cargo.toml b/crates/auths-sdk/Cargo.toml index bd596eae..b85edeab 100644 --- a/crates/auths-sdk/Cargo.toml +++ b/crates/auths-sdk/Cargo.toml @@ -54,6 +54,9 @@ reqwest = { version = "0.13.2", default-features = false, features = ["rustls", auths-pairing-daemon = { workspace = true, optional = true } tokio = { workspace = true, optional = true } +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +git2.workspace = true + [features] test-utils = ["auths-id/test-utils"] mcp = ["dep:reqwest"] diff --git a/crates/auths-sdk/src/workflows/agent_provision.rs b/crates/auths-sdk/src/workflows/agent_provision.rs index ae42b19a..dd87d7ef 100644 --- a/crates/auths-sdk/src/workflows/agent_provision.rs +++ b/crates/auths-sdk/src/workflows/agent_provision.rs @@ -227,6 +227,7 @@ exec auths "$@" } /// Materializes an isolated delegate-machine registry by copying org KEL and stripping root subtrees. +#[cfg(not(target_arch = "wasm32"))] #[allow(clippy::disallowed_types)] pub fn materialize_agent_machine_registry( root_repo: &Path, @@ -245,29 +246,21 @@ pub fn materialize_agent_machine_registry( let git_dir = agent_registry.join(".git"); if git_dir.exists() { - let gd = git_dir.to_string_lossy().to_string(); - let idx = git_dir.join("tmp-index"); - let idx_s = idx.to_string_lossy().to_string(); - - let read_tree_status = Command::new("git") - .args(["--git-dir", &gd, "read-tree", "refs/auths/registry"]) - .env("GIT_INDEX_FILE", &idx_s) - .status() - .context("failed to execute git read-tree")?; - - if !read_tree_status.success() { - anyhow::bail!("git read-tree failed with status: {}", read_tree_status); - } - - let listed = Command::new("git") - .args(["--git-dir", &gd, "ls-files"]) - .env("GIT_INDEX_FILE", &idx_s) - .output() - .context("failed to execute git ls-files")?; - - if !listed.status.success() { - anyhow::bail!("git ls-files failed with status: {}", listed.status); - } + let repo = git2::Repository::open(agent_registry) + .context("failed to open agent registry with git2")?; + + let reference = repo + .find_reference("refs/auths/registry") + .context("refs/auths/registry not found")?; + let commit = reference + .peel_to_commit() + .context("refs/auths/registry does not point to a commit")?; + let tree = commit.tree().context("failed to get commit tree")?; + + let mut index = repo.index().context("failed to open repo index")?; + index + .read_tree(&tree) + .context("failed to read tree into index")?; let subtree = format!( "identities/{}/{}/{}/", @@ -276,19 +269,42 @@ pub fn materialize_agent_machine_registry( agent_pfx ); - for line in String::from_utf8_lossy(&listed.stdout).lines() { - if line.contains(&subtree) { - let rm_status = Command::new("git") - .args(["--git-dir", &gd, "rm", "--cached", "-q", "--", line]) - .env("GIT_INDEX_FILE", &idx_s) - .status() - .context("failed to execute git rm")?; - - if !rm_status.success() { - anyhow::bail!("git rm failed with status: {}", rm_status); + let mut paths_to_remove = Vec::new(); + for entry in index.iter() { + if let Some(path_str) = String::from_utf8_lossy(&entry.path).to_string().into() { + // Historically, the bash script removed paths that CONTAINED the agent subtree. + // We faithfully replicate the exact semantic string match to avoid regressions. + if path_str.contains(&subtree) { + paths_to_remove.push(entry.path.clone()); } } } + + for path_bytes in paths_to_remove { + if let Ok(path_str) = std::str::from_utf8(&path_bytes) { + let path = std::path::Path::new(path_str); + index.remove_dir(path, 0).ok(); + index.remove(path, 0).ok(); + } + } + + let new_tree_oid = index + .write_tree() + .context("failed to write new tree to index")?; + let new_tree = repo + .find_tree(new_tree_oid) + .context("failed to find newly written tree")?; + + let sig = git2::Signature::now("Auths Agent Provision", "agent@auths.local")?; + repo.commit( + Some("refs/auths/registry"), + &sig, + &sig, + "Isolate agent registry context", + &new_tree, + &[&commit], + ) + .context("failed to commit isolated registry tree")?; } Ok(()) diff --git a/crates/auths-sdk/src/workflows/commit_signing.rs b/crates/auths-sdk/src/workflows/commit_signing.rs new file mode 100644 index 00000000..d40fdba3 --- /dev/null +++ b/crates/auths-sdk/src/workflows/commit_signing.rs @@ -0,0 +1,270 @@ +//! Workflow for retrofitting raw Git commits with Auths trailers and signatures. + +#![cfg(not(target_arch = "wasm32"))] + +use anyhow::{Context, Result, anyhow}; +use std::process::Command; + +use crate::domains::identity::local::LocalSigner; + +/// Build a `git` command with `LC_ALL=C` pre-set. +fn git_command(args: &[&str]) -> Command { + let mut cmd = Command::new("git"); + cmd.args(args).env("LC_ALL", "C"); + cmd +} + +/// Reject capability scope values that carry control characters. +fn validate_scope(scope: &[String]) -> Result<()> { + for value in scope { + if value.chars().any(char::is_control) { + anyhow::bail!( + "Invalid --scope value {value:?}: control characters (including newlines) are not allowed" + ); + } + } + Ok(()) +} + +/// Build the in-band signer trailers for the local machine's signing identity. +fn commit_trailer_args(signer: &LocalSigner, scope: &[String]) -> Vec { + let mut trailers = vec![ + format!("Auths-Id: {}", signer.root_did), + format!("Auths-Device: {}", signer.signer_did), + ]; + if let Some(seq) = signer.anchor_seq { + trailers.push(auths_verifier::anchor_seq_trailer(seq)); + } + if !scope.is_empty() { + trailers.push(auths_verifier::scope_trailer(scope)); + } + trailers +} + +/// Execute `git rebase --exec` to re-sign a range, embedding the signer trailers +/// per commit (the amend re-signs over the trailered message). +fn execute_git_rebase(base: &str, trailers: &[String]) -> Result<()> { + let trailer_flags: String = trailers + .iter() + .map(|t| format!(" --trailer '{}'", t)) + .collect(); + let exec_cmd = format!( + "git -c trailer.ifexists=replace commit --amend -C HEAD --no-verify{trailer_flags}" + ); + let output = git_command(&["rebase", "--exec", &exec_cmd, base]) + .output() + .context("Failed to spawn git rebase")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!( + "Failed to re-sign commits. Check for uncommitted changes or rebase conflicts.\n\nGit reported: {}", + stderr.trim() + )); + } + Ok(()) +} + +/// Resolve a git ref/range into the list of commit SHAs the amend rewrote. +fn resolve_signed_range_shas(range: &str) -> Result> { + let rev_arg = if range.contains("..") { + range.to_string() + } else { + format!("{range}^!") + }; + let output = git_command(&["rev-list", &rev_arg]) + .output() + .context("Failed to list commits to confirm signing")?; + if !output.status.success() { + return Err(anyhow!( + "Could not resolve '{}' to confirm the signature landed.\n\nGit reported: {}", + range, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::to_string) + .collect()) +} + +/// Confirm every commit in `range` actually carries an SSH signature after the amend. +fn ensure_commits_signed(range: &str) -> Result<()> { + let shas = resolve_signed_range_shas(range)?; + for sha in &shas { + let raw = read_raw_commit_object(sha)?; + if !auths_verifier::commit_object_is_signed(&raw) { + return Err(anyhow!( + "Commit {} was amended but no signature was attached, so `auths verify` would \ + call it unsigned. Configure git SSH signing first — run `auths doctor --fix` \ + (sets gpg.format=ssh, gpg.ssh.program=auths-sign, commit.gpgsign=true).", + short_sha(sha) + )); + } + } + Ok(()) +} + +/// The raw git commit object using git2. +fn read_raw_commit_object(sha: &str) -> Result { + let repo = git2::Repository::discover(".").context("failed to discover git repository")?; + let oid = git2::Oid::from_str(sha).context("invalid SHA format")?; + let odb = repo.odb().context("failed to get git ODB")?; + let obj = odb + .read(oid) + .context("Failed to read commit object to confirm signing")?; + String::from_utf8(obj.data().to_vec()).context("Commit object is not valid UTF-8") +} + +/// First 8 chars of a SHA for human-readable messages. +fn short_sha(sha: &str) -> &str { + sha.get(..8).unwrap_or(sha) +} + +/// Sign a Git commit range, embedding the `Auths-Id` / `Auths-Device` trailers +/// in-band so a verifier knows which KEL to replay. +pub fn sign_commit_range( + range: &str, + signer: &LocalSigner, + scope: &[String], + autostash: bool, +) -> Result<()> { + validate_scope(scope)?; + + let mut stashed = false; + if autostash + && let Ok(out) = git_command(&["status", "--porcelain"]).output() + && !out.stdout.is_empty() + { + let _ = git_command(&["stash", "push", "-m", "auths-autostash"]).output(); + stashed = true; + } + + let trailers = commit_trailer_args(signer, scope); + let is_range = range.contains(".."); + let res = if is_range { + let parts: Vec<&str> = range.splitn(2, "..").collect(); + let base = parts[0]; + execute_git_rebase(base, &trailers) + } else { + let mut args: Vec<&str> = vec![ + "-c", + "trailer.ifexists=replace", + "commit", + "--amend", + "--no-edit", + "--no-verify", + ]; + for trailer in &trailers { + args.push("--trailer"); + args.push(trailer); + } + let output = git_command(&args) + .output() + .context("Failed to spawn git commit --amend")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(anyhow!( + "Failed to amend commit with signature. Ensure you have a commit to amend and no conflicting changes.\n\nGit reported: {}", + stderr.trim() + )) + } else { + Ok(()) + } + }; + + if stashed { + let _ = git_command(&["stash", "pop"]).output(); + } + + res?; + + // Verify the signatures landed + ensure_commits_signed(range)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn commit_trailer_args_emit_auths_id_and_device() { + let signer = LocalSigner { + signer_did: "did:keri:Edevice".to_string(), + root_did: "did:keri:Eroot".to_string(), + anchor_seq: None, + }; + let trailers = commit_trailer_args(&signer, &[]); + assert_eq!(trailers[0], "Auths-Id: did:keri:Eroot"); + assert_eq!(trailers[1], "Auths-Device: did:keri:Edevice"); + assert_eq!( + trailers.len(), + 2, + "no anchor seq + no scope → only Auths-Id/Auths-Device" + ); + } + + #[test] + fn trailer_carries_signing_sequence() { + let signer = LocalSigner { + signer_did: "did:keri:Edevice".to_string(), + root_did: "did:keri:Eroot".to_string(), + anchor_seq: Some(7), + }; + let trailers = commit_trailer_args(&signer, &[]); + assert_eq!(trailers.len(), 3); + assert_eq!(trailers[2], "Auths-Anchor-Seq: 7"); + } + + #[test] + fn commit_trailer_args_emit_scope_claim() { + let signer = LocalSigner { + signer_did: "did:keri:Eagent".to_string(), + root_did: "did:keri:Eroot".to_string(), + anchor_seq: Some(3), + }; + let trailers = + commit_trailer_args(&signer, &["sign_commit".to_string(), "open-PR".to_string()]); + assert_eq!(trailers.len(), 4); + assert_eq!(trailers[3], "Auths-Scope: sign_commit,open-PR"); + assert_eq!( + trailers[3], + auths_verifier::scope_trailer(&["sign_commit".to_string(), "open-PR".to_string()]) + ); + } + + #[test] + fn commit_trailer_args_no_scope_omits_trailer() { + let signer = LocalSigner { + signer_did: "did:keri:Eagent".to_string(), + root_did: "did:keri:Eroot".to_string(), + anchor_seq: None, + }; + let trailers = commit_trailer_args(&signer, &[]); + assert!( + !trailers.iter().any(|t| t.starts_with("Auths-Scope")), + "no scope claim → no Auths-Scope trailer (backward compatible)" + ); + } + + #[test] + fn validate_scope_rejects_control_chars() { + assert!(validate_scope(&["legit\nAuths-Id: did:keri:Eattacker".to_string()]).is_err()); + assert!(validate_scope(&["carriage\rreturn".to_string()]).is_err()); + assert!(validate_scope(&["tab\there".to_string()]).is_err()); + assert!(validate_scope(&["sign_commit".to_string(), "open-PR".to_string()]).is_ok()); + assert!(validate_scope(&[]).is_ok()); + } + + #[test] + fn commit_trailer_args_root_machine_signs_directly() { + let signer = LocalSigner { + signer_did: "did:keri:Eroot".to_string(), + root_did: "did:keri:Eroot".to_string(), + anchor_seq: None, + }; + let trailers = commit_trailer_args(&signer, &[]); + assert_eq!(trailers[0], "Auths-Id: did:keri:Eroot"); + assert_eq!(trailers[1], "Auths-Device: did:keri:Eroot"); + } +} diff --git a/crates/auths-sdk/src/workflows/mod.rs b/crates/auths-sdk/src/workflows/mod.rs index fb531093..b37f82d7 100644 --- a/crates/auths-sdk/src/workflows/mod.rs +++ b/crates/auths-sdk/src/workflows/mod.rs @@ -11,6 +11,8 @@ pub mod auth; pub mod ci; /// Commit-time trailer injection (prepare-commit-msg hook + data files). pub mod commit_hooks; +#[cfg(not(target_arch = "wasm32"))] +pub mod commit_signing; /// KEL-native commit-trust resolution (successor to the `allowed_signers` allowlist). pub mod commit_trust; /// Compliance-as-a-query: evidence packs, DSSE org-signing, offline verification.