Securing RubyGems with TUF, Part 2

Applying The Update Framework (TUF) to RubyGems to secure it against nefarious activity.

Written by Xavier Shay.

This is the second part of the Securing RubyGems with TUF series. Start with part 1 if you haven’t read it yet.

In the last post, we covered how The Update Framework (TUF) protects clients from installing maliciously modified gems. In this post, we extend that system to allow developers to update their own gems. As a refresher, our current setup looks like this:

Developers, Developers, Developers

In our simplified example, RubyGems maintainers need to sign any changes to verified.txt with offline keys. This clearly does not scale up to thousands of developers pushing thousands of gems. I don’t want to require my gem to be re-promoted to verified.txt by hand everytime I push a new version! That would introduce an unacceptable window of opportuntiy for an attacker to compromise new versions of gems.

To support secure updating of verified gems, we need to allow developers to sign with their own offline keys. Let’s work through the process for the first and second uploads of the cane gem.

After building the gem, a developer also creates a metadata file for that gem. This file contains a digest of the gem file, and then is signed by the developer’s private key.

# cane.txt
{
"signature": { "keyid": "xavier-shay", "sig": "a255455e97a87412a7" },
"signed": {
"files": {
"gems/cane-0.0.1": "c17f2bf0a0db6fca5c362c233c5d1b58"
}
}
}
# xavier-shay.key
xavierpublickey-e455343e474

They then push their gem, the metadata file, and their public key up to RubyGems. Since RubyGems has not seen this public key before, it is added to recent.txtwhich is then re-signed with the online key. cane.txt cannot be changed by the server, since it does not have access to the developer’s private key, so it serves that up as-is.

(I removed the file entries from part 1 in the following metadata, since they are not relevant anymore.)

# targets/recent.txt
{
"signature": { "keyid": "online", "sig": "305781c28a40f863d9" },
"signed": {
"public_keys": { "xavier-shay": "xavierpublickey-e455343e474" },
"delegations": [
{ "name": "cane", "public_key": "xavier-shay" }
]
}
}
# targets/recent/cane.txt
{
"signature": { "keyid": "xavier-shay", "sig": "a255455e97a87412a7" },
"signed": {
"files": {
"gems/cane-0.0.1": "c17f2bf0a0db6fca5c362c233c5d1b58"
}
}
}

At this point, these metadata files are still vulnerable to an attacker with access to the server since recent.txt is signed by the online key. Note however that the server did not need to directly sign any of the files provided by the developer.

During the regular manual update cycle by RubyGems maintainers, the delegation for cane will be moved from recent.txt to verified.txt. Recall that the latter is signed by the offline key.

To upload a new version of the gem, a developer adds a file entry for the new gem into the cane.txt metadata, re-signs it, and uploads that alongside the new .gem file.

# targets/verified/cane.txt
{
"signature": { "keyid": "xavier-shay", "sig": "1476f7d118ebde3b431" },
"signed": {
"files": {
"gems/cane-0.0.1.gem": "c17f2bf0a0db6fca5c362c233c5d1b58",
"gems/cane-0.0.2.gem": "a199bad99bcc2561bb97eafadbaa8352"
}
}
}

The server can then replace its copy of cane.txt with the new one. The contents of verified.txt does not change, so it does not have to be re-signed. Viola! At this point, an attacker cannot provide a malicious version of cane without access to either the RubyGems offline key, or the developer offline key.

Magic?

Let me emphasize just how big deal this is: gem authors can publish gems, developers can verify that those gems are the same as when the author published them, no external key distribution mechanism is needed[^1], and the system is robust even if the main RubyGems server is hacked. All with no extra effort required by gem users and very little extra effort required by gem publishers.

Let’s walk through a client request for gems/cane-0.0.2.gem to see exactly how it is secured. (It will help to follow along on the diagram above.)

  1. Client requests targets.txt and verifies it with the offline public key that was distributed with RubyGems. Verification in this context means checking that the signature provided in the signatures section matches the content in signed.
# targets.txt { “signature”: { “keyid”: “offline”, “sig”: “9fa0385911ea8c1b3f2c” }, “signed”: { “public_keys”: { “online”: “7b1fd6094c6b87c196f1ff423527da38”, “offline”: “92e7c85d86d3d744344159084b3b600e” }, “delegations”: [ { “name”: “verified”, “public_key”: “offline” }, { “name”: “recent”, “public_key”: “online” } ] } }

2. Since gems/cane-0.0.2.gem is not listed in targets.txt, the client fetches the first delegation: verified.txt. This file is signed using the offline key, the public part of which we already have but is also provided in targets.txt for convenience.

# targets/verified.txt { “signature”: { “keyid”: “offline”, “sig”: “305781c28a40f863d97” }, “signed”: { “public_keys”: { “xavier-shay”: “xavierpublickey-e455343e474” }, “delegations”: [ { “name”: “cane”, “public_key”: “xavier-shay” } ] } }

3. gem/cane-0.0.2.gem is not listed in verified.txt either, so the process is repeated by fetching the first delegation to cane.txt. cane.txt is verified using the developer’s public key (xavierpublickey-e455343e474) provided in verified.txt.

# targets/verified/cane.txt { “signature”: { “keyid”: “xavier-shay”, “sig”: “1476f7d118ebde3b4317c12a1eb690b8” }, “signed”: { “files”: { “gems/cane-0.0.1.gem”: “c17f2bf0a0db6fca5c362c233c5d1b58”, “gems/cane-0.0.2.gem”: “a199bad99bcc2561bb97eafadbaa8352” } } }

4. gem/cane-0.0.2.gem is listed in cane.txt, so we download it and verify its digest against the one listed in cane.txt.

At every point along the way, we only ever trusted files that were secured using an offline key. Not too shabby!

In the next installment, I’ll cover some more esoteric attacks on packaging systems and how TUF defends against them.

[^1]: The current RubyGems signing system requires gem authors to publish their keys somewhere, and for other developers to manually import those after locating them. This is too much work, so virtually nobody does it.