1use anyhow::{Context, Result, anyhow};
4use clap::Parser;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use auths_sdk::core_config::EnvironmentConfig;
9use auths_sdk::domains::identity::local::{LocalSigner, resolve_local_signer};
10use auths_sdk::signing::PassphraseProvider;
11
12use super::artifact::sign::handle_sign as handle_artifact_sign;
13
14pub enum SignTarget {
16 Artifact(PathBuf),
17 CommitRange(String),
18}
19
20pub fn parse_sign_target(raw_target: &str) -> SignTarget {
33 let path = Path::new(raw_target);
34 if path.exists() {
35 SignTarget::Artifact(path.to_path_buf())
36 } else {
37 if looks_like_artifact_path(raw_target) {
38 eprintln!(
39 "Warning: '{}' looks like an artifact file path, but the file does not exist.\n\
40 Treating as a git commit range. If you meant to sign a file, check the path.",
41 raw_target
42 );
43 }
44 SignTarget::CommitRange(raw_target.to_string())
45 }
46}
47
48fn looks_like_artifact_path(target: &str) -> bool {
50 if target.starts_with("./") || target.starts_with("../") || target.contains('/') {
52 let lower = target.to_lowercase();
53 return ARTIFACT_EXTENSIONS.iter().any(|ext| lower.ends_with(ext));
54 }
55 let lower = target.to_lowercase();
57 ARTIFACT_EXTENSIONS.iter().any(|ext| lower.ends_with(ext))
58}
59
60const ARTIFACT_EXTENSIONS: &[&str] = &[
61 ".tar.gz", ".tgz", ".zip", ".whl", ".gem", ".jar", ".deb", ".rpm", ".dmg", ".exe", ".msi",
62 ".pkg", ".nupkg",
63];
64
65fn validate_scope(scope: &[String]) -> Result<()> {
80 for value in scope {
81 if value.chars().any(char::is_control) {
82 anyhow::bail!(
83 "Invalid --scope value {value:?}: control characters (including newlines) are not allowed"
84 );
85 }
86 }
87 Ok(())
88}
89
90fn commit_trailer_args(signer: &LocalSigner, scope: &[String]) -> Vec<String> {
96 let mut trailers = vec![
97 format!("Auths-Id: {}", signer.root_did),
98 format!("Auths-Device: {}", signer.signer_did),
99 ];
100 if let Some(seq) = signer.anchor_seq {
101 trailers.push(auths_verifier::anchor_seq_trailer(seq));
102 }
103 if !scope.is_empty() {
106 trailers.push(auths_verifier::scope_trailer(scope));
107 }
108 trailers
109}
110
111fn resolve_signer_trailer(
116 repo_opt: Option<&Path>,
117 env_config: &EnvironmentConfig,
118) -> Result<LocalSigner> {
119 let repo_path =
120 auths_sdk::storage_layout::resolve_repo_path(repo_opt.map(|p| p.to_path_buf()))?;
121 let ctx = crate::factories::storage::build_auths_context(&repo_path, env_config, None)
122 .context("Failed to build auths context for commit signing")?;
123 resolve_local_signer(&ctx).map_err(anyhow::Error::from).context(
124 "Could not resolve the local signing identity. Run `auths init`, or pair this device with `auths pair --join`.",
125 )
126}
127
128fn execute_git_rebase(base: &str, trailers: &[String]) -> Result<()> {
135 let trailer_flags: String = trailers
138 .iter()
139 .map(|t| format!(" --trailer '{}'", t))
140 .collect();
141 let exec_cmd = format!(
145 "git -c trailer.ifexists=replace commit --amend --no-edit --no-verify{trailer_flags}"
146 );
147 let output = crate::subprocess::git_command(&["rebase", "--exec", &exec_cmd, base])
148 .output()
149 .context("Failed to spawn git rebase")?;
150 if !output.status.success() {
151 let stderr = String::from_utf8_lossy(&output.stderr);
152 return Err(anyhow!(
153 "Failed to re-sign commits. Check for uncommitted changes or rebase conflicts.\n\nGit reported: {}",
154 stderr.trim()
155 ));
156 }
157 Ok(())
158}
159
160fn ensure_repo_root_pin(signer: &LocalSigner) {
165 let Ok(output) = crate::subprocess::git_command(&["rev-parse", "--show-toplevel"]).output()
166 else {
167 return;
168 };
169 if !output.status.success() {
170 return;
171 }
172 let toplevel = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
173 let auths_dir = toplevel.join(".auths");
174 let store = crate::adapters::config_store::FileConfigStore;
175 let root_did = signer.root_did.to_string();
176 if auths_sdk::workflows::roots::is_pinned_root(&store, &auths_dir, &root_did).unwrap_or(false) {
177 return;
178 }
179 if auths_sdk::workflows::roots::add_pinned_root(&store, &auths_dir, &root_did).is_ok() {
180 let roots_file = auths_dir.join("roots");
181 let _ =
182 crate::subprocess::git_command(&["add", "--", &roots_file.to_string_lossy()]).output();
183 eprintln!("auths: pinned your identity root in .auths/roots (staged for the next commit)");
184 }
185}
186
187fn resolve_signed_range_shas(range: &str) -> Result<Vec<String>> {
193 let rev_arg = if range.contains("..") {
194 range.to_string()
195 } else {
196 format!("{range}^!")
197 };
198 let output = crate::subprocess::git_command(&["rev-list", &rev_arg])
199 .output()
200 .context("Failed to list commits to confirm signing")?;
201 if !output.status.success() {
202 return Err(anyhow!(
203 "Could not resolve '{}' to confirm the signature landed.\n\nGit reported: {}",
204 range,
205 String::from_utf8_lossy(&output.stderr).trim()
206 ));
207 }
208 Ok(String::from_utf8_lossy(&output.stdout)
209 .lines()
210 .map(str::to_string)
211 .collect())
212}
213
214fn ensure_commits_signed(range: &str) -> Result<()> {
222 let shas = resolve_signed_range_shas(range)?;
223 for sha in &shas {
224 let raw = read_raw_commit_object(sha)?;
225 if !auths_verifier::commit_object_is_signed(&raw) {
226 return Err(anyhow!(
227 "Commit {} was amended but no signature was attached, so `auths verify` would \
228 call it unsigned. Configure git SSH signing first — run `auths doctor --fix` \
229 (sets gpg.format=ssh, gpg.ssh.program=auths-sign, commit.gpgsign=true).",
230 short_sha(sha)
231 ));
232 }
233 }
234 Ok(())
235}
236
237fn read_raw_commit_object(sha: &str) -> Result<String> {
240 let output = crate::subprocess::git_command(&["cat-file", "commit", sha])
241 .output()
242 .context("Failed to read commit object to confirm signing")?;
243 if !output.status.success() {
244 return Err(anyhow!(
245 "git cat-file commit {} failed: {}",
246 short_sha(sha),
247 String::from_utf8_lossy(&output.stderr).trim()
248 ));
249 }
250 String::from_utf8(output.stdout).context("Commit object is not valid UTF-8")
251}
252
253fn short_sha(sha: &str) -> &str {
255 sha.get(..8).unwrap_or(sha)
256}
257
258fn sign_commit_range(range: &str, signer: &LocalSigner, scope: &[String]) -> Result<()> {
269 ensure_repo_root_pin(signer);
270 validate_scope(scope)?;
271 let trailers = commit_trailer_args(signer, scope);
272 let is_range = range.contains("..");
273 if is_range {
274 let parts: Vec<&str> = range.splitn(2, "..").collect();
275 let base = parts[0];
276 execute_git_rebase(base, &trailers)?;
277 } else {
278 let mut args: Vec<&str> = vec![
282 "-c",
283 "trailer.ifexists=replace",
284 "commit",
285 "--amend",
286 "--no-edit",
287 "--no-verify",
288 ];
289 for trailer in &trailers {
290 args.push("--trailer");
291 args.push(trailer);
292 }
293 let output = crate::subprocess::git_command(&args)
294 .output()
295 .context("Failed to spawn git commit --amend")?;
296 if !output.status.success() {
297 let stderr = String::from_utf8_lossy(&output.stderr);
298 return Err(anyhow!(
299 "Failed to amend commit with signature. Ensure you have a commit to amend and no conflicting changes.\n\nGit reported: {}",
300 stderr.trim()
301 ));
302 }
303 }
304 ensure_commits_signed(range)?;
308 if crate::ux::format::is_json_mode() {
309 crate::ux::format::JsonResponse::success(
310 "sign",
311 &serde_json::json!({ "target": range, "type": "commit" }),
312 )
313 .print()?;
314 } else {
315 println!("✔ Signed: {}", range);
316 }
317 Ok(())
318}
319
320#[derive(Parser, Debug, Clone)]
322#[command(
323 about = "Sign a Git commit or artifact file.",
324 after_help = "Examples:
325 auths sign README.md # Sign a file → README.md.auths.json
326 auths sign HEAD # Sign the current commit
327 auths sign main..HEAD # Re-sign commits after main
328
329Artifacts:
330 Signing files creates a .auths.json attestation with your identity and device.
331 Use `auths verify` to check the signature.
332
333Commits:
334 Commit signing requires a linked device and Git configuration.
335 Verify with `auths verify HEAD` or `git log --show-signature`.
336
337Related:
338 auths verify — Verify signatures
339 auths device list — Check linked devices"
340)]
341pub struct SignCommand {
342 #[arg(help = "Commit ref, range, or artifact file path")]
344 pub target: String,
345
346 #[arg(long = "sig-output", value_name = "PATH")]
348 pub sig_output: Option<PathBuf>,
349
350 #[arg(long)]
352 pub force: bool,
353
354 #[arg(long)]
356 pub key: Option<String>,
357
358 #[arg(long)]
360 pub device_key: Option<String>,
361
362 #[arg(long = "expires-in", value_name = "N")]
364 pub expires_in: Option<u64>,
365
366 #[arg(long)]
368 pub note: Option<String>,
369
370 #[arg(long, value_delimiter = ',')]
374 pub scope: Vec<String>,
375}
376
377pub fn handle_sign_unified(
384 cmd: SignCommand,
385 repo_opt: Option<PathBuf>,
386 passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
387 env_config: &EnvironmentConfig,
388) -> Result<()> {
389 match parse_sign_target(&cmd.target) {
390 SignTarget::Artifact(path) => {
391 let device_key_alias = match cmd.device_key.as_deref() {
392 Some(alias) => alias.to_string(),
393 None => super::key_detect::auto_detect_device_key(repo_opt.as_deref(), env_config)?,
394 };
395 let commit_sha: Option<String> = None;
400 handle_artifact_sign(
401 &path,
402 cmd.sig_output,
403 cmd.key.as_deref(),
404 &device_key_alias,
405 cmd.expires_in,
406 cmd.note,
407 commit_sha,
408 repo_opt,
409 passphrase_provider,
410 env_config,
411 &None,
412 false,
413 cmd.force,
414 )
415 }
416 SignTarget::CommitRange(range) => {
417 let signer = resolve_signer_trailer(repo_opt.as_deref(), env_config)?;
418 sign_commit_range(&range, &signer, &cmd.scope)
419 }
420 }
421}
422
423impl crate::commands::executable::ExecutableCommand for SignCommand {
424 fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
425 handle_sign_unified(
426 self.clone(),
427 ctx.repo_path.clone(),
428 ctx.passphrase_provider.clone(),
429 &ctx.env_config,
430 )
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn commit_trailer_args_emit_auths_id_and_device() {
440 let signer = LocalSigner {
441 signer_did: "did:keri:Edevice".to_string(),
442 root_did: "did:keri:Eroot".to_string(),
443 anchor_seq: None,
444 };
445 let trailers = commit_trailer_args(&signer, &[]);
446 assert_eq!(trailers[0], "Auths-Id: did:keri:Eroot");
447 assert_eq!(trailers[1], "Auths-Device: did:keri:Edevice");
448 assert_eq!(
449 trailers.len(),
450 2,
451 "no anchor seq + no scope → only Auths-Id/Auths-Device"
452 );
453 }
454
455 #[test]
456 fn trailer_carries_signing_sequence() {
457 let signer = LocalSigner {
458 signer_did: "did:keri:Edevice".to_string(),
459 root_did: "did:keri:Eroot".to_string(),
460 anchor_seq: Some(7),
461 };
462 let trailers = commit_trailer_args(&signer, &[]);
463 assert_eq!(trailers.len(), 3);
464 assert_eq!(trailers[2], "Auths-Anchor-Seq: 7");
465 }
466
467 #[test]
468 fn commit_trailer_args_emit_scope_claim() {
469 let signer = LocalSigner {
470 signer_did: "did:keri:Eagent".to_string(),
471 root_did: "did:keri:Eroot".to_string(),
472 anchor_seq: Some(3),
473 };
474 let trailers =
475 commit_trailer_args(&signer, &["sign_commit".to_string(), "open-PR".to_string()]);
476 assert_eq!(trailers.len(), 4);
478 assert_eq!(trailers[3], "Auths-Scope: sign_commit,open-PR");
479 assert_eq!(
481 trailers[3],
482 auths_verifier::scope_trailer(&["sign_commit".to_string(), "open-PR".to_string()])
483 );
484 }
485
486 #[test]
487 fn commit_trailer_args_no_scope_omits_trailer() {
488 let signer = LocalSigner {
489 signer_did: "did:keri:Eagent".to_string(),
490 root_did: "did:keri:Eroot".to_string(),
491 anchor_seq: None,
492 };
493 let trailers = commit_trailer_args(&signer, &[]);
494 assert!(
495 !trailers.iter().any(|t| t.starts_with("Auths-Scope")),
496 "no scope claim → no Auths-Scope trailer (backward compatible)"
497 );
498 }
499
500 #[test]
501 fn validate_scope_rejects_control_chars() {
502 assert!(validate_scope(&["legit\nAuths-Id: did:keri:Eattacker".to_string()]).is_err());
505 assert!(validate_scope(&["carriage\rreturn".to_string()]).is_err());
506 assert!(validate_scope(&["tab\there".to_string()]).is_err());
507 assert!(validate_scope(&["sign_commit".to_string(), "open-PR".to_string()]).is_ok());
508 assert!(validate_scope(&[]).is_ok());
509 }
510
511 #[test]
512 fn commit_trailer_args_root_machine_signs_directly() {
513 let signer = LocalSigner {
515 signer_did: "did:keri:Eroot".to_string(),
516 root_did: "did:keri:Eroot".to_string(),
517 anchor_seq: None,
518 };
519 let trailers = commit_trailer_args(&signer, &[]);
520 assert_eq!(trailers[0], "Auths-Id: did:keri:Eroot");
521 assert_eq!(trailers[1], "Auths-Device: did:keri:Eroot");
522 }
523
524 #[test]
525 fn test_parse_sign_target_commit_ref() {
526 let target = parse_sign_target("HEAD");
527 assert!(matches!(target, SignTarget::CommitRange(_)));
528 }
529
530 #[test]
531 fn test_parse_sign_target_range() {
532 let target = parse_sign_target("main..HEAD");
533 assert!(matches!(target, SignTarget::CommitRange(_)));
534 }
535
536 #[test]
537 fn test_parse_sign_target_nonexistent_path_is_commit_range() {
538 let target = parse_sign_target("/nonexistent/artifact.tar.gz");
539 assert!(matches!(target, SignTarget::CommitRange(_)));
540 }
541
542 #[test]
543 fn test_parse_sign_target_file() {
544 use std::fs::File;
545 use tempfile::tempdir;
546 let dir = tempdir().unwrap();
547 let file_path = dir.path().join("artifact.tar.gz");
548 File::create(&file_path).unwrap();
549 let target = parse_sign_target(file_path.to_str().unwrap());
550 assert!(matches!(target, SignTarget::Artifact(_)));
551 }
552}