Introduction

A version control system like git doesn't just track changes, it also provides a record of who made those changes. This information can be used to check that commits are authorized, which can improve software supply chain security. In particular, checking a change's provenance can be used to remove intermediaries like forges, and package registries from a user's trusted computing base. But, authorship information can easily be forged.

Screenshot of a blog post about impersonating Linus Torvalds

An obvious solution to prevent forgeries is to require commits to be digitally signed. But, by itself a digital signature doesn't prevent forgeries. Anyone can generate a certificate with any user ID, and use it to sign commits:

mallory$ sq key generate --userid 'Neal H. Walfield <neal@sequoia-pgp.org>' --own-key --without-password
 - ┌ 13914CAD8DA9055E86973BCE16EABCF4A66A228B
   └ Neal H. Walfield <neal@sequoia-pgp.org>
   - certification created

...
mallory$ emacs main.rs
mallory$ git add main.rs
mallory$ git commit -m 'Clean up the code.'
[main (root-commit) a001000] Clean up the code.
 1 file changed, 21 insertions(+)
 create mode 100644 main.rs

When someone like Alice verifies the signature, they see that the commit is correctly signed, which it is:

alice$ git log -n1 --pretty=short --show-signature
commit a001000000000000000000000000000000000000
gpg: Signature made Fri Feb 21 08:42:16 2025 +01:00
gpg:                using EDDSA key F5FA62C39C1620C0DCE60A53FA23CCD7B28FB8CE
gpg: Good signature from "Neal H. Walfield <neal@sequoia-pgp.org>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg:          There is no indication that the signature belongs to the owner.
Primary key fingerprint: 1391 4CAD 8DA9 055E 8697  3BCE 16EA BCF4 A66A 228B
     Subkey fingerprint: F5FA 62C3 9C16 20C0 DCE6  0A53 FA23 CCD7 B28F B8CE
Author: Neal H. Walfield <neal@sequoia-pgp.org>

    Clean up the code.

But as the warning points out, the certificate may not actually belong to the stated owner. And, as the prompt suggests, it isn't.

What is needed is not only a list of entities that are allowed to modify the repository, but also the certificates they use to sign the commits. In other words, to authenticate a commit we need a signing policy, which says what certificates are authorized to make changes.

Creating a policy isn't complicated. But, for an end user it is time consuming, and requires diligently tracking the project to identify when maintainers come and go. That's too much work.

Instead, a project's maintainers could curate a list of entities that are allowed to add commits and update the signing policy, and enumerate the certificates they use to sign them. The tricky part is applying the policy. There are a number of edge cases that need to be handled like how to merge changes from external contributions, who is allowed to change the policy, and how to deal with compromised keys.

The Sequoia git project specifies a set of semantics, defines a policy language and file format, and provides a set of tools to manage a policy file, and authenticate commits.

Using sq-git is relatively straightforward. You start by adding a policy file, openpgp-policy.toml, to your project's repository. (As shown in the following chapters, sq-git helps you do that.) The policy is maintained in-band to allow it to evolve, just like the rest of the project. The openpgp-policy.toml file is basically a list of entities, the type of changes they are authorized to make, and their respective OpenPGP certificates.

Before you merge a pull request, you check that commits are authorized by the policy. Locally, this can be done by running sq-git log on the range of commits that you want to check. If your project uses CI, you add a job that automatically checks that all new commits are authorized.

Downstream users can use sq-git to check that there is a chain of trust from an older, known-good version of the software to a new version. This helps prevent the use of versions that include modifications that weren't authorized by the project's maintainers.

sq-git has a relatively simple two-step authentication rule. First, a commit is considered authorized if one of its parent's policies can authenticate it. A commit is considered authorized with respect to a trust root if there is a path from the trust root to the commit where every commit is authenticated by the parent commit on the path.

The following shows that there are multiple paths from the first commit to the last commit:

bob$ git log --decorate --pretty=short --graph
*   commit b009000000000000000000000000000000000000 (HEAD -> main)
|\  Merge: b005000 b006000
| | Author: Bob <bob@example.org>
| | 
| |     Merge Carol's change
| | 
| * commit b006000000000000000000000000000000000000 (carol/vroom)
|/  Author: Carol <carol@example.org>
|   
|       Use an O(log(n)) algorithm instead of one that takes O(n).
| 
* commit b005000000000000000000000000000000000000
| Author: Bob <bob@example.org>
| 
|     Add support for ACME's frob.
| 
* commit b004000000000000000000000000000000000000
| Author: Bob <bob@example.org>
| 
|     Fix a corner case.
| 
* commit b003000000000000000000000000000000000000
| Author: Bob <bob@example.org>
| 
|     Add a cool new feature.
| 
* commit b002000000000000000000000000000000000000
| Author: Alice <alice@example.org>
| 
|     Authorize Bob to be a release manager.
| 
* commit b001000000000000000000000000000000000000
  Author: Alice <alice@example.org>
  
      Add a signing policy.

Only one of those paths needs to be authenticated, because when the parent authenticates the merge commit, it implicitly authenticates the changes added by the other parents. In a certain sense, the fact that the other parents are recorded in the git history is purely decorative.

sq-git distinguishes itself from projects like sigstore in that all of the information required to authenticate commits is available locally, and no third-party authorities are required.

Signing Commits

Signing a commit means adding a digital signature to your commits. This allows someone who has your certificate to verify that you made the signature. Signing commits is a prerequisite to using sq-git.

If you are using Sequoia, you can create a certificate as follows:

$ sq key generate --name "Iliana" --email iliana@example.org --own-key
Please enter the password to protect key (press enter to not use a password):
                                                  Please repeat the password:
 - ┌ 5D4142463C04ADCF7372231DC06A1E7BD5E115DC
   └ Iliana
   - certification created

 - ┌ 5D4142463C04ADCF7372231DC06A1E7BD5E115DC
   └ <iliana@example.org>
   - certification created
...

Then you need to configure git to use it:

$ git --global gpg.gpgsign true
$ git --global user.signingKey 5D4142463C04ADCF7372231DC06A1E7BD5E115DC

Adding a Signing Policy to a Project

To start using sq-git with a project, you first add a signing policy. The two most important things that a signing policy says are: who is allowed to add commits to the project, and who is allowed to change the policy.

Imagine a project called Frob. Alice is the project's maintainer, Bob is a committer, and Carol occasionally makes contributions, but all of her contributions are first reviewed by Alice or Bob.

As the project's maintainer, Alice is responsible for creating and maintaining the signing policy. To mark herself as the project maintainer, she uses the sq-git policy authorize command:

alice$ sq-git policy authorize alice --project-maintainer \
    --cert A11CECD86FB6050466E6259C7993A17BA8537B3D
  - User alice was added.
  - User alice was granted the right sign-commit.
  - User alice was granted the right sign-tag.
  - User alice was granted the right sign-archive.
  - User alice was granted the right add-user.
  - User alice was granted the right retire-user.
  - User alice was granted the right audit.
  - User alice: new certificate A11CECD86FB6050466E6259C7993A17BA8537B3D.
alice$ git add openpgp-policy.toml
alice$ git commit -m 'Add a signing policy.'
[main (root-commit) b001000] Add a signing policy.
 1 file changed, 43 insertions(+)
 create mode 100644 openpgp-policy.toml

This adds a new entity, alice, to the openpgp-policy.toml file at the root of the working tree, gives her all rights, and associates an OpenPGP certificate with the entity.

By default, sq-git reads certificates from your local shared OpenPGP certificate directory. This is the same certificate store that sq uses. By default, it is stored under ~/.local/share/pgp.cert.d. If your tooling doesn't use the shared OpenPGP certificate directory, you can export the certificate to a file, and use the --cert-file argument instead of the --cert argument to designate the certificate.

Before Alice can authorize Bob, she needs to get his certificate. It's important that she make sure she has the correct certificate, as otherwise someone else will be allowed to make changes.

Alice might download Bob's certificate from his web page, his profile page on a forge, a public directory like keys.openpgp.org. This is a good start. But, before she uses the certificate, she should double check that it really belongs to Bob by asking him to send her his certificate's fingerprint via a different communication channel. The most secure option would be to meet in real life and exchange fingerprints. But sending it via a secure messenger like Signal, or checking the fingerprint in a call is usually enough. It's not only important for Alice to check with Bob for security reasons, though: Bob might want to use a separate certificate for signing his commits.

Once Alice has verified Bob's certificate, she can authorize him as follows:

alice$ sq-git policy authorize bob --release-manager \
    --cert B0B50C2B8C3558D225A3310C1A8FAB5E378DD32D
  - User bob was added.
  - User bob was granted the right sign-commit.
  - User bob was granted the right sign-tag.
  - User bob was granted the right sign-archive.
  - User bob: new certificate B0B50C2B8C3558D225A3310C1A8FAB5E378DD32D.
alice$ git add openpgp-policy.toml
alice$ git commit -m 'Authorize Bob to be a release manager.'
[main b002000] Authorize Bob to be a release manager.
 1 file changed, 38 insertions(+)

That is, she uses the same command as before, but instead of making Bob a project maintainer, she makes him a release manager. This means that he is allowed to add commits to the project and make releases, but not modify the project's signing policy. If Alice doesn't want Bob to make releases, then she could have made him a committer using the --committer option instead.

As a general rule of thumb, a project should have more than one project maintainer. This way, if one maintainer loses access to their certificate, another person can add a new certificate without violating the signing policy.

Since modifying the policy is a particularly sensitive operation, you might consider having two entities with different rights and certificates: one that is allowed to change the policy, and uses a certificate that is stored offline, e.g., on an OpenPGP card, and another that is only allowed to add commits, and uses an online certificate.

Authenticating Commits

When Bob adds a commit to Frob's repository, he can check that it is allowed by the policy using sq-git log like so:

bob$ emacs main.rs
bob$ git add main.rs
bob$ git commit -m 'Add a cool new feature.'
[main b003000] Add a cool new feature.
 1 file changed, 21 insertions(+)
 create mode 100644 main.rs
bob$ sq-git log --trust-root 'HEAD^'
b002000000000000000000000000000000000000..b003000000000000000000000000000000000000:
  Signer: bob [B0B50C2B8C3558D225A3310C1A8FAB5E378DD32D]
  Add a cool new feature.
Verified that there is an authenticated path from the trust root
b002000000000000000000000000000000000000 to b003000000000000000000000000000000000000.

sq-git log checks that there is a path from the trust root to the current commit, and that each commit can be authenticated by its parent.

If there are merge commits, there may be multiple paths. In this case, only one path needs to be authenticated: when the parent authenticates the merge commit, it implicitly authenticates the changes added by the other parents.

The --trust-root argument specifies the start of the path. By using HEAD^, Bob is asking if the current commit is allowed by the parent commit's policy.

sq-git authenticates a range of commits. The trust root is the start of the range. We call it the trust root, because this is what the user trusts; the authentication status of the target commit is derived from the trust root.

sq-git requires each user to select their own trust root; for security reasons, this cannot be delegated to the project.

Consider Dave. When deciding whether to use a project, he first does some due diligence. He looks at the code, researches the project and its maintainers, and examines the most recent commit's signing policy. If he is happy, he uses that commit as his trust root for that project. Alternatively, if the project has been audited, he might decide to rely on the auditors, and use the commit that they reviewed as his trust root. Or, Dave might yolo it, and set the trust root to the current commit. This is like trust on first use (TOFU): sq-git log will alert him to future changes that violate the singing policy, which is less secure, but still helpful.

Dave records the trust root in his local checkout by setting git's sequoia.trustRoot configuration key to the hash of the commit that he audited:

dave$ git log -n1
commit b003000000000000000000000000000000000000
Author: Bob <bob@example.org>
Date:   Fri Feb 21 08:42:14 2025 +0100

    Add a cool new feature.
dave$ git config sequoia.trustRoot b003000000000000000000000000000000000000

It's important to use a hash and not use a branch or a tag, as these may be changed by a third party when you pull from an upstream repository.

When Dave fetches updates from the upstream repository, he can use sq-git to check that there is an authenticated path from his trust root to the latest commit.

To illustrate, let's say that Bob adds a few commits:

bob$ emacs main.rs
bob$ git add main.rs
bob$ git commit -m 'Fix a corner case.'
[main b004000] Fix a corner case.
 1 file changed, 21 insertions(+), 7 deletions(-)
bob$ emacs main.rs
bob$ git add main.rs
bob$ git commit -m 'Add support for ACME'\''s frob.'
[main b005000] Add support for ACME's frob.
 1 file changed, 9 insertions(+), 12 deletions(-)

Later, Dave updates his local repository, and authenticates the changes:

dave$ sq-git log
b004000000000000000000000000000000000000..b005000000000000000000000000000000000000:
  Signer: bob [B0B50C2B8C3558D225A3310C1A8FAB5E378DD32D]
  Add support for ACME's frob.
b003000000000000000000000000000000000000..b004000000000000000000000000000000000000:
  Signer: bob [B0B50C2B8C3558D225A3310C1A8FAB5E378DD32D]
  Fix a corner case.
Verified that there is an authenticated path from the trust root
b003000000000000000000000000000000000000 to b005000000000000000000000000000000000000.

Since Dave set the trust root in his git configuration to b003000000000000000000000000000000000000, he doesn't have to pass the --trust-root argument.

In this case, sq-git didn't complain, which means that it found a path from the trust root to the current commit.

Let's say that Carol adds a commit:

carol$ git switch -c carol/vroom main
Switched to a new branch 'carol/vroom'
carol$ emacs main.rs
carol$ git add main.rs
carol$ git commit -m 'Use an O(log(n)) algorithm instead of one that takes O(n).'
[carol/vroom b006000] Use an O(log(n)) algorithm instead of one that takes O(n).
 1 file changed, 21 insertions(+), 7 deletions(-)

If Dave were to pull her changes and try to authenticate them, he would see that he can't authenticate Carol's commit:

dave$ sq-git log
b005000000000000000000000000000000000000..b006000000000000000000000000000000000000:
  Error: Key `F712919FDC76B971A5ABAFC10DDD0BDCFD7D7C4F` missing
  Use an O(log(n)) algorithm instead of one that takes O(n).
Error: Could not verify commits b003000000000000000000000000000000000000..b006000000000000000000000000000000000000

Caused by:
    0: While verifying commit b006000000000000000000000000000000000000
    1: Key `F712919FDC76B971A5ABAFC10DDD0BDCFD7D7C4F` missing

sq-git correctly identify that the commit was not authorized. The missing key error message means that the certificate used to sign the commit was not found in the parent commit's policy file. This is exactly what we expect and want: the parent commit does not authorize Carol to add commits.

There are two main reasons for unauthorized commits. First, it may be that the maintainers made a mistake. For instance, Alice accidentally merged Carol's commit without first verifying that the commit is authorized by the policy. Second, an attacker added a commit. This could happen if an the attacker compromised the upstream repository by gaining access to the server, or by convincing the operator of the forge to give them access to the repository. The latter is sometimes reasonable if the original maintainer has abandoned the project. sq-git is not able to distinguish these different cases. So, it is up to the user to investigate what happened, and decide what to do.

Let's imagine that Carol sees that sq-git log is unhappy with her commit, and she tries to fix it by adding herself to the signing policy.

carol$ git switch -c carol/make-carol-a-committer main
Switched to a new branch 'carol/make-carol-a-committer'
carol$ sq-git policy authorize carol --committer \
    --cert CA501F894EBD6193655CB77C476D56394D4E67DC
  - User carol was added.
  - User carol was granted the right sign-commit.
  - User carol: new certificate CA501F894EBD6193655CB77C476D56394D4E67DC.
carol$ git add openpgp-policy.toml
carol$ git commit -m 'Authorize Carol to be a committer.'
[carol/make-carol-a-committer b007000] Authorize Carol to be a committer.
 1 file changed, 36 insertions(+)
carol$ git cherry-pick b006000000000000000000000000000000000000
[carol/make-carol-a-committer b008000] Use an O(log(n)) algorithm instead of one that takes O(n).
 Date: Fri Feb 21 08:42:15 2025 +0100
 1 file changed, 21 insertions(+), 7 deletions(-)
carol$ sq-git log --trust-root 'HEAD^'
b007000000000000000000000000000000000000..b008000000000000000000000000000000000000:
  Signer: carol [CA501F894EBD6193655CB77C476D56394D4E67DC]
  Use an O(log(n)) algorithm instead of one that takes O(n).
Verified that there is an authenticated path from the trust root
b007000000000000000000000000000000000000 to b008000000000000000000000000000000000000.

Now, sq-git log can authenticate Carol's commit! So, she pushes her branch, and thinks that everything is okay. When Dave tries to authenticate it, however, he still sees an error:

dave$ sq-git log
b007000000000000000000000000000000000000..b008000000000000000000000000000000000000:
  Signer: carol [CA501F894EBD6193655CB77C476D56394D4E67DC]
  Use an O(log(n)) algorithm instead of one that takes O(n).
b005000000000000000000000000000000000000..b007000000000000000000000000000000000000:
  Error: Key `F712919FDC76B971A5ABAFC10DDD0BDCFD7D7C4F` missing
  Authorize Carol to be a committer.
Error: Could not verify commits b003000000000000000000000000000000000000..b008000000000000000000000000000000000000

Caused by:
    0: While verifying commit b007000000000000000000000000000000000000
    1: Key `F712919FDC76B971A5ABAFC10DDD0BDCFD7D7C4F` missing

This is because although sq-git log can authenticate the commit with Carol's functional change, it correctly rejects the commit where she adds herself to the signing policy: the parent commit doesn't authorize that, and Dave's trust root comes earlier.

If Bob is happy with Carol's functional change, he can merge it:

bob$ git merge --no-ff carol/vroom -m 'Merge Carol'\''s change'
Merge made by the 'ort' strategy.
 main.rs | 28 +++++++++++++++++++++-------
 1 file changed, 21 insertions(+), 7 deletions(-)

Notice that Bob uses the --no-ff switch to force a merge commit. This results in the following history:

bob$ git log --decorate --pretty=short --graph
*   commit b009000000000000000000000000000000000000 (HEAD -> main)
|\  Merge: b005000 b006000
| | Author: Bob <bob@example.org>
| | 
| |     Merge Carol's change
| | 
| * commit b006000000000000000000000000000000000000 (carol/vroom)
|/  Author: Carol <carol@example.org>
|   
|       Use an O(log(n)) algorithm instead of one that takes O(n).
| 
* commit b005000000000000000000000000000000000000
| Author: Bob <bob@example.org>
| 
|     Add support for ACME's frob.
| 
* commit b004000000000000000000000000000000000000
| Author: Bob <bob@example.org>
| 
|     Fix a corner case.
| 
* commit b003000000000000000000000000000000000000
| Author: Bob <bob@example.org>
| 
|     Add a cool new feature.
| 
* commit b002000000000000000000000000000000000000
| Author: Alice <alice@example.org>
| 
|     Authorize Bob to be a release manager.
| 
* commit b001000000000000000000000000000000000000
  Author: Alice <alice@example.org>
  
      Add a signing policy.

The merge commit has two parent's: Carol's commit, and Bob's commit. Although Bob's commit can still not authenticate Carol's commit, it can authenticate Bob's merge commit. Therefore there is an unbroken path from Dave's trust root to Bob's merge commit. Now, when Dave runs sq-git with his trust root, he can again authenticate the changes:

dave$ sq-git log
b005000000000000000000000000000000000000..b009000000000000000000000000000000000000:
  Signer: bob [B0B50C2B8C3558D225A3310C1A8FAB5E378DD32D]
  Merge Carol's change
b006000000000000000000000000000000000000..b009000000000000000000000000000000000000:
  Cached positive verification
b005000000000000000000000000000000000000..b006000000000000000000000000000000000000:
  Error: Key `F712919FDC76B971A5ABAFC10DDD0BDCFD7D7C4F` missing
  Use an O(log(n)) algorithm instead of one that takes O(n).
b004000000000000000000000000000000000000..b005000000000000000000000000000000000000:
  Signer: bob [B0B50C2B8C3558D225A3310C1A8FAB5E378DD32D]
  Add support for ACME's frob.
b003000000000000000000000000000000000000..b004000000000000000000000000000000000000:
  Signer: bob [B0B50C2B8C3558D225A3310C1A8FAB5E378DD32D]
  Fix a corner case.
Verified that there is an authenticated path from the trust root
b003000000000000000000000000000000000000 to b009000000000000000000000000000000000000.

Note that the Carol's commit, b006000000000000000000000000000000000000, still can't be authenticated. But because there is an alternate path to the trust root, sq-git is happy.

Instead of using an empty merge commit, Bob could have re-signed Carol's commit using his certificate:

bob$ git reset --hard b006000000000000000000000000000000000000
HEAD is now at b006000 Use an O(log(n)) algorithm instead of one that takes O(n).
bob$ git commit --amend --allow-empty --reuse-message=HEAD
[main b010000] Use an O(log(n)) algorithm instead of one that takes O(n).
 Author: Carol <carol@example.org>
 Date: Fri Feb 21 08:42:15 2025 +0100
 1 file changed, 21 insertions(+), 7 deletions(-)

This preserves the Carol's authorship information, but replaces the signature, and updates the committer. Now there is an authenticated path from Dave's trust root to the branch's tip:

dave$ git log -n1
commit b010000000000000000000000000000000000000
Author: Carol <carol@example.org>
Date:   Fri Feb 21 08:42:15 2025 +0100

    Use an O(log(n)) algorithm instead of one that takes O(n).
dave$ sq-git log
b005000000000000000000000000000000000000..b010000000000000000000000000000000000000:
  Signer: bob [B0B50C2B8C3558D225A3310C1A8FAB5E378DD32D]
  Use an O(log(n)) algorithm instead of one that takes O(n).
b004000000000000000000000000000000000000..b005000000000000000000000000000000000000:
  Signer: bob [B0B50C2B8C3558D225A3310C1A8FAB5E378DD32D]
  Add support for ACME's frob.
b003000000000000000000000000000000000000..b004000000000000000000000000000000000000:
  Signer: bob [B0B50C2B8C3558D225A3310C1A8FAB5E378DD32D]
  Fix a corner case.
Verified that there is an authenticated path from the trust root
b003000000000000000000000000000000000000 to b010000000000000000000000000000000000000.

Authenticating a Pull Request

Relying on maintainers to run sq-git is a sure way to ensure that it won't happen. The Sequoia project provides OCI images for ease of use inside of CI pipelines.

Gitlab

To authenticate commits from a Gitlab CI pipeline, there is a script included in the project's repository at scripts/gitlab.sh. You can run it as a job inside a project's .gitlab-ci.yml manifest like so:

authenticate-commits:
  stage: test
  image: registry.gitlab.com/sequoia-pgp/sequoia-git:latest
  before_script: []
  script:
    - sq-git policy describe
    - /usr/sbin/gitlab.sh # Script baked-in to image
  after_script: []
  rules:
    # TODO: We currently only authenticate the changes on non-merged
    # branches where we use the default branch as the trust root.  For
    # the default branch, the project needs to set an explicit trust
    # root.
    - if: '$CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH'

GitHub

To use sq-git to authenticate a pull request on GitHub, you can use the sequoia-pgp/authenticate-commits Action. This GitHub action checks that the commits are authorized by the last commit of the merge base. This video shows a demonstration of the action.

Screenshot of an MR being merged using the fast-forward action

Note: GitHub's interface for merging pull requests offers three merge strategies, but unfortunately none of them are appropriate for use with sq-git, because they all modify the commits. When using sq-git, it is necessary to either manually rebase and fast forward the change, or to add a signed merge commit. It is possible to use the sequoia-pgp/fast-forward action to fast forward pull requests. When enabled for a repository, an authorized user can add a comment containing /fast-forward to the pull request, and the action will fast forward the merge base without modifying the commits.