Skip to content

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

Merged
stormer78 merged 7 commits into
OpenVTC:mainfrom
robert-affinidi:feat/signed-by-did-trailer
Aug 30, 2026
Merged

feat(did-git-sign): add git trailer for git-host email compatibility#25
stormer78 merged 7 commits into
OpenVTC:mainfrom
robert-affinidi:feat/signed-by-did-trailer

Conversation

@robert-affinidi

Copy link
Copy Markdown
Contributor

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 Code Review

1 AI-confirmed issue.

Mandatory to check: 🔒 Security Code Review Report

Details

🛡️ Security Code Review Report — PR #25

Field Value
Repository OpenVTC/verifiable-git-infrastructure
Branch feat/signed-by-did-trailermain
Validated 2026-09-05
Scan ID 1f6e3cc5
Validator AI Security Validation Agent

🗺️ Scan Coverage

Modules scanned: 4 · with findings: 1 · files: 8 · findings: 6

Module Files scanned Findings
crates/did-git-sign 4 6
crates/vgi-core 2 0
crates/verify-trust 1 0
docs 1 0

Executive Summary

Category Confirmed Must-Review-By-Human
Security Issues 1 0

🔒 Security Issues

Confirmed Vulnerabilities (1)

🟡 TOCTOU race between core.hooksPath check and write in install_hook_dispatcher

Field Detail
Severity MEDIUM
Location crates/did-git-sign/src/init.rs
Finding ID github_pr-20a09f81db34
CWE CWE-367
OWASP A04:2021-Insecure Design
Detection Source threat_model

🧠 AI Triage:

  • Severity reassessed: HIGH → MEDIUM — CWE-367 TOCTOU bugs are real but this instance requires an attacker with local code execution ability racing a narrow window during init invocation — no network exposure, no privilege escalation beyond what local execution already grants, and no CVSS score or public exploit evidence provided. The severity gates for 'high' require reachability ≥5 and exploitability ≥5 with production/staging environment context; here the environment is unknown/local-tool and exploit maturity is 'none' with no EPSS/CISA KEV support, capping this at medium under the evidence-based calibration model.
  • Composite score: 4.1
  • Environment: unknown

📝 Description:

install_hook_dispatcher reads the current core.hooksPath, checks it isn't owned by another tool, then later writes hook files and finally sets core.hooksPath. Between the read and the final write there is a window where the config could change (e.g. concurrent init/uninstall run, or another tool setting hooksPath) leading to inconsistent state.

🌱 Root Cause: Check-then-act pattern on external mutable state (git config) without atomicity or locking between the ownership check and the final config write.

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

if let Some(existing) = git_config_get(scope, "core.hooksPath")?
    && Path::new(existing.trim()) != hooks_dir
{
    anyhow::bail!(...);
}
...
std::fs::create_dir_all(&hooks_dir)?;
for hook_name in STANDARD_GIT_HOOKS {
    ...
    write_executable_hook(&hook_path, &content)?;
}
git_config(scope, "core.hooksPath", &hooks_dir_str)?;

🎯 Attack Scenario:

A concurrent process (e.g., another init invocation or an attacker-controlled script racing with the install) changes core.hooksPath between the check and the write, causing did-git-sign to silently overwrite another tool's hook configuration or install into a directory whose ownership assumption is now false.

🔍 Validation Log

  • Verdict: ✅ Confirmed True Positive
  • Confidence: 65%
  • AI Validation Evidence: EVIDENCE FOUND: install_hook_dispatcher in init.rs performs: 'if let Some(existing) = git_config_get(scope, "core.hooksPath")? && Path::new(existing.trim()) != hooks_dir { anyhow::bail!(...) }' followed later by 'std::fs::create_dir_all(&hooks_dir)?; for hook_name in STANDARD_GIT_HOOKS { ... write_executable_hook(&hook_path, &content)?; } git_config(scope, "core.hooksPath", &hooks_dir_str)?;'. This is a clear check-then-act pattern: the check happens once, then multiple non-atomic file writes oc
  • 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.

Complementary: 🛡️ **Threat Model & Affect Analysis**
Details

🛡️ Threat Model & Affect Analysis — PR #25

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

ℹ️ This report contains theoretical threats and impact analysis for the MR.
Unlike the Security Code Review 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

Migrates did-git-sign's DID-to-commit identity binding mechanism from git's user.email field to a Signed-by-DID: git trailer written by a newly installed commit-msg hook. This lets user.email stay an ordinary address for GitHub/GitLab account attribution while preserving tamper-evidence (trailer lives inside the signed commit message payload). Introduces a full 24-hook dispatcher via core.hooksPath takeover to install the trailer-writing hook while delegating to any pre-existing repository hooks.

Diff: +348 / -45 lines
Types: feature, security, docs

⚠️ Security Implications

🟠 Identity claim moved from signed committer header to a hook-dependent trailer, weakening the guarantee's default robustness

Identity claim moved from signed committer header to a hook-dependent trailer, weakening the guarantee's default robustness

Action: Implement server-side (pre-receive) enforcement of the Signed-by-DID trailer as a non-optional backstop, independent of the client-side hook, and make hook-install failure fatal in init rather than a warning.

🧩 Affected Components

Component Impact Change What Changed
DID Identity Claim Mechanism critical modified The DID identity claim moves from user.email (part of the signed git committer header) to a Signed-by-DID: trailer inside the commit mes
Git Hook Dispatcher / core.hooksPath Management high new A new subsystem generates and installs 24 shell scripts (1 commit-msg trailer-writer + 23 delegating stubs) into a dedicated hooks directory
Uninstall / Cleanup Logic medium modified uninstall() now also unsets did-git-sign.key and conditionally unsets core.hooksPath if it matches a freshly recomputed expected path.

📁 File Classifications

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

  • Type: security

crates/did-git-sign/README.md

  • Type: docs

🛡️ STRIDE Threat Model

Identified Threats (10)

🟠 STRIDE-1: Hook Directory Takeover via core.hooksPath Race in install_hook_dispatcher

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

Description: install_hook_dispatcher in crates/did-git-sign/src/init.rs allows a local attacker to win a TOCTOU race between directory population and core.hooksPath assignment due to sequential non-atomic writes, resulting in execution of attacker-controlled hook scripts on every git operation.

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

std::fs::create_dir_all(&hooks_dir)?;
for hook_name in STANDARD_GIT_HOOKS {
    let hook_path = hooks_dir.join(hook_name);
    ...
    write_executable_hook(&hook_path, &content)?;
}
git_config(scope, "core.hooksPath", &hooks_dir_str)?;

Attack Scenario:

  1. Attacker with local filesystem access (e.g. shared CI runner, multi-tenant dev box) monitors for did-git-sign init execution.
  2. install_hook_dispatcher() calls std::fs::create_dir_all(&hooks_dir) then loops writing each of STANDARD_GIT_HOOKS via write_executable_hook before finally calling git_config(scope, "core.hooksPath", &hooks_dir_str).
  3. Between directory creation and the final git_config call, attacker with write access to the hooks_dir path (e.g. predictable ~/.config/did-git-sign/hooks or .git/did-git-sign-hooks) races to replace a hook file such as pre-push or post-checkout with a malicious payload before write_executable_hook completes or before core.hooksPath is committed.
  4. Because write_executable_hook only refuses to clobber files that don't contain the 'Installed by did-git-sign' marker string, an attacker who pre-creates a file containing that marker string can plant persistent malicious content that survives the ownership check.
  5. Once core.hooksPath is set, every git operation (commit, push, merge) in the repository/machine triggers the delegating stub, executing attacker code with the victim's privileges.
  6. Attacker exfiltrates SSH signing key material or injects malicious commits signed by the victim's legitimate DID key.

Preconditions: Attacker has local write access to the target hooks_dir path prior to or during did-git-sign init execution., Victim runs did-git-sign init on a shared or compromised filesystem.

Existing Controls: write_executable_hook refuses to overwrite a hook file it did not create, checked via a marker string. • install_hook_dispatcher refuses to overwrite an existing core.hooksPath pointing elsewhere.

Recommended Mitigations: Populate hooks_dir contents atomically (e.g. write to temp dir then atomic rename) before setting core.hooksPath. • Use a marker beyond a matched substring, e.g. cryptographic signature or fixed file permissions, to authenticate hook ownership. • Set restrictive directory permissions (0700) on hooks_dir immediately upon creation before writing any files. • Verify hooks_dir is not a symlink and is owned by the current user before writing.


🟡 STRIDE-2: DID Trailer Injection via Whitespace/Newline Bypass in commit-msg Hook

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

Description: The generated commit-msg hook script in COMMIT_MSG_HOOK allows a local attacker to spoof or corrupt the Signed-by-DID trailer due to insufficient DID value sanitization beyond whitespace stripping, resulting in a forged or malformed identity claim being embedded into signed commit content.

Evidence: crates/did-git-sign/src/init.rs:~560-600

DID=$(printf '%s' "${DID_GIT_SIGN_KEY:-}" | tr -d '\r\n')
[ -z "$DID" ] && DID=$(git config did-git-sign.key 2>/dev/null)
[ -z "$DID" ] && exit 0
case "$DID" in
    did:*) ;;
    *) exit 0 ;;
esac

Attack Scenario:

  1. Attacker sets DID_GIT_SIGN_KEY or did-git-sign.key to a value that begins with 'did:' but contains embedded control characters, git trailer syntax delimiters, or additional newline-adjacent trailer-like content that the case statement's whitespace check ([[:space:]]) does not fully cover (e.g. carriage-return-stripped but embedded colon-based trailer injection like 'did:x:evil\nSigned-off-by: attacker a@a').
  2. The DID value passes the 'did:*' prefix check and the whitespace rejection since tr -d only strips literal CR/LF from the DID_GIT_SIGN_KEY env var but the git config value path (git config did-git-sign.key) is not passed through the same tr -d sanitization in the shown hook logic.
  3. git interpret-trailers --trailer "Signed-by-DID: $DID" "$msg_file" is invoked with the attacker-controlled DID string interpolated unquoted-adjacent into the trailer value, potentially allowing trailer-block confusion if the DID string itself resembles additional trailer key:value pairs.
  4. verify-trust later parses the Signed-by-DID trailer and may misidentify the intended DID or accept a crafted value that resolves to an attacker-controlled DID document, given lib.rs parsing logic was not available for full review.
  5. A commit gets a Signed-by-DID trailer claiming an identity that does not match operator intent, and since the trailer sits inside the signed payload, the forged claim survives signature verification with the legitimate key, causing CI to attribute a commit to a different persona than the actual key owner intended.

Preconditions: Attacker controls the local git config did-git-sign.key or DID_GIT_SIGN_KEY environment variable, e.g. via a compromised shell profile or CI environment variable injection., verify-trust's trailer parser does not independently re-validate the DID format beyond what the hook checks.

Existing Controls: Hook enforces 'did:*' prefix. • Hook rejects DIDs containing whitespace via a case pattern match. • git interpret-trailers with --if-exists doNothing avoids clobbering an existing trailer.

Recommended Mitigations: Sanitize the git-config-sourced DID value with the same tr -d '\r\n' pass applied to the env var. • Validate DID syntax against a strict regex (e.g. RFC 3986-compliant DID method syntax) rather than only a prefix and whitespace check. • Have verify-trust strictly validate trailer key/value structure and reject multi-line or control-character-laden trailer values. • Quote and escape the DID value defensively before passing to git interpret-trailers.


🟠 STRIDE-3: Silent Downgrade to Unsigned Identity Claim via --no-verify Bypass

Field Detail
Category Spoofing, Repudiation, Tampering
Severity High
Likelihood Likely
CVSS 7.1 CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N
Residual Severity Medium
CWE CWE-354,CWE-345
CAPEC CAPEC-668,CAPEC-580
OWASP A08:2021 - Software and Data Integrity Failures

Description: git commit --no-verify in a repository configured with did-git-sign allows a legitimate or malicious local user to bypass the commit-msg hook entirely due to git's own hook-bypass mechanism not being blocked by did-git-sign, resulting in a signed commit whose user.email diverges from the actual signing key without the compensating Signed-by-DID trailer.

Evidence: crates/did-git-sign/README.md:~205-215

Signing still refuses a commit whose claim and key disagree, naming both
halves, rather than writing one that fails in CI as `unknownKey`. That now only
happens if you write a `Signed-by-DID:` trailer yourself, or commit with the
hook bypassed (`--no-verify`) in a repo whose `user.email` is a differ

Attack Scenario:

  1. Repository is configured via did-git-sign init with did-git-sign.key set and core.hooksPath pointing at the dispatcher, per the diff in init.rs.
  2. Attacker (or compromised CI script) runs git commit --no-verify -m "…", which git itself skips execution of the commit-msg hook (a native git feature, not specific to did-git-sign) as documented in the README's own admission: 'That now only happens if you write a Signed-by-DID: trailer yourself, or commit with the hook bypassed (--no-verify) in a repo whose user.email is a different DID.'
  3. commit.gpgsign=true still causes sign (COMP-001, EP-003) to be invoked and produce a valid sshsig signature over the commit, since sign.rs's own consistency check (claim vs. key) was not shown but the README states signing 'refuses a commit whose claim and key disagree' — however this refusal logic operates on user.email vs. selected key, and with the redesign, user.email is deliberately left as an ordinary address, decoupling it from the DID entirely.
  4. The commit is pushed with a valid signature but no Signed-by-DID trailer, and CI's verify-trust step (EP-006) is expected to reject it as noSignerDid — but if any CI path caches prior verification state, uses a permissive fallback for legacy user.email-based DIDs, or the repository allows re-tagging/force-push after a manual bypass of CI gating, the commit could be merged.
  5. Result: a valid Ed25519 signature exists on a commit with no verifiable DID claim, undermining the entire non-repudiation guarantee the system is built to provide, and creating a repudiation vector where the actual committer can later deny authorship since the trailer-based provenance chain has a gap.

Preconditions: Local user has commit access and chooses (or is scripted) to pass --no-verify., CI enforcement of the noSignerDid check is the only backstop; no server-side pre-receive hook independently verifies the trailer.

Existing Controls: verify-trust is documented to reject commits lacking a DID claim as noSignerDid. • README explicitly documents this bypass path as a known limitation.

Recommended Mitigations: Enforce Signed-by-DID trailer validation via a server-side pre-receive hook in addition to client-side commit-msg hook, since client-side hooks are inherently bypassable. • Document and default to branch protection rules requiring the noSignerDid CI check to pass before merge, with no override capability for standard contributors. • Add a corroborating CI check that cross-references commit.gpgsign metadata against the presence of the trailer, flagging any signed-but-untrailered commit as high-priority for manual review.


🟡 STRIDE-4: Legacy user.email Fallback Enables DID Downgrade Attack in verify-trust

Field Detail
Category Spoofing, Tampering
Severity Medium
Likelihood Possible
CVSS 6.4 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-706,CWE-346
CAPEC CAPEC-141,CAPEC-151
OWASP A07:2021 - Identification and Authentication Failures

Description: verify-trust's fallback path for 'older commits that carry the DID in user.email' allows an attacker to force acceptance of a downgraded identity-claim mechanism due to dual code paths for DID discovery, resulting in inconsistent trust semantics that could be exploited to mask a missing or forged trailer with a spoofed user.email.

Evidence: crates/did-git-sign/README.md:~30-33

A `commit-msg` hook installed by `init` writes
it; older commits that carry the DID in `user.email` still verify, through a
fallback in `verify-trust`.

Attack Scenario:

  1. verify-trust (COMP-002, EP-006) — per README — 'older commits that carry the DID in user.email still verify, through a fallback in verify-trust', meaning the verifier must accept two structurally different sources of identity claim: the Signed-by-DID trailer (new) and user.email (legacy).
  2. An attacker crafts a new commit (not an old legacy one) whose committer user.email is deliberately set to a DID string that they do not fully control the corresponding key for, or that partially collides with an intended target DID, and does not include a Signed-by-DID trailer.
  3. If verify-trust's fallback logic determines DID-ness by pattern-matching user.email (e.g. checking for a 'did:' prefix) without distinguishing 'commit predates the trailer feature' from 'commit simply omitted the trailer', the fallback path is reachable by any commit, not just legacy ones — since git commit timestamps and commit-msg hook installation dates are not cryptographically bound to feature availability.
  4. This allows an attacker to intentionally choose the legacy path (weaker validation surface, potentially different key-matching logic, or different resolution caching) over the modern trailer path, exploiting whichever fallback branch has weaker or differently-scoped checks (e.g. no consistency check that the committer's key matches the DID stated only in user.email, if sign was bypassed).
  5. Because the full verify-trust source (crates/verify-trust/src/lib.rs) was not available in the reduced context, the exact resolution-and-caching behavior for each path cannot be confirmed, but the dual-path design is inherently a larger attack surface than a single canonical identity-claim mechanism.

Preconditions: verify-trust implements a legacy-compatible fallback that trusts user.email as a DID claim without provably restricting it to genuinely pre-trailer-era commits., Attacker can create git commits with arbitrary committer email content.

Existing Controls: Fallback is explicitly documented as intended only for pre-existing legacy commits. • Modern flow decouples user.email from DID entirely, reducing new commits' reliance on the fallback.

Recommended Mitigations: Restrict the legacy user.email fallback to commits authored before a specific cutover commit/tag/date, verified cryptographically (e.g. via a repository-wide feature-flag commit marker) rather than accepting it universally. • Require the same key-to-DID binding validation for the legacy path as for the trailer path, so no downgrade in verification rigor exists between the two. • Emit a deprecation warning or CI annotation whenever the legacy fallback path is used on a commit newer than the trailer rollout, to enable monitoring for abuse.


🟡 STRIDE-5: Symlink/Path Traversal in expected_hooks_dir via git rev-parse Output

Field Detail
Category Tampering, Elevation of Privilege
Severity Medium
Likelihood Unlikely
CVSS 5.3 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-59,CWE-706
CAPEC CAPEC-132,CAPEC-17
OWASP A01:2021 - Broken Access Control

Description: expected_hooks_dir in crates/did-git-sign/src/init.rs allows a local attacker controlling the working directory or a maliciously crafted .git structure to redirect the hook installation path due to unsanitized trust in git rev-parse --absolute-git-dir output, resulting in writing hook files to an attacker-influenced location outside the intended repository.

Evidence: crates/did-git-sign/src/init.rs:~330-345

let output = Command::new("git")
    .args(["rev-parse", "--absolute-git-dir"])
    .output()
    .context("failed to find .git directory")?;
let git_dir = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
Ok(Some(git_dir.join("did-git-sign-hooks")))

Attack Scenario:

  1. Attacker sets up a malicious repository or git worktree structure (e.g. a .git file pointing to a symlinked or crafted gitdir, as permitted by git worktrees/submodules) such that git rev-parse --absolute-git-dir returns an attacker-influenced path.
  2. Victim runs did-git-sign init inside this crafted repository, and expected_hooks_dir(false) trusts the command output verbatim: let git_dir = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()); Ok(Some(git_dir.join("did-git-sign-hooks"))).
  3. install_hook_dispatcher then calls std::fs::create_dir_all(&hooks_dir) and writes hook files into this attacker-influenced path, which — if it resolves via a symlink to a location such as a shared system directory, another user's home directory, or an unrelated repository the victim also uses — plants attacker-controlled or victim-authored hook scripts in an unintended location.
  4. Because core.hooksPath is then set to reference this path, subsequent git operations in the crafted repository (or any repository the symlink target ultimately resolves to) execute the dispatcher, potentially exposing signing key usage patterns or corrupting hooks for other tooling.
  5. This is a lower-severity variant since it requires the victim to already be operating inside an attacker-supplied repository structure, but is a realistic supply-chain scenario (e.g. cloning a malicious repo and running init inside it).

Preconditions: Victim runs did-git-sign init inside an attacker-crafted or attacker-influenced git repository/worktree structure., git rev-parse --absolute-git-dir can be influenced by .git file redirection, worktrees, or GIT_DIR environment variable manipulation.

Existing Controls: Command output is trimmed of whitespace before use, avoiding trivial injection via trailing content. • create_dir_all requires filesystem-level write permission, limiting blast radius to accessible paths.

Recommended Mitigations: Canonicalize and validate that the resolved git_dir is within an expected repository boundary before joining and writing to it. • Reject GIT_DIR/rev-parse output that resolves through symlinks to paths outside the current working tree without explicit confirmation. • Run hook installation with the least-privilege filesystem context possible.


🟡 STRIDE-6: core.hooksPath Ownership Check Race Enables Silent Hijack of Existing Hook Managers

Field Detail
Category Tampering, Denial of Service
Severity Medium
Likelihood Possible
CVSS 6.1 CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-362,CWE-367
CAPEC CAPEC-25
OWASP A04:2021 - Insecure Design

Description: install_hook_dispatcher in crates/did-git-sign/src/init.rs allows a race condition between reading and later relying on core.hooksPath ownership due to a check-then-act pattern without locking, resulting in a window where a concurrently-running process (e.g. husky/lefthook installer) could take ownership of the same config key, leading to hook execution inconsistency or takeover.

Evidence: crates/did-git-sign/src/init.rs:~505-520

if let Some(existing) = git_config_get(scope, "core.hooksPath")?
    && Path::new(existing.trim()) != hooks_dir
{
    anyhow::bail!(
        "{scope} core.hooksPath is already set to '{existing}'; refusing to overwrite it. ..."
    );
}

Attack Scenario:

  1. install_hook_dispatcher reads the current core.hooksPath via git_config_get(scope, "core.hooksPath") and, finding it either unset or equal to its own expected path, proceeds to populate hooks_dir and later call git_config(scope, "core.hooksPath", &hooks_dir_str).
  2. Concurrently, another process (e.g. a package manager postinstall script running husky install, or a second did-git-sign init invocation triggered by a build script) also checks and sets core.hooksPath during this same window.
  3. Because there is no file lock or atomic compare-and-swap on the git config value between the check and the final set, both processes can believe they successfully claimed the slot, and the last writer wins non-deterministically.
  4. If husky's hooks path wins the race after did-git-sign has already written its dispatcher scripts (or vice versa), the resulting core.hooksPath may point to a directory that does not contain the Signed-by-DID trailer logic, silently disabling commit provenance enforcement without any error being surfaced to the user.
  5. This is particularly impactful in CI/CD pipelines or automated dev-environment bootstrap scripts where multiple setup tools run in parallel or in a non-deterministic order across cache-warm builds.

Preconditions: Multiple hook-managing tools (did-git-sign, husky, lefthook, pre-commit) run install/init concurrently or in a race-prone bootstrap sequence., No file-system or git-level lock coordinates core.hooksPath writes across tools.

Existing Controls: install_hook_dispatcher refuses to proceed if core.hooksPath is already set to a different value at the time of the check. • The refusal path emits a clear, actionable error message naming the conflict.

Recommended Mitigations: Use git config --local --replace-all with an optimistic lock pattern (read core.hooksPath immediately before write, abort if changed) or a filesystem lock file during install. • Document that concurrent hook-manager installation is unsupported and recommend serializing setup steps in CI bootstrap scripts. • Add a post-install verification step that re-reads core.hooksPath after installation completes and warns if it no longer matches the expected dispatcher path.


🟡 STRIDE-7: Non-Fatal Hook Install Failure Leaves Warning-Only Signal for Signing Continuity

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

Description: The install() function in crates/did-git-sign/src/init.rs allows a hook-installation failure to be silently downgraded to a stderr warning due to non-fatal error handling in the install_hook_dispatcher call site, resulting in operators mistakenly believing DID-trailer provenance is active when it is not enforced client-side.

Evidence: crates/did-git-sign/src/init.rs:~107-124

if let Err(e) = install_hook_dispatcher(args.global) {
    eprintln!(
        "warning: could not install the git hook that writes the Signed-by-DID trailer:\n  {e}\n  ..."
    );
}

Attack Scenario:

  1. install_hook_dispatcher() fails (e.g. because core.hooksPath is already claimed by husky, or filesystem permissions deny directory creation), and the call site in install() catches this via if let Err(e) = install_hook_dispatcher(args.global) { eprintln!(...) } rather than propagating the error and aborting the overall init.
  2. The rest of did-git-sign init completes successfully: SSH signing keys are generated, commit.gpgsign is enabled, allowed_signers is configured, and did-git-sign.key is set — everything needed for sign to produce valid signatures.
  3. An operator or CI bootstrap script that only checks the exit code of did-git-sign init (a common automation pattern) sees success (exit 0) and proceeds, unaware that the commit-msg hook — the only mechanism that writes the Signed-by-DID trailer — was never installed.
  4. Every subsequent commit is validly signed (Ed25519/sshsig) but carries no DID trailer, and depending on the CI verify-trust check's strictness and whether the legacy user.email fallback is coincidentally satisfied, commits may either fail loudly (noSignerDid) after several commits have accumulated, or — worse — silently pass if user.email happens to still be a stale DID from a prior config, per the interaction with STRIDE-4.
  5. Root-caused only at CI time, well after the developer began committing, creating wasted work, confusing failures, and potential accidental merges if CI gating is imperfect.

Preconditions: Automation or CI treats did-git-sign init exit code as the sole success signal without parsing stderr for the specific hook-install warning., core.hooksPath conflict or filesystem permission issue exists at install time.

Existing Controls: The warning message is detailed and actionable, naming the specific failure and remediation steps. • The warning explicitly states that sign will refuse to sign without a DID claim (per the code comment), which is a compensating control if accurate.

Recommended Mitigations: Make install() return a non-zero exit code (or a structured warning field surfaced prominently, not just via eprintln!) when a security-relevant sub-step like hook installation fails. • Provide a did-git-sign doctor or --verify subcommand that CI bootstrap scripts can run to positively confirm hook installation and core.hooksPath alignment before relying on it. • Log hook-install failures to a machine-readable status file that CI can assert on, in addition to the human-readable stderr message.


🔵 STRIDE-8: Uninstall Path Confusion Leaves Stale core.hooksPath Referencing Removed Directory

Field Detail
Category Tampering, Denial of Service
Severity Low
Likelihood Possible
CVSS 4.5 CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:L/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-459,CWE-704
CAPEC CAPEC-462
OWASP A04:2021 - Insecure Design

Description: unset_did_git_sign_hooks_path in crates/did-git-sign/src/init.rs allows core.hooksPath to be left dangling due to reliance on an exact path-equality check against a freshly recomputed expected_hooks_dir value, resulting in git silently falling back to default hook behavior (or erroring on missing hooksPath) after uninstall if the recomputed path differs from what was actually configured (e.g. after a HOME change, global/local mismatch, or manual edits).

Evidence: crates/did-git-sign/src/init.rs:~307-317

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 Path::new(configured.trim()) == expect

Attack Scenario:

  1. Operator runs did-git-sign uninstall, which calls unset_did_git_sign_hooks_path(scope, global) to decide whether to remove core.hooksPath.
  2. The function recomputes expected_hooks_dir(global) fresh at uninstall time and compares it via exact PathBuf equality against the currently configured core.hooksPath value.
  3. If the environment has changed since install (e.g. XDG_CONFIG_HOME or HOME changed, the repository was moved, or the user previously ran init with --global and later locally, creating scope ambiguity), the recomputed expected path no longer matches the configured value even though the configured value was in fact set by did-git-sign.
  4. unset_did_git_sign_hooks_path returns Ok(false) (not equal, do nothing), silently leaving core.hooksPath pointing at a directory that install's own hooks_dir removal (if any file cleanup logic exists elsewhere) may have deleted or that the uninstall flow otherwise no longer maintains.
  5. Subsequent git operations either execute stale/removed hook scripts (if the directory still physically exists but is orphaned) or fail/no-op if git can't find the configured hooksPath, silently disabling all hook-based enforcement (Signed-by-DID trailer writing) without any explicit user notification, since this is a distinct code path from the install-time warning in STRIDE-7.

Preconditions: User's environment (HOME, XDG paths) or scope (global vs local) differs between install time and uninstall time., No file cleanup step independently reconciles core.hooksPath against actual filesystem hook directories.

Existing Controls: The comparison logic exists at all (some implementations skip verification entirely and blindly unset). • Errors reading core.hooksPath are surfaced as warnings rather than silently swallowed.

Recommended Mitigations: Store the originally-configured hooks_dir path in a metadata file at install time and read it back at uninstall time instead of recomputing it, to avoid drift. • Warn explicitly when core.hooksPath is set but does not match the expected path, rather than silently no-op'ing. • Provide a did-git-sign status command showing current hook configuration state versus expected state for troubleshooting.


🟠 STRIDE-9: Global core.hooksPath Cross-Repository Contamination Executes Untrusted Hooks Machine-Wide

Field Detail
Category Tampering, Elevation of Privilege, Information Disclosure
Severity High
Likelihood Likely
CVSS 7.6 CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N
Residual Severity Medium
CWE CWE-668,CWE-807
CAPEC CAPEC-176,CAPEC-586
OWASP A04:2021 - Insecure Design

Description: the --global install path in crates/did-git-sign/src/init.rs allows every repository on the machine to be silently subjected to the did-git-sign hook dispatcher due to global core.hooksPath scope, resulting in the delegating hook logic executing (and the commit-msg trailer logic firing) for repositories the user did not intend to participate in DID-based signing, including potentially untrusted cloned repositories.

Evidence: crates/did-git-sign/README.md:~78-84

This also sets `did-git-sign.key` and `core.hooksPath` for **every repository
on the machine** — that pair decides the identity your commits claim, and it
must match the key that signs them. Right for one community; wrong for two...

Attack Scenario:

  1. User runs did-git-sign init --global, which per the README and code sets a global core.hooksPath pointing at ~/.config/did-git-sign/hooks, containing the dispatcher for all 24 standard hooks including pre-commit, post-checkout, pre-push, and post-merge.
  2. User later clones an untrusted third-party repository that itself relies on core.hooksPath being unset or pointing at its own tooling (e.g. a repo bundling its own security scanning pre-commit setup expecting to configure core.hooksPath itself during its own setup script).
  3. Because the global setting takes precedence unless overridden locally, and did-git-sign's init 'refuses to take core.hooksPath if something else already owns it' only at the time did-git-sign itself is being installed — not retroactively when a new repository is later cloned — the untrusted repository's own hook-setup tooling either silently fails to take core.hooksPath (since did-git-sign already owns it globally) or, worse, the delegating stub in did-git-sign's dispatcher execs the repository's own .git/hooks/ per its design, meaning any malicious hook scripts committed directly into that untrusted repo's .git/hooks (which is not normally tracked by git and must be manually placed, but could be provisioned via a malicious bootstrap/install script bundled with the repo) get executed transparently through the delegation chain.
  4. This creates an unexpected trust bridge: the delegating hooks make it appear to any repository-specific tooling as though hooks 'just work' as normal, masking the fact that a machine-wide interception layer is present, which could be leveraged by a malicious repository's setup script to detect (via probing behavior differences) that did-git-sign is active and adjust its attack accordingly, or simply rely on the delegation to ensure its own malicious .git/hooks payload still executes exactly as if core.hooksPath were unset.
  5. Separately, and more directly: cloning an untrusted repo and running any git command that triggers a hook (e.g. git commit, git merge) inside it will fire commit-msg and inject a Signed-by-DID trailer using the globally-configured DID key — meaning the user's DID identity is asserted (and cryptographically signed) on commits made in repositories the user may not intend to associate with that persona at all, especially for pseudonymous or context-separated identities as the README's whole 'community persona' discussion is designed to prevent.

Preconditions: User has run --global install of did-git-sign., User subsequently interacts with repositories they did not intend to bind to the globally-configured DID persona.

Existing Controls: README explicitly warns: 'Right for one community; wrong for two, and quietly so, since commits in the other community would claim this DID.' • init prints a per-remote alternative recommendation when --global is used. • Delegating hooks preserve repository-specific hook execution, reducing (but not eliminating) functional breakage.

Recommended Mitigations: Default to local (--local) installation and require an explicit, loudly-confirmed opt-in for --global, given the cross-repository identity-binding risk. • Provide a per-repository allow/deny list so the global dispatcher only injects the Signed-by-DID trailer in repositories the user has explicitly opted into, rather than every repository on the machine. • Detect and warn when a git command is run inside a repository lacking any prior did-git-sign local configuration but inheriting the global hooksPath, prompting the user to confirm persona binding on first use per repository.


🟡 STRIDE-10: Missing Input Validation on DID Value Allows Trailer Block Structural Confusion in git interpret-trailers

Field Detail
Category Tampering, Information Disclosure
Severity Medium
Likelihood Possible
CVSS 5.7 CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-20,CWE-150
CAPEC CAPEC-267,CAPEC-136
OWASP A03:2021 - Injection

Description: the COMMIT_MSG_HOOK script in crates/did-git-sign/src/init.rs allows an attacker who controls the local did-git-sign.key git config value to inject additional trailer-like lines due to relying solely on a 'did:' prefix check and a whitespace-only rejection filter, resulting in potential trailer-block structure manipulation that could confuse downstream automated parsers (verify-trust or other CI tooling) reading the commit message trailers.

Evidence: crates/did-git-sign/src/init.rs:~583-596

case "$DID" in
    *[[:space:]]*)
        echo "did-git-sign: the selected DID contains whitespace; refusing to write a trailer" >&2
        exit 1
        ;;
esac

git interpret-trailers --in-place --if-exists doNothing \
    --trailer "Signed-by-DID: $DID" "$msg_file" || exit 1

Attack Scenario:

  1. Attacker with local write access to a shared or CI-managed repository's git config (e.g. via a malicious pre-existing .git/config committed accidentally, or a build script that sets local config from an untrusted source) sets did-git-sign.key to a crafted string starting with 'did:' but containing embedded non-whitespace structural characters recognized by git's trailer syntax, such as colons or the RFC 822-style 'Key: Value' pattern repeated (e.g. 'did:web:evil.example\x01Reviewed-by: attacker a@a' where \x01 is a non-whitespace separator git interpret-trailers might still parse across, depending on trailer separator configuration).
  2. The whitespace check case "$DID" in *[[:space:]]*) ... exit 1 ;; esac only catches POSIX [:space:] characters (space, tab, newline, CR, FF, VT) and does not reject other control characters or Unicode look-alikes that could still be interpreted specially by downstream consumers or terminal rendering (e.g. ANSI escape sequences for log-injection into CI console output).
  3. git interpret-trailers --trailer "Signed-by-DID: $DID" embeds this value verbatim into the commit message trailer block; because git's trailer parsing is line-oriented and configurable (trailer.separators), an attacker who knows or influences the repository's trailer.* configuration could craft a DID value that, when combined with specific separator configs, causes the resulting trailer block to be mis-parsed as multiple trailers instead of one.
  4. Downstream consumers such as verify-trust (COMP-002) or third-party changelog/release-note generators that also parse git trailers could then be confused into associating an attacker-injected key/value pair (e.g. a fake 'Reviewed-by' or 'Signed-off-by') with the commit, laundering a false attestation through the legitimate signing pipeline.
  5. Even absent full trailer confusion, unfiltered control characters (e.g. ANSI escape codes) embedded in the DID value that flow into CI log output when tooling prints 'Signed-by-DID: ' create a terminal/log injection vector (CWE-150-adjacent), potentially misleading operators reviewing CI logs.

Preconditions: Attacker can set the local did-git-sign.key git config value (requires local write access or a supply-chain foothold in a build/config script)., verify-trust or other consumers do not perform independent strict DID syntax validation before trusting the trailer content.

Existing Controls: Prefix check requires the value start with 'did:'. • POSIX whitespace characters are rejected outright. • git interpret-trailers --if-exists doNothing prevents duplicate trailers from the hook's own re-runs.

Recommended Mitigations: Apply a strict allow-list character validation (e.g. only alphanumerics, colons, periods, hyphens per DID method-name/method-specific-id syntax) instead of a blocklist-style whitespace check. • Reject any control character (0x00-0x1F, 0x7F) in the DID value, not just POSIX space-class characters. • Have verify-trust independently re-validate the DID syntax with a strict parser/grammar before resolving it, never trusting the raw trailer value structurally.



🍝 PASTA Threat Model

Application Purpose

did-git-sign binds decentralized identifiers (DIDs) to git commits via SSH-signed sshsig signatures and a Signed-by-DID trailer, enabling verify-trust and CI pipelines to cryptographically attribute commits to decentralized identities rather than centralized forge accounts.

Inherent Risks

  • Client-side git hooks are fundamentally bypassable by any user with --no-verify or direct signature-forging access to their own repository.
  • A single core.hooksPath slot per repository/machine creates unavoidable contention with other hook-management tooling.
  • Trust in the DID-to-commit binding is only as strong as the weakest verification path (legacy user.email fallback vs. modern trailer), and dual-path designs inherently widen the attack surface.
  • Global installation scope conflates identity binding across unrelated repositories and communities.

Objectives

Risk: Treat any bypass of the DID-claim mechanism (e.g. --no-verify) as an accepted residual risk requiring compensating server-side controls.; Treat core.hooksPath contention as a configuration risk requiring explicit conflict detection rather than silent overwrite.
Business: Provide cryptographically verifiable, decentralized commit provenance as a differentiator for trust-sensitive open-source and consortium software supply chains.
Security: Ensure every signed commit carries a verifiable, non-forgeable DID claim inside the signed payload.; Prevent a mismatch between the signing key and the claimed identity from ever producing a commit that appears valid.
Financial: Avoid costly incident response and reputational remediation from a supply-chain compromise traced to unverifiable commit authorship.
Compliance: Support auditability requirements for software supply-chain integrity frameworks (e.g. SLSA, in-toto) via cryptographic commit provenance.
Functional: Sign commits with SSH keys bound to DIDs and write a tamper-evident identity trailer without breaking existing forge-based (GitHub/GitLab) account attribution.
Operational: Support multiple community personas per contributor without configuration drift between the signing key and the identity claim.; Coexist with existing hook-management tools (husky, lefthook, pre-commit) without silently disabling them.

Business Impact Analysis (2)

BIA-1: Commit Provenance and Identity Attestation (Critical)

The end-to-end process of generating a DID-bound SSH signature and Signed-by-DID trailer at commit time, then verifying that binding in CI before merge.

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

  • Stakeholders: CI/CD Pipeline Operators / Consortium Governance Bodies / Contributors / Forge Platform (GitHub/GitLab) / Security/Compliance Auditors
  • Dependencies: git CLI / gpg.ssh signing subsystem / verify-trust CI check / DID document resolution service (webvh method)
  • Disruptions: core.hooksPath conflict silently disables trailer injection / --no-verify bypass produces signed but unattributed commits / Global install cross-contaminates unrelated repositories' identity claims
  • Impacts: Loss of non-repudiation for merged commits, undermining supply-chain audit trail / False attribution of commits to the wrong community persona / CI false-negative allowing unverifiable commits to merge

BIA-2: Git Hook Lifecycle Management (High)

Installation, coexistence, and removal of the commit-msg trailer hook and delegating stubs for all standard git hooks via core.hooksPath.

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

  • Stakeholders: Contributors / DevOps/Platform Engineering / Third-Party Hook Tooling Vendors (husky, lefthook, pre-commit)
  • Dependencies: core.hooksPath git configuration slot / Local filesystem write access to hooks directory / git rev-parse for git-dir resolution
  • Disruptions: Race condition during hook directory population / Ownership conflict with existing hook managers / Stale core.hooksPath after uninstall due to path recomputation drift
  • Impacts: Other tooling's hooks silently stop running / Provenance enforcement silently disabled / Operator confusion requiring manual remediation

Technical Scope

Roles (1):

Actors (3): AC-1 Contributor · AC-2 CI Pipeline Service · AC-3 Third-Party Hook Manager (husky/lefthook/pre-commit)

Entry Points (6): EP-001 CLI init command · EP-002 CLI uninstall command · EP-003 CLI sign command (git gpg.ssh.program) · EP-004 commit-msg hook trigger · EP-005 Delegating hook stubs (all standard hooks) · EP-006 verify-trust library call

Threat Actors (3): TA-1 Malicious Insider Contributor · TA-2 Local Multi-Tenant Attacker · TA-3 Malicious Repository Publisher

Infrastructure (2): IF-1 Contributor Local Machine · IF-2 CI/CD Runner Infrastructure

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

External Entities (3): EE-1 Contributor Workstation User · EE-2 CI/CD Runner · EE-3 DID Resolution Service (webvh)

System Components (5): SC-1 did-git-sign CLI (init/uninstall/sign) · SC-2 Generated Git Hook Dispatcher · SC-3 verify-trust CI Verifier · SC-4 Local Git Config Store · SC-5 DID Document Registry (webvh)

Resources And Assets (4): RA-1 Ed25519 SSH Signing Private Key · RA-2 did-git-sign.key / core.hooksPath Git Config · RA-3 Signed-by-DID Git Trailer · RA-4 allowed_signers File

Technologies And Dependencies (5): TD-1 anyhow · TD-2 base64 · TD-3 dirs · TD-4 git CLI · TD-5 POSIX sh

Use Cases (3)

  • Persona-Bound Commit Signing: A contributor commits code; git invokes did-git-sign sign as the gpg.ssh.program, which signs the commit with the persona's Ed25519 key while the commit-msg hook writes the Signed-by-DID trailer, prod
  • CI Commit Provenance Verification: A CI pipeline runner checks out a pushed commit and invokes verify-trust to parse the Signed-by-DID trailer, resolve the claimed DID against the external registry, and confirm the resolved key matches
  • Hook Dispatcher Installation and Coexistence: An operator runs did-git-sign init, which checks whether core.hooksPath is already owned by another tool, then populates a hooks directory with a commit-msg trailer script and 23 delegating stubs so e

📋 Risk Registry (5)

ID Title Severity Residual Priority Effort
RISK-1 Shared/multi-tenant machines allow local attackers to race or hijack hook installation and steal signing key usage High Medium Short-Term Medium
RISK-2 Client-side-only enforcement of the DID claim means --no-verify or missing server-side checks allow signed-but-unattributed commits to merge High Medium Immediate High
RISK-3 Global installation scope silently binds a contributor's DID identity to every repository on the machine, including untrusted third-party clones High Medium Short-Term Medium
RISK-4 Insufficient DID value sanitization creates trailer/log injection surface reachable via local git config or environment variables Medium Low Medium-Term Low
RISK-5 Non-fatal error handling and stale-state drift in hook lifecycle management cause silent loss of provenance enforcement Medium Low Medium-Term Medium

⚔️ Attack Scenarios (4)

SC-1: did-git-sign CLI (init/uninstall/sign)

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["1. System Component"]
    direction LR
    SC1@{ shape: rect, label: "SC-1: did-git-sign CLI" }
  end
  subgraph SL2["2. Weaknesses"]
    direction LR
    CWE367@{ shape: rect, label: "CWE-367: TOCTOU Race Condition" }
    CWE362@{ shape: rect, label: "CWE-362: Concurrent Execution Race" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    CAPEC27@{ shape: rect, label: "CAPEC-27: Leveraging Race Conditions" }
    CAPEC25@{ shape: rect, label: "CAPEC-25: Forced Deadlock" }
  end
  subgraph SL4["4. Threats"]
    direction LR
    S1@{ shape: rect, label: "STRIDE-1: Hook Directory Takeover<br><i>High / Possible</i>" }
    S6@{ shape: rect, label: "STRIDE-6: Ownership Check Race<br><i>Medium / Possible</i>" }
  end
  subgraph SL5["5. Threat Actors"]
    direction LR
    TA2@{ shape: rect, label: "TA-2: Local Multi-Tenant Attacker<br><i>Escalate privileges via shared hook install race</i>" }
  end
  SC1 --> CWE367
  SC1 --> CWE362
  CWE367 --> CAPEC27
  CWE362 --> CAPEC25
  CAPEC27 --> S1
  CAPEC25 --> S6
  S1 --> TA2
  S6 --> TA2
  linkStyle 0 stroke:#FF0000,stroke-width:2px
  linkStyle 1 stroke:#FFA500,stroke-width:2px
  linkStyle 2 stroke:#FF0000,stroke-width:2px
  linkStyle 3 stroke:#FFA500,stroke-width:2px
  linkStyle 4 stroke:#FF0000,stroke-width:2px
  linkStyle 5 stroke:#FFA500,stroke-width:2px
  linkStyle 6 stroke:#FF0000,stroke-width:2px
  linkStyle 7 stroke:#FFA500,stroke-width:2px
Loading

SC-2: Generated Git Hook Dispatcher

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["1. System Component"]
    direction LR
    SC2@{ shape: rect, label: "SC-2: Generated Git Hook Dispatcher" }
  end
  subgraph SL2["2. Weaknesses"]
    direction LR
    CWE354@{ shape: rect, label: "CWE-354: Improper Validation of Integrity Check" }
    CWE20@{ shape: rect, label: "CWE-20: Improper Input Validation" }
    CWE668@{ shape: rect, label: "CWE-668: Exposure of Resource to Wrong Sphere" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    CAPEC668@{ shape: rect, label: "CAPEC-668: Key Negotiation of Bluetooth Attack (bypass analogue)" }
    CAPEC267@{ shape: rect, label: "CAPEC-267: Leverage Alternate Encoding" }
    CAPEC176@{ shape: rect, label: "CAPEC-176: Configuration/Environment Manipulation" }
  end
  subgraph SL4["4. Threats"]
    direction LR
    S3@{ shape: rect, label: "STRIDE-3: --no-verify Bypass<br><i>High / Likely</i>" }
    S2@{ shape: rect, label: "STRIDE-2: DID Trailer Injection<br><i>Medium / Possible</i>" }
    S9@{ shape: rect, label: "STRIDE-9: Global Cross-Repo Contamination<br><i>High / Likely</i>" }
  end
  subgraph SL5["5. Threat Actors"]
    direction LR
    TA1@{ shape: rect, label: "TA-1: Malicious Insider Contributor<br><i>Evade non-repudiation</i>" }
    TA3@{ shape: rect, label: "TA-3: Malicious Repository Publisher<br><i>Trick victim into signing under wrong persona</i>" }
  end
  SC2 --> CWE354
  SC2 --> CWE20
  SC2 --> CWE668
  CWE354 --> CAPEC668
  CWE20 --> CAPEC267
  CWE668 --> CAPEC176
  CAPEC668 --> S3
  CAPEC267 --> S2
  CAPEC176 --> S9
  S3 --> TA1
  S2 --> TA1
  S9 --> TA3
  linkStyle 0 stroke:#FF0000,stroke-width:2px
  linkStyle 1 stroke:#FFA500,stroke-width:2px
  linkStyle 2 stroke:#FF0000,stroke-width:2px
  linkStyle 3 stroke:#FF0000,stroke-width:2px
  linkStyle 4 stroke:#FFA500,stroke-width:2px
  linkStyle 5 stroke:#FF0000,stroke-width:2px
  linkStyle 6 stroke:#FF0000,stroke-width:2px
  linkStyle 7 stroke:#FFA500,stroke-width:2px
  linkStyle 8 stroke:#FF0000,stroke-width:2px
  linkStyle 9 stroke:#FF0000,stroke-width:2px
  linkStyle 10 stroke:#FFA500,stroke-width:2px
  linkStyle 11 stroke:#FF0000,stroke-width:2px
Loading

SC-3: verify-trust CI Verifier

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["1. System Component"]
    direction LR
    SC3@{ shape: rect, label: "SC-3: verify-trust CI Verifier" }
  end
  subgraph SL2["2. Weaknesses"]
    direction LR
    CWE706@{ shape: rect, label: "CWE-706: Use of Incorrectly-Resolved Name/Reference" }
    CWE346@{ shape: rect, label: "CWE-346: Origin Validation Error" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    CAPEC141@{ shape: rect, label: "CAPEC-141: Cache Poisoning (fallback analogue)" }
    CAPEC151@{ shape: rect, label: "CAPEC-151: Identity Spoofing" }
  end
  subgraph SL4["4. Threats"]
    direction LR
    S4@{ shape: rect, label: "STRIDE-4: Legacy Fallback Downgrade<br><i>Medium / Possible</i>" }
  end
  subgraph SL5["5. Threat Actors"]
    direction LR
    TA1b@{ shape: rect, label: "TA-1: Malicious Insider Contributor<br><i>Forge attribution via weaker legacy path</i>" }
  end
  SC3 --> CWE706
  SC3 --> CWE346
  CWE706 --> CAPEC141
  CWE346 --> CAPEC151
  CAPEC141 --> S4
  CAPEC151 --> S4
  S4 --> TA1b
  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
Loading

SC-4: Local Git Config Store

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["1. System Component"]
    direction LR
    SC4@{ shape: rect, label: "SC-4: Local Git Config Store" }
  end
  subgraph SL2["2. Weaknesses"]
    direction LR
    CWE459@{ shape: rect, label: "CWE-459: Incomplete Cleanup" }
    CWE59@{ shape: rect, label: "CWE-59: Improper Link Resolution" }
    CWE755@{ shape: rect, label: "CWE-755: Improper Handling of Exceptional Conditions" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    CAPEC462@{ shape: rect, label: "CAPEC-462: Cross-Domain Search-Path Alteration" }
    CAPEC132@{ shape: rect, label: "CAPEC-132: Symlink Attack" }
    CAPEC548@{ shape: rect, label: "CAPEC-548: Contaminate Resource" }
  end
  subgraph SL4["4. Threats"]
    direction LR
    S8@{ shape: rect, label: "STRIDE-8: Stale hooksPath after uninstall<br><i>Low / Possible</i>" }
    S5@{ shape: rect, label: "STRIDE-5: Symlink Path Traversal<br><i>Medium / Unlikely</i>" }
    S7@{ shape: rect, label: "STRIDE-7: Non-Fatal Install Failure<br><i>Medium / Likely</i>" }
  end
  subgraph SL5["5. Threat Actors"]
    direction LR
    TA2b@{ shape: rect, label: "TA-2: Local Multi-Tenant Attacker<br><i>Exploit stale/dangling config state</i>" }
    TA3b@{ shape: rect, label: "TA-3: Malicious Repository Publisher<br><i>Craft repo to redirect hook install path</i>" }
  end
  SC4 --> CWE459
  SC4 --> CWE59
  SC4 --> CWE755
  CWE459 --> CAPEC462
  CWE59 --> CAPEC132
  CWE755 --> CAPEC548
  CAPEC462 --> S8
  CAPEC132 --> S5
  CAPEC548 --> S7
  S8 --> TA2b
  S5 --> TA3b
  S7 --> TA2b
  linkStyle 0 stroke:#00FF00,stroke-width:2px
  linkStyle 1 stroke:#FFA500,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:#FFA500,stroke-width:2px
  linkStyle 6 stroke:#00FF00,stroke-width:2px
  linkStyle 7 stroke:#FFA500,stroke-width:2px
  linkStyle 8 stroke:#FFA500,stroke-width:2px
  linkStyle 9 stroke:#00FF00,stroke-width:2px
  linkStyle 10 stroke:#FFA500,stroke-width:2px
  linkStyle 11 stroke:#FFA500,stroke-width:2px
Loading

📊 Risk Summary

Total Threats: 10

By Severity: Low: 1 · High: 3 · Medium: 6

By Category: Tampering: 10 · Elevation of Privilege: 3 · Spoofing: 3 · Repudiation: 2 · Denial of Service: 3 · Information Disclosure: 2

🎯 Attack Surface

Kill Chain 1: A local multi-tenant attacker (TA-2) on a shared build or development machine exploits the non-atomic sequence in install_hook_dispatcher (STRIDE-1) — directory creation, then per-hook file writes, then finally the core.hooksPath config write — to plant a malicious hook file that survives the ownership marker check, or races the ownership check itself (STRIDE-6) against a concurrently-installing tool like husky; once core.hooksPath is committed, every subsequent git operation on that machine executes attacker code with the victim's privileges, providing a path to exfiltrate the Ed25519 signing key (RA-1) directly from process memory before its zeroization completes. Kill Chain 2: A malicious insider contributor (TA-1) with legitimate commit access chains the client-side-only enforcement gap (STRIDE-3, git commit --no-verify) with the dual-path DID discovery weakness in verify-trust (STRIDE-4) — producing a validly-signed commit with no Signed-by-DID trailer, then relying on either a permissive legacy user.email fallback or an imperfect CI gate to have it merged; because the trailer is the sole non-repudiation anchor for modern commits, this chain launders code changes into the trusted history while preserving deniability for the actual author. Kill Chain 3: A malicious repository publisher (TA-3) crafts a git repository or worktree structure that manipulates git rev-parse --absolute-git-dir output (STRIDE-5) to redirect where did-git-sign init writes its hook dispatcher, combined with the global installation cross-contamination weakness (STRIDE-9) — a victim who previously ran --global init and later clones this repository has their DID identity and signing key silently exposed to the untrusted repository's execution context, since the delegating hooks transparently chain to any .git/hooks scripts the malicious repository provisions via its own setup tooling. Kill Chain 4: Any of the above chains is compounded by the non-fatal error handling in install() (STRIDE-7) — if hook installation fails partway due to any of the above conflicts, the overall init command still reports success, meaning automation and CI bootstrap scripts that check only the exit code will proceed with the false assumption that DID-trailer enforcement (RA-3) is active, extending the window during which unattributed or attacker-manipulated commits can accumulate before CI's verify-trust check ultimately catches (or fails to catch) the gap.

🛡️ Risk Mitigation Strategy

Priority 1 (Immediate): The most severe systemic gap is that DID-claim enforcement is entirely client-side and trivially bypassable via git commit --no-verify (STRIDE-3), with a compensating server-side control absent from the reviewed artifacts. This single gap undermines every other control in the system, since a non-repudiation architecture that can be silently disabled by the committer it is meant to hold accountable provides no real guarantee. Server-side pre-receive hook enforcement of the Signed-by-DID trailer, paired with branch protection requiring the noSignerDid CI check with no override for standard contributors, must be implemented before this tool can be relied upon for supply-chain-critical repositories. Priority 2 (Short-Term): The hook installation lifecycle (STRIDE-1, STRIDE-6, STRIDE-9) exhibits classic TOCTOU and shared-resource-contention patterns across three related but distinct threats — non-atomic directory population, unlocked core.hooksPath ownership checks, and unscoped global identity binding. These should be addressed together via an install-time redesign: populate hook contents in a temporary directory with restrictive permissions, atomically rename into place, re-verify core.hooksPath ownership immediately before the final config write, and default to local-only installation with global scope requiring explicit, loudly-surfaced confirmation given its cross-repository identity-binding consequences. Priority 3 (Medium-Term): Input validation on the DID value throughout the commit-msg hook (STRIDE-2, STRIDE-10) currently relies on blocklist-style whitespace and prefix checks rather than a strict grammar, creating a narrow but real trailer-confusion and log-injection surface; verify-trust should independently and strictly validate DID syntax rather than trusting hook-side sanitization, and the hook itself should move to an allow-list character model. Priority 4 (Medium-Term): Observability gaps — non-fatal install failures reported only v


Generated by Agentic Sec — Threat Model & Affect Analysis Agent

📊 Summary & findings
✅ Confirmed ⚠️ Must-Review-By-Human
1 0

Confirmed (1)

  • 🟡 TOCTOU race between core.hooksPath check and write in install_hook_dispatcher (triaged HIGH→MEDIUM)

@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

@affinidi-appsecurity-bot

Copy link
Copy Markdown

🔄 Security Rescan Update

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

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

  • 🟠 Weak ownership marker allows hijack of global git hooks (core.hooksPath takeover)
  • 🟡 Client-side-only Signed-by-DID trailer injection bypassable via --no-verify, enabling identity omission/repudiation

📊 2 confirmed issues currently open on this change.

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


🔄 Automated Security Rescan Update

…able

Review fixes for the trailer flow. The hook wrote the trailer in a way
neither Linux nor git could handle, and taking core.hooksPath could
silently disable a repository's existing hooks.

- commit-msg hook: delegate trailer placement to `git interpret-trailers`.
  `sed -i ''` is BSD-only — GNU sed reads the empty argument as a filename,
  fails, and leaves the file unchanged, so the strip loop that re-tested the
  same condition spun forever and `git commit` hung on Linux. And on a
  one-line message (`git commit -m fix`) the trailer was appended straight
  onto the subject, where git's own parser reads no trailers at all: `git
  log --format=%(trailers)`, forges and DCO tooling all saw nothing.
- commit-msg hook: read `DID_GIT_SIGN_KEY` before `did-git-sign.key`, the
  same precedence the signer uses. Otherwise `DID_GIT_SIGN_KEY=… git commit`
  wrote one DID into the trailer, signed with another, and refused its own
  commit.
- commit-msg hook: make `Signed-off-by:` opt-in via `did-git-sign.signoff`.
  A DCO sign-off asserts something about the committer's right to submit the
  code; a signing tool must not assert it on their behalf.
- init: refuse to take `core.hooksPath` in *both* scopes. Only `--global`
  was guarded, so a local install silently clobbered husky/lefthook/
  pre-commit — the delegating hooks fall back to `$git_dir/hooks`, never to
  the path that was configured before, so those hooks just stopped running.
- init: write the hook directory before pointing `core.hooksPath` at it. A
  hook write can fail partway, and the config previously already named a
  half-populated directory while the caller swallowed the error as a warning.
- init: replace `to_str().unwrap()` with a real error, and say in the
  best-effort warning that signing *stops* without the hook rather than
  merely losing a trailer.
- sign: collapse `check_committer_matches_key` onto `signer_did`. Its
  trailing `match committer_did(...)` had become unreachable for both
  `Some(_)` arms — `signer_did` already falls back to `committer_did` — so
  a mismatched legacy committer got the generic message instead of the one
  naming the fix.
- Cover the hook with tests that run it, not tests that grep its source.
  Both bugs above passed every `contains` assertion that existed.
- README/RUNBOOK: document the trailer, `did-git-sign.key` as the single
  identity selector, and the hook dispatcher. They still described
  `user.email = <DID#key-id>` as load-bearing and told users to fix
  `noSignerDid` by re-running init to set it.
- Fix five clippy lints failing CI.

Claude-Session: https://claude.ai/code/session_01H8m2hRAbLeAms1aHnT2bJe
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit 79ee068 into OpenVTC:main Aug 30, 2026
6 checks passed
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.

3 participants