# did-git-sign
A standalone CLI tool that signs git commits using DID Ed25519 keys managed by a
[Verifiable Trust Agent (VTA)](https://github.com/LF-Decentralized-Trust-labs/verifiable-trust-infrastructure).
It acts as a git SSH signing proxy — no private key material ever touches disk.
## How It Works
Git supports pluggable signing programs via `gpg.ssh.program`. When you commit,
git calls `did-git-sign` with the commit data on stdin. The tool:
1. Loads its config (`.did-git-sign.json`) and retrieves the VTA credential from the OS keyring
2. Authenticates with the VTA (or reuses a cached token)
3. Fetches the Ed25519 signing key from the VTA on-the-fly
4. Produces an SSH signature (PROTOCOL.sshsig format) and writes it to stdout
5. Zeroizes the key material from memory
Your DID verification method ID (e.g. `did:webvh:abc:example.com#key-0`) is
recorded in a `Signed-by-DID:` git trailer, linking every commit to your
decentralized identity.
That trailer is load-bearing, not decorative. An sshsig blob carries a raw
Ed25519 key and **no identity**, so the trailer is the only place a commit
states which DID signed it — [`verify-trust`][verify-trust] reads it, resolves
that DID, and requires it to publish the signing key. A commit carrying no DID
claim fails CI as `noSignerDid` however valid its signature.
The trailer sits inside the commit message, which is part of the payload the
signature covers, so it is as tamper-evident as the committer header was. It
lives there rather than in `user.email` so that `user.email` can stay an
ordinary address, which is what GitHub and GitLab match commits against when
attributing them to an account. 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`.
Signing therefore refuses when the committer names a DID other than the key's;
see [Selecting which community persona signs](#selecting-which-community-persona-signs).
## Prerequisites
- A running VTA with your persona DID(s) and Ed25519 signing key(s) provisioned.
- Your **VTA DID** (e.g. `did:webvh:scid:your-vta.example.com`). `init`
discovers the service URL from the DID document, mints a short-lived admin
did:key for the setup session, and prints the `pnm contexts create` command
to authorise it.
## Install
```bash
cargo install did-git-sign
```
`did-git-sign` is the signing half of
[Verifiable Git Infrastructure (VGI)](https://github.com/OpenVTC/verifiable-git-infrastructure);
the CI verifier is the separate [`verify-trust`](https://crates.io/crates/verify-trust) crate.
## Setup
### Per-repository
```bash
did-git-sign init --vta-did did:webvh:scid:your-vta.example.com
```
`init` resolves the VTA, mints a temporary admin did:key, and prints a
`pnm contexts create …` command. Run it in your Personal Network Manager to
authorise the setup session, press Enter, then select the persona and signing
key interactively. This writes `.did-git-sign.json` in the current directory
and configures the local git repo.
### Global (all repositories)
```bash
did-git-sign init --global --vta-did did:webvh:scid:your-vta.example.com
```
Saves config to `~/.config/did-git-sign/` and sets global git config.
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, and
quietly so, since commits in the other community would claim this DID. `init`
prints the per-remote alternative when you use `--global`; see
[Selecting which community persona signs](#selecting-which-community-persona-signs).
`init` refuses to take `core.hooksPath` if something else already owns it
(husky, lefthook, pre-commit), rather than silently stopping those hooks.
### Non-interactive
Name the persona and key to skip the picker (and `--yes` to skip the
"press Enter once authorised" prompt):
```bash
did-git-sign init \
--vta-did did:webvh:scid:your-vta.example.com \
--key-id your-vta-key-id \
--did-key-id did:webvh:scid:your-vta.example.com#key-0 \
--name "Your Name" \
--yes
```
### Options
| `--vta-did` | VTA DID; the service URL is discovered from its document (required) |
| `--context` | Context id to provision into (default `did-git-sign`) |
| `--key-id` | VTA key id for the signing key (skips interactive selection) |
| `--did-key-id` | DID verification-method id to sign as (skips interactive selection) |
| `--name` | Git `user.name` (optional) |
| `--vta-url` | Override the VTA URL instead of resolving it from the DID |
| `--global` | Configure global git instead of per-repo |
| `--yes` | Assume the admin grant is already registered; skip the prompt |
### What `init` configures
The `init` command performs the following:
1. **Saves config** to `.did-git-sign.json` (local) or `~/.config/did-git-sign/config.json` (global) — contains only `key_id`, `did_key_id`, and `user_name`
2. **Stores VTA credentials** (URL, DIDs, private key) in the OS keyring (macOS Keychain / Linux Secret Service)
3. **Verifies VTA connectivity** by authenticating and fetching the signing key
4. **Configures git:**
- `gpg.format = ssh`
- `gpg.ssh.program = did-git-sign`
- `gpg.ssh.defaultKeyFile = <config path>`
- `commit.gpgsign = true`
- `user.signingKey = <config path>`
- `did-git-sign.key = <DID#key-id>` — selects the signing persona *and* is
the claim the `commit-msg` hook writes into the trailer; see below
- `core.hooksPath = <hook dispatcher>` — see below
- `user.name = <name>` (if provided)
`user.email` is left alone: it stays an ordinary address so forges can
attribute your commits to your account.
5. **Creates an `allowed_signers` file** for signature verification and sets `gpg.ssh.allowedSignersFile`
6. **Installs a `commit-msg` hook** that appends the `Signed-by-DID:` trailer.
Because `core.hooksPath` is a single slot, the hook directory it installs
also carries a delegating stub for every other standard hook, each of which
execs the repository's own `.git/hooks/<name>` — so hooks you already have,
and hooks you add later, keep running. `uninstall` removes the directory and
unsets `core.hooksPath`.
## Usage
After setup, commits are signed automatically:
```bash
git commit -m "my signed commit"
```
Verify signatures:
```bash
git log --show-signature
```
Check your configuration and VTA connectivity:
```bash
did-git-sign health
```
### Showing names instead of DIDs
`init`'s context and DID pickers label each entry with a human name where the
VTA has one — an ACL label, or the context's own name — falling back to an
abbreviated DID. Pass `--resolve-agent-names` to `init` or `health` to also
read back the agent name a DID document claims (`example.com/@alice`):
```bash
did-git-sign health --resolve-agent-names
```
Each claimed name is resolved forward and must lead back to the DID that claims
it before it is shown as that DID's; a claim that does not round-trip is tagged
`[unverified]`, because `alsoKnownAs` is self-asserted and an unchecked name is
only what a DID says about itself. Resolution costs an outbound HTTPS fetch per
claimed name, so it is opt-in. Names never replace the DID in a summary or
diagnostic — they are printed above it.
### Selecting which community persona signs
With more than one provisioned persona, you can choose which one signs without
re-running `init`. At sign time the signing key is resolved in this order:
1. The `DID_GIT_SIGN_KEY` environment variable (per-invocation override).
2. The `did-git-sign.key` per-repo git config setting.
3. The `did_key_id` in the config file git points at (the `init` default).
The value is the persona's `did:webvh:…#key-N`. It must have credentials stored
in the keyring (i.e. you ran `init` for that persona); otherwise signing fails
with a clear message rather than silently signing as a different persona.
```bash
# One commit as a specific persona:
DID_GIT_SIGN_KEY=did:webvh:abc:example.com#key-1 git commit -m "…"
# Pin a persona for this repository:
git config did-git-sign.key did:webvh:abc:example.com#key-1
```
**One setting, so the persona and the claim cannot drift.** The `commit-msg`
hook reads the same selector the signer does, in the same order —
`DID_GIT_SIGN_KEY`, then `did-git-sign.key` — so whatever picks the key also
writes the claim. This is why the second `user.email` line each example used to
carry is gone: there is nothing left to keep in step by hand.
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 different DID.
For contributors in more than one community, do not manage this per repository
by hand: a `git config --local` you forget does not error, it signs as the
wrong community. Use git's conditional includes, one file per community:
```ini
# ~/.gitconfig
[includeIf "hasconfig:remote.*.url:https://github.com/OpenVTC/**"]
path = ~/.config/git/community-openvtc
```
```ini
# ~/.config/git/community-openvtc
[did-git-sign]
key = did:webvh:abc:example.com#key-0
```
`hasconfig:remote.*.url` (git ≥ 2.36) keys off the remote, so membership follows
the repository rather than where it was cloned; `includeIf "gitdir:…"` matches
on path instead if your layout is authoritative.
## Security Model
- **No key material on disk** — the VTA credential private key is stored in the
OS keyring, and the Ed25519 signing key is fetched from the VTA at sign-time
and held only in memory.
- **Token caching** — the VTA access token is cached in the OS keyring to avoid
re-authentication on every commit. Tokens are validated with a 30-second
safety margin before reuse.
- **Zeroization** — signing key material is zeroized immediately after use via
the `zeroize` crate.
- **`DID_GIT_SIGN_SSH_KEYGEN` override is test-only.** The path to `ssh-keygen`
used for the verify / find-principals / check-novalidate delegation paths
can be overridden via this environment variable so test fixtures can point
at a mock binary. **Do not set it in production.** An attacker with write
access to your environment could redirect signature verification to a
binary that always returns success and silently accept forged signatures.
The override has no effect on the *signing* path, which never invokes
`ssh-keygen`.
## Architecture
```
git commit
|
v
git calls: did-git-sign -Y sign -f .did-git-sign.json -n git
| (stdin: commit data)
v
did-git-sign:
1. Load config from .did-git-sign.json (key_id + did_key_id only)
2. Load VTA credentials from OS keyring
3. Authenticate with VTA (or use cached token from keyring)
4. Fetch Ed25519 key: VTA.get_key_secret(key_id)
5. Sign commit data (PROTOCOL.sshsig format)
6. Output SSH signature to stdout
7. Zeroize key material
|
v
git stores signature in commit
```
## Config File Format
The `.did-git-sign.json` file contains only your DID identity:
```json
{
"did_key_id": "did:webvh:abc123:example.com#key-0",
"user_name": "Your Name"
}
```
**No VTA credentials or key identifiers are stored on disk.** All VTA
configuration and sensitive material is stored in the OS keyring under the
service name `did-git-sign`:
| `{did_key_id}:vta` | VTA URL, VTA DID, credential DID, credential private key, signing key ID |
| `{did_key_id}:token` | Cached VTA access token and expiry |
[verify-trust]: https://crates.io/crates/verify-trust