Skip to main content

ferroday_cage/provision/alpine/keyring/
mod.rs

1//! The keys a repository's signatures are verified against, and the sets the
2//! crate bundles for Alpine and postmarketOS.
3//!
4//! An apk signature names the key that made it by file name, so a [`KeySet`] is
5//! keyed the same way: `alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub` is
6//! both what the signature spells and what the repository publishes the key
7//! under.
8//!
9//! # A key set is the whole trust anchor
10//!
11//! An Alpine signing key is a bare RSA public key in PEM `SubjectPublicKeyInfo`
12//! form. It carries no expiry, no self-signature, and no revocation, so none of
13//! the validity and freshness rules an OpenPGP certificate is held to have an
14//! analogue here: what the set holds is what is trusted, and the only way to
15//! withdraw a key is to stop shipping it. A bundled set can therefore only be
16//! refreshed by a release of this crate, and a caller who needs a different
17//! answer sooner assembles one with [`KeySet::insert`].
18//!
19//! # The bundle is partitioned by architecture
20//!
21//! Alpine does not sign every architecture with one key. `alpine-keys` installs
22//! three keys on x86_64, two entirely different ones on aarch64, two more on
23//! armv7, and so on: eighteen distinct keys across ten architectures, with only
24//! the two oldest serving more than one. A signature made for x86_64 does not
25//! verify on an aarch64 root under `apk`, and it does not here either.
26//!
27//! [`ALPINE`] mirrors that partition rather than flattening it, and mirrors it
28//! mechanically: it is the `usr/share/apk/keys/<architecture>/` tree of the
29//! `alpine-keys` package, key for key. Refreshing the bundle is therefore
30//! re-reading that package rather than deciding which architectures still
31//! count, which is a rule a later reader can check rather than a judgement they
32//! would have to reconstruct.
33//!
34//! Alpine rotates its signing key per release and keeps the superseded ones in
35//! that package, so an architecture's set spans every release the archive still
36//! serves rather than only the current one.
37
38use std::collections::BTreeMap;
39use std::fmt;
40
41use rsa::RsaPublicKey;
42use rsa::pkcs8::DecodePublicKey;
43
44use super::AlpineError;
45
46/// One key of the bundle: the file name a signature spells it by, and the PEM
47/// the crate ships.
48pub(super) struct Bundled {
49    /// The file name the repository publishes the key under, which is what a
50    /// `.SIGN.` member's name carries.
51    pub(super) name: &'static str,
52    /// The key itself, PEM `SubjectPublicKeyInfo`.
53    pub(super) pem: &'static str,
54}
55
56/// One Alpine key of the bundle, named by the hexadecimal stem `alpine-keys`
57/// publishes it under.
58///
59/// The stem produces the file name and the contents both, so the two cannot
60/// drift apart: a stem naming a file this directory does not hold fails to
61/// compile.
62macro_rules! alpine_key {
63    ($stem:literal) => {
64        Bundled {
65            name: concat!("alpine-devel@lists.alpinelinux.org-", $stem, ".rsa.pub"),
66            pem: include_str!(concat!(
67                "alpine-devel@lists.alpinelinux.org-",
68                $stem,
69                ".rsa.pub"
70            )),
71        }
72    };
73}
74
75/// The Alpine signing keys the crate bundles, by the architecture each serves.
76///
77/// This is `alpine-keys`' own `usr/share/apk/keys/<architecture>/` partition,
78/// mirrored key for key; see the module documentation for why it is a mirror
79/// rather than a selection. The architectures are the ones that package covers,
80/// which is a superset of what the archive publishes today: `mips64` was
81/// dropped after Alpine 3.15 and its key is kept because the package keeps it,
82/// so an archived mirror still verifies.
83pub(super) const ALPINE: &[(&str, &[Bundled])] = &[
84    (
85        "aarch64",
86        &[alpine_key!("58199dcc"), alpine_key!("616ae350")],
87    ),
88    ("armhf", &[alpine_key!("524d27bb"), alpine_key!("616a9724")]),
89    ("armv7", &[alpine_key!("524d27bb"), alpine_key!("616adfeb")]),
90    ("loongarch64", &[alpine_key!("66ba20fe")]),
91    ("mips64", &[alpine_key!("5e69ca50")]),
92    (
93        "ppc64le",
94        &[alpine_key!("58cbb476"), alpine_key!("616abc23")],
95    ),
96    (
97        "riscv64",
98        &[alpine_key!("60ac2099"), alpine_key!("616db30d")],
99    ),
100    ("s390x", &[alpine_key!("58e4f17d"), alpine_key!("616ac3bc")]),
101    (
102        "x86",
103        &[
104            alpine_key!("4a6a0840"),
105            alpine_key!("5243ef4b"),
106            alpine_key!("61666e3f"),
107        ],
108    ),
109    (
110        "x86_64",
111        &[
112            alpine_key!("4a6a0840"),
113            alpine_key!("5261cecb"),
114            alpine_key!("6165ee59"),
115        ],
116    ),
117];
118
119/// postmarketOS's signing key, which is one key for every architecture it
120/// publishes.
121const POSTMARKETOS: Bundled = Bundled {
122    name: "build.postmarketos.org.rsa.pub",
123    pem: include_str!("build.postmarketos.org.rsa.pub"),
124};
125
126/// The signing keys a repository's index and packages are verified against.
127///
128/// Obtained from the bundle with [`alpine`](Self::alpine) or
129/// [`postmarketos`](Self::postmarketos), or assembled from a caller's own keys
130/// with [`insert`](Self::insert) — which is also how a set is extended, since a
131/// repository publishing its own packages beside a distribution's signs them
132/// with a key no bundle can know.
133///
134/// A set is the whole trust anchor for the repository it is attached to. An apk
135/// signing key is a bare RSA public key: it carries no expiry, no
136/// self-signature, and no revocation, so none of the validity rules an OpenPGP
137/// certificate is held to have an analogue here, and nothing narrows a set after
138/// it is built. A bundled set can only be refreshed by a release of this crate;
139/// a caller who needs a different answer sooner assembles one with
140/// [`insert`](Self::insert).
141#[derive(Clone, Default)]
142pub struct KeySet {
143    keys: BTreeMap<String, Held>,
144}
145
146/// One trusted key: the parsed form a signature is verified against, and the
147/// text it was given as.
148///
149/// The text is kept rather than re-encoded because a bootstrap writes the
150/// repository's keys into the finished root, where `apk` reads them back — and
151/// a key written back as it arrived is one a caller can diff against the file
152/// they supplied.
153#[derive(Clone)]
154struct Held {
155    key: RsaPublicKey,
156    pem: String,
157}
158
159impl fmt::Debug for KeySet {
160    /// Names the keys rather than rendering them. A public key's modulus is
161    /// bulky and says nothing a reader of a rendering can act on, while the
162    /// names are exactly what a refused signature is diagnosed against.
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        f.debug_struct("KeySet")
165            .field("keys", &self.keys.keys())
166            .finish()
167    }
168}
169
170impl KeySet {
171    /// An empty key set, which accepts nothing.
172    pub fn new() -> KeySet {
173        KeySet::default()
174    }
175
176    /// The bundled Alpine keys that sign `architecture`.
177    ///
178    /// Alpine does not sign every architecture with one key: `alpine-keys`
179    /// installs three keys on x86_64, two entirely different ones on aarch64,
180    /// two more on armv7, and so on, with only the two oldest of the eighteen
181    /// serving more than one architecture. A signature made for x86_64 does not
182    /// verify on an aarch64 root under `apk`, and it does not here either, so
183    /// this is the set for one architecture rather than the whole bundle.
184    ///
185    /// `architecture` is the repository's, so it is always a real one —
186    /// `noarch` is a property of a package rather than an archive a set can be
187    /// built for.
188    ///
189    /// # Errors
190    ///
191    /// Returns [`AlpineError::Config`] for an architecture the bundle holds no
192    /// keys for, naming the ones it does. The architecture is not otherwise
193    /// refused: a mirror may serve one this crate has never heard of, and the
194    /// remedy is a set of the caller's own rather than a newer release of this
195    /// crate.
196    pub fn alpine(architecture: &str) -> Result<KeySet, AlpineError> {
197        let Some((_, keys)) = ALPINE.iter().find(|(name, _)| *name == architecture) else {
198            return Err(AlpineError::Config {
199                reason: format!(
200                    "the crate bundles no Alpine signing keys for the architecture \
201                     {architecture:?}; it holds keys for {}. To provision an architecture the \
202                     bundle does not cover, build a key set of your own with KeySet::insert",
203                    ALPINE
204                        .iter()
205                        .map(|(name, _)| *name)
206                        .collect::<Vec<_>>()
207                        .join(", "),
208                ),
209            });
210        };
211        Ok(KeySet::bundled(keys))
212    }
213
214    /// The bundled postmarketOS signing key.
215    ///
216    /// postmarketOS signs every architecture it publishes with one key, so this
217    /// takes none.
218    pub fn postmarketos() -> KeySet {
219        KeySet::bundled(std::slice::from_ref(&POSTMARKETOS))
220    }
221
222    /// The set holding every key of `bundled`.
223    ///
224    /// The keys are compiled into the crate, so one that does not parse is a
225    /// defect in this crate rather than anything a caller did or can act on. A
226    /// test parses every bundled key, which is where that would be caught.
227    fn bundled(bundled: &[Bundled]) -> KeySet {
228        let mut set = KeySet::new();
229        for key in bundled {
230            set.insert(key.name, key.pem)
231                .expect("a key bundled with the crate is a usable RSA public key");
232        }
233        set
234    }
235
236    /// Adds the PEM-encoded public key `pem` under `name`.
237    ///
238    /// `name` is the file name the repository publishes the key under, which is
239    /// what a signature member's name carries — for example
240    /// `alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub`, or the
241    /// `<name>.rsa.pub` that `abuild-sign` writes for a repository of the
242    /// caller's own. A signature naming a key the set does not hold under
243    /// exactly that name is refused, so the name matters as much as the bytes.
244    ///
245    /// A key added under a name already present replaces it.
246    ///
247    /// # Errors
248    ///
249    /// Returns [`AlpineError::Signature`] for a key that is not a PEM
250    /// `SubjectPublicKeyInfo` holding an RSA public key.
251    pub fn insert(&mut self, name: impl Into<String>, pem: &str) -> Result<(), AlpineError> {
252        let name = name.into();
253        let key = RsaPublicKey::from_public_key_pem(pem).map_err(|err| {
254            AlpineError::signature(
255                name.clone(),
256                format!("{name} is not a usable RSA public key: {err}"),
257            )
258        })?;
259        self.keys.insert(
260            name,
261            Held {
262                key,
263                pem: pem.to_string(),
264            },
265        );
266        Ok(())
267    }
268
269    /// The names of the keys held, in order.
270    ///
271    /// These are the names a signature is matched against, so this is what a
272    /// caller inspects to see what a repository is trusted on.
273    pub fn names(&self) -> impl Iterator<Item = &str> {
274        self.keys.keys().map(String::as_str)
275    }
276
277    /// The key held under `name`, or `None` where the set holds none.
278    ///
279    /// A signature names its key exactly, so this is an exact lookup: a set
280    /// holding the same key under a different name verifies nothing.
281    pub(super) fn get(&self, name: &str) -> Option<&RsaPublicKey> {
282        self.keys.get(name).map(|held| &held.key)
283    }
284
285    /// Every key as the name it is published under and the text it was given
286    /// as, in name order.
287    ///
288    /// What a bootstrap writes into `/etc/apk/keys` for the repositories it
289    /// installed from, so a later `apk` inside the root trusts what this one
290    /// did.
291    pub(super) fn pems(&self) -> impl Iterator<Item = (&str, &str)> {
292        self.keys
293            .iter()
294            .map(|(name, held)| (name.as_str(), held.pem.as_str()))
295    }
296
297    /// Whether the set holds no keys at all, and so accepts nothing.
298    pub fn is_empty(&self) -> bool {
299        self.keys.is_empty()
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn every_bundled_key_parses() {
309        // `bundled` expects this, so the expectation is checked here rather
310        // than discovered by a caller: a bundled key that does not parse is a
311        // defect in this crate.
312        for (architecture, keys) in ALPINE {
313            let set = KeySet::alpine(architecture)
314                .unwrap_or_else(|err| panic!("{architecture} is in the bundle: {err}"));
315            assert_eq!(set.names().count(), keys.len(), "{architecture}");
316        }
317        assert_eq!(KeySet::postmarketos().names().count(), 1);
318    }
319
320    #[test]
321    fn a_key_is_held_under_the_name_a_signature_spells() {
322        // The whole scheme rests on this: a signature member names its key by
323        // the file name the repository publishes it under.
324        let set = KeySet::alpine("x86_64").expect("x86_64 is in the bundle");
325        assert!(
326            set.names()
327                .any(|name| name == "alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub"),
328            "the current x86_64 key is held under its published name: {:?}",
329            set.names().collect::<Vec<_>>(),
330        );
331    }
332
333    #[test]
334    fn the_architecture_partitions_are_all_but_disjoint() {
335        // The finding this bundle is shaped by. Only the two oldest keys serve
336        // more than one architecture, so an x86_64 signature does not verify on
337        // an aarch64 root.
338        let x86_64 = KeySet::alpine("x86_64").expect("x86_64 is in the bundle");
339        let aarch64 = KeySet::alpine("aarch64").expect("aarch64 is in the bundle");
340        assert!(
341            !x86_64
342                .names()
343                .any(|name| aarch64.names().any(|o| o == name)),
344            "x86_64 and aarch64 share no key",
345        );
346
347        let mut shared = Vec::new();
348        for (architecture, keys) in ALPINE {
349            for key in *keys {
350                if ALPINE.iter().any(|(other, keys)| {
351                    other != architecture && keys.iter().any(|o| o.name == key.name)
352                }) {
353                    shared.push(key.name);
354                }
355            }
356        }
357        shared.sort_unstable();
358        shared.dedup();
359        assert_eq!(
360            shared,
361            [
362                "alpine-devel@lists.alpinelinux.org-4a6a0840.rsa.pub",
363                "alpine-devel@lists.alpinelinux.org-524d27bb.rsa.pub",
364            ],
365            "only the two oldest keys serve more than one architecture",
366        );
367    }
368
369    #[test]
370    fn an_architecture_the_bundle_does_not_cover_is_refused_by_name() {
371        // Not because the architecture is invalid — a mirror may serve one this
372        // crate has never heard of — but because there is nothing to trust it
373        // on, and the message says what to do about that.
374        let err = KeySet::alpine("sparc64").expect_err("the bundle has no sparc64 keys");
375        let reason = err.to_string();
376        assert!(reason.contains("sparc64"), "{reason}");
377        assert!(
378            reason.contains("x86_64"),
379            "the covered set is named: {reason}"
380        );
381        assert!(reason.contains("KeySet::insert"), "{reason}");
382    }
383
384    #[test]
385    fn a_key_that_is_not_a_public_key_is_refused() {
386        let mut set = KeySet::new();
387        assert!(set.is_empty());
388        let err = set
389            .insert("mine.rsa.pub", "-----BEGIN PUBLIC KEY-----\nnope\n")
390            .expect_err("the key does not parse");
391        assert!(matches!(err, AlpineError::Signature { .. }), "{err}");
392        assert!(set.is_empty(), "a refused key is not held");
393    }
394
395    #[test]
396    fn a_caller_key_extends_a_bundled_set() {
397        // The route for a repository publishing its own packages beside a
398        // distribution's: the bundle plus a key `abuild-sign` made.
399        let mut set = KeySet::alpine("x86_64").expect("x86_64 is in the bundle");
400        let bundled = set.names().count();
401        set.insert("mine.rsa.pub", POSTMARKETOS.pem)
402            .expect("a well-formed key is accepted");
403        assert_eq!(set.names().count(), bundled + 1);
404        assert!(set.names().any(|name| name == "mine.rsa.pub"));
405    }
406
407    #[test]
408    fn a_rendering_names_the_keys_rather_than_holding_them() {
409        let rendered = format!("{:?}", KeySet::postmarketos());
410        assert!(
411            rendered.contains("build.postmarketos.org.rsa.pub"),
412            "{rendered}"
413        );
414        assert!(
415            !rendered.contains("BEGIN PUBLIC KEY"),
416            "the key itself is not in the rendering: {rendered}",
417        );
418    }
419}