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/// Build the in-band signer trailers for the local machine's signing identity:
66/// `Auths-Id` = root identity, `Auths-Device` = signing device, and (when the root
67/// KEL tip is known) `Auths-Anchor-Seq` = the delegator-anchoring position at
68/// signing, so a verifier can order this commit against a later revocation by KEL
69/// position. The trailers ride in the commit message body, covered by the signature.
70fn commit_trailer_args(signer: &LocalSigner, scope: &[String]) -> Vec<String> {
71    let mut trailers = vec![
72        format!("Auths-Id: {}", signer.root_did),
73        format!("Auths-Device: {}", signer.signer_did),
74    ];
75    if let Some(seq) = signer.anchor_seq {
76        trailers.push(auths_verifier::anchor_seq_trailer(seq));
77    }
78    // The capabilities this commit claims it exercises. A verifier rejects a claim
79    // outside the signer's delegator-anchored grant (`CommitVerdict::OutsideAgentScope`).
80    if !scope.is_empty() {
81        trailers.push(auths_verifier::scope_trailer(scope));
82    }
83    trailers
84}
85
86/// Resolve the local signing identity → the trailer values to embed in-band.
87///
88/// Resolution reads identity + registry only (no key decryption), so it needs no
89/// passphrase. Fails clearly when this machine has no resolvable signing identity.
90fn resolve_signer_trailer(
91    repo_opt: Option<&Path>,
92    env_config: &EnvironmentConfig,
93) -> Result<LocalSigner> {
94    let repo_path =
95        auths_sdk::storage_layout::resolve_repo_path(repo_opt.map(|p| p.to_path_buf()))?;
96    let ctx = crate::factories::storage::build_auths_context(&repo_path, env_config, None)
97        .context("Failed to build auths context for commit signing")?;
98    resolve_local_signer(&ctx).map_err(anyhow::Error::from).context(
99        "Could not resolve the local signing identity. Run `auths init`, or pair this device with `auths pair --join`.",
100    )
101}
102
103/// Execute `git rebase --exec` to re-sign a range, embedding the signer trailers
104/// per commit (the amend re-signs over the trailered message).
105///
106/// Args:
107/// * `base` - The exclusive base ref (commits after this ref will be re-signed).
108/// * `trailers` - The `Auths-Id` / `Auths-Device` trailer strings.
109fn execute_git_rebase(base: &str, trailers: &[String]) -> Result<()> {
110    // did:keri values and integer sequences are `[A-Za-z0-9_:.\- ]`, safe to
111    // single-quote in the exec shell.
112    let trailer_flags: String = trailers
113        .iter()
114        .map(|t| format!(" --trailer '{}'", t))
115        .collect();
116    let exec_cmd = format!("git commit --amend --no-edit --no-verify{trailer_flags}");
117    let output = crate::subprocess::git_command(&["rebase", "--exec", &exec_cmd, base])
118        .output()
119        .context("Failed to spawn git rebase")?;
120    if !output.status.success() {
121        let stderr = String::from_utf8_lossy(&output.stderr);
122        return Err(anyhow!(
123            "Failed to re-sign commits. Check for uncommitted changes or rebase conflicts.\n\nGit reported: {}",
124            stderr.trim()
125        ));
126    }
127    Ok(())
128}
129
130/// Ensure the signer's root is pinned in the repo's committed `.auths/roots` and
131/// staged, so the pin (the trust declaration teammates and CI inherit) lands with
132/// the next commit. Idempotent and best-effort — a pin failure never fails the
133/// sign; self-trust already covers the signer's own verification.
134fn ensure_repo_root_pin(signer: &LocalSigner) {
135    let Ok(output) = crate::subprocess::git_command(&["rev-parse", "--show-toplevel"]).output()
136    else {
137        return;
138    };
139    if !output.status.success() {
140        return;
141    }
142    let toplevel = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
143    let auths_dir = toplevel.join(".auths");
144    let store = crate::adapters::config_store::FileConfigStore;
145    let root_did = signer.root_did.to_string();
146    if auths_sdk::workflows::roots::is_pinned_root(&store, &auths_dir, &root_did).unwrap_or(false) {
147        return;
148    }
149    if auths_sdk::workflows::roots::add_pinned_root(&store, &auths_dir, &root_did).is_ok() {
150        let roots_file = auths_dir.join("roots");
151        let _ =
152            crate::subprocess::git_command(&["add", "--", &roots_file.to_string_lossy()]).output();
153        eprintln!("auths: pinned your identity root in .auths/roots (staged for the next commit)");
154    }
155}
156
157/// Sign a Git commit range, embedding the `Auths-Id` / `Auths-Device` trailers
158/// in-band so a verifier knows which KEL to replay. Amending triggers auths-sign
159/// via git's signing program; the trailer (idempotent via git's
160/// `addIfDifferentNeighbor`) is part of the signed message body.
161///
162/// Args:
163/// * `range` - A git ref or range (e.g., "HEAD", "main..HEAD").
164/// * `signer` - The resolved local signing identity (root + device DIDs).
165/// * `scope` - Capabilities this commit claims (emitted as an `Auths-Scope` trailer).
166fn sign_commit_range(range: &str, signer: &LocalSigner, scope: &[String]) -> Result<()> {
167    ensure_repo_root_pin(signer);
168    let trailers = commit_trailer_args(signer, scope);
169    let is_range = range.contains("..");
170    if is_range {
171        let parts: Vec<&str> = range.splitn(2, "..").collect();
172        let base = parts[0];
173        execute_git_rebase(base, &trailers)?;
174    } else {
175        let mut args: Vec<&str> = vec!["commit", "--amend", "--no-edit", "--no-verify"];
176        for trailer in &trailers {
177            args.push("--trailer");
178            args.push(trailer);
179        }
180        let output = crate::subprocess::git_command(&args)
181            .output()
182            .context("Failed to spawn git commit --amend")?;
183        if !output.status.success() {
184            let stderr = String::from_utf8_lossy(&output.stderr);
185            return Err(anyhow!(
186                "Failed to amend commit with signature. Ensure you have a commit to amend and no conflicting changes.\n\nGit reported: {}",
187                stderr.trim()
188            ));
189        }
190    }
191    if crate::ux::format::is_json_mode() {
192        crate::ux::format::JsonResponse::success(
193            "sign",
194            &serde_json::json!({ "target": range, "type": "commit" }),
195        )
196        .print()?;
197    } else {
198        println!("✔ Signed: {}", range);
199    }
200    Ok(())
201}
202
203/// Sign a Git commit or artifact file.
204#[derive(Parser, Debug, Clone)]
205#[command(
206    about = "Sign a Git commit or artifact file.",
207    after_help = "Examples:
208  auths sign README.md                    # Sign a file → README.md.auths.json
209  auths sign HEAD                         # Sign the current commit
210  auths sign main..HEAD                   # Re-sign commits after main
211
212Artifacts:
213  Signing files creates a .auths.json attestation with your identity and device.
214  Use `auths verify` to check the signature.
215
216Commits:
217  Commit signing requires a linked device and Git configuration.
218  Verify with `auths verify HEAD` or `git log --show-signature`.
219
220Related:
221  auths verify      — Verify signatures
222  auths device list — Check linked devices"
223)]
224pub struct SignCommand {
225    /// Git ref, commit range (e.g. HEAD, main..HEAD), or path to an artifact file.
226    #[arg(help = "Commit ref, range, or artifact file path")]
227    pub target: String,
228
229    /// Output path for the signature file. Defaults to <FILE>.auths.json.
230    #[arg(long = "sig-output", value_name = "PATH")]
231    pub sig_output: Option<PathBuf>,
232
233    /// Local alias of the identity key (for artifact signing).
234    #[arg(long)]
235    pub key: Option<String>,
236
237    /// Local alias of the device key (for artifact signing, required for files).
238    #[arg(long)]
239    pub device_key: Option<String>,
240
241    /// Duration in seconds until expiration (per RFC 6749).
242    #[arg(long = "expires-in", value_name = "N")]
243    pub expires_in: Option<u64>,
244
245    /// Optional note to embed in the attestation (for artifact signing).
246    #[arg(long)]
247    pub note: Option<String>,
248
249    /// Capabilities this commit claims it exercises (comma-separated), e.g.
250    /// `--scope sign_commit`. Emitted as an `Auths-Scope` trailer so a verifier can
251    /// reject a claim outside the signer's delegator-anchored grant. Commit-only.
252    #[arg(long, value_delimiter = ',')]
253    pub scope: Vec<String>,
254}
255
256/// Handle the unified sign command.
257///
258/// Args:
259/// * `cmd` - Parsed SignCommand arguments.
260/// * `repo_opt` - Optional path to the Auths identity repository.
261/// * `passphrase_provider` - Provider for key passphrases.
262pub fn handle_sign_unified(
263    cmd: SignCommand,
264    repo_opt: Option<PathBuf>,
265    passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
266    env_config: &EnvironmentConfig,
267) -> Result<()> {
268    match parse_sign_target(&cmd.target) {
269        SignTarget::Artifact(path) => {
270            let device_key_alias = match cmd.device_key.as_deref() {
271                Some(alias) => alias.to_string(),
272                None => super::key_detect::auto_detect_device_key(repo_opt.as_deref(), env_config)?,
273            };
274            let commit_sha = super::git_helpers::resolve_head_silent();
275            handle_artifact_sign(
276                &path,
277                cmd.sig_output,
278                cmd.key.as_deref(),
279                &device_key_alias,
280                cmd.expires_in,
281                cmd.note,
282                commit_sha,
283                repo_opt,
284                passphrase_provider,
285                env_config,
286                &None,
287                false,
288            )
289        }
290        SignTarget::CommitRange(range) => {
291            let signer = resolve_signer_trailer(repo_opt.as_deref(), env_config)?;
292            sign_commit_range(&range, &signer, &cmd.scope)
293        }
294    }
295}
296
297impl crate::commands::executable::ExecutableCommand for SignCommand {
298    fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
299        handle_sign_unified(
300            self.clone(),
301            ctx.repo_path.clone(),
302            ctx.passphrase_provider.clone(),
303            &ctx.env_config,
304        )
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn commit_trailer_args_emit_auths_id_and_device() {
314        let signer = LocalSigner {
315            signer_did: "did:keri:Edevice".to_string(),
316            root_did: "did:keri:Eroot".to_string(),
317            anchor_seq: None,
318        };
319        let trailers = commit_trailer_args(&signer, &[]);
320        assert_eq!(trailers[0], "Auths-Id: did:keri:Eroot");
321        assert_eq!(trailers[1], "Auths-Device: did:keri:Edevice");
322        assert_eq!(
323            trailers.len(),
324            2,
325            "no anchor seq + no scope → only Auths-Id/Auths-Device"
326        );
327    }
328
329    #[test]
330    fn trailer_carries_signing_sequence() {
331        let signer = LocalSigner {
332            signer_did: "did:keri:Edevice".to_string(),
333            root_did: "did:keri:Eroot".to_string(),
334            anchor_seq: Some(7),
335        };
336        let trailers = commit_trailer_args(&signer, &[]);
337        assert_eq!(trailers.len(), 3);
338        assert_eq!(trailers[2], "Auths-Anchor-Seq: 7");
339    }
340
341    #[test]
342    fn commit_trailer_args_emit_scope_claim() {
343        let signer = LocalSigner {
344            signer_did: "did:keri:Eagent".to_string(),
345            root_did: "did:keri:Eroot".to_string(),
346            anchor_seq: Some(3),
347        };
348        let trailers =
349            commit_trailer_args(&signer, &["sign_commit".to_string(), "open-PR".to_string()]);
350        // Auths-Id, Auths-Device, Auths-Anchor-Seq, Auths-Scope (last).
351        assert_eq!(trailers.len(), 4);
352        assert_eq!(trailers[3], "Auths-Scope: sign_commit,open-PR");
353        // Round-trips through the verifier's own formatter.
354        assert_eq!(
355            trailers[3],
356            auths_verifier::scope_trailer(&["sign_commit".to_string(), "open-PR".to_string()])
357        );
358    }
359
360    #[test]
361    fn commit_trailer_args_no_scope_omits_trailer() {
362        let signer = LocalSigner {
363            signer_did: "did:keri:Eagent".to_string(),
364            root_did: "did:keri:Eroot".to_string(),
365            anchor_seq: None,
366        };
367        let trailers = commit_trailer_args(&signer, &[]);
368        assert!(
369            !trailers.iter().any(|t| t.starts_with("Auths-Scope")),
370            "no scope claim → no Auths-Scope trailer (backward compatible)"
371        );
372    }
373
374    #[test]
375    fn commit_trailer_args_root_machine_signs_directly() {
376        // On the root machine signer == root → both trailers carry the same DID.
377        let signer = LocalSigner {
378            signer_did: "did:keri:Eroot".to_string(),
379            root_did: "did:keri:Eroot".to_string(),
380            anchor_seq: None,
381        };
382        let trailers = commit_trailer_args(&signer, &[]);
383        assert_eq!(trailers[0], "Auths-Id: did:keri:Eroot");
384        assert_eq!(trailers[1], "Auths-Device: did:keri:Eroot");
385    }
386
387    #[test]
388    fn test_parse_sign_target_commit_ref() {
389        let target = parse_sign_target("HEAD");
390        assert!(matches!(target, SignTarget::CommitRange(_)));
391    }
392
393    #[test]
394    fn test_parse_sign_target_range() {
395        let target = parse_sign_target("main..HEAD");
396        assert!(matches!(target, SignTarget::CommitRange(_)));
397    }
398
399    #[test]
400    fn test_parse_sign_target_nonexistent_path_is_commit_range() {
401        let target = parse_sign_target("/nonexistent/artifact.tar.gz");
402        assert!(matches!(target, SignTarget::CommitRange(_)));
403    }
404
405    #[test]
406    fn test_parse_sign_target_file() {
407        use std::fs::File;
408        use tempfile::tempdir;
409        let dir = tempdir().unwrap();
410        let file_path = dir.path().join("artifact.tar.gz");
411        File::create(&file_path).unwrap();
412        let target = parse_sign_target(file_path.to_str().unwrap());
413        assert!(matches!(target, SignTarget::Artifact(_)));
414    }
415}