Ditching OpenPGP, a new approach to signing APT repositories

Over the past few years, it has become clear that OpenPGP is a major disappointment for repository signing, the interfaces around being the cause for multiple security vulnerabilities; and limited development speed and deprecation of algorithms and key sizes causing uncertainty about long term safety of LTS releases.

This document outlines a new approach to signing repositories. For the time being, one algorithm is being used: Ed25519 with SHA512, also used by signify-openbsd, minisign, and OpenSSH (ssh-ed25519).

Reference implementation - python-aptsign: https://salsa.debian.org/apt-team/python-aptsign/

earlier proof of concept (C++): https://gist.github.com/julian-klode/4514ce39d3dc62647b502e5a8cf6a3ef (slightly different format, but proofs enough :D)

Signing Release files

Syntax

The Release and ?InRelease files gain an additional multiline field of the form

Signatures:
 <algorithm> <base64(public signing key||signature)>
 <algorithm> <base64(public signing key||signature)>
 <algorithm> <base64(public signing key||signature)>

Each line must start with exactly one space character, followed by a base64 block. There must be no whitespace following the base64 block.

The base64 is an encoded version of a concatenation of

The algorithm for now may be one of 'ed25519' or 'ed448', though there may not be any practical ed448 implementations available for some time.

There SHALL NOT be more than one such field. However, if a broken file with multiple fields exists, clients MUST either validate them all or reject the file.

Rationale - multiple fields: We do not allow multiple Signatures fields, as deb822 forbids that, and there's a risk of breaking existing consumers. For verification, it is easier to just concatenate multiple signature fields and verify them all rather than keeping track of whether we've already seen a Signatures field.

Rationale - encoding: Encoding the entire signature in one base64 blob simplifies parsing, we don't have to split text strings. We have split out the algorithm bit to make it easy to see whether a signature is ed25519 or ed448 or some future format.

Calculating the signature

The signature is calculated over the entire file, trimmed of any trailing whitespace, including empty trailing lines. The file must have '\n' line endings, but clients MAY normalize different line endings to '\n' to ease parsing. Signed content MUST end with a newline character.

The following Python code demonstates how to gather the signed buffer (self.buffer) and the signatures (self.signatures). You can then verify the signatures, or append new signatures and render them back out (print buffer, print signatures, then print an empty line so parsers expecting sections to be terminated by an empty line work).

   1     def read_file(self, lines: typing.Iterable[bytes]) -> None:
   2         """Read the given file"""
   3         in_signatures = False
   4 
   5         for line in lines:
   6             # Lines consisting of just whitespace end a section, we only
   7             # allow one section.
   8             if not line.strip():
   9                 break
  10 
  11             in_signatures = in_signatures and line.startswith(b" ")
  12 
  13             if line == b"Signatures:\n":
  14                 in_signatures = True
  15             elif in_signatures:
  16                 self.signatures.append(Signature(base64.b64decode(line.strip())))
  17             else:
  18                 self.buffer += line
  19 
  20         for line in lines:
  21             print("E: Unexpected second section", file=sys.stderr)
  22             sys.exit(1)

Rationale: New line normalization is permitted for clients to simplify programming. C++ getline() does not include newline characters, so it is impossible to know whether a file ends with a final new line, or inside the line, hence the requirement on the file format to keep the parsing logic simple.

Verification guidelines

Clients shall verify all signatures from supported algorithms in the Release file, whether a given key is considered trusted or not. If no signatures are provided, or none are compatible with the client, the repository should be considered untrusted.

Rationale: This ensures that repositories do not fail for a subset of users because the key they trust has an invalid signature.

Key format #1: Simple keys

In this format, a public key is a simple 32-byte Ed25519 key. There is no expiry or other metadata associated with it.

Key format #2: with signing subkeys

There are two types of keys: Primary key and signing (sub)keys. A primary key is an Ed25519 key that is usually kept offline; and the signing subkeys are the keys that actually sign the repo. Both may be the same key.

A *public signing key* as used is a concatenation of the following things:

Verification: When a client checks an updated Release file, it MUST fail validation if one of the signing subkeys has a lower serial number than it saw for the primary key in the previous release file(s). This effectively means that as soon as a client sees a new subkey for the repository, all previous subkeys are revoked.

Signing: For signing static repositories, such as Ubuntu stable releases, or a Debian stable release, it is advisable to use a one-time signing subkey that is thrown away after signing.

Subkeys may reuse/share serial numbers provided that they will never be used for the same repository. For example, a -security and an -updates suite may both be signed by the same primary key, but different subkeys with the serial 0.

Co-existence with GPG signatures

apt-sign signatures are part of the Release file directly, and can hence co-exist with/inside detached Release.gpg or clearsigned ?InRelease files.

Support for GPG signatures will be removed after Debian and Ubuntu shipped one stable (in case of Ubuntu: LTS) release with warnings for missing apt-sign signatures.

Once a repository provides Ed25519 signatures from trusted keys, GPG signatures will no longer be verified.

Key storage in APT

Rough draft:

First party repository signing keys are likely configured by apt.conf snippets defining trusted keyrings. There'll be like a default keyring, and zero or more named keyrings, and they can be referred to by the signed-by field. Each keyring is a list of base64-encoded Ed25519 public keys.

Third party repositories will likely be configured by a list of base64-encoded Ed25519 keys being listed directly in the Signed-By field in a deb822 sources.list file, instead of using a keyring.

This is similar to the untrusted comment approach used by signify(1) and minisign(1). We have opted to include the full public key instead of a key identifier in our signatures, in order to be able to validate repositories without having to have the public key on a separate channel, e.g. for linting repositories and to ensure that repositories do not just break for some users whose keys have wrong signatures.

Furthermore, our signatures are in a single line, and the signature algorithm is encoded in the field name instead of the value.

We have tools to convert apt signatures to signify-openbsd public keys and signatures, allowing Release files to be validated by a second set of tools.

Changelog

0.2:

0.3: