did-git-sign 0.4.12

Git commit signing proxy using DID Ed25519 keys via VTA
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
use anyhow::{Context, Result};
use ed25519_dalek::SigningKey;
use std::io::Read;
use std::path::Path;
use vgi_core::{GIT_SSHSIG_NAMESPACE, create_ssh_signature};

use crate::config::{self, SigningConfig};
use crate::policy;
use crate::vta;

/// Refuse to sign in any sshsig namespace other than `git`.
///
/// git signs commits, tags and push certificates in the `git` namespace, and
/// that is the only use the persona key is provisioned for. Without this, the
/// same key would sign for `file`, `email` or any other namespace a caller
/// passes with `-n`. This is friction against misuse through this binary, not
/// a boundary: see the security model in [`crate::policy`].
fn check_namespace(namespace: &str) -> Result<()> {
    if namespace == GIT_SSHSIG_NAMESPACE {
        return Ok(());
    }
    anyhow::bail!(
        "did-git-sign: refusing to sign in sshsig namespace {namespace:?}. did-git-sign only \
         signs git objects, in the {GIT_SSHSIG_NAMESPACE:?} namespace; use ssh-keygen with a \
         separate key for other namespaces."
    )
}

/// Environment variable that selects which persona signs (R-G-1), as a
/// per-invocation override. Its value is the persona's `did:webvh:…#key-N`.
pub const SIGNING_KEY_ENV: &str = "DID_GIT_SIGN_KEY";

/// Per-repo git config key that selects which persona signs (R-G-1):
/// `git config did-git-sign.key did:webvh:…#key-N`.
pub const SIGNING_KEY_GIT_CONFIG: &str = "did-git-sign.key";

/// Where the effective signing-key selection came from (for clear diagnostics).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeySource {
    /// The [`SIGNING_KEY_ENV`] environment variable.
    Env,
    /// The [`SIGNING_KEY_GIT_CONFIG`] per-repo git config setting.
    GitConfig,
    /// The `did_key_id` from the config file git passed via `-f`.
    ConfigFile,
}

impl std::fmt::Display for KeySource {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let s = match self {
            KeySource::Env => "the DID_GIT_SIGN_KEY environment variable",
            KeySource::GitConfig => "git config did-git-sign.key",
            KeySource::ConfigFile => "the did-git-sign config file",
        };
        f.write_str(s)
    }
}

/// Pure selection precedence (R-G-1): env var > per-repo git config > the config
/// file's own `did_key_id`. Whitespace is trimmed and an empty value is treated
/// as unset, so an exported-but-empty variable doesn't shadow the others.
fn select_signing_key(
    config_did: &str,
    env: Option<&str>,
    git: Option<&str>,
) -> (String, KeySource) {
    let clean = |v: Option<&str>| {
        v.map(str::trim)
            .filter(|s| !s.is_empty())
            .map(str::to_string)
    };
    if let Some(v) = clean(env) {
        return (v, KeySource::Env);
    }
    if let Some(v) = clean(git) {
        return (v, KeySource::GitConfig);
    }
    (config_did.to_string(), KeySource::ConfigFile)
}

/// Read the per-repo git config selector (`did-git-sign.key`), if set. Runs in
/// the current directory — git sets the signer's cwd to the repo.
fn git_config_signing_key() -> Option<String> {
    let out = std::process::Command::new("git")
        .args(["config", "--get", SIGNING_KEY_GIT_CONFIG])
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
    (!v.is_empty()).then_some(v)
}

/// Resolve the effective signing `did_key_id` and where it came from (R-G-1),
/// reading the env var and the per-repo git config around the pure
/// [`select_signing_key`].
fn resolve_signing_key(config_did: &str) -> (String, KeySource) {
    let env = std::env::var(SIGNING_KEY_ENV).ok();
    let git = git_config_signing_key();
    select_signing_key(config_did, env.as_deref(), git.as_deref())
}

/// The bare DID of a verification-method id (`did:webvh:…#key-0` → `did:webvh:…`).
fn bare_did(did_key_id: &str) -> &str {
    did_key_id
        .split(['#', '?', '/'])
        .next()
        .unwrap_or(did_key_id)
}

/// Refuse to sign a commit whose signer identity disagrees with the key
/// being used (R-G-3).
///
/// Two settings choose an identity — the DID the commit claims, and the
/// persona selection that picks the key — and when they name different DIDs
/// the commit is born unverifiable. `verify-trust` would reject it as
/// `unknownKey`: the claimed DID does not publish the key that signed. That
/// is a correct verdict pointing at the wrong thing, arriving in CI, on a
/// commit already written. Catching it here turns a confusing remote failure
/// into a local one that names both halves.
///
/// The claim is read the same way `verify-trust` reads it — [`signer_did`]:
/// the `Signed-by-DID:` trailer first, then the committer email for legacy
/// commits. At sign time the trailer may already be present (the commit-msg
/// hook ran) or absent (no hook, legacy flow), so only a *conflicting* claim
/// is refused.
///
/// Only commit-shaped payloads are checked. A payload with no `committer`
/// header (a tag, or a non-git namespace) carries no claim to disagree with.
/// The comparison is on **bare DIDs**, matching what the verifier actually
/// requires — signing with `#key-1` while the claim says `#key-0` verifies
/// fine, since the check is that the DID publishes the key, not which one.
fn check_committer_matches_key(data: &[u8], did_key_id: &str, source: KeySource) -> Result<()> {
    use vgi_core::{committer_identity, conflicting_signer_dids, signer_did};

    let signing_did = bare_did(did_key_id);

    // Two explicit claims that disagree with each other. No key satisfies
    // both, so there is no point asking which one matches ours —
    // `verify-trust` fails the commit closed whichever key signs it.
    if let Some((trailer, committer)) = conflicting_signer_dids(data) {
        anyhow::bail!(
            "did-git-sign: Signed-by-DID trailer claims '{trailer}' but committer claims \
             '{committer}'. Remove one claim or make them match before signing."
        );
    }

    match signer_did(data) {
        Some(claimed) if claimed == signing_did => Ok(()),
        Some(claimed) => anyhow::bail!(
            "did-git-sign: this commit claims signer '{claimed}' but would be signed with a key \
             held by '{signing_did}' (selected via {source}), so it would fail verification as \
             unknownKey. Point the claim at the signing key — \
             `git config did-git-sign.key '{did_key_id}'` for the Signed-by-DID trailer, or \
             `git config user.email` for a legacy DID committer — or select the persona \
             matching the claim."
        ),
        // Tags and non-git namespaces carry no committer header, so there is
        // no commit identity claim for this guard to compare.
        None if committer_identity(data).is_none() => Ok(()),
        // A committer exists but neither it nor a trailer names a DID: the
        // commit-msg hook did not run.
        None => anyhow::bail!(
            "did-git-sign: no Signed-by-DID trailer and user.email is not a DID, so the commit \
             would state no signer identity and fail verification as noSignerDid. Run \
             'did-git-sign init' to install the commit-msg hook, or set user.email to \
             '{did_key_id}'."
        ),
    }
}

/// Handle the signing invocation from git.
/// Git calls: `did-git-sign -Y sign -f <config_path> -n <namespace> <file_to_sign>`
/// The file to sign is passed as a positional argument; the armored SSH signature is written
/// to `<file_to_sign>.sig` on disk, matching ssh-keygen behaviour. Falls back to stdout
/// when no file argument is present (stdin mode).
pub async fn handle_sign(
    config_path: &Path,
    namespace: &str,
    sign_file: Option<&Path>,
) -> Result<()> {
    // Checked before anything is read, so a refused namespace touches neither
    // the buffer nor the VTA.
    check_namespace(namespace)?;

    // Read data to sign from the file argument (git passes the buffer file path)
    // or fall back to stdin for compatibility.
    let data = if let Some(path) = sign_file {
        std::fs::read(path)
            .with_context(|| format!("failed to read file to sign: {}", path.display()))?
    } else {
        let mut buf = Vec::new();
        std::io::stdin()
            .read_to_end(&mut buf)
            .context("failed to read data from stdin")?;
        buf
    };

    // Policy gate: parent process must be git, audit every attempt. This
    // guards against accidental and naive use, not against code running as
    // the user; see the module docs of `policy`.
    let decision = policy::evaluate(namespace, sign_file, &data);
    policy::write_audit(&decision);
    if !decision.allowed {
        anyhow::bail!(
            "did-git-sign: signing refused by policy (parent process {:?} is not git). \
             did-git-sign signs only when git runs it as gpg.ssh.program. \
             Attempt recorded in {}.",
            decision.parent_name.as_deref().unwrap_or("<unknown>"),
            policy::audit_log_path()
                .map(|p| p.display().to_string())
                .unwrap_or_else(|_| "<audit log unavailable>".to_string())
        );
    }

    // Load the config git pointed us at (`-f`).
    let cfg = SigningConfig::load(config_path)?;

    // R-G-1: resolve which community persona signs — an env var or a per-repo
    // git-config setting may override the config file's persona. R-G-2: when an
    // override names a persona with no stored credentials, fail clearly rather
    // than silently signing as an arbitrary (the config file's) persona.
    let (did_key_id, source) = resolve_signing_key(&cfg.did_key_id);
    if did_key_id != cfg.did_key_id && config::load_vta_credentials(&did_key_id).is_err() {
        anyhow::bail!(
            "did-git-sign: the signing persona '{did_key_id}' selected via {source} has no \
             stored credentials. Run `did-git-sign init` for that persona, or clear the \
             override ({} / `git config --unset {}`).",
            SIGNING_KEY_ENV,
            SIGNING_KEY_GIT_CONFIG,
        );
    }
    // R-G-3: the committer header is the identity claim this signature will be
    // checked against. Refuse now if it names a different DID than the key we
    // are about to sign with — see [`check_committer_matches_key`].
    check_committer_matches_key(&data, &did_key_id, source)?;

    let cfg = SigningConfig {
        did_key_id,
        user_name: cfg.user_name,
    };

    // Authenticate with VTA and fetch signing key
    let (client, creds) = vta::authenticate(&cfg).await?;
    let seed = vta::get_signing_key(&client, &creds.key_id).await?;

    // Create Ed25519 signing key from seed
    let signing_key = SigningKey::from_bytes(seed.as_bytes());
    let verifying_key = signing_key.verifying_key();

    // Build the SSH signature
    let signature = create_ssh_signature(&signing_key, &verifying_key, namespace, &data)?;

    // Write the signature to <file>.sig, mirroring ssh-keygen -Y sign behaviour.
    // Git reads the signature back from that path after the signing program exits.
    // Fall back to stdout only when no input file was given (stdin mode).
    if let Some(path) = sign_file {
        // Append ".sig" to the full path (not replace the extension), matching ssh-keygen.
        let mut sig_os = path.as_os_str().to_owned();
        sig_os.push(".sig");
        let sig_path = std::path::PathBuf::from(sig_os);
        std::fs::write(&sig_path, signature.as_bytes())
            .with_context(|| format!("failed to write signature to {}", sig_path.display()))?;
    } else {
        print!("{signature}");
    }

    Ok(())
}

/// Test that signing works by creating a signature and verifying the output format.
/// Used by the `verify` subcommand.
pub fn test_sign(
    signing_key: &SigningKey,
    verifying_key: &ed25519_dalek::VerifyingKey,
    data: &[u8],
) -> Result<()> {
    let signature = create_ssh_signature(signing_key, verifying_key, "git", data)?;
    if !signature.starts_with("-----BEGIN SSH SIGNATURE-----") {
        anyhow::bail!("signature output has invalid format");
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    const SIGNER: &str = "did:webvh:QmSigner:example.com";

    fn commit_committed_by(committer: &str) -> Vec<u8> {
        format!(
            "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
             author A U Thor <a@example.com> 1700000000 +0000\n\
             committer A U Thor <{committer}> 1700000000 +0000\n\
             \n\
             a message\n"
        )
        .into_bytes()
    }

    #[test]
    fn a_matching_committer_and_key_may_sign() {
        let commit = commit_committed_by(&format!("{SIGNER}#key-0"));
        assert!(
            check_committer_matches_key(&commit, &format!("{SIGNER}#key-0"), KeySource::ConfigFile)
                .is_ok()
        );
    }

    #[test]
    fn a_different_key_of_the_same_did_still_signs() {
        // The verifier requires the claimed DID to publish the signing key, not
        // that the fragments agree — being stricter here would fail commits CI
        // would happily accept.
        let commit = commit_committed_by(&format!("{SIGNER}#key-0"));
        assert!(
            check_committer_matches_key(&commit, &format!("{SIGNER}#key-3"), KeySource::GitConfig)
                .is_ok()
        );
    }

    #[test]
    fn signing_as_one_community_while_claiming_another_is_refused() {
        // The multi-community footgun: a persona override moved but user.email
        // did not, so the commit would be born unverifiable.
        let commit = commit_committed_by("did:webvh:QmOther:community.example#key-0");
        let error =
            check_committer_matches_key(&commit, &format!("{SIGNER}#key-0"), KeySource::GitConfig)
                .unwrap_err()
                .to_string();

        assert!(error.contains("QmOther"), "names the claim: {error}");
        assert!(error.contains("QmSigner"), "names the key's DID: {error}");
        assert!(
            error.contains("git config did-git-sign.key"),
            "names where the selection came from: {error}"
        );
    }

    #[test]
    fn a_non_did_committer_without_trailer_is_refused() {
        // No trailer and no DID in committer email = missing commit-msg hook.
        let commit = commit_committed_by("alice@example.com");
        let error =
            check_committer_matches_key(&commit, &format!("{SIGNER}#key-0"), KeySource::ConfigFile)
                .unwrap_err()
                .to_string();
        assert!(
            error.contains("noSignerDid"),
            "must refuse when no DID claim exists: {error}"
        );
    }

    #[test]
    fn trailer_matching_key_is_accepted() {
        let commit = format!(
            "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
             author A <a@x.com> 1700000000 +0000\n\
             committer A <alice@example.com> 1700000000 +0000\n\
             \n\
             message\n\
             \n\
             Signed-by-DID: {SIGNER}#key-0\n"
        );
        assert!(
            check_committer_matches_key(
                commit.as_bytes(),
                &format!("{SIGNER}#key-0"),
                KeySource::ConfigFile
            )
            .is_ok()
        );
    }

    #[test]
    fn trailer_conflicting_with_key_is_refused() {
        let commit = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
             author A <a@x.com> 1700000000 +0000\n\
             committer A <alice@example.com> 1700000000 +0000\n\
             \n\
             message\n\
             \n\
             Signed-by-DID: did:webvh:QmOther:other.example#key-0\n";
        assert!(
            check_committer_matches_key(
                commit.as_bytes(),
                &format!("{SIGNER}#key-0"),
                KeySource::ConfigFile
            )
            .is_err()
        );
    }

    #[test]
    fn conflicting_trailer_and_committer_dids_are_refused() {
        let commit = format!(
            "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
             author A <a@x.com> 1700000000 +0000\n\
             committer A <did:webvh:QmOther:other.example#key-0> 1700000000 +0000\n\
             \n\
             message\n\
             \n\
             Signed-by-DID: {SIGNER}#key-0\n"
        );
        let error = check_committer_matches_key(
            commit.as_bytes(),
            &format!("{SIGNER}#key-0"),
            KeySource::ConfigFile,
        )
        .unwrap_err()
        .to_string();
        assert!(error.contains("Signed-by-DID"), "names trailer: {error}");
        assert!(error.contains("committer"), "names committer: {error}");
    }

    #[test]
    fn a_payload_with_no_committer_is_not_a_claim_to_check() {
        // Tags and non-git namespaces carry no committer header, so there is
        // nothing to disagree with — signing must not be blocked.
        assert!(
            check_committer_matches_key(b"not a commit object", SIGNER, KeySource::ConfigFile)
                .is_ok()
        );
    }

    #[test]
    fn signing_key_selection_precedence() {
        // env wins over git config and the config file (R-G-1).
        let (key, src) = select_signing_key("cfg#k", Some("env#k"), Some("git#k"));
        assert_eq!(key, "env#k");
        assert_eq!(src, KeySource::Env);

        // git config wins over the config file when no env override.
        let (key, src) = select_signing_key("cfg#k", None, Some("git#k"));
        assert_eq!(key, "git#k");
        assert_eq!(src, KeySource::GitConfig);

        // Falls back to the config file's own did_key_id (back-compat).
        let (key, src) = select_signing_key("cfg#k", None, None);
        assert_eq!(key, "cfg#k");
        assert_eq!(src, KeySource::ConfigFile);
    }

    #[test]
    fn signing_key_selection_ignores_blank_overrides() {
        // An exported-but-empty / whitespace var must not shadow lower precedence.
        let (key, src) = select_signing_key("cfg#k", Some("   "), Some("git#k"));
        assert_eq!(key, "git#k");
        assert_eq!(src, KeySource::GitConfig);

        let (key, src) = select_signing_key("cfg#k", Some(""), None);
        assert_eq!(key, "cfg#k");
        assert_eq!(src, KeySource::ConfigFile);

        // A value is trimmed.
        let (key, _) = select_signing_key("cfg#k", Some("  env#k \n"), None);
        assert_eq!(key, "env#k");
    }

    #[test]
    fn key_source_messages_name_the_origin() {
        assert!(KeySource::Env.to_string().contains("DID_GIT_SIGN_KEY"));
        assert!(
            KeySource::GitConfig
                .to_string()
                .contains("did-git-sign.key")
        );
    }

    #[test]
    fn only_the_git_namespace_may_sign() {
        assert!(check_namespace("git").is_ok());
        for namespace in ["file", "email", "", "Git", "git ", "git\0"] {
            let error = check_namespace(namespace).unwrap_err().to_string();
            assert!(
                error.contains("namespace"),
                "{namespace:?} must be refused with a namespace error: {error}"
            );
        }
    }

    /// The namespace is refused before the buffer, config or VTA are touched:
    /// every path handed in here is missing, so any later step would fail with
    /// a different error.
    #[tokio::test]
    async fn a_non_git_namespace_is_refused_before_anything_is_read() {
        let missing = std::path::Path::new("/nonexistent/did-git-sign/buffer");
        let error = handle_sign(missing, "file", Some(missing))
            .await
            .unwrap_err()
            .to_string();
        assert!(
            error.contains("refusing to sign in sshsig namespace \"file\""),
            "{error}"
        );
    }

    #[test]
    fn test_test_sign_accepts_valid_key() {
        let seed = [0xAA; 32];
        let signing_key = SigningKey::from_bytes(&seed);
        let verifying_key = signing_key.verifying_key();
        assert!(test_sign(&signing_key, &verifying_key, b"test data").is_ok());
    }

    /// Regression guard: the .sig path must be formed by appending ".sig" to the full
    /// filename, not by replacing an existing extension.  git's buffer files can have
    /// names like "COMMIT_EDITMSG" (no extension) or, in theory, dotted names.
    /// Using Path::with_extension("sig") would silently drop any existing extension,
    /// so the production code uses OsString::push instead.  This test encodes that
    /// contract so any future refactor breaks loudly.
    #[test]
    fn sig_path_appends_dot_sig_not_replaces_extension() {
        let base = std::path::Path::new("/tmp/buffer.diff");
        let mut sig_os = base.as_os_str().to_owned();
        sig_os.push(".sig");
        let sig_path = std::path::PathBuf::from(sig_os);
        assert_eq!(sig_path, std::path::PathBuf::from("/tmp/buffer.diff.sig"));

        // Also verify a name with no extension is handled correctly.
        let base2 = std::path::Path::new("/tmp/COMMIT_EDITMSG");
        let mut sig_os2 = base2.as_os_str().to_owned();
        sig_os2.push(".sig");
        let sig_path2 = std::path::PathBuf::from(sig_os2);
        assert_eq!(
            sig_path2,
            std::path::PathBuf::from("/tmp/COMMIT_EDITMSG.sig")
        );
    }
}