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/// Sign a Git commit range, embedding the `Auths-Id` / `Auths-Device` trailers
131/// in-band so a verifier knows which KEL to replay. Amending triggers auths-sign
132/// via git's signing program; the trailer (idempotent via git's
133/// `addIfDifferentNeighbor`) is part of the signed message body.
134///
135/// Args:
136/// * `range` - A git ref or range (e.g., "HEAD", "main..HEAD").
137/// * `signer` - The resolved local signing identity (root + device DIDs).
138/// * `scope` - Capabilities this commit claims (emitted as an `Auths-Scope` trailer).
139fn sign_commit_range(range: &str, signer: &LocalSigner, scope: &[String]) -> Result<()> {
140    let trailers = commit_trailer_args(signer, scope);
141    let is_range = range.contains("..");
142    if is_range {
143        let parts: Vec<&str> = range.splitn(2, "..").collect();
144        let base = parts[0];
145        execute_git_rebase(base, &trailers)?;
146    } else {
147        let mut args: Vec<&str> = vec!["commit", "--amend", "--no-edit", "--no-verify"];
148        for trailer in &trailers {
149            args.push("--trailer");
150            args.push(trailer);
151        }
152        let output = crate::subprocess::git_command(&args)
153            .output()
154            .context("Failed to spawn git commit --amend")?;
155        if !output.status.success() {
156            let stderr = String::from_utf8_lossy(&output.stderr);
157            return Err(anyhow!(
158                "Failed to amend commit with signature. Ensure you have a commit to amend and no conflicting changes.\n\nGit reported: {}",
159                stderr.trim()
160            ));
161        }
162    }
163    if crate::ux::format::is_json_mode() {
164        crate::ux::format::JsonResponse::success(
165            "sign",
166            &serde_json::json!({ "target": range, "type": "commit" }),
167        )
168        .print()?;
169    } else {
170        println!("✔ Signed: {}", range);
171    }
172    Ok(())
173}
174
175/// Sign a Git commit or artifact file.
176#[derive(Parser, Debug, Clone)]
177#[command(
178    about = "Sign a Git commit or artifact file.",
179    after_help = "Examples:
180  auths sign README.md                    # Sign a file → README.md.auths.json
181  auths sign HEAD                         # Sign the current commit
182  auths sign main..HEAD                   # Re-sign commits after main
183
184Artifacts:
185  Signing files creates a .auths.json attestation with your identity and device.
186  Use `auths verify` to check the signature.
187
188Commits:
189  Commit signing requires a linked device and Git configuration.
190  Verify with `auths verify HEAD` or `git log --show-signature`.
191
192Related:
193  auths verify      — Verify signatures
194  auths device list — Check linked devices"
195)]
196pub struct SignCommand {
197    /// Git ref, commit range (e.g. HEAD, main..HEAD), or path to an artifact file.
198    #[arg(help = "Commit ref, range, or artifact file path")]
199    pub target: String,
200
201    /// Output path for the signature file. Defaults to <FILE>.auths.json.
202    #[arg(long = "sig-output", value_name = "PATH")]
203    pub sig_output: Option<PathBuf>,
204
205    /// Local alias of the identity key (for artifact signing).
206    #[arg(long)]
207    pub key: Option<String>,
208
209    /// Local alias of the device key (for artifact signing, required for files).
210    #[arg(long)]
211    pub device_key: Option<String>,
212
213    /// Duration in seconds until expiration (per RFC 6749).
214    #[arg(long = "expires-in", value_name = "N")]
215    pub expires_in: Option<u64>,
216
217    /// Optional note to embed in the attestation (for artifact signing).
218    #[arg(long)]
219    pub note: Option<String>,
220
221    /// Capabilities this commit claims it exercises (comma-separated), e.g.
222    /// `--scope sign_commit`. Emitted as an `Auths-Scope` trailer so a verifier can
223    /// reject a claim outside the signer's delegator-anchored grant. Commit-only.
224    #[arg(long, value_delimiter = ',')]
225    pub scope: Vec<String>,
226}
227
228/// Handle the unified sign command.
229///
230/// Args:
231/// * `cmd` - Parsed SignCommand arguments.
232/// * `repo_opt` - Optional path to the Auths identity repository.
233/// * `passphrase_provider` - Provider for key passphrases.
234pub fn handle_sign_unified(
235    cmd: SignCommand,
236    repo_opt: Option<PathBuf>,
237    passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
238    env_config: &EnvironmentConfig,
239) -> Result<()> {
240    match parse_sign_target(&cmd.target) {
241        SignTarget::Artifact(path) => {
242            let device_key_alias = match cmd.device_key.as_deref() {
243                Some(alias) => alias.to_string(),
244                None => super::key_detect::auto_detect_device_key(repo_opt.as_deref(), env_config)?,
245            };
246            let commit_sha = super::git_helpers::resolve_head_silent();
247            handle_artifact_sign(
248                &path,
249                cmd.sig_output,
250                cmd.key.as_deref(),
251                &device_key_alias,
252                cmd.expires_in,
253                cmd.note,
254                commit_sha,
255                repo_opt,
256                passphrase_provider,
257                env_config,
258                &None,
259                false,
260            )
261        }
262        SignTarget::CommitRange(range) => {
263            let signer = resolve_signer_trailer(repo_opt.as_deref(), env_config)?;
264            sign_commit_range(&range, &signer, &cmd.scope)
265        }
266    }
267}
268
269impl crate::commands::executable::ExecutableCommand for SignCommand {
270    fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
271        handle_sign_unified(
272            self.clone(),
273            ctx.repo_path.clone(),
274            ctx.passphrase_provider.clone(),
275            &ctx.env_config,
276        )
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn commit_trailer_args_emit_auths_id_and_device() {
286        let signer = LocalSigner {
287            signer_did: "did:keri:Edevice".to_string(),
288            root_did: "did:keri:Eroot".to_string(),
289            anchor_seq: None,
290        };
291        let trailers = commit_trailer_args(&signer, &[]);
292        assert_eq!(trailers[0], "Auths-Id: did:keri:Eroot");
293        assert_eq!(trailers[1], "Auths-Device: did:keri:Edevice");
294        assert_eq!(
295            trailers.len(),
296            2,
297            "no anchor seq + no scope → only Auths-Id/Auths-Device"
298        );
299    }
300
301    #[test]
302    fn trailer_carries_signing_sequence() {
303        let signer = LocalSigner {
304            signer_did: "did:keri:Edevice".to_string(),
305            root_did: "did:keri:Eroot".to_string(),
306            anchor_seq: Some(7),
307        };
308        let trailers = commit_trailer_args(&signer, &[]);
309        assert_eq!(trailers.len(), 3);
310        assert_eq!(trailers[2], "Auths-Anchor-Seq: 7");
311    }
312
313    #[test]
314    fn commit_trailer_args_emit_scope_claim() {
315        let signer = LocalSigner {
316            signer_did: "did:keri:Eagent".to_string(),
317            root_did: "did:keri:Eroot".to_string(),
318            anchor_seq: Some(3),
319        };
320        let trailers =
321            commit_trailer_args(&signer, &["sign_commit".to_string(), "open-PR".to_string()]);
322        // Auths-Id, Auths-Device, Auths-Anchor-Seq, Auths-Scope (last).
323        assert_eq!(trailers.len(), 4);
324        assert_eq!(trailers[3], "Auths-Scope: sign_commit,open-PR");
325        // Round-trips through the verifier's own formatter.
326        assert_eq!(
327            trailers[3],
328            auths_verifier::scope_trailer(&["sign_commit".to_string(), "open-PR".to_string()])
329        );
330    }
331
332    #[test]
333    fn commit_trailer_args_no_scope_omits_trailer() {
334        let signer = LocalSigner {
335            signer_did: "did:keri:Eagent".to_string(),
336            root_did: "did:keri:Eroot".to_string(),
337            anchor_seq: None,
338        };
339        let trailers = commit_trailer_args(&signer, &[]);
340        assert!(
341            !trailers.iter().any(|t| t.starts_with("Auths-Scope")),
342            "no scope claim → no Auths-Scope trailer (backward compatible)"
343        );
344    }
345
346    #[test]
347    fn commit_trailer_args_root_machine_signs_directly() {
348        // On the root machine signer == root → both trailers carry the same DID.
349        let signer = LocalSigner {
350            signer_did: "did:keri:Eroot".to_string(),
351            root_did: "did:keri:Eroot".to_string(),
352            anchor_seq: None,
353        };
354        let trailers = commit_trailer_args(&signer, &[]);
355        assert_eq!(trailers[0], "Auths-Id: did:keri:Eroot");
356        assert_eq!(trailers[1], "Auths-Device: did:keri:Eroot");
357    }
358
359    #[test]
360    fn test_parse_sign_target_commit_ref() {
361        let target = parse_sign_target("HEAD");
362        assert!(matches!(target, SignTarget::CommitRange(_)));
363    }
364
365    #[test]
366    fn test_parse_sign_target_range() {
367        let target = parse_sign_target("main..HEAD");
368        assert!(matches!(target, SignTarget::CommitRange(_)));
369    }
370
371    #[test]
372    fn test_parse_sign_target_nonexistent_path_is_commit_range() {
373        let target = parse_sign_target("/nonexistent/artifact.tar.gz");
374        assert!(matches!(target, SignTarget::CommitRange(_)));
375    }
376
377    #[test]
378    fn test_parse_sign_target_file() {
379        use std::fs::File;
380        use tempfile::tempdir;
381        let dir = tempdir().unwrap();
382        let file_path = dir.path().join("artifact.tar.gz");
383        File::create(&file_path).unwrap();
384        let target = parse_sign_target(file_path.to_str().unwrap());
385        assert!(matches!(target, SignTarget::Artifact(_)));
386    }
387}