Skip to content

Gpg signing support - #34

Merged
emostov merged 3 commits into
mainfrom
zeke/akshar-gpg-signing-v2
Sep 14, 2026
Merged

emostov merged 3 commits into
mainfrom
zeke/akshar-gpg-signing-v2

Conversation

@emostov

@emostov emostov commented Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator

Adds tk gpg, which signs Git commits and tags with an OpenPGP key whose private key is a Turnkey wallet account. Git calls tk as its gpg.program; the private key never leaves Turnkey.

tk gpg keys create --wallet-id <uuid> --user-id "Your Name <you@example.com>"
tk gpg keys export | gpg --import
git config --global gpg.program tk
git config --global user.signingkey <FINGERPRINT>
git commit -S -m test && git verify-commit HEAD

Also: tk gpg keys add | remove | list, tk gpg sign <file>, and the git shim. Full setup is in docs/gpg-signing.md.

Design decisions

Keys are wallet accounts, not private keys. Each OpenPGP key is one P-256 wallet account with an uncompressed address at BIP-32 path m/5261136'/0'/<index>'/0' (0x504750, ASCII "PGP"). The account name holds the OpenPGP user ID. Nothing else is stored server-side: the wallet listing is the source of truth, keys list --wallet-id reads it back, and keys add re-registers a key from it. Accounts in the namespace that this feature could not have made (wrong curve, unnamed, name that is not a user ID) are skipped but still occupy their index.

The fingerprint is derived, never stored as truth. An OpenPGP v4 fingerprint is SHA-1 over the public key packet, which is a function of the point and the key creation time. We use the wallet account's createdAt as the OpenPGP creation time, so the fingerprint is stable across machines and re-exports and needs no clock. The registry keys entries by fingerprint but re-derives it on read and rejects an entry whose point and time do not produce its key, so a registry file cannot make one key impersonate another.

Export is byte-stable. The self-signature over the user ID uses the key's creation time, fixed algorithm preferences, and no clock read, so keys export yields identical bytes every time. This lets users re-import or paste the key into GitHub at any point without a "which export was it" problem. Detached signatures are the only place the clock is read.

Our own RFC 4880 byte layer instead of a PGP crate. The auth crate gains a small openpgp module: new-format packets, MPIs, subpackets, armor with CRC-24, v4 public key packets, and v4 signature packets. We only need to produce a public key block and detached signatures for one algorithm (ECDSA P-256, SHA-256), and signing is delegated to Turnkey through a SignDigest trait. Pulling in sequoia or rpgp would add a large dependency surface and a private-key model we do not use. The fingerprint computation is checked in tests against an independent hand computation and gpg --list-packets, and the e2e suite has GnuPG verify our output.

Signing needs one Turnkey request and no wallet read. The registered entry carries the point, creation time, and user ID, so sign and export go straight to sign_raw_payload with HashFunction::NoOp on the OpenPGP digest. This matters for the git shim, which runs on every commit.

A key's organization selects the credential. Keys are stored in the identity registry, shared by every profile, because a key belongs to an organization rather than to a profile. When signing, resolve_for_organization uses an explicit identity (--profile, TK_PROFILE, the TURNKEY_* bundle, or --organization-id) if given and rejects it with OrganizationMismatch if it belongs to another organization. Otherwise it searches profiles for that organization: the only one, or the active profile when several match. So git commit works with no tk flags after a tk login, and never silently signs through the wrong org.

The git shim is its own output boundary. main.rs detects gpg-shaped arguments (--status-fd, --keyid-format, -bsau, -bsa) before clap and hands them to gpg::shim. Its stdout, stderr, and exit code are gpg's contract: the armored signature on stdout, [GNUPG:] BEGIN_SIGNING and SIG_CREATED lines on fd 2, errors as one line. Anything that is not a signing call, such as --verify, is exec'd to the real gpg (or TK_GPG_PROGRAM) without loading config or credentials. Only descriptor 2 is accepted for --status-fd, since stdout carries the signature.

Selecting a key is one code path. registry::select resolves an optional KeyName against any set of keys, whether the registry or a wallet, with a Scope that fixes the wording of the resulting SelectError. Git's user.signingkey (a fingerprint suffix of at least a long key ID) and the committer ident (an exact user ID) both parse into KeyName. A name that matches nothing fails rather than falling back to the only key, and an empty set is reported before any comparison so the remedy can be "create one". Remediation text is added at the command layer; the shim rewords it for git users, who can set user.signingkey but not pass tk flags.

Parsing at the boundary. UserId rejects empty values and NUL/CR/LF at clap parse time; SigningKeyName normalizes GnuPG's spacing and trailing ! and requires 16+ hex chars; Fingerprint and UncompressedPoint carry proof of shape. Downstream code takes these types rather than re-checking strings.

Tests

  • Unit tests cover the fingerprint against an independent computation, armor and packet framing, and malformed API responses the live API cannot produce (bad P-256 address, missing or non-numeric createdAt, bad signature scalars, a wallet listing whose cursor never advances).
  • Non-ignored tests in the e2e binary run the real binary locally: shim passthrough to a stand-in gpg, missing program, empty registry, tampered registry entry, and organization mismatch.
  • Ignored e2e tests run against the API: create, list, export (byte-stable), sign, remove, add, profile selection by organization, and a full git commit -S / git verify-commit round trip through the shim with GnuPG verifying the result.

@emostov
emostov marked this pull request as draft September 12, 2026 03:30
@emostov emostov changed the title Trim the gpg feature to e2e-covered tests and flatter framing code Edits to initial gpg code Sep 12, 2026
@emostov
emostov force-pushed the zeke/akshar-gpg-signing-v2 branch from c902559 to 696b106 Compare September 12, 2026 04:04
@emostov
emostov changed the base branch from akshar/gpg-signing-v2 to main September 12, 2026 04:06
@emostov emostov changed the title Edits to initial gpg code Gpg signing support Sep 12, 2026
@socket-security

socket-security Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedsha1@​0.10.710010093100100

View full report

@emostov
emostov force-pushed the zeke/akshar-gpg-signing-v2 branch 6 times, most recently from 4f1c7bf to ae6abc6 Compare September 13, 2026 21:42
@emostov
emostov force-pushed the zeke/akshar-gpg-signing-v2 branch from ae6abc6 to e2d057b Compare September 14, 2026 02:16
@emostov
emostov force-pushed the zeke/akshar-gpg-signing-v2 branch from 72dd10e to 1087e98 Compare September 14, 2026 02:38
@emostov
emostov marked this pull request as ready for review September 14, 2026 02:39
@emostov
emostov merged commit 9dcc9c0 into main Sep 14, 2026
7 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.

1 participant