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.
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.
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.