Subject: bug#75552: Non-committers can't keep authenticated forks updated

From: Liliana Marie Prikler <[email-masked]>
Date: Wed, 15 Jan 2025 18:59:24 +0100

Hi,

Am Mittwoch, dem 15.01.2025 um 15:48 +0000 schrieb 45mg:
> 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.
Let's recall that the entity you need to trust is still yourself in
most of those cases.  

> 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).
I think you're making this more complicated than it needs to be. 
checkout, authenticate, rebase*, merge* ought to have you covered.

* you can authenticate after these if you're paranoid 

> 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]).
No, it wouldn't.  You would rebase those changes on top of what you
already have on those respective branches.

> > 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.
I don't see why in your particular use case you can not use a channel
on top of Guix rather than replicating Guix itself.  Now there might be
some weird edge case I'm overlooking where you cut deep into the
dependency graph and that makes sense, but I sure hope that's a rare
edge case in and of itself.

> > 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.
Yeah, I think this scheme will still end up in [4].  As pointed out in
[8], "primary" is just a convention that we can't rely on.  So let's
just talk about the idea of widening one channel introduction to any
number of channel introductions – we can always store a mapping of HEAD
→ first authenticated commit and then assert that this set is a subset
of what we declare as introductions.  (This mapping will also make
authentication as efficient as it currently is, since we don't need to
reauthenticate everything all the time.)

Is this good enough?  No: an attacker could easily add their own
introduction and call it a day.  In fact, this scheme is even worse
than what was exploited in [4], because they never need commit access
to the Guix repo to do so.  Ahh, but wait!  `guix pull` on the user's
side uses their clean set of channels for authentication.  Those only
have upstream Guix… unless you actually pull your own fork or manage an
attack as outlined below (in which case you do need commit access for
some amount of time).

> > 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?
I mean, the promise we do make is that all commits starting from a
certain commit are signed.  So IMHO, this effectively breaks that :)

>   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.
Simpler, yes, but less stealthy.  Most contributors don't concern
themselves with the specifics of any particular branch, and you may
even be able to dress up your evil branch as a good branch until the
point where you finally merge it.

> 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'.
They can make an arbitrary number of unsigned commits before needing to
sign off one commit that will be merged.  If they follow the style of
merging master into their branch and then their branch into master,
said commit can even be empty, though that would no longer be stealthy.
Now if they were to I don't know, bump 9000 Rust packages or something
like that, they have a lot of space to exploit the as-of-yet in this
manner unexploited, but still weak SHA-1 hashes Git uses.

> 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.
Reverting this change could land you in early 2025.  And worse, your
attacker could lure you onto their branch if you happen to land on any
bad commit in the meantime.

Cheers

[8] https://lists.gnu.org/archive/html/help-guix/2025-01/msg00116.html