Skip to content

The msvcrt.zip Tangled Setup

For a long time, I've signed all my git commits with my GPG key. There's some problems with this, and it doesn't provide as much verification as it seems like it should, but I've done it.

Recently (well, recently enough) I started contributing to Gentoo GURU, which requires not only signed commits, but signed pushes. It has a custom gitlite setup that requires signed pushes and transparently logs the push certificates.

And I do the same thing now, on Tangled.

How?

Well, Tangled lets you run your own knotserver to host your own repositories (still accessible through their appview, unless you also run your own), and it turns out that the repository storage is just... git repos. Bare repos, so the actual files aren't present on the filesystem (directly), but they're just git repos, and you can do whatever you would like with them.

I initially got this all set up with gitolite, so I had the hooks already, and they just work with Tangled (because of course they do, they're just git hooks, and it's just git repos). It's overall not that complicated of a setup, but it feels a little cursed, and I want to share the cursed knowledge :)

Git Config

You need to set up a couple things in your git config, per-repo or globally. It's a lot easier to set this up globally, and that's what I've done:

  1. receive.advertisepushoptions should be enabled to allow clients to send signed pushes.
  2. receive.certnonceseed should be a high-entropy random string so that git can securely generate nonces for each push.
  3. You'll want your git user on the server to have its own name and email since it will be authoring commits.

This can be done in /etc/gitconfig or in ~/.gitconfig. Oddly enough, I have it split across both...

The GPG Wrapper

Since a lot of this requires actually validating GPG signatures, we need a good way to manage a GPG key database. I came up with this delightful little script at some point, that just wraps GPG and uses a custom key database, which you use by adding it to the .gitconfig of the user running your knotserver:

#!/bin/sh
export GNUPGHOME=/opt/git/.utils
exec gpg --no-default-keyring --keyring /opt/git/.utils/gitolite.gpg "$@"

You should be able to import keys into this keyring pretty easily just by using the wrapper like you would GPG. You might want to change the keyring's name, as the one in this script is a holdover from when I was doing this with gitolite.

Requiring signed commits (pre-receive hook)

This is the easier hook of the three I'll be showing. All it does is go through every commit that's pushed and reject it if the signature isn't valid. Because this uses git-verify-commit, it should also reject signatures that aren't in the keyring; if this is a problem for you, then you may want to adjust how this works.

#!/bin/bash

#########################
# require-signed-commit #
#########################
# author:      demize
# description: require signed commits

while read old_sha new_sha ref; do
  if [ "${old_sha}" = "0000000000000000000000000000000000000000" ]; then
    # if it's the first commit, do the whole history /shrug
    REV_RANGE="${new_sha}"
  else
    REV_RANGE="${old_sha}..${new_sha}"
  fi
  for commit in $(git rev-list --reverse "${REV_RANGE}"); do
    if ! git verify-commit $commit >/dev/null 2>&1; then
      echo "Rejecting unsigned commit: ${commit}"
      exit 1
    fi
  done
done

The first check makes sure that it isn't checking the signature on the root commit (which should be unsigned and empty), and if that's the old_sha passed to the hook by git, then it specifies a single commit to rev-list, which will get turned into a list of every commit on the branch.

Requiring signed pushes (pre-receive hook)

This works similarly to the last one, but git itself handles a lot of the verification before it invokes the hook, so it's actually totally different:

#!/bin/bash

#######################
# require-signed-push #
#######################
# author:      demize
# description: require signed pushes

# important so nobody can mess with the transparency logs
while read old_sha new_sha ref; do
  if [ "${ref,,}" = "refs/meta/push-certs" ]; then
    echo "Rejecting push that updates certificate log"
    exit 1
  fi
done

if [ "$GIT_PUSH_CERT_STATUS" != "G" ]; then
  echo "Rejecting unsigned push"
  exit 1
fi

if [ "$GIT_PUSH_CERT_NONCE_STATUS" != "OK" ]; then
  echo "Rejecting push with bad nonce--potential replay attack"
  exit 1
fi

Since this specifically checks for G as the status (see the git-log documentation for the other options), this will reject any push that isn't signed by a known key. It also validates the nonce used for the push, and will reject a push with an invalid nonce.

It also makes sure the first thing it checks is that you aren't trying to cheat the system and write to the certificate log, which will be stored under the ref refs/meta/push-certs.

Speaking of...

Storing push certificates (post-receive hook)

We just use the contrib script directly from Gitolite. This has to be a post-receive hook, so it can't fail (or rather, failures are meaningless), which is why this is separate from the hook that requires push signatures.

This is why you need your git user to have a name and email address: the way it stores push certificates is by adding it to the special ref meta/push-certs, which means it has to write commits.

Verifying the push certificates

This is a pain. They have a valid inline signature, but they aren't actually clearsigned, they're a detached signature concatenated onto the certificate itself:

$ git show 45dd21ac93150db6275803c232b9c6ca2ec5f636:refs/heads/draft
certificate version 0.1
pusher 34dc11e9 1765152733 -0500
pushee git.msvcrt.zip:msvcrt.zip/msvcrt-blog
nonce 1765152733-e7395cefeb8fd7b8ab997ffc223aaa14068178ea
push-option verbose-ci

4d3ed21e7e3486926d32540a7670ce92c9ec74d9 29727d1f4e424cd7b09d28e0bf12e99460f84da6 refs/heads/draft
-----BEGIN PGP SIGNATURE-----

iJEEABYKADkWIQQ0AFdlIjX9aj0PPy2uzmloNNwR6QUCaTYX3RsUgAAAAAAEAA5t
YW51MiwyLjUrMS4xMSwyLDIACgkQrs5paDTcEemmUgEA7XGzZf3C9ARno2DjAUHW
9wUYRHtsKb2r4BMGezokbMAA/3VItrlaLjL7VFZpa+yLXXd3I/1RJR8bgawTdvTT
O7kK
=N3dY
-----END PGP SIGNATURE-----

You can verify the certificate by splitting it into two files, one that ends with the last line before the signature (no newline at the end of the file) and one that just contains the signature, and then running gpg --verify cert.asc cert (assuming you named the files that way). I've tried to come up with a neat one-liner for this, but I haven't been able to. If you can, please let me know, I'll update this post with it!

You also might have some trouble seeing the refs at all. They're weird refs, ones you can't really treat as branches, so fetching them is awkward and tracking them doesn't really work right. The easiest way to work with them is to fetch them with a specific format of refspec, e.g. git fetch origin refs/meta/push-certs:refs/remotes/origin/meta/push-certs, which will tell git to add it as a local ref tracking the remote ref meta/push-certs, but this still won't work how you expect (and you'll have to update it manually if you actually want it updated).

It's probably easier to just do git fetch origin refs/meta/push-certs and then mess with FETCH_HEAD to actually verify the latest certificate, but if you need to see the whole history, this works. That said, if you do track it, you can do tricks like git log refs/meta/push-certs -- refs/heads/main to see all the pushes that updated main. It's... not easy to work with, though.

Okay, but... why?

I'm not going to try to convince you this is a good idea. I like the idea that you can verify that I both wrote my commits and meant for them to be used the way they were used, which is something you can get by combining signed commits and signed pushes, but...

Mostly?

Because I can.