cli.sgit.ai / Research / Design 02: signing commits and tags with your own key, the way Git does it

Design 02: signing commits and tags with your own key, the way Git does it

Date2026-10-28 · 03:04 UTCKinddesignResearch topicsgit keysWritten bythe sgit-ai CLI team (Claude Code sessions), for Dinis CruzSourceteam/humans/dinis_cruz/claude-code-web/10/28/design__02__signing-with-your-own-key.md in SGit-AI__CLI at 036e33b. Published here as written; the CLI repository is where the work happened.

Read with design__00__index-and-reconciliation.md: where the two disagree (shared foundations F1–F9, the in-progress rules), the index wins.

Roadmap item: 2.2 in roadmap__git-parity.md (priority 1). Status: design, not built. Written against c57aea9+ on claude/charming-babbage-5hzcxq. Size: about 4 days with tests (section 10). Gate: none. Section 5.9 proves why; two items must still be confirmed before release (5.9.4, D7).


1. Summary and the exact Git behaviour matched

Git does not decide who is allowed to write. It signs with the key the user already has, stores the signature in the commit, and leaves trust to whoever verifies. sgit does the same, as an optional second signature next to the per-clone ECDSA signature it already makes.

1.1 Signing

ConcernWhat Git doesWhat sgit will do
Key sourceuser.signingKey; for SSH, gpg.ssh.defaultKeyCommand when unsetSame keys, read from the user's Git config (5.6)
Formatsgpg.format = openpgp (default), ssh, x509openpgp and ssh. x509 is refused by name (non-goal)
Programgpg.program / gpg.openpgp.program; gpg.ssh.program (default ssh-keygen)Same keys, same defaults
SSH signing callssh-keygen -Y sign -n git -f <key> (a literal key goes in a temporary file, with -U meaning "the private half is in the agent")Same argv. The payload goes on stdin and the signature comes back on stdout, so the payload never touches disk (5.5)
GPG signing callgpg --status-fd=2 -bsau <key>, payload on stdin, armored detached signature on stdoutgpg --status-fd=2 -bsu <key> (binary, not armored, so it is stored compactly); SIG_CREATED required on the status fd
When it signscommit.gpgSign, tag.gpgSign, tag.forceSignAnnotated, -S / --gpg-sign[=<key>], --no-gpg-sign, tag -s / -u <key>The same flags. The automatic switch is sgit.gpgSign (D2), so an existing commit.gpgSign = true does not change what agents do after an upgrade
Where it livesgpgsig header in the commit, excluded from the signed bytesuser_signature member in the commit JSON, excluded from both signatures' signed bytes (5.1)
Failure"gpg failed to sign the data", no commit is writtenSame: refuse, nothing written, exit 1

1.2 Verifying

ConcernWhat Git doesWhat sgit will do
SSH trustgpg.ssh.allowedSignersFile, gpg.ssh.revocationFileSame files, read in-process (5.4), with ssh-keygen as a fallback for the cases in 5.4.5
SSH verifyssh-keygen -Y find-principals, then -Y verify -n git -I <principal> -Overify-time=<commit date>; if no principal matches, -Y check-novalidateThe same three outcomes, computed in-process
GPG verifygpg --keyid-format=long --status-fd=1 --verify <sigfile> -, then parse GOODSIG/BADSIG/ERRSIG/EXPSIG/EXPKEYSIG/REVKEYSIG/VALIDSIG/TRUST_*Same command and parser
Result codes%G?: G good, B bad, U good with unknown validity, X good but expired signature, Y good but expired key, R good but revoked key, E cannot check, N no signatureSame letters, plus word names (5.2)
Displaylog --show-signature, verify-commit, verify-tag, tag -vhistory log --show-signature, history show --show-signature, check verify, vault tag show

What stays as it is:


2. Scope and non-goals

2.1 In scope

2.2 Non-goals

Each one is listed with where it belongs instead.

Non-goalWhere it goes
A list of who may write, carried in the vault; owner keys; joining; revocation recordsSGitHub (future server), roadmap section 4
A vault policy requiring user signatures (a user-signatures-required feature)Not Git behaviour. The local gate is check verify --require-user-signature
X.509 / gpgsmLater, if asked
SSH certificates (cert-authority lines) verified in-processDelegated to ssh-keygen when installed; otherwise reported as cannot-check (5.4.5)
Binary KRL revocation files in-processSame delegation
Re-signing history that a rewrite produces (pack import/export, migrate)Signatures are dropped and counted (7.6), as git filter-repo drops them
A name/email author field in commitsA separate parity item. The principal from the allowed-signers file is what identifies the signer
User signatures on pack index headersNot needed (7.11)
A new top-level commandRule 9: everything below is flags on existing commands

3. CLI surface

3.1 Flags

Git's spelling everywhere it exists.

CommandNew flagsNotes
sgit commit-S, --gpg-sign, -S<KEY>, --gpg-sign=<KEY>, --no-gpg-signThe key form must be attached (as in Git): -S msg must not read msg as a key, because message is an optional positional. See 3.3.
sgit writesameAgents normally pass nothing; the switch is off unless sgit.gpgSign is set
sgit pull, sgit branch merge-S / --gpg-sign[=KEY] / --no-gpg-signSign the merge commit (Git: pull -S, merge -S)
sgit history revert --as-commitsameGoes through commit() (Vault__Sync__History_Edit.py:105)
sgit vault tag create-s / --sign, -u <KEY> / --local-user <KEY>, --no-signGit tag -s / -u; every sgit tag is annotated, so tag.forceSignAnnotated counts as tag.gpgSign
sgit history log--show-signatureAlso a user_signature object in --json
sgit history show--show-signature
sgit check verify--user (show per-commit user-signature lines with --verbose), --require-user-signature, --jsonThe summary line for user signatures is printed whenever any checked commit has one
sgit vault tag shownoneAlways reports the user signature when present

3.2 Configuration

Configuration is read from Git's config files (5.6). There is nothing to set in sgit.

KeyMeaning in sgitDefault
sgit.gpgSignsign every commit and tag (the automatic switch, D2)false
user.signingKeykey to sign with (GPG key id/email/fingerprint; SSH private-key path, public-key path, or literal ssh-… / key::ssh-…)unset
gpg.formatopenpgp / ssh (x509 refused by name)openpgp
gpg.program, gpg.openpgp.programgpg binarygpg
gpg.ssh.programssh-keygen binaryssh-keygen
gpg.ssh.allowedSignersFiletrust file for SSHunset → every good SSH signature is untrusted (U), as in Git
gpg.ssh.revocationFilerevoked SSH keys (plain public-key lines; a KRL is delegated, 5.4.5)unset
gpg.ssh.defaultKeyCommandprints a key to use when user.signingKey is unset (Git: e.g. ssh-add -L)unset
gpg.minTrustLevellowest GPG trust that counts as goodGit's default (5.7.3)
commit.gpgSign, tag.gpgSign, tag.forceSignAnnotatedread only for display (sgit doctor mentions them), never acted on unless sgit.gpgSign is also true (D2)n/a

3.3 How the attached key form is parsed

CLI__Main.run gets a pre-parse step, CLI__Argv__Signing.normalise(argv):

--gpg-sign-key is a hidden option, so -S msg keeps meaning "sign, message msg". The step applies only to argv tokens in the commands listed in 3.1, and only when they come before --.

3.4 Output

Signing. A signed commit adds one line to the existing commit output:

Signed with ssh key SHA256:uK0E…Qx8 (user.signingKey ~/.ssh/id_ed25519.pub)

A signing failure exits 1 and writes nothing:

error: signing failed: ssh-keygen: agent refused operation
       nothing was committed. Run again without signing: sgit commit --no-gpg-sign

history log --show-signature. The line goes under each commit, worded as Git words it:

  commit obj-cas-imm-3f9a…
  Good "git" signature for alice@example.com with ED25519 key SHA256:uK0E…Qx8
  Clone signature: verified (key-rnd-imm-0011223344556677)
  Date:      …

The other outcomes:

OutcomeFirst line
untrusted (U)Good "git" signature with ED25519 key SHA256:… and then No principal matched.
bad (B)BAD signature from <principal or key fingerprint>
GPGthe gpg status summarised: Good signature from "Alice <a@x>" [full] / [unknown]
none (N)nothing (Git prints nothing for an unsigned commit)

check verify. One new summary line, printed only when at least one commit carries a user signature:

Checked 412 commit(s): 412 verified, 0 bad, 0 unsigned, 0 without a known key, 0 missing
User signatures: 37 good, 2 untrusted, 0 bad, 0 expired key, 0 revoked, 0 cannot check, 373 none

vault tag show v1.0:

  Signature: signature verified
  Signed by: alice@example.com (ssh, good)

3.5 Exit codes

CommandExit 0Exit 1
commit / write / pull / branch merge / vault tag create with signing onsigned and writtensigning failed; nothing written (no commit, no ref move, no tag object)
check verifynothing badas today a bad clone signature, plus any user signature that is bad. untrusted, expired-key, revoked, cannot-check and none do not fail (Git parity: verify-commit exits 0 for U)
check verify --require-user-signatureevery checked commit is goodany commit is not good
vault tag showunchanged rule: the tag's clone signature must verifyadditionally a bad user signature

3.6 JSON shapes

The same object appears in history log --json, history show --json, check verify --json (per commit with --verbose) and vault tag show --json. All values are strings:

"user_signature": {
  "status":          "good",
  "code":            "G",
  "format":          "ssh",
  "principal":       "alice@example.com",
  "key_type":        "ED25519",
  "key_fingerprint": "SHA256:uK0E…Qx8",
  "signed_at_ms":    "1761600000000",
  "reason":          ""
}

status is one of good, untrusted, bad, expired-signature, expired-key, revoked, cannot-check, none.

check verify --json adds:

{
  "total": 412,
  "counts": { "verified": 412, "bad": 0, "unsigned": 0, "no-key": 0, "missing": 0 },
  "user_counts": { "good": 37, "untrusted": 2, "bad": 0, "expired-signature": 0, "expired-key": 0,
                   "revoked": 0, "cannot-check": 0, "none": 373 },
  "user_by_status": { "untrusted": ["obj-cas-imm-…", "…"], "bad": [] },
  "first_failure": null
}

4. How the code works today

4.1 Commit schema and the signing input

File:lineWhat it does
sgit_ai/schemas/Schema__Object_Commit.py:11-21Fields: schema, tree_id, parents, timestamp_ms, message_enc, branch_id, signature, author_key_id, author_signature (reserved), attestations (reserved).
sgit_ai/safe_types/Safe_Str__Signature.py:5-14^[A-Za-z0-9+/=]{1,256}$, max 256, strict. Too small for an SSHSIG or OpenPGP signature. A 300-character author_signature is rejected (tested 28 Oct), so the reserved field cannot be reused without breaking every existing client.
sgit_ai/storage/Vault__Commit.py:34-36signing_bytes: JCS of commit.json() minus signature (sorted keys, compact, UTF-8, ensure_ascii=False).
sgit_ai/storage/Vault__Commit.py:38-40legacy_signing_bytes: json.dumps(commit.json()) with signature: None, default separators. Used for commits without author_key_id.
sgit_ai/storage/Vault__Commit.py:42-76create_commit: builds the schema, signs signing_bytes(commit.json()) with the clone key, stores json.dumps(commit.json()) encrypted (random IV) and content-addressed.
sgit_ai/storage/Vault__Commit.py:78-103load_commit: decrypt, json.loads, Schema__Object_Commit.from_json(raw). Unknown keys are ignored and do not reappear in .json() (tested 28 Oct with a dict-valued user_signature, for both commits and tags).
sgit_ai/storage/Vault__Commit.py:111-128verify_commit_signature: tries signing_bytes, then legacy_signing_bytes when author_key_id is null.

4.2 Signature checks today

File:lineWhat it does
sgit_ai/core/actions/verify/Vault__Signatures.py:28-52_key_for finds the clone key (author_key_id, else the branch index), fetching missing key files (Vault__Key_Fetch); status_of returns verified / bad / unsigned / no-key / missing.
sgit_ai/core/actions/verify/Vault__Signatures.py:54-86verify_chain walks all parents. Used by check verify, pull (Vault__Incoming_Check.require_signatures, sgit_ai/core/actions/pull/Vault__Incoming_Check.py:13-55), clone (Step__Clone__Verify_Signatures.py:24-70) and push (Vault__Sync__Push.py:208).

4.3 Tags

File:lineWhat it does
sgit_ai/schemas/Schema__Object_Tag.pyschema, name, commit_id, message, timestamp_ms, tagger_branch, tagger_key_id, signature.
sgit_ai/core/actions/tag/Vault__Sync__Tag.py:92-140create signs _signing_bytes(tag) with the clone key.
sgit_ai/core/actions/tag/Vault__Sync__Tag.py:182-184_signing_bytes is JCS minus signature.
sgit_ai/core/actions/tag/Vault__Sync__Tag.py:196-210_status: verified / bad / unsigned / no-key.
sgit_ai/core/actions/tag/Vault__Sync__Tag.py:65-80resolve refuses an unverified tag (TM-R06).

4.4 Where commits are made

SiteWho signs todayUser signature?
Vault__Sync__Commit.commit (sgit_ai/core/actions/commit/Vault__Sync__Commit.py:162-187)clone keyyes: this covers commit, --amend, merge-resolution commits, and history revert --as-commit (via Vault__Sync__History_Edit.py:105)
Vault__Sync__Commit.write_file (Vault__Sync__Commit.py:327-337)clone keyyes (sgit write)
Step__Pull__Merge merge commit (sgit_ai/workflow/pull/Step__Pull__Merge.py:158-181)clone keyyes (pull, branch merge)
Step__Pull__Merge rewind re-apply (Step__Pull__Merge.py:285-301)clone keyyes (it is the user's work re-committed)
Vault__Sync.init first commit (sgit_ai/core/Vault__Sync.py:116-123)clone keyno (an empty init commit; Git signs the first commit only if asked, and the user has no way to ask here). Follows -S if passed to init? Not in v1
Vault__Ignore__Apply (sgit_ai/core/actions/admin/Vault__Ignore__Apply.py:75)clone keyyes, it is a user action (vault ignore); follows the switch
Step__Move__Write_Sentinel_Commits (sgit_ai/workflow/move/steps/Step__Move__Write_Sentinel_Commits.py:108)clone keyno (automated, mid-workflow; D5)
Vault__Pack_Convert (sgit_ai/core/actions/pack/Vault__Pack_Convert.py:150)clone key of the new vaultno: rewrites history; old user signatures dropped and counted (7.6)
Migration__Tree_IV_Determinism._rewrite_commits (sgit_ai/migrations/tree_iv/Migration__Tree_IV_Determinism.py:342)noneno: same as above

4.5 The cross-client rule

tests/_fixtures/interop_vectors.json, key commit_signing, states the JCS rule and gives a stored commit with its signing bytes and signature. The web UI follows that rule. The rule text changes in 5.10.

4.6 Existing subprocess pattern

sgit_ai/core/serve/Container__Command_Runner.py:10-31: argv lists only, capture_output, text=True, timeouts mapped to code 124, a missing binary to 127, and # nosec B603 comments for Bandit. Signing needs bytes on stdin/stdout, so it gets its own runner (5.5) following the same pattern.


5. Design

5.1 The signed bytes

There is one definition, used by both signatures (clone and user), for commits and for tags:

SIGNATURE_MEMBERS = ('signature', 'user_signature')            # class attribute on Vault__Commit and Vault__Sync__Tag

signing_bytes(d) = UTF-8( JCS( { k: v for k, v in d.items() if k not in SIGNATURE_MEMBERS } ) )
                 = json.dumps(body, sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode('utf-8')

It does not cover the clone signature, and the clone signature does not cover it. Either can be checked alone, and stripping one leaves the other valid. That is what Git does with gpgsig and the commit.

5.2 New types and schemas

All new files follow the existing Safe type layout. Validation is strict_validation = True unless noted.

New fileTypeDefinition
sgit_ai/safe_types/Enum__User_Signature_Format.pyEnum__User_Signature_Format(str, Enum)SSH = 'ssh', OPENPGP = 'openpgp'
sgit_ai/safe_types/Safe_Str__User_Signature_Blob.pySafe_Str__User_Signature_Blob(Safe_Str)regex ^[A-Za-z0-9+/]{16,8188}={0,2}$ (MATCH mode), max_length = 8192, allow_empty = False, trim_whitespace = True. Base64 of the binary signature: the SSHSIG blob (not armored), or the OpenPGP signature packet. RSA-8192 SSHSIG ≈ 2.9 KB of base64, and OpenPGP RSA-4096 ≈ 0.8 KB, so 8 KB leaves room.
sgit_ai/schemas/Schema__User_Signature.pySchema__User_Signature(Type_Safe)format : Enum__User_Signature_Format = None, blob : Safe_Str__User_Signature_Blob = None
sgit_ai/schemas/Schema__Object_Commit.py (changed)new fielduser_signature : Schema__User_Signature = None # the signer's own key (SSH/OpenPGP), Git's gpgsig; excluded from both signatures' signed bytes
sgit_ai/schemas/Schema__Object_Tag.py (changed)new fieldsame
sgit_ai/safe_types/Enum__User_Signature_Status.pyEnum__User_Signature_Status(str, Enum)GOOD='good', UNTRUSTED='untrusted', BAD='bad', EXPIRED_SIGNATURE='expired-signature', EXPIRED_KEY='expired-key', REVOKED='revoked', CANNOT_CHECK='cannot-check', NONE='none', with code() → G U B X Y R E N
sgit_ai/safe_types/Safe_Str__Signer_Principal.pySafe_Str__Signer_Principal(Safe_Str)printable, no control characters (regex replaces [\x00-\x1f\x7f]), max 256. From the verifier's own file or keyring, never stored in the vault
sgit_ai/safe_types/Safe_Str__Key_Fingerprint.pySafe_Str__Key_Fingerprint(Safe_Str)^(SHA256:[A-Za-z0-9+/]{43}|[0-9A-F]{40}|[0-9A-F]{64})$ (SSH SHA256, OpenPGP v4, OpenPGP v5/v6)
sgit_ai/safe_types/Safe_Str__Signing_Key_Spec.pySafe_Str__Signing_Key_Spec(Safe_Str)what user.signingKey holds: a path, a key id/email/fingerprint, or a literal public key. Printable, max 4096, no NUL/newline
sgit_ai/schemas/Schema__User_Signature__Result.pySchema__User_Signature__Result(Type_Safe)status : Enum__User_Signature_Status = None, format : Enum__User_Signature_Format = None, principal : Safe_Str__Signer_Principal = None, key_type : Safe_Str__Key_Type = None (ED25519, ECDSA, RSA, ED25519-SK, ECDSA-SK, or OpenPGP algo name), key_fingerprint : Safe_Str__Key_Fingerprint = None, reason : Safe_Str__Text = None
sgit_ai/schemas/Schema__Signing_Config.pySchema__Signing_Config(Type_Safe)sign : bool = False, format : Enum__User_Signature_Format = None, signing_key : Safe_Str__Signing_Key_Spec = None, gpg_program : Safe_Str__File_Path = None, ssh_program : Safe_Str__File_Path = None, allowed_signers_file : Safe_Str__File_Path = None, revocation_file : Safe_Str__File_Path = None, default_key_command : Safe_Str__Command_Line = None, min_trust_level : Enum__GPG_Trust = None, source : Safe_Str__Text = None (for doctor: where each value came from)

How the field is written. Vault__Commit.create_commit stores data = commit.json(); if data.get('user_signature') is None: del data['user_signature'], and the same for tags. So an unsigned commit is byte-identical to today's format. Nothing grows and no new null member appears for the web UI to meet.

5.3 Classes

There is a new package, sgit_ai/crypto/signing/. It has an __init__.py (source only; tests have none, per rule 8). Everything is a Type_Safe class, with no module-level functions and no @staticmethod.

5.3.1 Signing

Class (file = class name)ResponsibilityKey methods
Signing__Command_Runnerthe one place signing runs a programwhich(name) -> str; run(argv: list, stdin: bytes, timeout: int, env: dict) -> dict(code, out: bytes, err: bytes). Rules in 5.5
Git__Config__Readerread the user's Git configread() -> dict (lower-cased keys → last value); _read_with_git(); _read_files() (fallback parser, 5.6.2)
Signing__Config__Resolverflags > Git config → Schema__Signing_Configresolve(flags: Schema__Signing_Flags) -> Schema__Signing_Config; for_tags(...)
SSH__Signerssh-keygen -Y signsign(payload: bytes, config) -> Schema__User_Signature
SSH__Signer__In_Processsigns with an unencrypted OpenSSH private-key file through cryptography, producing the same SSHSIG as ssh-keygen (ed25519 byte-identical, 9.3)sign(payload, key_path) -> Schema__User_Signature. Used when gpg.ssh.program is not found and user.signingKey names an unencrypted private-key file (D4). Also what the unit tests sign with.
GPG__Signergpg -bsusign(payload, config) -> Schema__User_Signature
User__Signerfaçade picked from the configfrom_config(config) -> User__Signer; sign(payload) -> Schema__User_Signature; describe() -> str ("ssh key SHA256:… (user.signingKey …)")

5.3.2 Verifying

Class (file = class name)ResponsibilityKey methods
SSH__WireSSH wire primitives over a bytes buffer with bounds checksread_uint32(), read_string(), read_mpint(), at_end(); writers for the signer
SSH__Sig__Blobparse/build SSHSIG (5.4.1)parse(blob: bytes) -> SSH__Sig__Blob; fields public_key_blob, namespace, reserved, hash_algorithm, signature_blob; signed_data(payload) -> bytes; armor() / dearmor(text)
SSH__Public_Keyparse a wire public key and verify a signature with itfrom_wire(blob); from_openssh_line(text); fingerprint() -> 'SHA256:…'; key_type() -> str; verify(signature_blob, data) -> bool (5.4.3)
SSH__Allowed_Signersparse the allowed-signers file (5.4.4)load(path); principals_for(public_key, namespace, at_time_s) -> list; matches_key_outside_window(...)
SSH__Revocation_Listplain-key revocation file; KRL detectionload(path); is_revoked(public_key) -> bool; is_krl() -> bool
SSH__Sig__Verifierthe in-process find-principals + verify + check-novalidateverify(sig: Schema__User_Signature, payload, at_time_ms, config) -> Schema__User_Signature__Result
SSH__Sig__Verifier__Keygendelegate to ssh-keygen for certificates and KRLssame signature as above
GPG__Status_Parserparse [GNUPG:] linesparse(stdout: bytes) -> dict (5.7.2)
GPG__Verifiergpg --verifyverify(sig, payload, at_time_ms, config) -> Schema__User_Signature__Result
User__Signature__Verifierfaçade with cacheverify(obj_id: str, sig, payload, at_time_ms) -> Schema__User_Signature__Result
User__Signature__Cache.sg_vault/local/user-signature-cache.jsonget(obj_id, trust_digest), put(...); written with Vault__Storage.write_private (0600, atomic)

5.3.3 Vault-level

Class (file = class name)ResponsibilityKey methods
Vault__User_Signatures (sgit_ai/core/actions/verify/)per-commit / per-chain user-signature status, next to Vault__Signaturesstatus_of(c, read_key, commit_id) -> Schema__User_Signature__Result; verify_chain(c, read_key, head, stop_at, limit, boundaries) -> dict(user_counts, user_by_status)
CLI__Argv__Signing (sgit_ai/cli/)3.3's argv normalisationnormalise(argv: list) -> list

5.4 SSH: exact formats and the in-process verifier

5.4.1 SSHSIG layout (OpenSSH PROTOCOL.sshsig)

Blob (what we store, base64 in blob):

byte[6]  MAGIC_PREAMBLE  "SSHSIG"
uint32   SIG_VERSION     0x00000001
string   publickey       (wire-encoded public key; for certificates, the certificate)
string   namespace       "git"
string   reserved        ""   (verifiers ignore its content)
string   hash_algorithm  "sha512" | "sha256"
string   signature       (an SSH signature: string algorithm, then the algorithm's blob)

Signed data (what the key actually signs):

byte[6]  MAGIC_PREAMBLE  "SSHSIG"
string   namespace
string   reserved
string   hash_algorithm
string   H(message)       SHA-512 or SHA-256 of signing_bytes (5.1)

Armor (only for display and for ssh-keygen): -----BEGIN SSH SIGNATURE-----, base64 wrapped at 70 columns, -----END SSH SIGNATURE-----.

Parser rules (every one of these has a test):

5.4.2 Fingerprints and display

5.4.3 Key types verifiable in-process with cryptography 49.0 (installed)

SSH key typeHowIn-process?
ssh-ed25519Ed25519PublicKey.from_public_bytes(pk32).verify(sig64, signed_data)yes
ecdsa-sha2-nistp256/384/521public point → EllipticCurvePublicNumbers. The signature blob is mpint r, mpint s → utils.encode_dss_signature(r, s); verify with SHA-256/384/512 respectivelyyes
ssh-rsa key, rsa-sha2-256/512 signatureRSAPublicNumbers(e, n); PKCS1v15 with SHA-256/512yes
sk-ssh-ed25519@openssh.comkey: string pk32, string application. Signature: string sig64, byte flags, uint32 counter. Signed message = SHA256(application) ‖ flags ‖ counter ‖ SHA256(signed_data) (OpenSSH PROTOCOL.u2f). Ed25519 verify over thatyes (parsed by SSH__Public_Key, not by load_ssh_public_key)
sk-ecdsa-sha2-nistp256@openssh.comkey: string curve, string Q, string application. Signature: string ecdsa_sig(mpint r, mpint s), byte flags, uint32 counter; same message construction; ECDSA-SHA256yes
*-cert-v01@openssh.com (certificates)needs the CA in a cert-authority linedelegated to ssh-keygen (5.4.5)
ssh-dssOpenSSH no longer signs with DSArejected

sk flags: user-presence bit 0x01 is required unless the matching allowed-signers line has no-touch-required. Verify-required (0x04) is required when the line has verify-required. These are the same rules ssh-keygen applies.

5.4.4 The allowed-signers file

Format from ssh-keygen(1), ALLOWED SIGNERS section: one entry per line, principals [options] keytype base64-key [comment]. # starts a comment and blank lines are ignored.

The three outcomes, as Git produces them:

CaseStatusShown
A line matches (key, namespace, validity) and the signature verifiesgoodGood "git" signature for <principal> …
The signature verifies but no line matches the key (or there is no file)untrusted (U, Git's check-novalidate path)Good "git" signature with <TYPE> key <fp> / No principal matched.
A line matches the key but valid-after/valid-before exclude the commit timeexpired-keywith the reason key not valid at the commit time (valid-before 20261001)
The key is in the revocation filerevoked
The signature does not verify, or there is a namespace mismatchbad
A key type or a line that needs delegation, with no ssh-keygen installedcannot-checkthe reason names the missing program

How Git's exact behaviour on valid-before is reported is not fully pinned down here. ssh-keygen -Y verify fails, and Git then falls back to check-novalidate. The xver run (9.5) records what Git 2.43 prints, and the label is aligned to it before release.

5.4.5 Delegation to ssh-keygen

SSH__Sig__Verifier__Keygen is used only when one of these holds:

  1. the signature's public key is a certificate;
  2. the allowed-signers file has a cert-authority line;
  3. the revocation file is a binary KRL (magic SSHKRL\n\0).

It reproduces Git's calls exactly:

ssh-keygen -Y find-principals -f <allowed> -s <sigfile> [-Overify-time=<YYYYMMDDHHMMSS>]
ssh-keygen -Y verify -n git -f <allowed> -I <principal> -s <sigfile> [-r <revocation>] [-Overify-time=…]   (payload on stdin)
ssh-keygen -Y check-novalidate -n git -s <sigfile>                                                         (payload on stdin)

5.4.6 Time

Commit time is timestamp_ms, chosen by the committer (as Git's committer date is). valid-after/valid-before are checked against it, exactly as Git passes -Overify-time=<committer date>. 6.5 covers what that does and does not prove.

5.5 Running ssh-keygen and gpg

Signing__Command_Runner.run(argv, stdin, timeout, env):

A timeout is a failure with its own message.

| Case | argv | |---|---| | SSH, key is a private-key path | [ssh_program, '-Y', 'sign', '-n', 'git', '-f', <path>], stdout = armored SSHSIG | | SSH, key is a .pub path | same; ssh-keygen finds the private half in the agent | | SSH, literal key (key::ssh-… or ssh-…) | write the literal to .sg_vault/local/tmp/signing-<random>.pub (0600) and pass -f <that> -U. -U exists from OpenSSH 8.9; on an older ssh-keygen the call fails and the error names the version | | SSH, no key but gpg.ssh.defaultKeyCommand set | run it (shlex.split, never a shell; Git runs it through a shell, a documented difference), take the first line of output starting ssh- or key::, then as the literal case | | GPG | [gpg_program, '--status-fd=2', '-bsu', <key>], stdout = binary signature. Success requires [GNUPG:] SIG_CREATED in stderr's status lines and exit 0 |

5.6 Configuration resolution

5.6.1 Order (first set wins per key)

  1. Command flags: -S / --gpg-sign[=KEY] / --no-gpg-sign; for tags -s / -u KEY / --no-sign. A key given here also turns signing on (Git: -S<key>).
  2. The user's Git config, global then system (5.6.2): sgit.gpgSign for the on/off switch; user.signingKey, gpg.* for how.
  3. Defaults: signing off; format openpgp (Git's default) unless user.signingKey looks like an SSH key (a literal ssh- or key::, or a path ending .pub or to an OpenSSH private key). Git requires gpg.format = ssh for that; inferring it is a convenience, not a contradiction, because Git would fail on that key with openpgp. Flag D3b.

Repository-level Git config (.git/config of a repository the vault happens to sit in) is not read (D3). Vault content is never read for signing settings: no file in the working copy or the vault can change how or whether sgit signs (6.4).

5.6.2 Reading Git's config

Git__Config__Reader._read_with_git() runs these when git is on PATH:

git config --global --includes --null --list
git config --system --includes --null --list

Both run in the home directory, with Git's own environment honoured (GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM, GIT_CONFIG_NOSYSTEM, XDG_CONFIG_HOME). The output is NUL-separated key\nvalue records. Global overrides system, and within a file the last value wins.

When git is not installed, _read_files() falls back to a small INI reader:

The result is cached for the process, and sgit doctor gains a "Signing" block showing each value and its source.

5.7 OpenPGP specifics

5.7.1 Verify call (Git's)

gpg --keyid-format=long --status-fd=1 --verify <sigfile> -

The payload goes on stdin. <sigfile> is the binary signature in .sg_vault/local/tmp/ (0600, removed in finally).

5.7.2 Status parsing (GPG__Status_Parser, as Git's gpg-interface.c)

Only lines starting exactly with [GNUPG:] on the status fd are read. gpg escapes newlines in user ids, so a user id cannot inject a status line. Nothing else is read.

StatusResult
GOODSIG <keyid> <uid>candidate good
BADSIGbad
ERRSIGcannot-check (reason: missing public key, unsupported algorithm)
EXPSIGexpired-signature
EXPKEYSIGexpired-key
REVKEYSIGrevoked
VALIDSIG <fpr> …fingerprint (key_fingerprint), primary fingerprint as the last field
TRUST_UNDEFINED / TRUST_NEVERuntrusted (U)
TRUST_MARGINAL / TRUST_FULLY / TRUST_ULTIMATEgood (G), subject to 5.7.3
The principalthe <uid> from GOODSIG, unescaped (%XX), control characters removed

5.7.3 gpg.minTrustLevel

undefined | never | marginal | fully | ultimate. A good signature below it becomes untrusted. Unset, Git treats marginal and above as G and lower as U; same here.

5.8 File-by-file changes

Core changes:

FileChange
sgit_ai/schemas/Schema__Object_Commit.pyadd user_signature (5.2)
sgit_ai/schemas/Schema__Object_Tag.pyadd user_signature
sgit_ai/storage/Vault__Commit.pySIGNATURE_MEMBERS; signing_bytes and legacy_signing_bytes drop user_signature; create_commit(..., user_signer: User__Signer = None) signs with the user key first (if given), then the clone key, over the same bytes. Serialisation drops a None user_signature. If the user signer raises, create_commit raises before storing anything
sgit_ai/core/actions/tag/Vault__Sync__Tag.py_signing_bytes drops user_signature; create(..., user_signer=None); _describe adds user_signature (result dict)
sgit_ai/core/actions/verify/Vault__User_Signatures.pynew (5.3.3)
sgit_ai/core/Vault__Sync.pyverify_signatures(...) also returns user_counts / user_by_status (computed in the same walk, from commits already loaded)

Commit sites:

FileChange
sgit_ai/core/actions/commit/Vault__Sync__Commit.pycommit(...) and write_file(...) take signing: Schema__Signing_Flags = None, resolve a User__Signer, and pass it to create_commit. The ref is written only after a successful store (already so)
sgit_ai/workflow/pull/Step__Pull__Merge.pythe merge commit and the rewind re-apply commit take the signer from the workflow input (Schema__Pull__State gains signing : Schema__Signing_Flags = None)
sgit_ai/core/actions/history/Vault__Sync__History_Edit.pyrevert_commit(..., signing=None) passes it to commit()
sgit_ai/core/actions/admin/Vault__Ignore__Apply.pypasses the resolved signer (follows sgit.gpgSign)
sgit_ai/core/actions/pack/Vault__Pack_Convert.py, sgit_ai/migrations/tree_iv/Migration__Tree_IV_Determinism.pycount the source commits that had a user_signature and report user_signatures_dropped in their result dicts (7.6)

Other new classes:

FileChange
sgit_ai/crypto/signing/*.pynew (5.3)
sgit_ai/schemas/Schema__Signing_Flags.pynew: sign : Enum__Sign_Choice = None (ON/OFF/unset), key : Safe_Str__Signing_Key_Spec = None

CLI:

FileChange
sgit_ai/cli/CLI__Argv__Signing.pynew (3.3)
sgit_ai/cli/CLI__Main.pyrun() calls CLI__Argv__Signing().normalise(argv); commit/write/pull parsers get the flags (:374-388 and the write/pull parsers); output line "Signed with …"
sgit_ai/plugins/history/CLI__History.pylog and show get --show-signature (:76-105)
sgit_ai/plugins/check/CLI__Check.pyverify gets --user, --require-user-signature, --json (:23-27)
sgit_ai/cli/CLI__Vault.pycmd_check_verify (:1832-1852) prints the user line, exit codes per 3.5, JSON; _print_log (:2274-2317) passes show_signature to the inspector
sgit_ai/cli/CLI__Tag.pycreate gets -s/--sign, -u/--local-user, --no-sign (:41-47); show prints "Signed by"
sgit_ai/objects/Vault__Inspector.pyinspect_commit_chain / inspect_commit_dag (:97, :319) carry user_signature (raw schema) per commit; format_commit_log (:366-420) prints the Git-worded lines when show_signature; --json includes the result object
sgit_ai/cli/CLI__Doctor* (where doctor lives)a "Signing" block (5.6.2)

5.9 Compatibility: why no gate is needed

5.9.1 Clients from before this change

5.9.2 New clients reading old commits

user_signature is absent, so the status is none. signing_bytes and legacy_signing_bytes are unchanged for these commits, because removing an absent or null member changes nothing. The legacy path needs the explicit removal in 5.1, and a test pins it.

5.9.3 Unsigned commits

They are byte-identical to today's (the member is not written when None).

5.9.4 Two things to confirm before release

  1. The web UI. If the web UI computes the clone signing bytes from the raw stored JSON rather than from its own schema, it will now include user_signature and report a bad signature on user-signed commits. Its rule must become "remove signature and user_signature". The interop vector (5.10) makes that testable for them. Until they confirm, a user-signed commit may show as bad in the web UI. Unsigned commits are unaffected. (D7)
  2. The oldest sgit still in use. The xver run (9.5) must show that released builds (0.19.0, 0.21.0, 0.24.0, 0.26.0) ignore the unknown member.

5.10 Interop vectors to add (tests/_fixtures/interop_vectors.json)

New key commit_user_signing:

Changed: the existing commit_signing.rule text gains "and user_signature". Its bytes are unchanged, because that commit has no user signature.

OpenPGP: no vector, because the signature is not deterministic. Covered by real-gpg tests (9.4).


6. Security and threat model

6.1 What TM-R01 becomes

TM-R01 is the attack where any read-key holder with store write access mints a clone key and authors commits that check verify calls verified. After this change:

6.2 Key material never enters the vault

6.3 What vault readers and the host learn

WhoWhat they learn
Read-key holdersThe signer's public key and, through their own allowed-signers file, possibly an identity. The same as Git, where a signed commit names its key.
The host (TM-R08)It sees ciphertext only, but a signed commit is larger: SSH ed25519 ≈ +300 bytes, RSA-4096 ≈ +1.5 KB, OpenPGP ≈ +0.5–1 KB. So the host can tell which commits carry a user signature and roughly what key type. New row TM-R44.

6.4 Subprocesses (TM-R45)

6.5 Principals and time

ConcernDetail
Principal spoofing (TM-R46)The principal shown comes only from the verifier's own allowed-signers file (or gpg's uid on GOODSIG). The commit never supplies it. Control characters are removed before display (Safe_Str__Signer_Principal), so a crafted uid cannot forge output lines.
Namespace and cross-protocol replay (D1)Signatures use namespace git so existing namespaces="git" lines work, which GitHub's own instructions produce. An sgit payload is a JSON object starting with {; a Git commit payload starts with tree and a Git tag payload with object. A signature made for one cannot verify as the other, because the bytes differ.
Replay onto another sgit objectThe signed bytes contain tree_id, parents, timestamp_ms, message_enc (ciphertext), branch_id and author_key_id, and for tags the name and the commit. A signature cannot be moved to a different commit or tag: any other commit has different bytes. Copying the whole commit object is copying the commit itself. In another vault it does not decrypt (different read key), and after rekey or pack convert the bytes change.
Timestamps (TM-R47)valid-after/valid-before and GPG key expiry are judged against timestamp_ms, which the signer chooses. A holder of a key that has since expired can backdate a commit into the window. This is exactly Git's situation (it passes the committer date). Revocation (gpg.ssh.revocationFile, gpg revocation) does not depend on time and catches a known-compromised key. Accepted.
StrippingA read-key holder with write access can make a new commit without the user signature. It has a different id, so descendants do not point at it unless history is rewritten, which the rewind guard reports (TM-F12/R05). The original signed commit cannot be altered: AES-GCM plus the content address.
Downgrade--require-user-signature treats none as failure, so a gate cannot be passed by omitting the signature.
Parser hardening5.4.1 rules. GPG status lines are read only by exact prefix. Blob size is capped by the Safe type (8 KB of base64) before anything is parsed.

6.6 New threat-model rows

IDRow (short)Kind
TM-R44A user signature enlarges the commit ciphertext: the host learns which commits are user-signed and roughly the key typeaccepted, metadata (extends TM-R08)
TM-R45Signing and GPG verification run programs named by the user's global/system Git config; never by vault content or repository configaccepted, Git parity; Fixed-style tests that vault content cannot influence argv
TM-R46Trust in a user signature is the verifier's own file or keyring; the vault carries none, and verifiers may disagree; the principal is never taken from the commitaccepted by design; SGitHub later
TM-R47Key validity windows and expiry are judged at the self-asserted commit timeaccepted, Git parity
TM-R01 (edit)closed per verifier for user-signed commitsedit

7. Interactions

#AreaBehaviour
7.1commit --amendA new commit, so it is re-signed if signing is on, as Git does. The old commit's user signature is not carried, because its bytes differ.
7.2Merges (pull, branch merge)The merge commit is user-signed when signing is on (Git's merge -S, pull -S). The rewind re-apply commit (Step__Pull__Merge.py:285-301) is the clone's own work re-committed, so it is user-signed under the same switch. A signing failure aborts the merge before the ref moves, leaving the merge state as on conflict. Message: "the merge is staged; run sgit commit to sign and finish, or sgit merge-abort".
7.3history revert --as-commitThrough commit(), so signed by the same rule. A failed signature already restores the working copy (Vault__Sync__History_Edit.py:106-109).
7.4history undo / history resetNo new commits, nothing to sign.
7.5vault moveThe sentinel commits are not user-signed (D5). Commits carried into the new vault are re-encrypted and get new bytes, so their user signatures cannot survive. Move reports user_signatures_dropped, as 7.6.
7.6Pack import/export, migrateHistory is rewritten, so every user_signature is dropped. The result dict and CLI output report "N user signature(s) dropped: history was rewritten (re-sign by recommitting, as with git filter-repo)". No refusal, because the rewrite is an explicit owner action (D10).
7.7vault rekeyA new vault whose first commit is made by rekey commit through the commit path, so it is signed if the switch is on. The old history is not carried (as today).
7.8Read-only clonesVerification works: it needs only the read key and local trust. They cannot commit, so nothing is signed.
7.9Shallow and scoped clonesVerification covers the commits the clone has and stops at shallow boundaries, as verify_chain already does. Signing is unaffected, because the signed bytes are commit-level.
7.10signatures-requiredUnchanged, clone signatures only. No user-signatures-required feature (non-goal). Clone (Step__Clone__Verify_Signatures) and pull (Vault__Incoming_Check) do not look at user signatures. They would need local trust input, and Git's clone and pull do not check signatures by default either (merge.verifySignatures exists; a later opt-in sgit.verifySignatures could add it).
7.11Pack index headersNot user-signed. An index is a plan anchored to its commit (TM-R42), the commit carries the user signature, and the reader already refuses an index whose anchor does not match. A user prompt per index write would add friction and no assurance.
7.12Tagsvault tag create -s. resolve (TM-R06) still requires the clone signature to be verified, and does not require a user signature (Git's tag resolution does not verify either). vault tag show exits 1 on a bad user signature.
7.13history log --authorUnchanged by default. With --show-signature, --author also matches the principal of a good user signature (D12).
7.14dev dump, history show --jsonInclude the result object. dev dump shows user_signature.format and blob length only, never the blob.
7.15Web UISee 5.9.4 and D7.

8. Performance

OperationExpected costNotes
Sign with ssh-keygen (agent, ed25519)5–15 ms process start + agent round triponce per commit; invisible next to tree building
Sign with gpg (agent)20–60 msonce per commit
Sign in-process (ed25519 file)< 1 ms
Verify SSH in-process~0.1 ms (ed25519), ~0.3 ms (P-256), ~0.1 ms (RSA-4096 verify) + SHA-512 of ~600 bytescheck verify on 10,000 commits: about 1–2 s on top of the existing walk
Verify GPG15–40 ms per commit (one gpg process each)10,000 commits ≈ 3–7 min, so the cache matters (below)
Verify through ssh-keygen (delegated cases only)2–3 processes per commit, ~20–40 msrare: certificates and KRLs
git config read2 processes, ~5–10 ms totalonce per sgit process, cached

The cache (User__Signature__Cache, .sg_vault/local/user-signature-cache.json, 0600):


9. Test plan

All tests use real objects: real cryptography keys, real gpg where present, real git for config. No mocks. No __init__.py under tests/.

9.1 Unit: formats (tests/unit/crypto/signing/)

FileClassCases
test_SSH__Wire.pyTest_SSH__Wireuint32/string/mpint round trips; a string length past the end raises; negative mpint rejected for r/s; trailing bytes detected
test_SSH__Sig__Blob.pyTest_SSH__Sig__Blobparse the interop vector; armor/dearmor round trip (70-column wrap); wrong magic, version 2, namespace file, hash md5, oversized blob, trailing byte → each rejected with its own message; signed_data equals the vector hex
test_SSH__Public_Key.pyTest_SSH__Public_Keyed25519, nistp256/384/521, RSA 2048/4096 from generated keys (wire + OpenSSH line); fingerprints equal cryptography's OpenSSH serialisation hashed as ssh-keygen -l does (vector); sk-ed25519 and sk-ecdsa parsed from fixed wire bytes; ssh-dss rejected
test_SSH__Sig__Verifier.pyTest_SSH__Sig__Verifiersignatures from SSH__Signer__In_Process for every non-sk type verify; one flipped payload byte → bad; one flipped signature byte → bad; ssh-rsa (SHA-1) signature built by hand → bad; algorithm/key mismatch → bad; sk-ed25519 and sk-ecdsa signatures built in-test (key generated with cryptography, authenticator data assembled per PROTOCOL.u2f) verify; with the presence flag clear → bad, unless the line has no-touch-required → good
test_SSH__Allowed_Signers.pyTest_SSH__Allowed_Signersprincipal patterns (*, ?, ! negation, comma lists); namespaces="git" / "file"; valid-after/valid-before with and without Z, date-only and full forms; comments and blank lines; a quoted option with a comma; two lines with the same key, so two principals; cert-authority detected as delegation
test_SSH__Revocation_List.pyTest_SSH__Revocation_Listplain public-key lines; KRL magic detected
test_GPG__Status_Parser.pyTest_GPG__Status_Parserreal status transcripts captured from gpg 2.x (fixtures) for every row of 5.7.2; a uid containing %0A[GNUPG:] GOODSIG stays one uid; non-[GNUPG:] lines ignored
test_Git__Config__Reader.pyTest_Git__Config__Readerwith real git and GIT_CONFIG_GLOBAL=<tmp file> and GIT_CONFIG_NOSYSTEM=1: values, last-wins, booleans, include.path; the fallback parser on the same file (git hidden by an empty PATH dir) gives the same values except includes, with a warning
test_Signing__Config__Resolver.pyTest_Signing__Config__Resolverflag beats config; -S<key> turns signing on; --no-gpg-sign beats sgit.gpgSign; commit.gpgSign=true alone does not sign (D2); format inference from key spec; x509 refused by name
test_CLI__Argv__Signing.pyTest_CLI__Argv__Signing-S msg keeps msg as the message; -Skey, --gpg-sign=key; tokens after -- untouched; other commands untouched

9.2 Unit: schema, bytes and compatibility (tests/unit/storage/, tests/unit/schemas/)

FileClassCases
test_Schema__User_Signature.pyTest_Schema__User_Signatureround trip (from_json(obj.json()).json() == obj.json()); blob regex and 8 KB cap
test_Vault__Commit__User_Signature.pyTest_Vault__Commit__User_Signaturesigning_bytes with and without user_signature are equal; legacy_signing_bytes likewise (pins 5.1's legacy fix); an unsigned commit's stored JSON has no user_signature key and equals the pre-change bytes for the same inputs; a signed commit verifies its clone signature, its user signature, and both independently; a user-signer that raises leaves no object in the store
test_Vault__Commit__Old_Client_Compat.pyTest_Vault__Commit__Old_Client_Compattests/_fixtures/schemas_v0_26_0/Schema__Object_Commit.py is a frozen copy taken from git show v0.26.0:…, loaded by path in the test. Cases: it parses a user-signed commit; its json() lacks the member; signing_bytes over its json() verifies the clone signature. Same for tags.
test_Interop__Vectors.py (extended)existing classthe new commit_user_signing vector: bytes, SSHSIG signed data, blob, verify; the in-process signer reproduces the ed25519 blob byte for byte

9.3 SSH without ssh-keygen in this container

9.4 GPG (installed here)

tests/unit/crypto/signing/test_GPG__Signer_Verifier.py, class Test_GPG__Signer_Verifier:

9.5 Cross-version (xver)

  1. A new build creates a user-signed commit and tag.
  2. Released sgit-ai 0.19.0, 0.21.0, 0.24.0 and 0.26.0 each clone, pull, run check verify, history log and vault tag show, and make a commit on top.

9.6 End to end on the in-memory API (tests/unit/sync/test_User_Signing__End_To_End.py)

Class Test_User_Signing__End_To_End, two clones over Vault__API__In_Memory:

9.7 Security (tests/security/)

FileCase
test_Security__Fixed__Signing_Config.pyA vault whose content contains .gitconfig, .git/config, gpg.conf and allowed_signers at its root and in subfolders: none is read. argv is identical to that of an empty vault, checked by recording the argv in Signing__Command_Runner through a real subclass that writes the argv to a list, not a mock. Also: no shell=True anywhere under sgit_ai/crypto/signing/ (AST scan).
test_Security__Fixed__User_Signature_Replay.pyA signature copied from commit X onto commit Y → bad; copied from a commit onto a tag with the same timestamp → bad; namespace file → bad; a Git-format commit payload signed with namespace git does not verify as any sgit object.
test_Security__Known_Gaps.py (rows added)TM-R44 (the signed commit is larger), TM-R46 (two verifiers with different files disagree), TM-R47 (a backdated commit inside valid-before verifies good).

9.8 Acceptance checklist (implementation and deployment)

Format

Compatibility

Signing

Verifying

Security and release


10. Implementation steps (ordered commits)

#CommitContentsEstimate
1feat(signing): user_signature member, excluded from both signatures' bytesSafe types, Schema__User_Signature, commit/tag field, SIGNATURE_MEMBERS, legacy fix, omit-when-None, old-schema fixture and compat tests, interop vector skeleton0.5 d
2feat(signing): SSHSIG format and in-process verifierSSH__Wire, SSH__Sig__Blob, SSH__Public_Key (incl. sk), SSH__Allowed_Signers, SSH__Revocation_List, SSH__Sig__Verifier, SSH__Signer__In_Process, the OpenSSH-produced vector1 d
3feat(signing): Git config, ssh-keygen and gpgSigning__Command_Runner, Git__Config__Reader, Signing__Config__Resolver, SSH__Signer, GPG__Signer, GPG__Status_Parser, GPG__Verifier, SSH__Sig__Verifier__Keygen, User__Signer, User__Signature__Verifier, cache1 d
4feat(signing): sign commits, merges, writes, reverts and tagscreate_commit(user_signer=), commit/write/pull/merge/revert/ignore/tag wiring, CLI__Argv__Signing, flags, failure paths0.5 d
5feat(signing): show and check user signaturesVault__User_Signatures, history log/show --show-signature, check verify summary/exit/--json, vault tag show, doctor block, rewrite counts (7.6)0.5 d
6test+docs(signing): security tests, threat model, xver, docs9.7, TM rows, xver run, CHANGELOG, README0.5 d

Total about 4 days. Each commit leaves pytest tests/unit -n auto and pytest tests/security green.


11. Open decisions for Dinis

#DecisionRecommendation
D1SSH namespace: git (works with existing namespaces="git" allowed-signers lines, including the ones GitHub's instructions create) or sgit (separate domain)git. Payload formats cannot collide (6.5), and reuse of the user's Git setup is the point.
D2Automatic switch: honour Git's commit.gpgSign / tag.gpgSign directly, or require sgit's own sgit.gpgSign (set with git config --global sgit.gpgSign true)sgit.gpgSign. Otherwise every machine with commit.gpgSign=true starts signing sgit commits on upgrade, and agents without an unlocked agent start failing. It keeps the extension rule (off by default) and needs no sgit config command.
D3Read repository-level Git config when the vault sits inside a Git repositoryNo: global and system only, so nothing next to the vault can change signing.
D3bInfer gpg.format=ssh from an SSH-looking user.signingKey when gpg.format is unsetYes, with a one-line note in doctor.
D4In-process signing with an unencrypted key file when ssh-keygen is absentYes: Windows and minimal containers, and the same class the tests use. Encrypted keys still need ssh-keygen or the agent.
D5User-sign vault move sentinel commitsNo: automated commits mid-workflow; the clone signature covers them.
D6check verify exit code when user signatures are untrusted0 (Git parity); --require-user-signature is the strict gate.
D7Release before the web UI confirms its signing-bytes ruleHold the release of signing (verification can ship) until the web UI confirms, or ship with a release-note line. Unsigned commits are unaffected either way.
D8X.509 (gpgsm)Not in v1.
D9SSH certificates and KRLsDelegate to ssh-keygen when installed; otherwise cannot-check.
D10History rewrites drop user signaturesReport the count and proceed (Git filter-repo behaviour); no refusal.
D11Extend the secret guard to OpenSSH/OpenPGP private keysYes, as a small separate change.
D12history log --author matches the signer principal when --show-signature is givenYes; cheap with the cache.

This page as markdown · its source, admin/content/research/git-parity-design-signing.md