Skip to main content

auths_cli/commands/
sign.rs

1//! Unified sign command: signs a file artifact or a git commit range.
2
3use 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
14/// Represents the resolved target for a sign operation.
15pub enum SignTarget {
16    Artifact(PathBuf),
17    CommitRange(String),
18}
19
20/// Resolves raw CLI input into a concrete target type.
21///
22/// Checks the filesystem first. If no file exists at the path, assumes a Git reference.
23///
24/// Args:
25/// * `raw_target` - The raw string input from the CLI.
26///
27/// Usage:
28/// ```ignore
29/// let target = parse_sign_target("HEAD");
30/// assert!(matches!(target, SignTarget::CommitRange(_)));
31/// ```
32pub 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
48/// Heuristic: does the target look like an artifact file path rather than a git ref?
49fn looks_like_artifact_path(target: &str) -> bool {
50    // Path-shaped strings
51    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    // Bare filename with artifact extension
56    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
65/// Reject capability scope values that carry control characters.
66///
67/// A scope value rides in a single-line `Auths-Scope` commit trailer; a newline
68/// (or other control character) would split it into an attacker-chosen extra
69/// trailer — for example a second `Auths-Id` — which a verifier would then
70/// resolve instead of the real signer.
71///
72/// Args:
73/// * `scope`: The capability tokens supplied via `--scope`.
74///
75/// Usage:
76/// ```ignore
77/// validate_scope(&scope)?;
78/// ```
79fn 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
90/// Build the in-band signer trailers for the local machine's signing identity:
91/// `Auths-Id` = root identity, `Auths-Device` = signing device, and (when the root
92/// KEL tip is known) `Auths-Anchor-Seq` = the delegator-anchoring position at
93/// signing, so a verifier can order this commit against a later revocation by KEL
94/// position. The trailers ride in the commit message body, covered by the signature.
95fn 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    // The capabilities this commit claims it exercises. A verifier rejects a claim
104    // outside the signer's delegator-anchored grant (`CommitVerdict::OutsideAgentScope`).
105    if !scope.is_empty() {
106        trailers.push(auths_verifier::scope_trailer(scope));
107    }
108    trailers
109}
110
111/// Resolve the local signing identity → the trailer values to embed in-band.
112///
113/// Resolution reads identity + registry only (no key decryption), so it needs no
114/// passphrase. Fails clearly when this machine has no resolvable signing identity.
115fn 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
128/// Execute `git rebase --exec` to re-sign a range, embedding the signer trailers
129/// per commit (the amend re-signs over the trailered message).
130///
131/// Args:
132/// * `base` - The exclusive base ref (commits after this ref will be re-signed).
133/// * `trailers` - The `Auths-Id` / `Auths-Device` trailer strings.
134fn execute_git_rebase(base: &str, trailers: &[String]) -> Result<()> {
135    // did:keri values and integer sequences are `[A-Za-z0-9_:.\- ]`, safe to
136    // single-quote in the exec shell.
137    let trailer_flags: String = trailers
138        .iter()
139        .map(|t| format!(" --trailer '{}'", t))
140        .collect();
141    // `-c trailer.ifexists=replace`: re-signing a commit that already carries an
142    // Auths-* trailer replaces it in place rather than appending a second copy, so
143    // a re-signed commit (the recovery rewrite) keeps exactly one trailer per token.
144    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
160/// Ensure the signer's root is pinned in the repo's committed `.auths/roots` and
161/// staged, so the pin (the trust declaration teammates and CI inherit) lands with
162/// the next commit. Idempotent and best-effort — a pin failure never fails the
163/// sign; self-trust already covers the signer's own verification.
164fn 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
187/// Resolve a git ref/range into the list of commit SHAs the amend rewrote, so we
188/// can confirm each one actually carries a signature.
189///
190/// `HEAD`-style single refs resolve to that one commit; `base..tip` ranges resolve
191/// to every commit the rebase re-signed.
192fn 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
214/// Confirm every commit in `range` actually carries an SSH signature after the amend.
215///
216/// The amend embeds the trailers and *asks* git to sign, but git only signs when a
217/// signing program is configured (`gpg.format ssh` + `gpg.ssh.program`). When it is
218/// not, the rewrite lands unsigned — and `auths verify` would then call the commit
219/// `No signature found`. We use the verifier's own `gpgsig` detection as the single
220/// source of truth so success is never claimed for a commit the verifier rejects.
221fn 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
237/// The raw git commit object (`git cat-file commit <sha>`) — the bytes a verifier
238/// reads to decide whether a `gpgsig` SSH block is present.
239fn 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
253/// First 8 chars of a SHA for human-readable messages.
254fn short_sha(sha: &str) -> &str {
255    sha.get(..8).unwrap_or(sha)
256}
257
258/// Sign a Git commit range, embedding the `Auths-Id` / `Auths-Device` trailers
259/// in-band so a verifier knows which KEL to replay. Amending triggers auths-sign
260/// via git's signing program; the trailers (one per token — re-signing replaces
261/// rather than appends, via `trailer.ifexists=replace`) are part of the signed
262/// message body.
263///
264/// Args:
265/// * `range` - A git ref or range (e.g., "HEAD", "main..HEAD").
266/// * `signer` - The resolved local signing identity (root + device DIDs).
267/// * `scope` - Capabilities this commit claims (emitted as an `Auths-Scope` trailer).
268fn 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        // `-c trailer.ifexists=replace`: amending a commit that already carries an
279        // Auths-* trailer (a re-sign) replaces that trailer in place instead of
280        // appending a duplicate, so the message keeps exactly one trailer per token.
281        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    // The amend succeeded, but git only attaches a signature when a signing program
305    // is configured. Confirm one actually landed before claiming success — otherwise
306    // `auths verify` would call this commit unsigned and the success line would lie.
307    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/// Sign a Git commit or artifact file.
321#[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    /// Git ref, commit range (e.g. HEAD, main..HEAD), or path to an artifact file.
343    #[arg(help = "Commit ref, range, or artifact file path")]
344    pub target: String,
345
346    /// Output path for the signature file. Defaults to <FILE>.auths.json.
347    #[arg(long = "sig-output", value_name = "PATH")]
348    pub sig_output: Option<PathBuf>,
349
350    /// Overwrite the signature output file if it already exists.
351    #[arg(long)]
352    pub force: bool,
353
354    /// Local alias of the identity key (for artifact signing).
355    #[arg(long)]
356    pub key: Option<String>,
357
358    /// Local alias of the device key (for artifact signing, required for files).
359    #[arg(long)]
360    pub device_key: Option<String>,
361
362    /// Duration in seconds until expiration (per RFC 6749).
363    #[arg(long = "expires-in", value_name = "N")]
364    pub expires_in: Option<u64>,
365
366    /// Optional note to embed in the attestation (for artifact signing).
367    #[arg(long)]
368    pub note: Option<String>,
369
370    /// Capabilities this commit claims it exercises (comma-separated), e.g.
371    /// `--scope sign_commit`. Emitted as an `Auths-Scope` trailer so a verifier can
372    /// reject a claim outside the signer's delegator-anchored grant. Commit-only.
373    #[arg(long, value_delimiter = ',')]
374    pub scope: Vec<String>,
375}
376
377/// Handle the unified sign command.
378///
379/// Args:
380/// * `cmd` - Parsed SignCommand arguments.
381/// * `repo_opt` - Optional path to the Auths identity repository.
382/// * `passphrase_provider` - Provider for key passphrases.
383pub 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            // A file attestation does not bind a commit: the ambient git HEAD is unrelated to the
396            // file being signed, and inferring it would let whatever commit happens to be checked
397            // out be claimed as the attestation's provenance. Bind one explicitly with
398            // `auths artifact sign --commit <sha>`.
399            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        // Auths-Id, Auths-Device, Auths-Anchor-Seq, Auths-Scope (last).
477        assert_eq!(trailers.len(), 4);
478        assert_eq!(trailers[3], "Auths-Scope: sign_commit,open-PR");
479        // Round-trips through the verifier's own formatter.
480        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        // A newline would split the single-line Auths-Scope trailer, injecting an
503        // attacker-chosen trailer (e.g. a forged Auths-Id) into the signed body.
504        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        // On the root machine signer == root → both trailers carry the same DID.
514        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}