GPG Verification in a Web App

rubysecuritytoolingbuilding-in-public

One of the reasons we’re building Codex instead of using someone else’s git hosting is GPG enforcement. Not the “show a pretty badge if a commit happens to be signed” kind, but the “require signatures on protected branches and verify them against uploaded keys” kind.

Getting this right involved three pieces: key management, commit verification, and server-side signing.

Key upload and challenge verification

A user uploads their armored GPG public key through the UI. Before we trust it, we need to verify two things: that the key’s email matches the user’s email in Codex, and that the user actually controls the corresponding private key.

The email check is straightforward. gpg --with-colons --import-options show-only parses the key without importing it and returns the UIDs. At least one UID must match the user’s email.

The private key challenge is more interesting. We generate a random challenge token (UUID + timestamp) and present it to the user. They sign the token with their private key using gpg --sign, paste the signed message back, and we verify it. We import the public key into a temporary keyring, verify the signature, and check that the fingerprint matches. If it all checks out, the key is marked as verified. If not, the key is deleted from the keyring.

The challenge has a 15-minute expiry. This prevents someone from uploading a public key and then taking their time to steal the corresponding private key from the actual owner.

Commit signature verification

When someone pushes commits, the post-receive hook enqueues a VerifyCommitSignatureJob for each new commit. The job uses Rugged to extract the detached GPG signature and signed data from the raw commit object, then verifies the signature against our keyring.

The result goes into a CommitMetadata record: signature status (verified, unverified, no signature), the key fingerprint, and the verification timestamp. If the fingerprint matches a verified key owned by a Codex user, we associate the commit with that user. The UI shows a badge: green shield for verified, gray for unsigned, yellow for signed but unverifiable.

Key re-verification

Here’s a subtlety: when someone uploads a new GPG key, we don’t just verify future commits. We go back and re-check existing commits whose signatures match the new key’s fingerprint. A ReverifyCommitSignaturesJob runs through the commit history and updates any matching CommitMetadata records.

This means commits that were previously “signed but unverifiable” (because the key wasn’t in our system yet) retroactively get verified status. It’s a small thing that makes the verification badges trustworthy. They reflect current key state, not just the state at push time.

Server-side signing for merge commits

This is the part I didn’t expect to need. When Codex creates a merge commit (or a squash commit), that commit needs to be signed too. Otherwise you have a verified chain of developer commits followed by an unsigned merge commit, which undermines the whole signing story.

Codex has its own GPG key, a 4096-bit RSA key auto-generated on first use. The SignedCommitBuilder constructs the raw commit object (tree, parents, author, committer, message), signs it with the Codex key using gpg --detach-sign, and creates the commit via Rugged’s create_with_signature. Merge commits show up with a “Codex” badge indicating they were signed by the server.

The key is stored in a keyring at a configurable path and never leaves the server. The public key is available in the UI so team members can add it to their local keyrings for offline verification.

Enforcement via push policies

The verification system is passive. It checks and reports, but doesn’t block. Enforcement is a separate concern, handled by the push policy system. The required_signed_commits policy rejects any push containing an unsigned commit. You can scope it to specific branches: require signatures on main and release/* but allow unsigned commits on feature branches.

This separation matters because verification and enforcement have different failure modes. Verification is best-effort: a commit with an unknown key gets “unverifiable” status, which is informational. Enforcement is pass/fail: a push is either accepted or rejected. Keeping them separate means you can start with verification only (see who’s signing, identify gaps) and enable enforcement once the team is ready.