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}