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

Old 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


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

 <algorithm> <base64>
 <algorithm> <base64>
 <algorithm> <base64>

For example:

 apt-ed25519 +F0BDcXHPs/Uda5tnKuC3xdEKDjllyPyByZL6i0nB3SAfSQhati96A18rr0OTpzMCw1LnFmDooLitlOy+gxI2t3BeNcqrrH0Ueg5NsuNmnKGRSLo1YXSW3XmJOIWImm90nVMcEiEhmBZMef+U9s9Z+JX02pQFQ/dRu8uSt+JyfNobl95I25WTzOu7mUnMxrFQUe33WWCHWp9liGRW7el0agiTqGLLubLdThYBmsTp6aWPLKMC5pRjA==
 apt-ed25519 nwmO80uQM2fEzJWS0Ce3OKVPGcM2OWQL/TX2Q6Yy+9XHpz+ORmt1PPeo/jQuE8Rlj4CFuMqXvJp1SMYBqRI/zd5HMNPd/V0W6D+vks77l5q9t8o/6N5lMhMK/i/OFSqNL8Zy949qhqLwzF6a3Oyd6LF9MQX9fJD96GoRDmEPcIAuVj1uXitUc82uJQYaWtgccwOhf8RPWaP1+vuXiFpYlZlnILLMka9rYL4/ahGOukAmKl6m28Ka7g==

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

The following algorithms are currently defined:

The following algorithms are reserved:

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
   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
  11             in_signatures = in_signatures and line.startswith(b" ")
  13             if line == b"Signatures:\n":
  14                 in_signatures = True
  15             elif in_signatures:
  16                 self.signatures.append(Signature(base64.b64decode(line.split()[1])))
  17             else:
  18                 self.buffer += line
  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.

Signature format

The signature format is defined as follows:

Note that in the above signature, we describe a specific signature for release files. However, it is entirely valid to also sign other data, in such a case the domain (org.debian.apt) and the scope (release-file) should be changed.

All integers must be little endian.

To verify:

  1. Find the public key matching the key id
  2. Verify the attributes of the signing subkey
  3. Verify the raw signature of the annotated signing subkey
  4. Verify the raw signature of the annotated content

Verification: When a client checks an updated Release file, it MUST fail validation if one of the signing subkeys has a lower generation 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.

Multiple subkeys will share the same generation. APT will not verify revocations across repositories.

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.

Co-existence with OpenPGP 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 OpenPGP signatures will be removed after Debian and Ubuntu shipped one stable (in case of Ubuntu: LTS) release with warnings for missing apt-sign signatures.

OpenPGP and Ed25519 signatures must both be present for at least one Debian release and one Ubuntu LTS cycle before OpenPGP support is being removed.

Public key primary format

A public key is stored in a format similar to the signature, as:

name base64(key id || raw public key)

A public key file has one or more such lines, and optionally comments - anything following and including the character # is removed.

Example 1: A signed-by field with embedded keys:

  apt-ed25519 UYu0opBtAM8ggtsZ+OWPZVfWipY+6o1TaKQZV1iLkoPIaeOZ24lFNQ==
  apt-ed25519 mn6YGgMjBcPPKgUAKmWSVj3C/qyGl4QtEUFLeGV1Sgzs9ogpLziEcw== # comment

Example 2: A key file:

  apt-ed25519 UYu0opBtAM8ggtsZ+OWPZVfWipY+6o1TaKQZV1iLkoPIaeOZ24lFNQ==
  apt-ed25519 mn6YGgMjBcPPKgUAKmWSVj3C/qyGl4QtEUFLeGV1Sgzs9ogpLziEcw== # comment

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.