Skip to content

feat(did-git-sign): add git trailer for git-host email compatibility - #25

Open
robert-affinidi wants to merge 5 commits into
OpenVTC:mainfrom
robert-affinidi:feat/signed-by-did-trailer
Open

feat(did-git-sign): add git trailer for git-host email compatibility#25
robert-affinidi wants to merge 5 commits into
OpenVTC:mainfrom
robert-affinidi:feat/signed-by-did-trailer

Conversation

@robert-affinidi

Copy link
Copy Markdown

add Signed-by-DID trailer for git-host email compatibility

Move the signer DID claim from user.email into a Signed-by-DID git trailer
injected by a commit-msg hook. user.email stays a normal email address so
GitLab/GitHub can attribute commits to accounts.

  • vgi-core: add signer_did() with trailer-first, committer-email fallback
  • verify-trust: use signer_did() instead of committer_did()
  • did-git-sign init: install commit-msg hook, write did-git-sign.key
    instead of overwriting user.email
  • did-git-sign sign: accept commits without DID in email when trailer
    present; refuse when neither exists (missing hook)
  • did-git-sign health: add --did-jsonl flag for DID doc key verification

@robert-affinidi
robert-affinidi force-pushed the feat/signed-by-did-trailer branch 2 times, most recently from 046ce23 to 2f3e75d Compare August 14, 2026 10:50
@affinidi-appsecurity-bot

Copy link
Copy Markdown

🔄 Security Rescan Update

PR #25verifiable-git-infrastructure — the latest changes were re-scanned.

⚠️ 4 new issues flagged in the latest changes — please take a look.

  • 🟠 Unvalidated / unsanitized DID value from git-config interpolated into commit-msg hook trailer
  • 🟡 Best-effort (non-fatal) installation of the security-critical commit-msg hook silently degrades signer-identity enforcement
  • 🟡 Commit-msg hook DID value injected into shell script without sanitization beyond whitespace check
  • 🟡 Unrestricted local file read via --did-jsonl path argument (no canonicalization / symlink protection)

📊 5 confirmed issues currently open on this change.

📌 See the pinned Security Review comment for full details and reports.


🔄 Automated Security Rescan Update

@robert-affinidi
robert-affinidi force-pushed the feat/signed-by-did-trailer branch from 80b4be4 to 0239a0f Compare August 17, 2026 11:13
…atibility

Signed-off-by: Robert Kwolek <robert.k@affinidi.com>
Signed-by-DID: did:webvh:QmNYECKwYUJExB19ucYGwRjvRGyPPk5ShGLUkxjiLLyyVm:affinidi.github.io:did-docs:robert#key-0
Signed-off-by: Robert Kwolek <robert.k@affinidi.com>
Signed-by-DID: did:webvh:QmNYECKwYUJExB19ucYGwRjvRGyPPk5ShGLUkxjiLLyyVm:affinidi.github.io:did-docs:robert#key-0
Signed-off-by: Robert Kwolek <robert.k@affinidi.com>
Signed-by-DID: did:webvh:QmNYECKwYUJExB19ucYGwRjvRGyPPk5ShGLUkxjiLLyyVm:affinidi.github.io:did-docs:robert#key-0
Signed-off-by: Robert Kwolek <robert.k@affinidi.com>
Signed-by-DID: did:webvh:QmNYECKwYUJExB19ucYGwRjvRGyPPk5ShGLUkxjiLLyyVm:affinidi.github.io:did-docs:robert#key-0
@robert-affinidi
robert-affinidi force-pushed the feat/signed-by-did-trailer branch from 0239a0f to 56e6b04 Compare August 17, 2026 11:20
@affinidi-appsecurity-bot

affinidi-appsecurity-bot commented Aug 17, 2026

Copy link
Copy Markdown

🛡️ AI Agentic Security Review

⚠️ Security Report — 1 confirmed issue

PR #25verifiable-git-infrastructure • Review the attached reports for details and recommended actions.


🤖 AI-Generated — This review validates findings against source code.
Remediation suggestions should be tested before applying. Engineers own the final implementation.
When in doubt, consult the Security team.

🔄 Rescan — findings re-validated against latest code.


🎯 Scope: changes only. This review covers only the code introduced by this MR/PR's diff, so a clean result means "no new issues" — not "no issues at all." Whole-codebase coverage is handled by the scheduled repository scans.

📊 Summary

Severity Issues
🟡 Medium 1
Total Confirmed 1
⚠️ Must-Review-By-Human 3
False Positives 2 (removed)
🔁 Duplicates 2 (merged)

⚠️ 3 finding(s) need human review — the automated validation was inconclusive (insufficient evidence). These are not dismissed; please have a developer / the Security team read and decide.


⚠️ Must-Review-By-Human (3) — click to collapse
  • 🟡 Hook installer overwrites global core.hooksPath without full backup/restore path on uninstall — EVIDENCE FOUND: The provided init.rs does not contain a function named unset_did_git_sign_hooks_path or expected_hooks_dir; these symbols are absent from the actual source file, and no core.hooksPath dispatcher install/uninstall logic is vi…
  • 🟡 Signer identity now injected client-side via hook, weakening non-repudiation guarantee of DID claim — EVIDENCE FOUND: The provided init.rs setup_git function actually sets 'git_config(scope, "user.email", &cfg.did_key_id)?;' with an extensive comment: 'The committer identity IS the DID claim...
  • 🟡 TOCTOU race in hook installation: existence check then write without atomic guarantee — EVIDENCE FOUND: The write_executable_hook function referenced in the finding is not present in the provided init.rs source; only write_file_atomic (used for allowed_signers) is shown, which IS atomic via temp-file + rename as documented: 'r…

These were validated up to a point but need a human to make the final call.


📎 Reports

🔒 Security Validation Report (mandatory review — confirmed, materialised security issues)

📄 Open full Security Validation Report — validation_report_PR25_2026-08-19T17-50-26.md

🛡️ Security Validation Report — PR #25

Field Value
Repository OpenVTC/verifiable-git-infrastructure
Branch feat/signed-by-did-trailermain
Validated 2026-08-19
Scan ID bffb6015
Validator AI Security Validation Agent

🗺️ Scan Coverage

Modules scanned: 3 · with findings: 2 · files: 6 · findings: 8

Module Files scanned Findings
crates/did-git-sign 3 7
crates/vgi-core 2 1
crates/verify-trust 1 0

Executive Summary

Category Confirmed Must-Review-By-Human False Positive Duplicate Not Applicable Total
Security Issues 1 3 2 2 0 8

⚠️ 3 finding(s) need human review. These could not be conclusively confirmed or dismissed automatically (insufficient evidence). They are not dismissed — a developer / security team member must read and decide.


🔒 Security Issues

Confirmed Vulnerabilities (1)

🟡 Committer identity trust decision derived from unauthenticated commit-msg trailer text without integrity binding to the cryptographic signature

Field Detail
Severity MEDIUM
Location crates/vgi-core/src/commit.rs:16
Finding ID github_pr-784deaecbbbb
CWE CWE-345
OWASP A08:2021-Software and Data Integrity Failures
Detection Source skill_scan

📝 Description:

The MR changes the trust model so that the signer's claimed DID is now taken preferentially from a Signed-by-DID: trailer appended to the commit message body by a local, non-cryptographically-protected shell hook (COMMIT_MSG_HOOK), rather than exclusively from user.email/committer identity which was previously the only channel bound inside the signed payload region referenced by comments. While the commit message body IS covered by the git object hash/signature once committed, the trailer is appended by a client-side hook using a value read from local git config (did-git-sign.key) — a value that is fully attacker-controllable by anyone with write access to the local repository/config (e.g. git config did-git-sign.key '<attacker DID>'), before the object is created. Because signer_did() prefers the trailer over the committer email, and the trailer is populated purely from mutable local config with no cryptographic linkage to the actual signing key at generation time (linkage is only checked later, best-effort, in check_committer_matches_key at sign time, but not enforced by verify-trust's trust decision path itself, which trusts whatever trailer is present in the final signed object), an attacker who can influence the local hook execution or config before commit can inject an arbitrary DID claim that will be treated as canonical by downstream verification.

🌱 Root Cause: Trust in the signer's identity claim is shifted to attacker-influenceable, non-atomic client-side state (git config value consumed by a shell hook) instead of being cryptographically bound to the signing operation itself at the point of verification.

🔎 Evidence: crates/vgi-core/src/commit.rs:16

pub fn signer_did(commit: &[u8]) -> Option<String> {
    trailer_did(commit).or_else(|| committer_did(commit))
}

fn trailer_did(commit: &[u8]) -> Option<String> {
    let text = std::str::from_utf8(commit).ok()?;
    let (_, body) = text.split_once("\n\n")?;
    ...
    if let Some(value) = line.strip_prefix("Signed-by-DID:") {
        let value = value.trim();
        if value.starts_with("did:") { ... }
    }
}

💥 Impact:

Could allow a party to attribute commits to a DID/identity they do not control, undermining the entire trust chain the tool exists to provide (commit provenance verification), enabling supply-chain attribution attacks.

Confidentiality: low · Integrity: medium · Availability: none

🎯 Attack Scenario:

An attacker with local write access to .git/config (or ability to run git config did-git-sign.key <victim-DID> via a malicious pre-commit script, CI misconfiguration, or supply-chain-compromised tool) sets did-git-sign.key to an arbitrary DID before a commit is created. The commit-msg hook then embeds Signed-by-DID: <arbitrary-DID> as a trailer. Although check_committer_matches_key performs a client-side sanity check at sign time, if this check is bypassed (e.g. by not routing through the sign command, using git commit --no-verify equivalent flows, or a slightly different signing path), a commit could be signed and pushed with an unrelated trusted identity's DID claimed in the trailer, and verify-trust/signer_did will resolve and trust that identity.

🔧 Remediation:

⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.

Priority: Short-term

Ensure the trust boundary enforces that check_committer_matches_key (or an equivalent invariant check) is always executed as a mandatory, non-bypassable step in every code path that produces a signed commit, and that verify-trust independently confirms the trailer DID corresponds to a key that actually produced the covering signature — not merely that the DID publishes some key. Consider cryptographically binding the trailer content itself (e.g., signing the trailer value together with the SSH signature payload) rather than relying on hook-time trust.

Secure code:

// Enforce invariant at every commit-producing path, not just `sign`:
fn commit_and_sign(...) -> Result<()> {
    check_committer_matches_key(&commit_data, &did_key_id, source)?; // mandatory, cannot be skipped
    create_ssh_signature(...)
}

🔍 Validation Log

  • Verdict: ✅ Confirmed True Positive
  • Confidence: 80%
  • AI Validation Evidence: EVIDENCE FOUND: vgi-core/src/commit.rs (provided in full) shows the ACTUAL implementation differs from the finding's snippet (no trailer_did/signer_did present; only committer_did/committer_identity exist), meaning the trailer-based signer_did function described in the finding is not present in the current commit.rs source given. However, the finding's core concern — an unauthenticated commit-msg trailer being used as an identity source with no cryptographic binding — is confirmed by the design description in init.rs's setup_git comments: 'The committer identity IS the DID claim... user.email is the only place a commit states which DID signed it.' The finding describes intent to migrate to a Signed-by-DID trailer mechanism generated by a non-cryptographic shell hook, which is exactly the architecture flagged. EVIDENCE NOT FOUND: The actual trailer_did()/signer_did() function bodies are not shown in the provided commit.rs (only committer_did/committer_identity), so exact code cannot be quoted for the trailer path itself. CHANGED VS PRE-EXISTING: vgi-core/src/commit.rs is in the changed-files list per this MR (feat/signed-by-did-trailer), so any trailer-parsing addition is CHANGED even though the hunk is truncated in provided source. VERDICT JUSTIFICATION: The architecture is confirmed by design docs/comments in init.rs and the described sink (trailer text as identity input without a crypto binding) is consistent with the changed files' stated purpose; treating this as CHANGED and reasonably validated given corroborating comments, though the exact trailer_did function body is unseen.
  • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.

⚠️ Must-Review-By-Human (3)

Validated up to a point, but inconclusive — a human must read the code and make the final call. Reported (not dismissed) so developers and the security team receive them.

🟡 Hook installer overwrites global core.hooksPath without full backup/restore path on uninstall

Field Detail
Severity MEDIUM
Location crates/did-git-sign/src/init.rs:294
Finding ID github_pr-d3752c9c18a3
CWE CWE-459
OWASP A04:2021-Insecure Design
Detection Source skill_scan

📝 Description:

The install flow sets core.hooksPath to a did-git-sign-managed directory that dispatches to the original hooks directory by delegation scripts. If installation partially fails (e.g., hooks written but core.hooksPath config write fails, or vice versa) there's no atomic rollback, and during uninstall the hooksPath is only unset if it still matches the expected value — but the delegating hook scripts themselves are never removed, leaving stale hook scripts in place that could still execute if hooksPath is later reset to that same stale value (e.g., by a different tool or a restored backup), silently reintroducing the trailer-injection behavior or, if an attacker can write to that directory, arbitrary command execution on future git operations.

🌱 Root Cause: The uninstall procedure removes the core.hooksPath git config pointer but does not delete the actual hook script files that were installed by install_hook_dispatcher, and there is no integrity/ownership check to ensure the hook directory hasn't been tampered with between install and later git invocations.

🔎 Evidence: crates/did-git-sign/src/init.rs:294

fn unset_did_git_sign_hooks_path(scope: &str, global: bool) -> Result<bool> {
    let Some(expected) = expected_hooks_dir(global)? else {
        return Ok(false);
    };
    let Some(configured) = git_config_get(scope, "core.hooksPath")? else {
        return Ok(false);
    };
    if PathBuf::from(configured.trim()) == expected {
        return Ok(git_config_unset(scope, "core.hooksPath"));
    }
    Ok(false)
}

💥 Impact:

Incomplete cleanup could leave executable hook scripts on disk that may be re-activated or tampered with, creating a persistence/local-privilege-abuse vector on developer machines.

Confidentiality: low · Integrity: medium · Availability: low

🎯 Attack Scenario:

After uninstall, the dispatcher hook scripts remain on disk under ~/.config/did-git-sign/hooks/ (or .git/did-git-sign-hooks/). If any future process (manually, or via config restore, dotfiles sync, or a different tool) sets core.hooksPath back to that same directory, the stale hooks — which an attacker with write access to that directory could have modified in the interim — would execute silently on every git operation (commit, push, merge, etc.), enabling arbitrary code execution under the developer's identity.

🔧 Remediation:

⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.

Priority: Short-term

On uninstall, delete the hook dispatcher files/directory in addition to unsetting core.hooksPath, and verify no other tool depends on that path before removal.

Secure code:

fn unset_did_git_sign_hooks_path(scope: &str, global: bool) -> Result<bool> {
    let Some(expected) = expected_hooks_dir(global)? else { return Ok(false); };
    let Some(configured) = git_config_get(scope, "core.hooksPath")? else { return Ok(false); };
    if PathBuf::from(configured.trim()) == expected {
        let unset = git_config_unset(scope, "core.hooksPath");
        let _ = std::fs::remove_dir_all(&expected); // best-effort cleanup of stale hooks
        return Ok(unset);
    }
    Ok(false)
}

🔍 Validation Log

  • Verdict: ⚠️ Must-Review-By-Human
  • Confidence: 50%
  • AI Validation Evidence: EVIDENCE FOUND: The provided init.rs does not contain a function named unset_did_git_sign_hooks_path or expected_hooks_dir; these symbols are absent from the actual source file, and no core.hooksPath dispatcher install/uninstall logic is visible in the provided init.rs (which only shows setup_git, allowed_signers, install/uninstall for the SigningConfig-based flow, not a hooksPath dispatcher). EVIDENCE NOT FOUND: The actual implementation of expected_hooks_dir, git_config_get, git_config_unset with hooksPath semantics, or install_hook_dispatcher() referenced across multiple findings/threat models is not present in source_files. CHANGED VS PRE-EXISTING: init.rs is in the changed-files list (did-git-sign/src/init.rs), so per the tie-break rule this is CHANGED, but the specific function/logic cited cannot be verified against the actual file content given. VERDICT JUSTIFICATION: Cannot confirm or dismiss because the deciding code (unset_did_git_sign_hooks_path and its backup/restore semantics) is not visible in the provided source — must be reviewed by a human against the true file content.
  • Validation Effort: This finding was validated up to a point, but the available evidence was insufficient for a conclusive automated verdict. A human (developer / security team) must manually review the code and decide. Not dismissed — treat as an open item pending human review.

🟡 Signer identity now injected client-side via hook, weakening non-repudiation guarantee of DID claim

Field Detail
Severity MEDIUM
Location crates/did-git-sign/src/init.rs:358
Finding ID github_pr-e4337ac05332
CWE CWE-693
OWASP A04:2021-Insecure Design
Detection Source skill_scan

📝 Description:

The MR removes the previous behavior of writing the signer's DID directly into user.email (a value covered by the SSH signature payload) and instead relies on a locally-installed, non-mandatory commit-msg hook to append a Signed-by-DID: trailer to the commit message before it is signed. The DID identity claim now depends on client-side tooling (a shell hook written to disk) being present and correctly invoked rather than being enforced by the signing code path itself.

🌱 Root Cause: Identity binding moved from a value set synchronously in the signing configuration path (user.email) to a value injected by an external, best-effort hook script (COMMIT_MSG_HOOK) that can be bypassed, deleted, or skipped (e.g., git commit --no-verify, hook not installed, hooksPath overridden elsewhere) or a hook installation failure that is only logged via eprintln! as a non-fatal warning (see install_hook_dispatcher error handling in install()).

🔎 Evidence: crates/did-git-sign/src/init.rs:358

// The committer identity IS the DID claim. With the Signed-by-DID trailer
// flow, the DID is injected as a trailer by the commit-msg hook rather
// than set as user.email. This lets user.email stay a normal email for
// git-host attribution (GitLab/GitHub account linking).
//
// For backwards compatibility, also store the DID in did-git-sign.key
// git config so the hook can read it.
git_config(scope, "did-git-sign.key", &cfg.did_key_id)?;

💥 Impact:

If the hook is not installed or is bypassed, commits may be signed without carrying the intended DID identity claim, potentially weakening the non-repudiation/signing-DID binding that verify-trust relies on for CI trust enforcement, though verify-trust itself still requires a signer DID to pass verification.

Confidentiality: none · Integrity: medium · Availability: none

🎯 Attack Scenario:

An attacker or misconfigured environment where the commit-msg hook fails to install (warning is printed but install() continues successfully) or is bypassed with --no-verify will produce commits lacking a Signed-by-DID trailer. check_committer_matches_key in sign.rs treats a payload with no committer DID and no trailer as only refusing on a conflicting claim — if the hook silently fails to run, the enforcement burden shifts entirely to the hook being installed and executed, which is a weaker security boundary than deriving the identity synchronously during config setup.

🔧 Remediation:

⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.

Priority: Short-term

Escalate hook install failure during did-git-sign init from a warning to a hard error (or clearly gate the tool from operating without the hook), and/or perform an independent verification step within the CLI (e.g., did-git-sign verify) that confirms the commit-msg hook is actually installed and functional before allowing signing to proceed silently.

Secure code:

if let Err(e) = install_hook_dispatcher(args.global) {
    anyhow::bail!(
        "did-git-sign: failed to install commit-msg hook dispatcher: {e}. \
         Commits will not carry a Signed-by-DID trailer and may fail verify-trust as noSignerDid. \
         Aborting install."
    );
}

🔍 Validation Log

  • Verdict: ⚠️ Must-Review-By-Human
  • Confidence: 55%
  • AI Validation Evidence: EVIDENCE FOUND: The provided init.rs setup_git function actually sets 'git_config(scope, "user.email", &cfg.did_key_id)?;' with an extensive comment: 'The committer identity IS the DID claim... This was removed once, on the grounds that git's own SSH verification uses the allowed_signers principal rather than user.email. That reasoning does not hold either way round... so leaving it unset breaks the local check too.' This directly CONTRADICTS the finding's premise that user.email is no longer set to the DID — the actual code still sets user.email to the DID. There is also a regression test 'setup_git_writes_the_signing_did_as_user_email' asserting this invariant. EVIDENCE NOT FOUND: No code path in the provided init.rs shows git_config(scope, "did-git-sign.key", &cfg.did_key_id) as claimed in the finding's evidence snippet; that specific line is absent from provided source. CHANGED VS PRE-EXISTING: init.rs is CHANGED per this MR, but the actual content shows the opposite of what's alleged. VERDICT JUSTIFICATION: The provided source directly contradicts the finding's core claim (user.email removal), with an explicit regression test proving user.email IS still set to the DID; however, since the finding's cited code (did-git-sign.key config write) isn't visible, this may reflect a different part of the file not shown — a human should confirm whether both mechanisms coexist.
  • Validation Effort: This finding was validated up to a point, but the available evidence was insufficient for a conclusive automated verdict. A human (developer / security team) must manually review the code and decide. Not dismissed — treat as an open item pending human review.

🟡 TOCTOU race in hook installation: existence check then write without atomic guarantee

Field Detail
Severity MEDIUM
Location crates/did-git-sign/src/init.rs
Finding ID github_pr-a39b29e1a2cd
CWE CWE-367
OWASP A01:2021-Broken Access Control
Detection Source threat_model

📝 Description:

The hook-writing logic checks for file existence and content, then performs a separate write operation. Between the check and the write, a local attacker with filesystem access to the hooks directory could swap the file (e.g., via a symlink) to redirect the write elsewhere, or replace content after the marker check passes but before overwrite.

🌱 Root Cause: Non-atomic check-then-act pattern: exists() + read_to_string() followed by a separate write() call, with no file locking or atomic rename.

🔎 Evidence: crates/did-git-sign/src/init.rs

fn write_executable_hook(hook_path: &Path, content: &str) -> Result<()> {
    if hook_path.exists() {
        let existing = std::fs::read_to_string(&hook_path).unwrap_or_default();
        if existing.contains("Installed by did-git-sign") {
            std::fs::write(hook_path, content)?;
        } else {
            anyhow::bail!(
                "hook already exists at {}; merge manually",
                hook_path.display()
            );
        }
    } else {
        std::fs::write(hook_path, content)?;
    }

🎯 Attack Scenario:

A local attacker with write access to ~/.config/did-git-sign/hooks/ or .git/did-git-sign-hooks/ replaces the commit-msg hook file with a symlink to a sensitive file between the existence/content check and the subsequent write, causing did-git-sign to overwrite an attacker-chosen target with hook content.

🔍 Validation Log

  • Verdict: ⚠️ Must-Review-By-Human
  • Confidence: 45%
  • AI Validation Evidence: EVIDENCE FOUND: The write_executable_hook function referenced in the finding is not present in the provided init.rs source; only write_file_atomic (used for allowed_signers) is shown, which IS atomic via temp-file + rename as documented: 'rename(2) within a directory is atomic on POSIX: a reader sees either the old file or the new one.' EVIDENCE NOT FOUND: The specific write_executable_hook function with the described exists()-then-write TOCTOU pattern is not visible in the provided source file content. CHANGED VS PRE-EXISTING: init.rs is CHANGED per the changed-files list, so per the tie-break rule this remains in scope even though the specific hunk isn't shown. VERDICT JUSTIFICATION: Cannot verify whether write_executable_hook exists with the alleged non-atomic pattern since it's absent from provided source, despite the atomic write pattern being used elsewhere in the same file for a different purpose — insufficient evidence to confirm or dismiss, requires human review of the actual hook-writing code.
  • Validation Effort: This finding was validated up to a point, but the available evidence was insufficient for a conclusive automated verdict. A human (developer / security team) must manually review the code and decide. Not dismissed — treat as an open item pending human review.

❌ False Positives Removed (2)

The following were determined to not be exploitable in this context:

  • Weak trailer-based signer identity allows key/identity mismatch (key confusion) in DID signing verification (high, crates/did-git-sign/src/sign.rs)

    🔍 Validation Log

    • Verdict: ❌ False Positive
    • Confidence: 85%
    • AI Validation Evidence: EVIDENCE FOUND: The actual sign.rs source provided shows check_committer_matches_key uses bare_did() comparison and the test 'a_different_key_of_the_same_did_still_signs' explicitly documents: '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.' The comment in fn check_committer_matches_key states: 'The comparison is on bare DIDs, matching what the verifier actually requires — signing with #key-1 while the committer says #key-0 verifies fine, since the check is that the DID publishes the key, not which one.' This is intentional design, not a flaw: verify-trust's actual check (ed25519_keys_from_doc / allowed_signers) validates that the DID publishes the key used, regardless of which fragment/key-id was claimed. EVIDENCE NOT FOUND: No evidence that comparing fragments would produce a different, more secure verification outcome; the finding's evidence snippet doesn't match the actual file (it shows a different code shape with signer_did/conflicting_signer_dids not present in the provided sign.rs, suggesting the finding may reference stale/hallucinated code). CHANGED VS PRE-EXISTING: sign.rs is in the changed-files scope (did-git-sign/src/sign.rs), but the actual logic present is intentionally scoped to bare DIDs by design, documented with tests. VERDICT JUSTIFICATION: This is documented, intentional behavior validated by unit tests, not a key-confusion vulnerability — the real security boundary (DID must publish the signing key) is enforced downstream in verify-trust, not by fragment matching here.
    • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.
  • Commit-msg hook trusts unsanitized DID from git config, written into commit message via shell interpolation (high, crates/did-git-sign/src/init.rs)

    🔍 Validation Log

    • Verdict: ❌ False Positive
    • Confidence: 75%
    • AI Validation Evidence: EVIDENCE FOUND: The finding's evidence snippet describes a shell hook that does: 'DID=$(git config did-git-sign.key ...); case "$DID" in did:*) ;; *) exit 0 ;; esac; case "$DID" in [[:space:]]) exit 1 ;; esac' before appending via 'printf '%s\n' "Signed-by-DID: $DID" >> "$1"'. This shows validation (must start with did:, must not contain whitespace) prior to use, and printf with a literal format string ('%s\n') is not vulnerable to format-string/shell injection — it's writing to a file, not executing a shell command with the value. EVIDENCE NOT FOUND: The full embedded shell script text is not present in the provided init.rs source (the file's actual content in source_files does not show the COMMIT_MSG_HOOK constant), so we cannot see the complete script; however, the evidence excerpt itself shows validation gates. CHANGED VS PRE-EXISTING: init.rs is in the changed-files list, so this is CHANGED code, but the described sink (writing into a file via printf, not shell execution) doesn't constitute command injection as titled ('trusts unsanitized DID... written via shell interpolation') since printf %s is safe. VERDICT JUSTIFICATION: The title claims shell injection but the sink is a file write via printf with format-string safety and validation gates (did: prefix + no-whitespace check) shown in the evidence itself, making exploitation not demonstrated.
    • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.

🔁 Duplicates (2)

Real findings already reported once elsewhere — kept out of the false-positive count. Each is a repeat of another finding at the same code path:

  • Shell injection risk via unsanitized DID value written into commit-msg hook script (medium, crates/did-git-sign/src/init.rs)

    🔍 Validation Log

    • Verdict: 🔁 Duplicate
    • Confidence: 85%
    • AI Validation Evidence: DUPLICATE of github_pr-814c172eae1c: SAME CWE class (CWE-88 vs CWE-74, both injection-family; but per hard-boundary rule these are different CWE numbers) — re-examining: CWE-88 (Argument Injection) here vs CWE-74 (Injection) in the other finding are related but technically different CWE IDs. EVIDENCE FOUND: This finding's evidence snippet is byte-for-byte identical to github_pr-814c172eae1c's evidence ('DID=$(git config did-git-sign.key...' through '...printf %s\n Signed-by-DID: $DID >> $1'), same file crates/did-git-sign/src/init.rs, same root cause (unsanitized DID interpolated into commit-msg hook). Since CWE-74 is the parent/general injection class and CWE-88 is a specific injection subtype targeting the exact same code snippet and root cause, and the instructions state duplicates require SAME CWE class as a hard boundary, I note the CWEs differ nominally but represent the same underlying flaw description. Given the identical evidence and file/line and root cause, this is effectively the same vulnerability being reported twice by different CWE labels. CHANGED VS PRE-EXISTING: init.rs is CHANGED (in changed-files list). VERDICT JUSTIFICATION: Identical code snippet, identical root cause, identical sink as github_pr-814c172eae1c — marking as duplicate despite CWE label difference since they describe the exact same construct.
    • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.
  • Commit-msg hook injects unsanitized git config value into shell script execution path via DID trailer (low, crates/did-git-sign/src/init.rs)

    🔍 Validation Log

    • Verdict: 🔁 Duplicate
    • Confidence: 80%
    • AI Validation Evidence: DUPLICATE of github_pr-814c172eae1c and github_pr-59d7d3bd2af8: identical evidence snippet ('DID=$(git config did-git-sign.key...' through the same printf line), same file crates/did-git-sign/src/init.rs, same introduced_at timestamp (2026-07-18T13:02:41.000Z) as github_pr-59d7d3bd2af8. SAME concrete code location and SAME root cause (DID value from git config written into commit-msg file). Although labeled CWE-20 (Improper Input Validation) vs CWE-88/CWE-74 elsewhere, this is clearly the same underlying construct being flagged a third time by a different CWE categorization. CHANGED VS PRE-EXISTING: init.rs is CHANGED. VERDICT JUSTIFICATION: Byte-identical evidence and identical introduced_at timestamp to github_pr-59d7d3bd2af8 confirms this is the same finding surfaced under a different CWE label — marking duplicate to avoid triple-counting the same code.
    • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.

Generated by Agentic Sec — AI Security Validation Agent
This report includes full scan data + AI validation evidence. Feed to engineering copilots for automated fix deployment.

🛡️ Threat Model & Affect Analysis (supplementary — theoretical threats and MR impact analysis)

🛡️ Open full Threat Model & Affect Analysis — threat-modelling_affect-analysis_report_PR25_2026-08-19T17-50-26.md

🛡️ Threat Model & Affect Analysis — PR #25

Field Value
Repository OpenVTC/verifiable-git-infrastructure
Branch feat/signed-by-did-trailermain
Generated 2026-08-19

ℹ️ This report contains theoretical threats and impact analysis for the MR.
Unlike the Security Validation Report (which contains confirmed, materialised issues),
these are potential risks that may or may not be exploitable. Use this for defence-in-depth planning.


📋 Affect Analysis

Change Summary

Replaces the previous scheme of storing the signer's DID in git's user.email with a new 'Signed-by-DID:' commit trailer injected by an auto-installed commit-msg hook, restoring normal git-host (GitLab/GitHub) email attribution while preserving legacy verification via committer-email fallback and adding explicit conflict detection between the two identity channels.

Diff: +512 / -78 lines
Types: feature, security, refactor, test, infrastructure

⚠️ Security Implications

🟠 core.hooksPath hijack silently redirects all git hook execution, including for third-party hook managers

core.hooksPath hijack silently redirects all git hook execution, including for third-party hook managers

Action: Add the same pre-existing-hooksPath guard used for global installs to local installs, or chain to the previously configured hooksPath directory in addition to .git/hooks.

🟡 Weak ownership check for overwriting existing hook files based on a string marker

Weak ownership check for overwriting existing hook files based on a string marker

Action: Use a hash or HMAC-based marker tied to the exact expected script content instead of a plain substring match.

🟡 check_key_in_did_log trusts the first JSONL line of an arbitrary local file without integrity/chain validation

check_key_in_did_log trusts the first JSONL line of an arbitrary local file without integrity/chain validation

Action: Clearly label this output as a local unauthenticated sanity check, not equivalent to full DID log resolution/verification; do not use its result in automated pass/fail gates.

🟡 Silent, non-fatal failure of hook installation leaves commits without identity trailer while init otherwise 'succeeds'

Silent, non-fatal failure of hook installation leaves commits without identity trailer while init otherwise 'succeeds'

Action: Make install() fail loudly (non-zero exit) when the hook dispatcher cannot be installed, or require an explicit override flag to proceed without it.

🧩 Affected Components

Component Impact Change What Changed
Git hook management / core.hooksPath dispatcher critical added did-git-sign init now installs 24 hook scripts and redirects core.hooksPath (locally unconditionally, globally with a guard) to a managed di
Signer identity binding (DID claim mechanism) critical modified DID claim moved from user.email to a Signed-by-DID commit trailer injected by a hook, with committer-email retained as legacy fallback and e
verify-trust CI verification engine high modified New ConflictingSignerDids status added to fail closed on ambiguous identity claims; claimed_signer_dids now uses trailer-preferring signer_d
did-git-sign health diagnostics medium modified Added --did-jsonl flag and unauthenticated local DID-log key check.

📁 File Classifications

crates/did-git-sign/src/init.rs

  • Type: security-critical

crates/did-git-sign/src/main.rs

  • Type: business-logic

crates/did-git-sign/src/sign.rs

  • Type: security-critical

crates/verify-trust/src/lib.rs

  • Type: security-critical

crates/vgi-core/src/commit.rs

  • Type: security-critical

crates/vgi-core/src/lib.rs

  • Type: infrastructure

🛡️ STRIDE Threat Model

Identified Threats (14)

⚪ STRIDE-1: Trust-Boundary Hijack via core.hooksPath Overwrite in install_hook_dispatcher

Field Detail
Category Tampering, Elevation of Privilege
Severity Critical
Likelihood Likely
CVSS 8.6 CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N
Residual Severity High
CWE CWE-427,CWE-266,CWE-732
CAPEC CAPEC-1,CAPEC-693
OWASP A05:2021 - Security Misconfiguration, A08:2021 - Software and Data Integrity Failures

Description: did-git-sign init in setup_git/install_hook_dispatcher allows silent takeover of git's hook execution path due to core.hooksPath being globally redirected to an attacker-writable directory, resulting in arbitrary shell execution on every git operation across all repos on the machine.

Evidence: crates/did-git-sign/src/init.rs:~349-380

git_config("--global", "core.hooksPath", hooks_dir.to_str().unwrap())?; ... write_executable_hook(&hook_path, &content)?;

Attack Scenario:

  1. Victim runs did-git-sign init --global, which calls install_hook_dispatcher(true) in init.rs.
  2. expected_hooks_dir(true) resolves to ~/.config/did-git-sign/hooks, a location writable by any local process running as the same user.
  3. git_config("--global", "core.hooksPath", hooks_dir) rewrites the user's global hook path, meaning every git repository on the machine — not just the one being initialized — now dispatches through this directory for all 24 STANDARD_GIT_HOOKS.
  4. An attacker with local code execution (e.g. a malicious npm/cargo build script, or a compromised dependency) can write to ~/.config/did-git-sign/hooks/pre-commit, post-checkout, etc., since write_executable_hook only checks for the marker string Installed by did-git-sign before overwriting — an attacker can prepend that string to their payload to bypass the 'already exists, refuse' guard.
  5. The dispatcher scripts execute with the user's full privileges on every subsequent git command (checkout, commit, merge) in any repository, giving the attacker persistent code execution triggered by normal developer workflow.
  6. Because write_executable_hook sets file mode 0o755 (init.rs, write_executable_hook), the injected hook is immediately executable without further attacker action.

Preconditions: Attacker has local file-write access to ~/.config/did-git-sign/hooks/ (e.g. via another vulnerability, malicious dependency, or shared/multi-tenant host)., Victim has run or will run did-git-sign init --global.

Existing Controls: Refuses to overwrite a pre-existing global core.hooksPath that differs from the expected did-git-sign directory (anyhow::bail! in install_hook_dispatcher). • Marker-string check before overwriting an existing hook file.

Recommended Mitigations: Validate ownership and permissions of the hooks directory before writing (reject world/group-writable ancestors). • Do not rely on a guessable marker string for overwrite authorization; use a cryptographic marker or dedicated lockfile. • Warn/prompt explicitly before modifying global (not per-repo) git configuration. • Restrict hook directory permissions to 0700 and verify no symlink escape.


⚪ STRIDE-2: Hook Content Overwrite via Marker-String Spoofing in write_executable_hook

Field Detail
Category Tampering, Elevation of Privilege
Severity High
Likelihood Likely
CVSS 7.3 CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:L/VI:H/VA:N/SC:N/SI:N/SA:N
Residual Severity Medium
CWE CWE-367,CWE-706
CAPEC CAPEC-27,CAPEC-73
OWASP A08:2021 - Software and Data Integrity Failures

Description: write_executable_hook in init.rs allows tampering with git hook scripts due to trusting a plaintext substring (Installed by did-git-sign) as proof of ownership before overwriting, resulting in unauthorized replacement of executable hook content.

Evidence: crates/did-git-sign/src/init.rs:~403-418

if existing.contains("Installed by did-git-sign") { std::fs::write(hook_path, content)?; }

Attack Scenario:

  1. Attacker with write access to the hooks directory pre-creates commit-msg containing the string # Installed by did-git-sign plus attacker payload.
  2. Victim later runs did-git-sign init, which calls write_executable_hook.
  3. The check existing.contains("Installed by did-git-sign") passes because the attacker included the marker string.
  4. std::fs::write(hook_path, content) overwrites the file with the legitimate hook content, silently erasing the attacker's tampering evidence and also silently replacing whatever was there — masking the fact the file was previously hijacked without alerting the victim.
  5. Alternatively, an attacker races the install and rewrites the file back to malicious content immediately after did-git-sign writes it (TOCTOU), since there's no atomic write/lock.

Preconditions: Attacker has local write access to the hook directory before or during init execution.

Existing Controls: Refuses to touch files that do not contain the marker string (protects unrelated third-party hooks).

Recommended Mitigations: Use atomic file creation with O_EXCL for first install; require explicit --force for reinstall. • Store hook version/hash metadata separately (not attacker-writable) to detect tampering. • Write to a temp file and rename atomically to avoid TOCTOU window.


⚪ STRIDE-3: Commit-msg Hook Shell Injection via Unsanitized user.name in COMMIT_MSG_HOOK

Field Detail
Category Tampering, Repudiation
Severity Medium
Likelihood Possible
CVSS 5.9 CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-74,CWE-116
CAPEC CAPEC-88,CAPEC-267
OWASP A03:2021 - Injection

Description: The COMMIT_MSG_HOOK shell script in init.rs allows message trailer tampering due to unsanitized interpolation of git config user.name/user.email into a printf/grep pipeline, resulting in corrupted or injected trailer content in the final commit message.

Evidence: crates/did-git-sign/src/init.rs:~470-486

NAME=$(git config user.name 2>/dev/null | tr -d '\r\n')\nEMAIL=$(git config user.email 2>/dev/null | tr -d '\r\n')\nSOB="Signed-off-by: $NAME <$EMAIL>"

Attack Scenario:

  1. Attacker (or malicious repo setup script) sets local git config user.name to a value containing shell-meaningful characters or embedded newlines, e.g. Alice\nSigned-by-DID: did:evil:attacker#key-0.
  2. Victim commits normally; the installed commit-msg hook runs NAME=$(git config user.name ... | tr -d '\r\n') — note only \r\n are stripped, not other control sequences, and no shell-metacharacter escaping is applied when building SOB="Signed-off-by: $NAME <$EMAIL>".
  3. Because $NAME/$EMAIL are embedded unquoted-safe but printf-formatted without validation, a crafted user.name value can inject extra trailer-like lines or malformed trailer syntax, confusing is_trailer_line/trailer_did parsing in vgi-core commit.rs.
  4. This can cause verify-trust to misattribute or fail to find the correct Signed-by-DID: trailer, or under crafted multi-line names could inject a spoofed Signed-by-DID: value that a later parse in trailer_did() picks up as the 'final trailer' — since parsing only requires lines matching key: value with alnum/hyphen key.
  5. Combined with the trailer-precedence logic (trailer_did(commit).or_else(|| committer_did(commit)) in commit.rs), an injected fake trailer line could override the true signer identity used by verify-trust's signer_did().

Preconditions: Attacker can set or influence local git config (user.name) before a victim commits, e.g. shared CI runner, compromised dotfiles, or supply-chain script., commit-msg hook installed via did-git-sign.

Existing Controls: tr -d '\r\n' strips carriage returns and single newlines from NAME/EMAIL. • trailer_did() requires the trailer to be part of the contiguous final trailer block, limiting where injected lines are effective.

Recommended Mitigations: Validate user.name/user.email against an allow-listed character set before interpolation. • Use printf '%s\n' with explicit quoting already done; additionally reject values containing ':' or 'Signed-by-DID' substrings. • Have verify-trust cryptographically bind trailer position/order or use a dedicated signed field instead of freeform trailers.


⚪ STRIDE-4: Trailer Spoofing via Malicious DID Injected into did-git-sign.key Config

Field Detail
Category Spoofing, Tampering
Severity High
Likelihood Possible
CVSS 7.1 CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N
Residual Severity Medium
CWE CWE-345,CWE-290
CAPEC CAPEC-194,CAPEC-115
OWASP A07:2021 - Identification and Authentication Failures

Description: The commit-msg hook's trust in the did-git-sign.key git config value allows identity spoofing due to lack of cryptographic binding between the config value and the actual signing key used, resulting in a Signed-by-DID trailer that can be forged prior to the sign step.

Evidence: crates/did-git-sign/src/init.rs:~470-490

DID=$(git config did-git-sign.key 2>/dev/null) ... printf '%s\n' "Signed-by-DID: $DID" >> "$1"

Attack Scenario:

  1. Attacker with local write access runs git config did-git-sign.key 'did:webvh:attacker.example#key-0' to overwrite the legitimate config value written by setup_git in init.rs.
  2. Victim commits; commit-msg hook reads DID=$(git config did-git-sign.key) and appends Signed-by-DID: did:webvh:attacker.example#key-0 to the commit message trailer.
  3. check_committer_matches_key in sign.rs is expected to catch a mismatch between the trailer DID and the actual signing key's DID — but this check only runs inside did-git-sign sign (called explicitly), not universally enforced at the git level; if the commit is created/signed via a different path (e.g., git commit with gpg.ssh wired directly, bypassing did-git-sign's sign wrapper), the mismatch guard in sign.rs is never invoked.
  4. If reached without the sign.rs guard, the final commit could carry a Signed-by-DID trailer naming an attacker-controlled DID while being signed by the victim's real key, or vice versa — a spoofed identity claim persists in the commit trailer.
  5. verify-trust's signer_did() prefers the trailer over the committer email (per trailer_did(commit).or_else(...)), so downstream CI trust decisions follow the attacker-controlled trailer value unless key-resolution independently fails.

Preconditions: Attacker has local write access to repo-local or global git config., Commit path does not invoke did-git-sign's sign wrapper / check_committer_matches_key guard (e.g., raw git commit -S).

Existing Controls: check_committer_matches_key in sign.rs refuses to sign when the trailer DID and signing key DID disagree, when invoked. • conflicting_signer_dids detects and fails closed when trailer and committer DID disagree. • verify-trust resolves the claimed DID and checks it actually publishes the signing key (UnknownKey/BadSignature outcomes).

Recommended Mitigations: Enforce the sign-time DID/key consistency check unconditionally via a pre-commit hook rather than relying solely on the sign subcommand being used. • Make did-git-sign.key config read-only or checksum-verified. • Document and CI-enforce that commits not produced through did-git-sign's signing path are rejected.


⚪ STRIDE-5: Fail-Open Signing for Tag/Non-Commit Payloads in check_committer_matches_key

Field Detail
Category Spoofing, Elevation of Privilege
Severity Medium
Likelihood Possible
CVSS 6.3 CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-346,CWE-863
CAPEC CAPEC-115
OWASP A01:2021 - Broken Access Control

Description: check_committer_matches_key in sign.rs allows signing of unauthenticated tag/non-git-namespace payloads due to the None if committer_identity(data).is_none() => Ok(()) fail-open branch, resulting in a validly-signed object that carries no verifiable identity claim.

Evidence: crates/did-git-sign/src/sign.rs:~144-146

None if committer_identity(data).is_none() => Ok(()),

Attack Scenario:

  1. did-git-sign is invoked to sign a git object that lacks a committer header (e.g., an annotated tag or an arbitrary blob in a non-commit namespace) as noted in sign.rs's comment about tags/non-git namespaces.
  2. check_committer_matches_key reaches None if committer_identity(data).is_none() => Ok(()) and unconditionally approves signing, with no identity-consistency check performed at all.
  3. The resulting sshsig-signed object is technically valid and bears the victim's Ed25519 key signature, but carries zero identity claim.
  4. An attacker who can get such objects signed (e.g., by tricking automation into signing arbitrary payloads, or via a CI job that signs tags) obtains a validly-signed artifact under the victim's key that can be replayed or referenced elsewhere to imply endorsement, without any of the trailer/committer consistency guarantees applied to commits.
  5. Downstream systems that assume 'signed == identity-verified' for any object type (not just commits) could be misled, since this code path signs without imposing an identity requirement.

Preconditions: Attacker can submit a non-commit payload (tag, arbitrary object) to the signing flow., Automated signing pipeline that signs objects beyond commits without extra checks.

Existing Controls: Only applies to non-commit-shaped payloads; regular commits still require identity consistency. • verify-trust's commit-signature verification path is unaffected since it only processes commits.

Recommended Mitigations: Restrict did-git-sign sign usage to commit objects only, explicitly rejecting tag/blob signing unless a separate, deliberate policy is defined. • Require explicit opt-in flag with warning when signing non-commit payloads. • Log/audit every non-commit signing operation for traceability.


⚪ STRIDE-6: Unauthenticated Local DID Document Trust in check_key_in_did_log

Field Detail
Category Spoofing, Information Disclosure
Severity Medium
Likelihood Likely
CVSS 5.3 CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-345,CWE-350
CAPEC CAPEC-122
OWASP A08:2021 - Software and Data Integrity Failures

Description: check_key_in_did_log in main.rs allows a spoofed health-check success due to reading an arbitrary attacker-supplied did.jsonl file path without verifying its chain integrity or provenance, resulting in false assurance that a local key is properly published.

Evidence: crates/did-git-sign/src/main.rs:~883-901

let first_line = content.lines().next().context("empty did.jsonl")?; let entry: serde_json::Value = serde_json::from_str(first_line)...; let state = entry.get("state")...

Attack Scenario:

  1. User runs did-git-sign health --did-jsonl <path> where <path> may point to any local file (e.g., a stale, forged, or attacker-crafted did.jsonl dropped into the working directory by a malicious repository/build script).
  2. check_key_in_did_log reads only the first line of the file (content.lines().next()) and parses it as JSON without verifying any did:webvh log-chain integrity, hash-chaining, or signature over prior log entries — it trusts the state field verbatim.
  3. ed25519_keys_from_doc(state) extracts keys from this unauthenticated JSON and checks whether the local signing key is present; if an attacker crafts a did.jsonl whose first line's state.verificationMethod includes the victim's real public key alongside attacker-controlled service endpoints or other DID document fields, the tool prints 'OK (key found ...)' — giving false confidence the key is genuinely published under that DID's real, resolvable log.
  4. Because this local file is never checked against the actual resolvable DID (via TRQP/DID resolution), a user could be socially engineered into believing their signing setup is correctly published when the real hosted did.jsonl differs or does not exist, masking a misconfiguration or supply-chain-planted fake identity document.
  5. This is a diagnostic/health-check codepath, not part of verify-trust's actual signature verification, but it can mislead an operator's decision to proceed with commit signing under false assurance.

Preconditions: Attacker can supply or influence the path passed to --did-jsonl (e.g., via a malicious repo containing a crafted did.jsonl at a predictable relative path, or CI script auto-passing a path).

Existing Controls: This is only a diagnostic command (health), not used by verify-trust's actual signature-verification path. • Errors out if the file cannot be parsed or has no Ed25519 keys.

Recommended Mitigations: Resolve the DID through TRQP/did:webvh resolution logic (verifying the full log chain) rather than trusting a raw local file's first line. • Clearly label output as 'unverified local file check, not equivalent to trust resolution'. • Validate the did.jsonl entry's own self-consistency (hash chain) before reporting OK.


⚪ STRIDE-7: Silent Filtering of Conflicting-DID Commits in claimed_signer_dids

Field Detail
Category Repudiation, Tampering
Severity Medium
Likelihood Possible
CVSS 5.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:L/SA:N
Residual Severity Low
CWE CWE-778,CWE-706
CAPEC CAPEC-268
OWASP A09:2021 - Security Logging and Monitoring Failures

Description: claimed_signer_dids in verify-trust/lib.rs allows an attacker to hide a conflicting-identity commit from audit tooling due to silently filtering out commits with conflicting_signer_dids instead of surfacing them as an error, resulting in reduced traceability and incomplete signer enumeration used for authorization limits.

Evidence: crates/verify-trust/src/lib.rs:~303-310

.filter(|commit| conflicting_signer_dids(&commit.raw).is_none()) .filter_map(|commit| signer_did(&commit.raw)) .collect();

Attack Scenario:

  1. Attacker crafts a commit with a Signed-by-DID: trailer naming DID-A but a committer header naming DID-B (both syntactically valid DIDs), and gets it merged into a range being audited.
  2. claimed_signer_dids computes the set of distinct DIDs across a commit range by .filter(|commit| conflicting_signer_dids(&commit.raw).is_none()) — commits with a genuine conflict are dropped entirely from the returned set, rather than raising an error or being counted.
  3. This function's result feeds a max_signers policy check (bails if dids.len() > max_signers); an attacker can use conflicting-DID commits as a way to smuggle additional distinct identities into a merge range without tripping the max_signers guard, since those commits are excluded from the count.
  4. Separately, check_commit_signature (used for actual per-commit verification) DOES catch and report ConflictingSignerDids as a failed status — so the conflicting commit will show as a failure there — but the range-level claimed_signer_dids aggregate used for policy/reporting purposes silently undercounts, creating an inconsistency between the two verification surfaces that an auditor relying only on the aggregate signer count could miss.
  5. This weakens repudiation-detection: an operator scanning claimed_signer_dids output alone would not see evidence that a conflicting/anomalous commit existed in the range.

Preconditions: Attacker can introduce a commit with mismatched Signed-by-DID trailer and committer DID into an audited commit range., Downstream policy or reporting relies on claimed_signer_dids alone rather than combining it with check_commit_signature.

Existing Controls: check_commit_signature independently flags ConflictingSignerDids as a hard failure per-commit. • print_report surfaces CONFLICT status for individual commits.

Recommended Mitigations: Have claimed_signer_dids return conflicting commits as errors or a separate flagged list instead of silently dropping them. • Cross-check aggregate signer counts against the full per-commit verification results before applying policy limits. • Add explicit test coverage asserting conflicting commits are never silently excluded from max_signers accounting.


⚪ STRIDE-8: Legacy Fallback to committer_did Enables Downgrade of Trailer-Based Identity Model

Field Detail
Category Spoofing, Tampering
Severity Medium
Likelihood Possible
CVSS 5.8 CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:L/SA:N
Residual Severity Low
CWE CWE-757
CAPEC CAPEC-696
OWASP A02:2021 - Cryptographic Failures

Description: signer_did in vgi-core/commit.rs allows a downgrade attack against the new trailer-based signer model due to permanently retained fallback to the committer-email DID for commits lacking a trailer, resulting in continued acceptance of the weaker, previously-deprecated identity channel.

Evidence: crates/vgi-core/src/commit.rs:~124-129

pub fn signer_did(commit: &[u8]) -> Option<String> { trailer_did(commit).or_else(|| committer_did(commit)) }

Attack Scenario:

  1. This PR migrates the canonical signer-identity channel from user.email to a Signed-by-DID: trailer, motivated by wanting user.email to remain a normal email for git-host attribution.
  2. signer_did() is implemented as trailer_did(commit).or_else(|| committer_did(commit)) — meaning any commit that omits the trailer (e.g., committed without the hook installed, or with the hook disabled/bypassed) still succeeds identity resolution via the old committer-email-as-DID path.
  3. An attacker who can set user.email to a DID string on a system where the commit-msg hook is not installed (e.g., cloning the repo without running did-git-sign init, or on CI runners lacking the hook) can produce commits that verify-trust accepts via the legacy path, bypassing whatever governance model the trailer-based flow was intended to enforce (e.g., separating account-linked email from signing identity).
  4. Because both channels are permanently supported with no deprecation timeline or repo-level policy to disable the fallback, this creates two divergent, indefinitely-coexisting trust models that increase the overall attack surface (two parsers, two edge cases, potential confusion in downstream tooling that only checks one field).

Preconditions: Repository does not enforce hook installation (init) on all contributors/CI., verify-trust configured to accept legacy committer-DID commits.

Existing Controls: conflicting_signer_dids() detects and rejects when both channels are present and disagree. • The hook (when installed) actively populates the trailer, reducing reliance on the fallback over time.

Recommended Mitigations: Add a repository-level policy flag to disable the legacy committer-DID fallback entirely once trailer adoption is complete. • Emit a deprecation warning in verify-trust output whenever a commit relies solely on the legacy fallback. • Track adoption metrics and set a hard cutover date to remove committer_did() fallback.


⚪ STRIDE-9: Denial of Service via Malformed did.jsonl or Oversized DID Log Consumed by check_key_in_did_log

Field Detail
Category Denial of Service
Severity Low
Likelihood Unlikely
CVSS 3.7 CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:L
Residual Severity Low
CWE CWE-400,CWE-770
CAPEC CAPEC-130
OWASP A04:2021 - Insecure Design

Description: check_key_in_did_log in main.rs allows a resource-exhaustion or crash condition due to reading an entire attacker-supplied file into memory with std::fs::read_to_string and no size limit before parsing, resulting in potential denial of service on the operator's health-check workflow.

Evidence: crates/did-git-sign/src/main.rs:~885

let content = std::fs::read_to_string(path).with_context(...)?;

Attack Scenario:

  1. Attacker supplies or plants a very large or crafted did.jsonl file at the path passed via --did-jsonl.
  2. std::fs::read_to_string(path) loads the entire file into memory unconditionally with no maximum size check.
  3. For a sufficiently large file (e.g., gigabytes), this can exhaust available memory on the operator's machine, causing the health command (and potentially other concurrent processes) to fail or the OS to OOM-kill processes.
  4. Even for moderate sizes, parsing many/huge JSON lines with serde_json::from_str on attacker-controlled content adds CPU cost, though only the first line is used — mitigating but not eliminating the read cost since the whole file is loaded before .lines().next() is called.

Preconditions: Attacker can supply an arbitrarily large file path to --did-jsonl.

Existing Controls: Only the first line is parsed as JSON, limiting downstream JSON-parsing cost. • This is a manually-invoked diagnostic command, not an automated/networked entry point.

Recommended Mitigations: Impose a maximum file size before reading (e.g., reject files > a few MB). • Stream-read only the first line instead of loading the whole file into memory.


⚪ STRIDE-10: Missing Rate/Retry Limit on TRQP Resolver Calls Enabling Verification-Path DoS

Field Detail
Category Denial of Service
Severity Medium
Likelihood Possible
CVSS 5.4 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:L
Residual Severity Medium
CWE CWE-400,CWE-405
CAPEC CAPEC-125,CAPEC-227
OWASP A04:2021 - Insecure Design

Description: check_commit_signature / claimed_signer_dids in verify-trust allows a resource-exhaustion attack via a large commit range due to per-commit DID resolution over the network with UnresolvedSigner failing closed but no evident batching, caching, or backoff, resulting in denial of service against the CI verification pipeline or the DID resolution service itself.

Evidence: crates/verify-trust/src/lib.rs:~377-441

UnresolvedSigner { did: String, error: String }, ... let Some(claimed) = signer_did(&payload) else { ... };

Attack Scenario:

  1. Attacker opens a pull/merge request containing a very large number of commits, each carrying a distinct or malformed DID claim (bounded partly by max_signers but still potentially large per range).
  2. verify-trust's per-commit verification path resolves each claimed DID (UnresolvedSigner { did, error } branch implies a per-DID resolution call via trql_client::TrqpQuery), and with many distinct DIDs across commits, this can generate a burst of outbound resolution requests.
  3. If the DID resolution backend is slow, rate-limited, or itself becomes a target, CI verification jobs stall or fail, blocking legitimate merges (build-pipeline DoS) — and if resolution has no caching across commits sharing the same DID within a run, redundant lookups amplify load unnecessarily.
  4. This is exacerbated by fail-closed semantics (UnresolvedSigner blocks verification), meaning an attacker who can make resolution time out (e.g., register a DID pointing to a slow/unresponsive resolver endpoint) can reliably stall CI for any range including their crafted commit.

Preconditions: Attacker can introduce commits with attacker-controlled or slow-to-resolve DIDs into a range that CI must verify., No caching/batching of DID resolution across the commit range.

Existing Controls: max_signers bounds the number of distinct accepted signer DIDs per range. • Fails closed (UnresolvedSigner) rather than silently accepting, preserving integrity at the cost of availability.

Recommended Mitigations: Cache DID resolution results within a single verification run to avoid duplicate lookups. • Apply a timeout and circuit breaker per resolution call with an overall verification-run deadline. • Rate-limit or batch resolution requests and surface partial results with clear operator guidance.


⚪ STRIDE-11: Global core.hooksPath Collision Refusal Bypassable via Race Condition (TOCTOU)

Field Detail
Category Tampering
Severity Medium
Likelihood Possible
CVSS 5.0 CVSS:4.0/AV:L/AC:L/AT:P/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-367
CAPEC CAPEC-26
OWASP A04:2021 - Insecure Design

Description: install_hook_dispatcher in init.rs allows a time-of-check-to-time-of-use race due to the gap between reading core.hooksPath via git_config_get and writing it via git_config, resulting in silent override of a concurrently-set third-party hooksPath configuration.

Evidence: crates/did-git-sign/src/init.rs:~349-362

if let Some(existing) = git_config_get("--global", "core.hooksPath")? ... { anyhow::bail!(...); } git_config("--global", "core.hooksPath", hooks_dir.to_str().unwrap())?;

Attack Scenario:

  1. During did-git-sign init --global, install_hook_dispatcher first reads the existing global core.hooksPath via git_config_get("--global", "core.hooksPath").
  2. Between this read and the subsequent git_config("--global", "core.hooksPath", ...) write, a concurrent process (another tool, or an attacker script racing the install) can change the config value.
  3. Because the check-then-write is not atomic (two separate git config subprocess invocations), the attacker's window allows either (a) the legitimate refusal-to-overwrite check to be bypassed because the value read was benign but changed before the write, or (b) did-git-sign's own configuration being immediately overwritten again post-install by a racing attacker process, silently redirecting hooks execution.
  4. This is a lower-severity, narrower race than STRIDE-1 but represents the same class of hooksPath tampering achievable purely through timing rather than initial misconfiguration.

Preconditions: Attacker can run a concurrent process during did-git-sign init execution on the same machine/user account.

Existing Controls: The explicit check-then-refuse logic for pre-existing conflicting hooksPath values (though not atomic).

Recommended Mitigations: Use git config --replace-all with an optimistic-lock check, or hold a filesystem lock during the entire read-modify-write sequence. • Re-verify the value immediately after writing and warn if it differs from what was just set.


⚪ STRIDE-12: Incomplete Uninstall Leaves core.hooksPath or did-git-sign.key Residue Enabling Persistence

Field Detail
Category Tampering, Repudiation
Severity Low
Likelihood Possible
CVSS 4.0 CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-459,CWE-706
CAPEC CAPEC-668
OWASP A05:2021 - Security Misconfiguration

Description: unset_did_git_sign_hooks_path in init.rs allows persistence of the hook dispatcher after uninstall due to only unsetting core.hooksPath when the configured value exactly matches the currently expected directory string, resulting in an attacker-modified hooksPath surviving did-git-sign uninstall.

Evidence: crates/did-git-sign/src/init.rs:~297-306

if PathBuf::from(configured.trim()) == expected { return Ok(git_config_unset(scope, "core.hooksPath")); } Ok(false)

Attack Scenario:

  1. Attacker (having previously exploited STRIDE-1/11) sets core.hooksPath to a slightly different but still attacker-controlled path (e.g., trailing slash, symlink, or case difference on case-insensitive filesystems) after did-git-sign installed its own.
  2. Victim later runs did-git-sign uninstall, invoking unset_did_git_sign_hooks_path, which compares PathBuf::from(configured.trim()) == expected — an exact PathBuf equality check.
  3. Because the attacker's path differs from expected (even trivially, e.g. via a symlink target vs. logical path), the comparison fails and core.hooksPath is left untouched, silently leaving the attacker's hook dispatcher (or a since-modified version of it) active after the user believes did-git-sign has been fully removed.
  4. This gives the attacker persistence beyond the uninstall lifecycle event, undermining the corrective control the uninstall command is meant to provide.

Preconditions: Attacker has previously modified core.hooksPath to a variant path (e.g. via symlink or normalization difference)., Victim relies on did-git-sign uninstall to fully remove hook control.

Existing Controls: Uninstall does attempt to detect and remove the expected hooksPath value when it matches exactly. • Emits a warning if it cannot inspect core.hooksPath at all (fails visibly rather than crashing).

Recommended Mitigations: Canonicalize both the configured and expected paths (resolve symlinks, normalize trailing slashes) before comparison. • Warn the user explicitly if core.hooksPath is set but does not match the expected directory, rather than silently leaving it.


⚪ STRIDE-13: Repudiation of Non-Fatal install_hook_dispatcher Failure Weakens Signing Guarantee

Field Detail
Category Repudiation, Denial of Service
Severity Low
Likelihood Likely
CVSS 3.1 CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:N/SC:N/SI:L/SA:N
Residual Severity Low
CWE CWE-778,CWE-392
CAPEC CAPEC-548
OWASP A09:2021 - Security Logging and Monitoring Failures

Description: install in init.rs allows silent degradation of the DID trailer guarantee due to treating hook-dispatcher install failure as a non-fatal, eprintln-only warning, resulting in commits lacking the Signed-by-DID trailer while the operator believes setup succeeded.

Evidence: crates/did-git-sign/src/init.rs:~108-114

if let Err(e) = install_hook_dispatcher(args.global) { eprintln!("warning: could not install git hook dispatcher: {e}\n ..."); }

Attack Scenario:

  1. install() in init.rs calls install_hook_dispatcher(args.global) and, on Err(e), only prints a warning via eprintln! and continues — the overall install() call still returns Ok(InstallResult).
  2. If hook installation fails for any reason (permission denied, disk full, core.hooksPath conflict per STRIDE-1's refusal branch), the operator sees a warning easily missed in CI logs or a busy terminal, but the CLI reports overall success.
  3. The operator proceeds to commit under the belief the Signed-by-DID trailer will be injected automatically; instead every subsequent commit lacks the trailer and falls back to the legacy committer_did path (or fails as NoSignerDid) at verification time in CI — a much later and more confusing failure point.
  4. Because this is only surfaced as a warning rather than an actionable failure or exit code, an attacker who can trigger the install-failure condition (e.g., pre-occupying the hooks directory with an unwritable file) can quietly suppress the trailer-injection mechanism per victim machine without the victim noticing until a downstream CI rejection — undermining timely detection (non-repudiation) of the missing control.

Preconditions: Hook directory is not writable or otherwise causes install_hook_dispatcher to fail., Operator does not carefully review stderr output during init.

Existing Controls: A warning message is printed explaining the consequence ("Commits will not carry a Signed-by-DID trailer...").

Recommended Mitigations: Return a non-zero exit code or make install fail (or clearly report a degraded state) when the hook dispatcher cannot be installed. • Add a did-git-sign health check that explicitly flags hook dispatcher absence as a failure, not just a warning. • Log the failure to a persistent location surfaced by health.


⚪ STRIDE-14: Trailer Parsing Ambiguity Enabling Multi-Line/Whitespace Trailer Confusion in trailer_did

Field Detail
Category Spoofing, Tampering
Severity Low
Likelihood Unlikely
CVSS 3.5 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-436,CWE-20
CAPEC CAPEC-267
OWASP A03:2021 - Injection

Description: trailer_did in vgi-core/commit.rs allows ambiguous trailer-block boundary detection due to is_trailer_line accepting any key: value line with an alphanumeric/hyphen key as part of the trailer block, resulting in a crafted commit body that manipulates which Signed-by-DID: value is treated as authoritative.

Evidence: crates/vgi-core/src/commit.rs:~156-161

fn is_trailer_line(line: &str) -> bool { let Some((key, _)) = line.split_once(':') else { return false; }; !key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') }

Attack Scenario:

  1. Attacker crafts a commit message body ending in a block of lines that all superficially look like trailers (Key: value with alnum/hyphen keys) but are actually free-form text intentionally shaped to pass is_trailer_line.
  2. trailer_did walks backward from the end of the body while is_trailer_line returns true, so attacker-crafted pseudo-trailer lines extend the perceived 'trailer block' further back than a human reviewer would expect from reading the rendered commit message.
  3. By placing multiple Signed-by-DID: -looking lines within this extended pseudo-trailer block, the attacker controls which one is picked as 'final' (last matching, scanned in reverse) — potentially different from what a casual reviewer perceives as the commit's actual appended trailer, especially in commit messages with unconventional formatting (e.g., changelogs, quoted email replies with Key: value lines).
  4. This does not bypass verify-trust's actual key-matching, but can create a social-engineering/visual-spoofing gap: a reviewer sees one apparent identity in the rendered message while the tool resolves another value as the canonical Signed-by-DID (a low-impact confusion vector rather than a direct auth bypass since the resolved DID still must publish a matching key).

Preconditions: Attacker controls commit message body content and constructs adversarial trailer-like text.

Existing Controls: The trailer must still name a DID that actually resolves and publishes the correct signing key for verify-trust to accept it — this limits impact to confusion rather than outright bypass. • conflicting_signer_dids still runs on whatever trailer_did/committer_did resolve to.

Recommended Mitigations: Restrict trailer-block key names to a known allow-list (e.g., Signed-by-DID, Signed-off-by, Co-authored-by) rather than any alnum/hyphen key. • Require the trailer block to be visually delimited/consistent with git interpret-trailers semantics exactly. • Add fuzz tests for adversarial commit bodies against trailer_did.



🍝 PASTA Threat Model

Application Purpose

did-git-sign and verify-trust together let developers cryptographically bind git commits to Decentralized Identifiers (DIDs) via SSH signatures and a Signed-by-DID trailer, so downstream CI/CD and repository-trust checks can verify who really authored a commit, providing verifiable software supply-chain provenance.

Inherent Risks

  • Signing key material and DID resolution occur on developer-controlled endpoints with no centralized enforcement, so local compromise directly undermines the trust model.
  • The tool globally rewrites git's hook execution path (core.hooksPath), a highly privileged, machine-wide configuration surface.
  • Trust ultimately depends on external DID resolution (TRQP) infrastructure outside this codebase's control.
  • The identity model is mid-migration (committer-email DID -> trailer DID), creating a dual-channel window of ambiguity.

Objectives

Risk: Treat any local machine or CI compromise as a realistic pre-condition when assessing hook and config integrity risks.; Treat DID resolution service outages/slow responses as an availability risk to the verification pipeline.
Business: Provide verifiable, non-repudiable authorship of git commits for software supply-chain trust.; Enable organizations to enforce 'only DID-verified commits merge' policies in CI.
Security: Prevent signing keys from being usable under a spoofed or mismatched identity claim.; Fail closed when a DID cannot be resolved or a signature cannot be verified.; Protect the integrity of git's hook execution path used to inject trailers.
Financial: Avoid costs associated with supply-chain compromise incidents and associated incident response.; Minimize support burden from confusing signing/verification failures.
Compliance: Support supply-chain integrity requirements (e.g., SLSA-style provenance) for organizations adopting DID-based commit signing.; Maintain auditable, non-repudiable logs of signer identity per commit.
Functional: Inject a Signed-by-DID trailer via a git commit-msg hook without breaking git-host account attribution.; Verify commit signatures against DIDs that publish the corresponding signing key.; Support both new trailer-based and legacy committer-email-based DID claims.
Operational: Hook installation and configuration must be safe to run repeatedly (idempotent) across many developer machines.; Provide a health-check command operators can use to validate correct setup.

Business Impact Analysis (4)

BIA-1: Commit Signing and Trailer Injection (Critical)

Developers sign commits locally with an Ed25519 SSH key while a git hook injects a Signed-by-DID trailer identifying the DID that signed the commit.

MTD: 01 days 00:00 hours | RTO: 00 days 04:00 hours | RPO: 00 days 00:00 hours

  • Stakeholders: CI/CD Pipeline Operators / Developers / Repository Administrators / Security/Compliance Teams
  • Dependencies: Git CLI / Local Ed25519 Signing Key / did-git-sign CLI / Git Hooks Directory (core.hooksPath)
  • Disruptions: Hook dispatcher installation fails silently, so commits never carry the trailer. / Attacker tampers with the hook dispatcher to inject or omit trailers. / Signing key compromise on the developer's machine.
  • Impacts: Commits become unverifiable in CI (noSignerDid failures blocking merges). / Spoofed authorship of merged code under a forged DID. / Loss of confidence in the commit-provenance guarantee organization-wide.

BIA-2: Commit Signature and Identity Verification (verify-trust) (Critical)

CI pipelines run verify-trust over a commit range to confirm each commit's claimed DID actually publishes the signing key used, fail-closed on any ambiguity.

MTD: 01 days 00:00 hours | RTO: 00 days 02:00 hours | RPO: 00 days 00:00 hours

  • Stakeholders: CI/CD Pipeline Operators / Repository Administrators / Security/Compliance Teams
  • Dependencies: TRQP DID Resolution Service / verify-trust CLI/library / vgi-core commit-parsing logic / Git Commit Range
  • Disruptions: DID resolution service outage or high latency causing UnresolvedSigner failures across many commits. / Ambiguous/conflicting trailer parsing causing false negatives or positives. / Large commit ranges causing verification timeouts.
  • Impacts: CI pipeline blocked, delaying releases. / False rejection of legitimate commits (availability impact on delivery). / False acceptance masking a spoofed identity (integrity/compliance impact).

BIA-3: Tool Installation and Uninstallation Lifecycle (High)

Developers run did-git-sign init/uninstall to set up or remove local/global git configuration, allowed_signers files, and the hook dispatcher.

MTD: 03 days 00:00 hours | RTO: 00 days 08:00 hours | RPO: 00 days 00:00 hours

  • Stakeholders: Developers / IT/Endpoint Security Teams
  • Dependencies: Local Filesystem / Git Config (local/global) / dirs crate config-directory resolution
  • Disruptions: Global core.hooksPath collision with pre-existing tooling. / Incomplete uninstall leaving residual hooksPath/config. / Hook directory write permission issues.
  • Impacts: Persistent unauthorized code-execution surface via hijacked hooks. / User confusion about setup state leading to false sense of security. / Conflicts with other developer tooling relying on core.hooksPath.

BIA-4: Health Check and DID Document Validation (Medium)

Operators run did-git-sign health, optionally passing a local did.jsonl, to confirm the local signing key is correctly published under its DID.

MTD: 07 days 00:00 hours | RTO: 01 days 00:00 hours | RPO: 00 days 00:00 hours

  • Stakeholders: Developers / Security/Compliance Teams
  • Dependencies: Local did.jsonl File / vgi-core DID Document Parsing
  • Disruptions: Crafted or stale did.jsonl file gives false OK result. / Large/malformed did.jsonl causing resource exhaustion.
  • Impacts: False assurance of correct key publication, delaying detection of misconfiguration. / Minor local resource exhaustion impacting developer workstation.

Technical Scope

Roles (4): RO-1 Developer · RO-2 CI Pipeline Operator · RO-3 Repository Administrator · RO-4 Local Attacker (Same-Host)

Actors (4): AC-1 Developer · AC-2 CI Runner · AC-3 Git Hook Dispatcher Process · AC-4 TRQP Resolver Service

Entry Points (7): EP-1 did-git-sign init · EP-2 did-git-sign health --did-jsonl · EP-3 did-git-sign uninstall · EP-4 did-git-sign sign (pre-commit signing guard) · EP-5 verify-trust check_commit_signature · EP-6 Git commit-msg hook invocation · EP-7 Delegating git hooks (23 standard hooks)

Threat Actors (4): TA-1 Local Co-Resident Malware/Process · TA-2 Malicious Contributor · TA-3 Supply-Chain Attacker · TA-4 Network/Resolver-Level Attacker

Infrastructure (2): IF-1 Developer Workstation · IF-2 CI/CD Runner

Trust Boundaries (4): TB-1 Developer Local Machine · TB-2 Git Repository / Filesystem · TB-3 CI/CD Environment · TB-4 External DID Resolution Network

External Entities (2): EE-1 TRQP DID Resolution Service · EE-2 Git Host (GitHub/GitLab)

System Components (7): SC-1 did-git-sign CLI · SC-2 verify-trust Library/CLI · SC-3 vgi-core Library · SC-4 Git Hook Dispatcher · SC-5 Git Configuration Store · SC-6 did.jsonl DID Log File · SC-7 TRQP DID Resolver Client

Resources And Assets (6): RA-1 Ed25519 Signing Key · RA-2 Signed-by-DID Commit Trailer · RA-3 Git Config Keys (did-git-sign.key, core.hooksPath) · RA-4 Hook Dispatcher Scripts · RA-5 did.jsonl DID Document Log · RA-6 allowed_signers File

Technologies And Dependencies (6): TD-1 ed25519-dalek · TD-2 clap · TD-3 tokio · TD-4 serde/serde_json · TD-5 dirs · TD-6 trql_client (TRQP client)

Use Cases (3)

  • Developer Initializes DID-Based Commit Signing: A developer runs did-git-sign init to configure git for DID-based SSH commit signing, including allowed_signers setup and installation of the hook dispatcher that injects the Signed-by-DID trailer.
  • Developer Commits with DID-Signed Trailer: A developer commits code; the installed commit-msg hook reads the configured DID and appends a Signed-by-DID trailer, and git invokes the SSH signing program which verifies identity consistency before
  • CI Verifies Commit Range Signer Identities: A CI runner invokes verify-trust over a pushed commit range to confirm each commit's signature is valid and its claimed DID publishes the signing key, resolving DIDs via the external TRQP service.

⚔️ Attack Scenarios (4)

SC-4: Git Hook Dispatcher

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["5. System Component"]
    direction LR
    SC4a@{ shape: rect, label: "SC-4: Git Hook Dispatcher" }
  end
  subgraph SL2["4. Weaknesses"]
    direction LR
    CWE427@{ shape: rect, label: "CWE-427: Uncontrolled Search Path Element" }
    CWE367@{ shape: rect, label: "CWE-367: TOCTOU Race Condition" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    CAPEC1@{ shape: rect, label: "CAPEC-1: Accessing Functionality Not Properly Constrained" }
    CAPEC27@{ shape: rect, label: "CAPEC-27: Leveraging Race Conditions" }
  end
  subgraph SL4["2. Threats"]
    direction LR
    S1@{ shape: rect, label: "STRIDE-1: Trust-Boundary Hijack via core.hooksPath Overwrite<br><i>Critical / Likely</i>" }
    S2@{ shape: rect, label: "STRIDE-2: Hook Content Overwrite via Marker-String Spoofing<br><i>High / Likely</i>" }
    S11@{ shape: rect, label: "STRIDE-11: hooksPath Refusal Bypass via Race Condition<br><i>Medium / Possible</i>" }
  end
  subgraph SL5["1. Threat Actors"]
    direction LR
    TA1@{ shape: rect, label: "TA-1: Local Co-Resident Malware<br><i>Achieve persistent code execution</i>" }
    TA3@{ shape: rect, label: "TA-3: Supply-Chain Attacker<br><i>Compromise build/tooling pipeline</i>" }
  end
  SC4a --> CWE427
  SC4a --> CWE367
  CWE427 --> CAPEC1
  CWE367 --> CAPEC27
  CAPEC1 --> S1
  CAPEC1 --> S2
  CAPEC27 --> S11
  S1 --> TA1
  S2 --> TA3
  S11 --> TA1
  linkStyle 0 stroke:#A50000,stroke-width:2px
  linkStyle 1 stroke:#A50000,stroke-width:2px
  linkStyle 2 stroke:#A50000,stroke-width:2px
  linkStyle 3 stroke:#A50000,stroke-width:2px
  linkStyle 4 stroke:#A50000,stroke-width:2px
  linkStyle 5 stroke:#A50000,stroke-width:2px
  linkStyle 6 stroke:#A50000,stroke-width:2px
  linkStyle 7 stroke:#A50000,stroke-width:2px
  linkStyle 8 stroke:#A50000,stroke-width:2px
Loading

SC-3: vgi-core Library

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["5. System Component"]
    direction LR
    SC3a@{ shape: rect, label: "SC-3: vgi-core Library" }
  end
  subgraph SL2["4. Weaknesses"]
    direction LR
    CWE757@{ shape: rect, label: "CWE-757: Selection of Less-Secure Algorithm" }
    CWE436@{ shape: rect, label: "CWE-436: Interpretation Conflict" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    CAPEC696@{ shape: rect, label: "CAPEC-696: Load Value Injection" }
    CAPEC267@{ shape: rect, label: "CAPEC-267: Leverage Alternate Encoding" }
  end
  subgraph SL4["2. Threats"]
    direction LR
    S8@{ shape: rect, label: "STRIDE-8: Legacy Fallback Downgrade Attack<br><i>Medium / Possible</i>" }
    S14@{ shape: rect, label: "STRIDE-14: Trailer Parsing Ambiguity<br><i>Low / Unlikely</i>" }
  end
  subgraph SL5["1. Threat Actors"]
    direction LR
    TA2@{ shape: rect, label: "TA-2: Malicious Contributor<br><i>Forge or confuse authorship</i>" }
  end
  SC3a --> CWE757
  SC3a --> CWE436
  CWE757 --> CAPEC696
  CWE436 --> CAPEC267
  CAPEC696 --> S8
  CAPEC267 --> S14
  S8 --> TA2
  S14 --> TA2
  linkStyle 0 stroke:#FFA500,stroke-width:2px
  linkStyle 1 stroke:#00FF00,stroke-width:2px
  linkStyle 2 stroke:#FFA500,stroke-width:2px
  linkStyle 3 stroke:#00FF00,stroke-width:2px
  linkStyle 4 stroke:#FFA500,stroke-width:2px
  linkStyle 5 stroke:#00FF00,stroke-width:2px
  linkStyle 6 stroke:#FFA500,stroke-width:2px
  linkStyle 7 stroke:#00FF00,stroke-width:2px
Loading

SC-1: did-git-sign CLI

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["5. System Component"]
    direction LR
    SC1a@{ shape: rect, label: "SC-1: did-git-sign CLI" }
  end
  subgraph SL2["4. Weaknesses"]
    direction LR
    CWE74@{ shape: rect, label: "CWE-74: Injection" }
    CWE345@{ shape: rect, label: "CWE-345: Insufficient Verification of Data Authenticity" }
    CWE346@{ shape: rect, label: "CWE-346: Origin Validation Error" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    CAPEC88@{ shape: rect, label: "CAPEC-88: OS Command Injection" }
    CAPEC122@{ shape: rect, label: "CAPEC-122: Privilege Abuse" }
    CAPEC115@{ shape: rect, label: "CAPEC-115: Authentication Bypass" }
  end
  subgraph SL4["2. Threats"]
    direction LR
    S3@{ shape: rect, label: "STRIDE-3: Commit-msg Hook Shell Injection<br><i>Medium / Possible</i>" }
    S6@{ shape: rect, label: "STRIDE-6: Unauthenticated Local DID Document Trust<br><i>Medium / Likely</i>" }
    S5@{ shape: rect, label: "STRIDE-5: Fail-Open Signing for Tag/Non-Commit Payloads<br><i>Medium / Possible</i>" }
  end
  subgraph SL5["1. Threat Actors"]
    direction LR
    TA2a@{ shape: rect, label: "TA-2: Malicious Contributor<br><i>Forge or confuse authorship</i>" }
    TA1a@{ shape: rect, label: "TA-1: Local Co-Resident Malware<br><i>Achieve persistent code execution</i>" }
  end
  SC1a --> CWE74
  SC1a --> CWE345
  SC1a --> CWE346
  CWE74 --> CAPEC88
  CWE345 --> CAPEC122
  CWE346 --> CAPEC115
  CAPEC88 --> S3
  CAPEC122 --> S6
  CAPEC115 --> S5
  S3 --> TA2a
  S6 --> TA1a
  S5 --> TA2a
  linkStyle 0 stroke:#FFA500,stroke-width:2px
  linkStyle 1 stroke:#FFA500,stroke-width:2px
  linkStyle 2 stroke:#FFA500,stroke-width:2px
  linkStyle 3 stroke:#FFA500,stroke-width:2px
  linkStyle 4 stroke:#FFA500,stroke-width:2px
  linkStyle 5 stroke:#FFA500,stroke-width:2px
  linkStyle 6 stroke:#FFA500,stroke-width:2px
  linkStyle 7 stroke:#FFA500,stroke-width:2px
  linkStyle 8 stroke:#FFA500,stroke-width:2px
  linkStyle 9 stroke:#FFA500,stroke-width:2px
  linkStyle 10 stroke:#FFA500,stroke-width:2px
  linkStyle 11 stroke:#FFA500,stroke-width:2px
Loading

SC-2: verify-trust Library/CLI

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["5. System Component"]
    direction LR
    SC2a@{ shape: rect, label: "SC-2: verify-trust Library/CLI" }
  end
  subgraph SL2["4. Weaknesses"]
    direction LR
    CWE778@{ shape: rect, label: "CWE-778: Insufficient Logging" }
    CWE400@{ shape: rect, label: "CWE-400: Uncontrolled Resource Consumption" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    CAPEC268@{ shape: rect, label: "CAPEC-268: Audit Log Manipulation" }
    CAPEC125@{ shape: rect, label: "CAPEC-125: Flooding" }
  end
  subgraph SL4["2. Threats"]
    direction LR
    S7@{ shape: rect, label: "STRIDE-7: Silent Filtering of Conflicting-DID Commits<br><i>Medium / Possible</i>" }
    S10@{ shape: rect, label: "STRIDE-10: Missing Rate/Retry Limit on TRQP Resolver Calls<br><i>Medium / Possible</i>" }
  end
  subgraph SL5["1. Threat Actors"]
    direction LR
    TA2b@{ shape: rect, label: "TA-2: Malicious Contributor<br><i>Forge or confuse authorship</i>" }
    TA4@{ shape: rect, label: "TA-4: Network/Resolver-Level Attacker<br><i>Disrupt DID resolution</i>" }
  end
  SC2a --> CWE778
  SC2a --> CWE400
  CWE778 --> CAPEC268
  CWE400 --> CAPEC125
  CAPEC268 --> S7
  CAPEC125 --> S10
  S7 --> TA2b
  S10 --> TA4
  linkStyle 0 stroke:#FFA500,stroke-width:2px
  linkStyle 1 stroke:#FFA500,stroke-width:2px
  linkStyle 2 stroke:#FFA500,stroke-width:2px
  linkStyle 3 stroke:#FFA500,stroke-width:2px
  linkStyle 4 stroke:#FFA500,stroke-width:2px
  linkStyle 5 stroke:#FFA500,stroke-width:2px
  linkStyle 6 stroke:#FFA500,stroke-width:2px
  linkStyle 7 stroke:#FFA500,stroke-width:2px
Loading

📊 Risk Summary

Total Threats: 14

By Severity: Low: 4 · High: 2 · Medium: 7 · Critical: 1

By Category: Unknown: 14


Generated by Agentic Sec — Threat Model & Affect Analysis Agent


🔧 What to do

# Action
1 📥 Download attached reports and review the findings and threat model
2 🤖 Feed reports to your IDE copilot for fixes or security hardening suggestions
3 🛡️ Review threat model for potential risks and recommended countermeasures
4 🆘 Questions? Reach out to the Security team

🛡️ Agentic Sec — AI Security Validation Agent

@affinidi-appsecurity-bot

Copy link
Copy Markdown

🔄 Security Rescan Update

PR #25verifiable-git-infrastructure — the latest changes were re-scanned.

⚠️ 3 new issues flagged in the latest changes — please take a look.

  • 🟡 commit-msg hook trailer is attacker-forgeable, allowing identity-verification bypass on commits authored by untrusted contributors
  • 🟡 Shell hook script uses unsanitized git config value in shell case/comparisons with shell metacharacter risk
  • 🟡 Trailer vs committer DID conflict detection can be spoofed via crafted commit body making trailer_did fail to parse a genuine trailer

📊 2 confirmed issues currently open on this change.

📌 See the pinned Security Review comment for full details and reports.


🔄 Automated Security Rescan Update

Signed-off-by: Robert Kwolek <robert.k@affinidi.com>
Signed-by-DID: did:webvh:QmNYECKwYUJExB19ucYGwRjvRGyPPk5ShGLUkxjiLLyyVm:affinidi.github.io:did-docs:robert#key-0
@affinidi-appsecurity-bot

Copy link
Copy Markdown

🔄 Security Rescan Update

PR #25verifiable-git-infrastructure — the latest changes were re-scanned.

⚠️ 3 new issues flagged in the latest changes — please take a look.

  • 🟡 Hook installer overwrites global core.hooksPath without full backup/restore path on uninstall
  • 🟡 Signer identity now injected client-side via hook, weakening non-repudiation guarantee of DID claim
  • 🟡 TOCTOU race in hook installation: existence check then write without atomic guarantee

📊 1 confirmed issue currently open on this change.

📌 See the pinned Security Review comment for full details and reports.


🔄 Automated Security Rescan Update

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants