Repository navigation
Enhanced user (identity) management #1516
Replies: 2 comments 2 replies
|
@MichaelMure @sudoforge - I have some of the code done for this as part of my agent-centric (but human-compatible) Short rationale: WRT this comment in #1391, I'm pretty agnostic to the structure of the commands but will comment more fully there. This work can be completed using the current |
|
A few comments:
|
Uh oh!
There was an error while loading. Please reload this page.
git-bug User Management Design Document
Problem Statement
Currently, git-bug requires creating a new identity on every machine. There is no mechanism to:
GenerateKey()function inentities/identity/key.gois not exposed via CLI).This document details the changes needed to the CLI commands in
commands/user/and the key infrastructure inentities/identity/key.goto support these workflows.Key Portability Model
Private keys are stored in the git-bug keyring, which defaults to
~/.config/git-bug/keyring/on Linux (an encrypted file-backend provided bygithub.com/99designs/keyring). git-bug does not provide commands to export private keys from the keyring. Instead, users are expected to copy the keyring files between machines directly, or manage them with a dotfile manager such as chezmoi. This keeps key material handling entirely outside of git-bug and avoids git-bug becoming a vector for accidental key exposure.Each keyring item is a separate file within the keyring directory, keyed by the key's fingerprint. This file-per-key layout is what makes external management (chezmoi, rsync, etc.) practical — individual key files can be selectively copied to specific machines without moving the entire keyring.
Human Users
Workflows
Workflow A — Portable Identity (same key, multiple machines)
git bug user new --generate-key— creates an identity with a key pair; the private key is stored in the local keyring.~/.config/git-bug/keyring/from Machine A to Machine B (directly, or via chezmoi, rsync, etc.).git bug user --remote— list remote identities to find the target identity's ID.git bug user adopt --remote <USER_ID>— adopts the remote identity after verifying that the matching private key exists in the local keyring.Workflow B — Adopt Identity from a Different Project
git bug user --url https://github.com/org/project-a.git— list identities from project A's repository to find the target ID.git bug user adopt --url https://github.com/org/project-a.git <USER_ID>— fetches and adopts the identity after verifying the private key is in the local keyring.Workflow C — External PGP Sub-Key
gpg --add-key).git bug user key import USER_ID --file subkey.asc— imports the sub-key's private key into the git-bug keyring for the specified identity and (if needed) adds the corresponding public key to that identity.Command Changes
New:
--remoteand--urlflags ongit bug user(list identities) —commands/user/user.goAdd a new
--remoteboolean flag. When set, the command fetches identity refs from all configured remotes into an in-memory store and lists them instead of local identities.Add a new
--urlstring flag. When set, the command fetches identity refs from the specified git URL into an in-memory store and lists them. This allows browsing identities from any git-bug repository without configuring a remote.--urland--remoteare mutually exclusive —--urltargets a single explicit URL, while--remotetargets all configured remotes.[local]) next to identities that also exist locally, so the user can see which ones are candidates for adoption.New:
--remoteand--urlflags ongit bug user show [USER_ID]—commands/user/user_show.goAdd a new
--remoteboolean flag. When set, resolve the identity from remote refs (viaidentity.ReadRemote) instead of the local cache.Add a new
--urlstring flag. When set, fetch identity refs from the specified git URL and resolve the identity from that store. Mutually exclusive with--remote.Additional changes (regardless of
--remote):"keys"entry to the--fieldflag's valid values. When selected, list the public key fingerprints associated with the identity.--field) output, add aKeys:section showing key fingerprints and whether the corresponding private key is available in the local keyring.git bug user new—commands/user/user_new.goAdd a new
--generate-key(-k) boolean flag. When set:identity.GenerateKey().NewRawcall on line 70 currently passesnilfor keys — pass[]*identity.Key{key}instead).key.StorePrivate(repo).Implementation sketch (in
runUserNew):New:
--remoteand--urlflags ongit bug user adopt USER_ID—commands/user/user_adopt.goAdd a new
--remoteboolean flag and a new--urlstring flag. These are mutually exclusive. When either is set, the adoption flow changes to:--remote: from all configured remotes.--url <git-url>: from the specified git URL. This allows adopting an identity from any git-bug repository without configuring a remote — e.g. adopting your identity from a different project.key.EnsurePrivateKey(repo). If no key yields a private key, abort with an error:repo.CopyRefto create a local ref from the remote ref). Copy referenced commit object, tree and blobs if using--url, otherwise fetch one remote identity.env.Backend.SetUserIdentity.When neither
--remotenor--urlis set, keep the existing behavior: resolve from the local cache and adopt directly (no key check required, since local identities may predate key support).New:
git bug user keysubcommand treeAdd a new subcommand group under
git bug user:git bug user key USER_ID(list)git bug user key generate USER_IDidentity.GenerateKey().Identity.Mutate()).key.StorePrivate(repo).git bug user key import USER_IDArguments:
USER_ID— required human ID or full ID of the target identity (as shown bygit bug userorgit bug user show).Flags:
--file/-i— input file path containing an armored PGP private key (required).Behavior:
USER_IDprefix (same resolution logic asgit bug user show).key.StorePrivate(repo).git bug user key revoke USERID FINGERPRINTArguments:
USER_ID— required human ID or full ID of the target identity (as shown bygit bug userorgit bug user show).FINGERPRINT— the fingerprint of the key to revoke (as shown bygit bug user key).Behavior:
ValidKeysAtTime()returns keys based on the lamport time of each version, so signatures made before the revocation version continue to verify.For delegated sub-keys (Entity-based keys), this command adds a PGP sub-key revocation signature to the Entity (via
entity.RevokeSubkey()) rather than removing the sub-key, preserving the cryptographic audit trail. See the Delegated Users — Revocation section for details.Changes to
entities/identity/key.goExport currently-private methods
The following methods are currently unexported and need to be exported for use by the command layer:
storePrivate(repo)StorePrivate(repo RepoKeyring)loadPrivate(repo)LoadPrivate(repo RepoKeyring)ensurePrivateKey(repo)EnsurePrivateKey(repo RepoKeyring)user adopt --remoteneeds to verify private key presenceExport error sentinel
Export
errNoPrivateKeyasErrNoPrivateKeyso command-layer code can check for it:New functions
KeyFromPrivateArmored(armoredData []byte) (*Key, error)Parse an armored PGP private key block and return a
Keywith both private and public fields populated. This supports thekey importworkflow.(k *Key) Fingerprint() stringReturn a human-readable fingerprint for display in CLI output.
(k *Key) KeyIdString() stringExpose the public key's ID string (currently only used internally via
k.public.KeyIdString()).(k *Key) HasPrivate() boolCheck whether the key has a private component loaded in memory.
(k *Key) MatchesPublic(other *Key) boolCompare two keys by their public key ID. Used during import to verify the imported private key matches a public key in the identity.
Changes to
entities/identity/identity.goFindKeyByFingerprintAdd a helper to find a key in the identity's current key set by fingerprint:
AddKeyConvenience method that wraps
Mutateto append a key:RevokeKeyRemove a key from the active key set by creating a new identity version without it. The key remains in prior versions, so
ValidKeysAtTime()continues to return it for lamport times before the revocation:Interaction between revocation and
ValidKeysAtTimeValidKeysAtTime()(inidentity.go) already walks identity versions in order and returns the key set from the latest version whose lamport time is ≤ the queried time. This means:No changes to
ValidKeysAtTimeare needed — the existing version-walk semantics naturally support time-scoped revocation.File Layout for New Commands
Security Considerations
user adopt --remotecommand refuses to proceed unless a matching private key is already present in the local keyring, preventing unauthorized identity claims.key importcommand validates that the imported file is a well-formed armored PGP private key and that its public component matches (or can be added to) the identity before storing anything."git-bug"inrepository/keyring.go). This is a known limitation noted in the existing code. Improving keyring security is out of scope for this change.Testing Strategy
entities/identity/key_test.go: Add tests forKeyFromPrivateArmored,Fingerprint,MatchesPublic,HasPrivate, and round-trip generate/store/load.commands/user/: Add integration tests for:user new --generate-key— verify identity is created with a key and private key is in keyring.user key generate— verify key is added to existing identity.user key import— verify imported key is stored and matched.user key revoke— verify key is removed from active set, prior signatures still verify, post-revocation signatures fail.user adopt --remote— verify adoption succeeds when private key is present, and fails with a clear error when it is not.user adopt --url— verify adoption from an arbitrary git URL works, including cross-project adoption.user --remote,user --url,user show --remote,user show --url— verify remote/URL identity listing/display.commands/user/remote_test.go: Extend existing remote tests to cover the adoption flow with and without private keys.repository.NewMockRepoKeyring()for all keyring-dependent tests.Summary of Changes
entities/identity/key.goStorePrivate,LoadPrivate,EnsurePrivateKey,ErrNoPrivateKey; addKeyFromPrivateArmored,Fingerprint,KeyIdString,HasPrivate,MatchesPublicentities/identity/identity.goFindKeyByFingerprint,AddKey,RevokeKeycommands/user/user.go--remoteand--urlflags, show remote source and local presence markercommands/user/user_show.go--remoteand--urlflags, addkeysfield, show key info in default outputcommands/user/user_new.go--generate-keyflag, pass generated key toNewRaw, store private keycommands/user/user_adopt.go--remoteand--urlflags: fetch remote identity (from configured remotes or arbitrary URL), require private key in keyring, merge to local, set as usercommands/user/user_key*.go(new)key(list),key generate,key import,key revokeDelegated Users
Overview
A human identity can delegate signing authority to an agent identity (e.g. a CI bot, an AI coding agent) by creating a PGP signing sub-key bound to the human's primary key. The agent signs operations with the sub-key; verifiers check the signature against the human's full public key (which includes the sub-key and its binding signature), establishing a cryptographic chain of trust from agent back to human.
Delegation Workflow
identity.CreateSigningSubkey()inkey.go(not exposed via CLI — this is a library operation invoked programmatically or by future tooling)."delegated-from": "<human-identity-id>". This makes the delegation relationship explicit and queryable.Keyring Layout for Delegation
The file-per-key keyring model supports delegation naturally:
On the human's machine, both files are present. On the agent's machine, only the sub-key file is present. The agent does not need (and should not have) the human's primary private key.
Changes to
entities/identity/key.go— Entity-Based Key ModelThe current
Keystruct stores barepacket.PublicKey/packet.PrivateKeypackets, which cannot represent the primary-to-sub-key binding relationship. Supporting delegation requires moving to a model based onopenpgp.Entity, which natively carries primary keys, sub-keys, binding signatures, and revocations.Rework the
KeystructReplace the internal representation to use
*openpgp.Entityas the authoritative data:The
Entitycarries:PrimaryKey/PrivateKey— the primary key pairSubkeys []openpgp.Subkey— sub-keys with their binding signatures (Subkey.Sig)Identities— UID packets with self-signaturesAccessors like
Public(),Private(),PGPEntity()are updated to delegate to the internal Entity.PGPEntity()becomes a trivial accessor instead of fabricating a synthetic Entity (which is the current behavior and is incompatible with delegation since it generates new self-signatures requiring the primary private key).Update public key serialization (JSON / Git)
MarshalJSONmust serialize the full public Entity (primary + sub-keys + binding signatures + UIDs) usingentity.Serialize(w)into a standardPGP PUBLIC KEY BLOCK. This replaces the current single-packet serialization.UnmarshalJSONmust read back the full Entity usingopenpgp.ReadKeyRing(after armor decode). Backward compatibility: if the armored block decodes to a lonepacket.PublicKey(legacy format), wrap it in a minimal Entity for read-only use (no delegation support for legacy keys).Update private key serialization (keyring)
Keep the existing one-item-per-key approach, but store each private key (primary and sub-keys) as separate keyring items keyed by fingerprint (not
KeyIdString(), which is a 64-bit key ID and risks collisions):<primary-fingerprint>PGP PRIVATE KEY BLOCKcontaining the primarypacket.PrivateKey<subkey-fingerprint>PGP PRIVATE KEY BLOCKcontaining the sub-keypacket.PrivateKeyThis is a change from the current scheme which uses
key.public.KeyIdString()as the keyring item key. Existing keyring items will need migration or dual-lookup during a transition period.Loading private material works by:
entity.PrivateKeyorentity.Subkeys[i].PrivateKey.New function:
CreateSigningSubkeyCreates a signing sub-key on the Key's Entity using
entity.AddSigningSubkey(config). Returns a newKeyrepresenting the sub-key (with its private material) for storage in the keyring. The calling code is responsible for:StorePrivate.This function is not exposed via CLI. It is a library operation for programmatic use.
Update
ensurePrivateKey/SigningKeyensurePrivateKeymust iterate all keys in the Entity (primary + sub-keys) and attempt to load private material by fingerprint. It should succeed if any signing-capable private key is found.Identity.SigningKey()should prefer a signing-capable sub-key over the primary key (standard OpenPGP practice), falling back to the primary if no sub-key private material is available.Update
ValidateandCloneValidate()must check that sub-keys have valid binding signatures issued by the primary key, and that signing sub-keys haveKeyFlagSignset in their binding signature.Clone()must deep-copy the full Entity including sub-keys and binding signatures.Verification Path
The verification path in
entity/dag/operation_pack.gobuilds anopenpgp.EntityListfromPGPEntity()calls and passes it toopenpgp.CheckDetachedSignature. SinceCheckDetachedSignaturenatively understands sub-keys and binding signatures, no changes are needed to the verification code — as long asPGPEntity()returns a complete Entity with sub-keys, signatures made by sub-keys will verify automatically.Revocation
Revocation must work for both human primary keys and delegated sub-keys, and must never invalidate signatures made before the revocation. git-bug uses two complementary mechanisms to achieve this:
Mechanism 1 — Identity version history (lamport-scoped)
ValidKeysAtTime()walks identity versions and returns the key set from the latest version whose lamport time is ≤ the queried time. When a key is revoked by publishing a new identity version (with the key removed or marked revoked), signatures made at earlier lamport times still verify because the earlier version's key set is used. This mechanism works identically for human primary keys and delegated sub-keys.Mechanism 2 — PGP sub-key revocation signatures (Entity-scoped)
For Entity-based keys (delegated sub-keys), revocation also produces a PGP sub-key revocation signature via
entity.RevokeSubkey(). This preserves the full cryptographic audit trail within the Entity:gpg --verifycan still verify historical signatures).openpgp.CheckDetachedSignaturerespects revocation signatures when checking theSubkey.Revocationslist.Revoking a human primary key
git bug user key revoke <FINGERPRINT>.ValidKeysAtTime()returns it for lamport times before the revocation.Revoking a delegated sub-key
git bug user key revoke <SUBKEY_FINGERPRINT>.entity.RevokeSubkey()).ValidKeysAtTime()excludes the revoked sub-key from the valid set for lamport times at or after the revocation version.gpg,git verify-commit) also see the revocation and reject new signatures from the sub-key, while still being able to verify historical ones (the sub-key packet and binding signature are preserved).Backward Compatibility
formatVersioninversion.go(currently2; bump to3).KeyIdString()(old)Summary of Delegated User Changes
entities/identity/key.go*openpgp.Entity; update serialization to full Entity format; change keyring item key from KeyIdString to fingerprint; addCreateSigningSubkeyentities/identity/key.goValidate()checks sub-key binding signatures and key flags;Clone()deep-copies full Entityentities/identity/identity.goSigningKey()prefers sub-keys;ensurePrivateKeyloads by fingerprint across primary + sub-keysentities/identity/key.go,identity.goRevokeKeyremoves keys from active set via new version; sub-key revocation adds PGP revocation signature viaentity.RevokeSubkey();ValidKeysAtTime()semantics ensure pre-revocation signatures remain validentity/dag/operation_pack.goCheckDetachedSignaturehandles sub-keys and revocations nativelyentities/identity/version.goformatVersionto3for identities using Entity-based keysentities/identity/identity.go"delegated-from"metadata referencing the human identity IDAll reactions