Subject: Re: Non-committers can't keep authenticated forks updated

From: 45mg <[email-masked]>
Date: Wed, 15 Jan 2025 15:48:46 +0000

Hi Liliana!

Liliana Marie Prikler <[email-masked]> writes:

> For most use cases, this is a non-issue.  Assuming you are a single
> committer to your fork, you can always rebase your changes on top of
> Guix (if you're willing to bump the introductory commit)

The idea of authentication is that once you trust the channel
introduction, you can be sure that everything you pull after that is
authentic. The introduction only needs to be trusted once. If you're
bumping the introduction every time, then you need to obtain and verify
the introduction every time. You're going from 'Trust On First Use' to
'Trust On Every Use'. Not ideal IMO.

> or sign the changes to Guix with your own key (if you are willing to
> accept that this changes the history). With multiple committers, you
> will need to do the latter.

While this could actually work, without changing the history, the
problem is that there is no easy way to authenticate upstream commits.

You could do it like this:
0) Before creating your fork, authenticate every commit in the Guix
   checkout (as described in the manual).
1) Switch to your branch that tracks upstream.
2) Pull from upstream.
3) Run `guix git authenticate`, supplying Guix's channel introduction as
   arguments.
4) After this succeeds, create and switch to a branch from the current
   tip of your upstream-tracking branch. Edit .guix_authorizations to
   add your key, and create a signed commit.
5) Merge this branch into your fork branch.
6) Switch back to your fork branch.
7) Delete the [guix "authentication"] section from .git/config.
8) Run `guix git authenticate` with the introduction of your fork
   branch, to authenticate the merge commit.

That's a lot of manual steps for every pull from upstream! While I do
have to give you credit for this idea - at least we now have a
workaround for people who are determined enough - I'm guessing a lot of
people will probably just skip authentication if it's going to be this
annoying. Authenticating a fresh clone from scratch will be even more
annoying, especially if you have multiple fork branches (eg. you're
tracking someone else's fork).

We could create a script to do all the steps for us, but if and when it
fails on whatever insane edge cases people are able to come up with,
they're going to need to understand all the steps involved anyway.
Abstraction is not a substitute for a clean underlying design.

Also I just want to point out that rebasing /will/ change the history.
The `guix pull` after every time you update your fork will need to be a
force-pull (--allow-downgrades [1]).

> Of course, you can also keep your own fork unauthenticated, which
> might be preferable if you only do local work anyway, but that's
> besides the issue here.

Yes, to be clear, I'm talking about the use-case where your fork is
hosted remotely, and you or someone else needs to pull changes from it.
For example, my prospective use case would be quickly bootstrapping Guix
on a new machine - I build my own installation image, and I'd want it to
pull from my fork. I can include my introduction into my installer, just
like the official one. But if the introduction changes before I use my
installer, then the first pull can't be authenticated.

> This does not state how the additional introductions are used, if at
> all.  It may mean that the additional introductions are pointless other
> than for blocking case 4.

My bad, I guess I forgot to explain that.

The purpose of the additional introductions is to make it so that signed
commits from upstream Guix, or commits from other people's forks, can
still be authenticated. As I mentioned above, the current design is not
suited to this.

To go a bit more into detail - we will accomplish authentication by
doing a postorder traversal of the commit tree, considering the latest
commit as the root node. We traverse its parents recursively until we
reach a commit whose parent is one of the channel introductions (primary
or additional). Then that commit and all its children are authenticated
from the introduction that we encountered. In this way, every commit is
authenticated from the introduction that is its most recent ancestor.

> I think this might still hide a serious flaw.  With the way *upstream*
> authentication works.  Let's flip the example in [6] around a little
> bit and construct the following:
>
> -A---B---C---D
>   \       \
>    \       \-E---F---💀
>     \               /
>      \----G--H--I*-/
>   
> Both A and I* are introductory commits on their various branches.  In
> 💀, any committer who has valid keys in both F and I* can merge a
> branch with unsigned commits, effectively voiding the invariant of
> BCEF, e.g. by undoing any changes that happened there.  Of course, they
> can do so with signed commits as well, given that they have commit
> access to the main repository, but the point still holds that they may
> introduce unsigned commits to any fork where their key is valid in.

So, my design enables an attacker who can make authorized signed commits
to also introduce changes made in unsigned commits. Hmm.

I don't think this compromises our current security guarantees, though?
If the attacker can already make trusted commits, then any attack they
can perform in the way you described can also be done directly with
signed commits onto F, as you pointed out. And the latter way would be
far simpler for them.

Also, the branch they merged into would not contain any unsigned
commits; the commit '💀' is still signed with a key authorized for F's
branch. So at most, we can say that the attacker can introduce /changes
made in/ unsigned commits, not 'introduce unsigned commits'.

Once you manage to revoke their commit access, you'd just revert the
'💀' commit and delete the GHI branch (which is the one that contains
unsigned commits). The same way you'd recover from them directly making
malicious changes on master.

[1] https://issues.guix.gnu.org/41604#16-lineno14