Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

server/lib/src/repo_sign.rs

//! ★ **The repository-metadata signing key** (lane HOL-SIGNED-APT-DNF, HOL-FIELD-TEST P4).
//!
//! The deb and rpm chain views ([`crate::chain_view`]) synthesize their indexes, so nobody
//! upstream signed them: until this module apt needed `[trusted=yes]` and dnf `gpgcheck=0`.
//! Now a Holger that holds a repository key signs what it serves:
//!
//! | Path (under a view) | Body |
//! |---|---|
//! | deb `dists/<suite>/InRelease` | `Release`, clear-signed (RFC 4880 §7) |
//! | deb `dists/<suite>/Release.gpg` | a detached armored signature over `Release` |
//! | rpm `repodata/repomd.xml.asc` | a detached armored signature over `repomd.xml` |
//!
//! and the public key is open at [`KEY_PATH`] (`/-/repo-key.asc`, no credential: a client
//! that must sign in to fetch the key it verifies with never verifies), which the console's
//! client setup names (`signed-by=` for apt, `repo_gpgcheck=1` + `gpgkey=` for dnf).
//!
//! **The key is the operator's, made on the box.** `holger repo-key create --config <ron>`
//! makes it ([`create`]) in the data root's key store, `<data root>/`[`DIR`]`/`[`SECRET_FILE`],
//! written 0600 and read through [`crate::keyfile::read_private`] like every other key Holger
//! holds (a Kubernetes secret volume mounted at `<data root>/repo-signing/` is the other shape
//! it takes). Holger never makes one on its own and no key lives in a repository: a box
//! without one serves its views unsigned, as before, and says so at boot. An operator may also
//! bring a key made elsewhere: an unprotected Ed25519 OpenPGP secret key in the same file
//! (`gpg --export-secret-keys --armor` of a key with no passphrase).
//!
//! **Why no OpenPGP crate signs here.** The only pure-Rust OpenPGP crate in the tree is rpgp
//! (`pgp =0.21.0`, MIT OR Apache-2.0), and it is about 93 crates: the default
//! `cargo install holger` is bounded (holger-recipe `cargo_install_holger.rs`) and the
//! `default + online` shape forbids OpenPGP outright (H2 #3, `every_shape_stays_slim.rs`),
//! so behind a feature it would leave exactly the builds people install unsigned. Signing
//! needs much less than a verifier: one v4 Ed25519 key, its user-ID self-signature, and
//! binary/text signature packets, over `ring` (already linked). This module writes those and
//! parses back only the key file it writes. Every shape it writes is verified by rpgp in the
//! tests, and by real apt (sqv/gpgv) and real dnf (librepo) in containers.

use std::path::{Path, PathBuf};

use base64::Engine as _;
use ring::signature::KeyPair as _;

/// The public key's door, open on every box.
pub const KEY_PATH: &str = "/-/repo-key.asc";
/// The key store's directory under the data root.
pub const DIR: &str = "repo-signing";
/// The secret key's file in [`DIR`].
pub const SECRET_FILE: &str = "secret.asc";
/// What a Holger with no repository key answers at [`KEY_PATH`].
pub const NO_KEY: &str = "this Holger holds no repository signing key, so its deb and rpm views \
serve unsigned metadata; the operator makes one on the box with `holger repo-key create --config <ron>`";

/// The user ID a key gets when the operator names none.
pub const DEFAULT_USER_ID: &str = "Holger repository signing key";

const OID_ED25519: [u8; 9] = [0x2B, 0x06, 0x01, 0x04, 0x01, 0xDA, 0x47, 0x0F, 0x01];
/// EdDSA (legacy), the v4 Ed25519 algorithm every apt, gpgv, sqv and rpm verifies.
const ALG_EDDSA: u8 = 22;
/// SHA-512.
const HASH_SHA512: u8 = 10;

/// Where the repository key of a box with `data_root` lives.
pub fn secret_path(data_root: &Path) -> PathBuf {
    data_root.join(DIR).join(SECRET_FILE)
}

/// **A repository signing key**: a v4 Ed25519 OpenPGP primary key with one user ID.
pub struct RepoKey {
    pair: ring::signature::Ed25519KeyPair,
    seed: [u8; 32],
    created: u32,
    user_id: String,
    fingerprint: [u8; 20],
    public_armor: String,
}

impl std::fmt::Debug for RepoKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("RepoKey")
            .field("fingerprint", &self.fingerprint_hex())
            .field("user_id", &self.user_id)
            .finish()
    }
}

impl RepoKey {
    /// A new key from the system's randomness, created at `created` (unix seconds).
    pub fn generate(user_id: &str, created: u32) -> anyhow::Result<Self> {
        let mut seed = [0u8; 32];
        ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut seed)
            .map_err(|_| anyhow::anyhow!("the system's randomness is unavailable"))?;
        Self::from_seed(seed, created, user_id)
    }

    fn from_seed(seed: [u8; 32], created: u32, user_id: &str) -> anyhow::Result<Self> {
        let user_id = user_id.trim();
        if user_id.is_empty() || user_id.contains(['\n', '\r']) {
            anyhow::bail!("a repository key's user ID is one non-empty line");
        }
        let pair = ring::signature::Ed25519KeyPair::from_seed_unchecked(&seed)
            .map_err(|e| anyhow::anyhow!("the repository key is not an Ed25519 key: {e}"))?;
        let mut k = Self {
            pair,
            seed,
            created,
            user_id: user_id.to_string(),
            fingerprint: [0; 20],
            public_armor: String::new(),
        };
        let body = k.public_body();
        let mut fp = vec![0x99];
        fp.extend_from_slice(&(body.len() as u16).to_be_bytes());
        fp.extend_from_slice(&body);
        let d = ring::digest::digest(&ring::digest::SHA1_FOR_LEGACY_USE_ONLY, &fp);
        k.fingerprint.copy_from_slice(d.as_ref());
        let mut block = packet(6, &body);
        block.extend(k.user_id_and_self_sig());
        k.public_armor = armor("PGP PUBLIC KEY BLOCK", &block);
        Ok(k)
    }

    /// The primary key's fingerprint, upper-case hex (as apt and gpg print it).
    pub fn fingerprint_hex(&self) -> String {
        self.fingerprint
            .iter()
            .map(|b| format!("{b:02X}"))
            .collect()
    }

    /// The user ID.
    pub fn user_id(&self) -> &str {
        &self.user_id
    }

    /// The public key, armored (`-----BEGIN PGP PUBLIC KEY BLOCK-----`): what
    /// [`KEY_PATH`] serves, apt's `signed-by=` file and dnf's `gpgkey=`.
    pub fn public_armor(&self) -> &str {
        &self.public_armor
    }

    fn key_id(&self) -> [u8; 8] {
        let mut id = [0u8; 8];
        id.copy_from_slice(&self.fingerprint[12..]);
        id
    }

    /// The v4 public-key packet body.
    fn public_body(&self) -> Vec<u8> {
        let mut b = vec![4];
        b.extend_from_slice(&self.created.to_be_bytes());
        b.push(ALG_EDDSA);
        b.push(OID_ED25519.len() as u8);
        b.extend_from_slice(&OID_ED25519);
        let mut point = vec![0x40];
        point.extend_from_slice(self.pair.public_key().as_ref());
        b.extend(mpi(&point));
        b
    }

    /// The user-ID packet and its positive self-certification (certify + sign).
    fn user_id_and_self_sig(&self) -> Vec<u8> {
        let uid = self.user_id.as_bytes();
        let body = self.public_body();
        let mut data = vec![0x99];
        data.extend_from_slice(&(body.len() as u16).to_be_bytes());
        data.extend_from_slice(&body);
        data.push(0xB4);
        data.extend_from_slice(&(uid.len() as u32).to_be_bytes());
        data.extend_from_slice(uid);
        let extra = [
            subpacket(27, &[0x03]),           // key flags: certify, sign
            subpacket(21, &[HASH_SHA512, 8]), // preferred hashes: SHA-512, SHA-256
            subpacket(30, &[0x01]),           // features: MDC
        ]
        .concat();
        let mut out = packet(13, uid);
        // The key's own creation time: the self-signature, and so the public block, are
        // the same bytes every time the key is read.
        out.extend(packet(
            2,
            &self.signature(0x13, &data, self.created, &extra),
        ));
        out
    }

    /// A v4 signature packet body of `sig_type` over `data`, made at `at`.
    fn signature(&self, sig_type: u8, data: &[u8], at: u32, extra_hashed: &[u8]) -> Vec<u8> {
        let mut issuer_fpr = vec![4];
        issuer_fpr.extend_from_slice(&self.fingerprint);
        let hashed = [
            subpacket(2, &at.to_be_bytes()),
            subpacket(33, &issuer_fpr),
            extra_hashed.to_vec(),
        ]
        .concat();
        let mut head = vec![4, sig_type, ALG_EDDSA, HASH_SHA512];
        head.extend_from_slice(&(hashed.len() as u16).to_be_bytes());
        head.extend_from_slice(&hashed);
        let mut ctx = ring::digest::Context::new(&ring::digest::SHA512);
        ctx.update(data);
        ctx.update(&head);
        ctx.update(&[4, 0xFF]);
        ctx.update(&(head.len() as u32).to_be_bytes());
        let digest = ctx.finish();
        let sig = self.pair.sign(digest.as_ref());
        let sig = sig.as_ref();
        let unhashed = subpacket(16, &self.key_id());
        let mut body = head;
        body.extend_from_slice(&(unhashed.len() as u16).to_be_bytes());
        body.extend_from_slice(&unhashed);
        body.extend_from_slice(&digest.as_ref()[..2]);
        body.extend(mpi(&sig[..32]));
        body.extend(mpi(&sig[32..]));
        body
    }

    /// **A detached, armored signature over `data`** (a binary-document signature):
    /// apt's `Release.gpg`, rpm-md's `repomd.xml.asc`.
    pub fn sign_detached(&self, data: &[u8], at: u32) -> String {
        armor(
            "PGP SIGNATURE",
            &packet(2, &self.signature(0x00, data, at, &[])),
        )
    }

    /// **`text`, clear-signed** (RFC 4880 §7): apt's `InRelease`. The signed text is the
    /// canonical one (lines joined by CRLF, trailing blanks dropped, the line end before the
    /// signature not part of it), dash-escaped where a line starts with `-`.
    pub fn sign_cleartext(&self, text: &str, at: u32) -> String {
        let body = text.strip_suffix('\n').unwrap_or(text);
        let lines: Vec<&str> = body
            .split('\n')
            .map(|l| l.strip_suffix('\r').unwrap_or(l))
            .collect();
        let canonical = lines
            .iter()
            .map(|l| l.trim_end_matches([' ', '\t']))
            .collect::<Vec<_>>()
            .join("\r\n");
        let sig = self.signature(0x01, canonical.as_bytes(), at, &[]);
        let mut out = String::from("-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA512\n\n");
        for l in &lines {
            if l.starts_with('-') {
                out.push_str("- ");
            }
            out.push_str(l);
            out.push('\n');
        }
        out.push_str(&armor("PGP SIGNATURE", &packet(2, &sig)));
        out
    }

    /// The secret key, armored (`-----BEGIN PGP PRIVATE KEY BLOCK-----`, unprotected): the
    /// file [`create`] writes 0600. `gpg --import` takes it.
    fn secret_armor(&self) -> String {
        let mut body = self.public_body();
        body.push(0); // no S2K: the file's mode protects it, as every key file Holger holds
        let secret = mpi(&self.seed);
        let sum = secret
            .iter()
            .fold(0u16, |a, b| a.wrapping_add(u16::from(*b)));
        body.extend_from_slice(&secret);
        body.extend_from_slice(&sum.to_be_bytes());
        let mut block = packet(5, &body);
        block.extend(self.user_id_and_self_sig());
        armor("PGP PRIVATE KEY BLOCK", &block)
    }

    /// **Read a secret key file**: an unprotected v4 Ed25519 OpenPGP secret key (the one
    /// [`create`] writes, or one exported from gpg with no passphrase), armored or binary.
    pub fn from_secret(bytes: &[u8]) -> anyhow::Result<Self> {
        let bin = if bytes.trim_ascii_start().starts_with(b"-----BEGIN") {
            dearmor(bytes)?
        } else {
            bytes.to_vec()
        };
        let mut rest = bin.as_slice();
        let mut secret: Option<(u32, [u8; 32], Vec<u8>)> = None;
        let mut user_id: Option<String> = None;
        while !rest.is_empty() {
            let (tag, body, next) = read_packet(rest)?;
            rest = next;
            match tag {
                5 if secret.is_none() => secret = Some(parse_secret(body)?),
                13 if user_id.is_none() && secret.is_some() => {
                    user_id = Some(String::from_utf8_lossy(body).into_owned())
                }
                _ => {}
            }
        }
        let Some((created, seed, public)) = secret else {
            anyhow::bail!("the file holds no OpenPGP secret key packet");
        };
        let key = Self::from_seed(seed, created, user_id.as_deref().unwrap_or(DEFAULT_USER_ID))?;
        if key.pair.public_key().as_ref() != public.as_slice() {
            anyhow::bail!("the secret key's public half is not the key's own");
        }
        Ok(key)
    }
}

/// `(created, seed, public key)` of a v4 Ed25519 secret-key packet with no S2K.
fn parse_secret(b: &[u8]) -> anyhow::Result<(u32, [u8; 32], Vec<u8>)> {
    let bad = |why: &str| anyhow::anyhow!("not a repository key Holger signs with: {why}");
    if b.len() < 6 || b[0] != 4 {
        return Err(bad("only a v4 key is taken"));
    }
    let created = u32::from_be_bytes([b[1], b[2], b[3], b[4]]);
    if b[5] != ALG_EDDSA {
        return Err(bad("only an Ed25519 (EdDSA) key is taken"));
    }
    let mut r = &b[6..];
    let oid_len = *r.first().ok_or_else(|| bad("truncated"))? as usize;
    if r.len() < 1 + oid_len || r[1..1 + oid_len] != OID_ED25519 {
        return Err(bad("the curve is not Ed25519"));
    }
    r = &r[1 + oid_len..];
    let (point, next) = read_mpi(r)?;
    r = next;
    if point.len() != 33 || point[0] != 0x40 {
        return Err(bad("the public point is not a native Ed25519 point"));
    }
    match r.first() {
        Some(0) => {}
        Some(_) => {
            return Err(bad(
                "the secret key is passphrase-protected; export it without a passphrase \
                 (the file is 0600 and Holger reads it unattended)",
            ))
        }
        None => return Err(bad("truncated")),
    }
    r = &r[1..];
    let start = r;
    let (s, next) = read_mpi(r)?;
    let used = start.len() - next.len();
    let sum = start[..used]
        .iter()
        .fold(0u16, |a, b| a.wrapping_add(u16::from(*b)));
    if next.len() < 2 || u16::from_be_bytes([next[0], next[1]]) != sum {
        return Err(bad("the secret key's checksum does not match"));
    }
    if s.len() > 32 {
        return Err(bad("the secret scalar is longer than an Ed25519 seed"));
    }
    let mut seed = [0u8; 32];
    seed[32 - s.len()..].copy_from_slice(&s);
    Ok((created, seed, point[1..].to_vec()))
}

/// **Make the box's repository key** in `data_root`'s key store (`holger repo-key create`):
/// refused when one is there (a key is replaced by the operator removing it, never by a
/// second create). The file is 0600 from its first byte and published without replacing
/// anything ([`write_new_private`]).
pub fn create(data_root: &Path, user_id: &str) -> anyhow::Result<RepoKey> {
    let file = secret_path(data_root);
    if file.exists() {
        anyhow::bail!("{}", already_there(&file));
    }
    let key = RepoKey::generate(user_id, now())?;
    let dir = data_root.join(DIR);
    std::fs::create_dir_all(&dir)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
    }
    match write_new_private(&file, key.secret_armor().as_bytes()) {
        Ok(()) => Ok(key),
        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
            anyhow::bail!("{}", already_there(&file))
        }
        Err(e) => Err(anyhow::anyhow!("{}: {e}", file.display())),
    }
}

fn already_there(file: &Path) -> String {
    format!(
        "{} already holds this box's repository key; it is never replaced by a create \
         (a client pinned its public half). Remove the file first to make a new one",
        file.display()
    )
}

/// **Write a new private file** (HOL-SIGN-2): `bytes` into `file`, which must not exist.
///
/// * The temporary beside it (`<file>.tmp`) is removed first, never reused: one left by a
///   create that died, or put there by anyone, keeps its own mode (a reused 0644 file stays
///   0644 after a truncate) and may be a symlink a write would follow.
/// * It is made with `O_CREAT|O_EXCL` (which follows no symlink) and mode 0600, then set
///   0600 whatever the umask, written and synced.
/// * It is published by a hard link, which fails with `AlreadyExists` when `file` appeared
///   meanwhile (a second create racing this one), where a rename would replace that key.
pub(crate) fn write_new_private(file: &Path, bytes: &[u8]) -> std::io::Result<()> {
    use std::io::Write as _;
    let mut tmp = file.as_os_str().to_owned();
    tmp.push(".tmp");
    let tmp = PathBuf::from(tmp);
    match std::fs::remove_file(&tmp) {
        Ok(()) => {}
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
        Err(e) => return Err(e),
    }
    {
        let mut o = std::fs::OpenOptions::new();
        o.write(true).create_new(true);
        #[cfg(unix)]
        std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600);
        let mut f = o.open(&tmp)?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt as _;
            f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
        }
        f.write_all(bytes)?;
        f.sync_all()?;
    }
    let linked = std::fs::hard_link(&tmp, file);
    let _ = std::fs::remove_file(&tmp);
    linked?;
    if let Some(dir) = file.parent() {
        if let Ok(d) = std::fs::File::open(dir) {
            let _ = d.sync_all();
        }
    }
    Ok(())
}

/// **The box's repository key**, when the operator made one: `None` when the key store
/// holds none; an error when it holds one that cannot be used (others can read it, it does
/// not parse), which refuses the boot rather than serving unsigned under a key the operator
/// thinks is in use.
pub fn load(data_root: &Path) -> anyhow::Result<Option<RepoKey>> {
    let file = secret_path(data_root);
    match crate::keyfile::read_private(&file, "Holger's repository signing key") {
        Ok(b) => RepoKey::from_secret(&b)
            .map(Some)
            .map_err(|e| anyhow::anyhow!("{}: {e}", file.display())),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(anyhow::anyhow!("{}: {e}", file.display())),
    }
}

/// Now, in unix seconds (a signature's creation time).
pub fn now() -> u32 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as u32)
        .unwrap_or(0)
}

// ── OpenPGP framing ─────────────────────────────────────────────────────────────────

/// A multiprecision integer: the bit count, then the bytes without leading zeros.
fn mpi(bytes: &[u8]) -> Vec<u8> {
    let first = bytes.iter().position(|b| *b != 0).unwrap_or(bytes.len());
    let v = &bytes[first..];
    let bits = match v.first() {
        Some(b) => (v.len() - 1) * 8 + (8 - b.leading_zeros() as usize),
        None => 0,
    };
    let mut out = (bits as u16).to_be_bytes().to_vec();
    out.extend_from_slice(v);
    out
}

fn read_mpi(b: &[u8]) -> anyhow::Result<(Vec<u8>, &[u8])> {
    if b.len() < 2 {
        anyhow::bail!("a truncated OpenPGP number");
    }
    let bits = u16::from_be_bytes([b[0], b[1]]) as usize;
    let n = bits.div_ceil(8);
    if b.len() < 2 + n {
        anyhow::bail!("a truncated OpenPGP number");
    }
    Ok((b[2..2 + n].to_vec(), &b[2 + n..]))
}

/// A new-format packet of `tag`.
fn packet(tag: u8, body: &[u8]) -> Vec<u8> {
    let mut out = vec![0xC0 | tag];
    let n = body.len();
    if n < 192 {
        out.push(n as u8);
    } else if n < 8384 {
        let l = n - 192;
        out.push(((l >> 8) + 192) as u8);
        out.push((l & 0xFF) as u8);
    } else {
        out.push(0xFF);
        out.extend_from_slice(&(n as u32).to_be_bytes());
    }
    out.extend_from_slice(body);
    out
}

/// One packet of `b`, old or new format (no partial lengths: a key file has none):
/// `(tag, body, the rest)`.
fn read_packet(b: &[u8]) -> anyhow::Result<(u8, &[u8], &[u8])> {
    let trunc = || anyhow::anyhow!("a truncated OpenPGP packet");
    let h = *b.first().ok_or_else(trunc)?;
    if h & 0x80 == 0 {
        anyhow::bail!("not an OpenPGP packet");
    }
    let (tag, len, at) = if h & 0x40 != 0 {
        let tag = h & 0x3F;
        let o = *b.get(1).ok_or_else(trunc)? as usize;
        match o {
            0..=191 => (tag, o, 2),
            192..=223 => {
                let o2 = *b.get(2).ok_or_else(trunc)? as usize;
                (tag, ((o - 192) << 8) + o2 + 192, 3)
            }
            255 => {
                let l = b.get(2..6).ok_or_else(trunc)?;
                (
                    tag,
                    u32::from_be_bytes([l[0], l[1], l[2], l[3]]) as usize,
                    6,
                )
            }
            _ => anyhow::bail!("a partial-length packet in a key file"),
        }
    } else {
        let tag = (h >> 2) & 0x0F;
        match h & 0x03 {
            0 => (tag, *b.get(1).ok_or_else(trunc)? as usize, 2),
            1 => {
                let l = b.get(1..3).ok_or_else(trunc)?;
                (tag, u16::from_be_bytes([l[0], l[1]]) as usize, 3)
            }
            2 => {
                let l = b.get(1..5).ok_or_else(trunc)?;
                (
                    tag,
                    u32::from_be_bytes([l[0], l[1], l[2], l[3]]) as usize,
                    5,
                )
            }
            _ => anyhow::bail!("an indeterminate-length packet in a key file"),
        }
    };
    let end = usize::checked_add(at, len).ok_or_else(trunc)?;
    let body = b.get(at..end).ok_or_else(trunc)?;
    Ok((tag, body, &b[end..]))
}

/// A signature subpacket (`len`, `type`, data).
fn subpacket(ty: u8, data: &[u8]) -> Vec<u8> {
    let mut out = Vec::with_capacity(data.len() + 2);
    let n = data.len() + 1;
    if n < 192 {
        out.push(n as u8);
    } else {
        let l = n - 192;
        out.push(((l >> 8) + 192) as u8);
        out.push((l & 0xFF) as u8);
    }
    out.push(ty);
    out.extend_from_slice(data);
    out
}

/// The OpenPGP CRC-24 of `data`.
fn crc24(data: &[u8]) -> u32 {
    let mut crc: u32 = 0x00B7_04CE;
    for b in data {
        crc ^= u32::from(*b) << 16;
        for _ in 0..8 {
            crc <<= 1;
            if crc & 0x0100_0000 != 0 {
                crc ^= 0x0186_4CFB;
            }
        }
    }
    crc & 0x00FF_FFFF
}

/// ASCII armor of `data` under `label`.
fn armor(label: &str, data: &[u8]) -> String {
    let b64 = base64::engine::general_purpose::STANDARD;
    let body = b64.encode(data);
    let mut out = format!("-----BEGIN {label}-----\n\n");
    for line in body.as_bytes().chunks(64) {
        out.push_str(std::str::from_utf8(line).unwrap_or_default());
        out.push('\n');
    }
    let crc = crc24(data).to_be_bytes();
    out.push('=');
    out.push_str(&b64.encode(&crc[1..]));
    out.push('\n');
    out.push_str(&format!("-----END {label}-----\n"));
    out
}

/// The bytes of the first armored block of `text` (its CRC checked when present).
fn dearmor(text: &[u8]) -> anyhow::Result<Vec<u8>> {
    let text = std::str::from_utf8(text).map_err(|_| anyhow::anyhow!("the armor is not text"))?;
    let mut lines = text.lines().map(str::trim_end);
    lines
        .by_ref()
        .find(|l| l.starts_with("-----BEGIN "))
        .ok_or_else(|| anyhow::anyhow!("no armor header"))?;
    // Armor headers (`Comment: …`) up to the blank line.
    for l in lines.by_ref() {
        if l.is_empty() {
            break;
        }
    }
    let mut b64 = String::new();
    let mut crc: Option<&str> = None;
    for l in lines {
        if l.starts_with("-----END ") {
            break;
        }
        if let Some(c) = l.strip_prefix('=') {
            crc = Some(c);
            continue;
        }
        b64.push_str(l);
    }
    let data = base64::engine::general_purpose::STANDARD
        .decode(b64.as_bytes())
        .map_err(|e| anyhow::anyhow!("the armor's base64 does not decode: {e}"))?;
    if let Some(c) = crc {
        let want = base64::engine::general_purpose::STANDARD
            .decode(c)
            .map_err(|e| anyhow::anyhow!("the armor's checksum does not decode: {e}"))?;
        if want.as_slice() != &crc24(&data).to_be_bytes()[1..] {
            anyhow::bail!("the armor's checksum does not match");
        }
    }
    Ok(data)
}

#[cfg(test)]
mod tests {
    use super::*;
    use pgp::composed::{
        CleartextSignedMessage, Deserializable, DetachedSignature, SignedPublicKey,
    };
    use pgp::types::KeyDetails as _;

    fn key() -> RepoKey {
        RepoKey::generate("Holger test repository key", 1_760_000_000).unwrap()
    }

    fn public(k: &RepoKey) -> SignedPublicKey {
        let (p, _) = SignedPublicKey::from_armor_single(k.public_armor().as_bytes()).unwrap();
        p.verify_bindings().expect("the self-signature verifies");
        p
    }

    /// ★ The public block is a key rpgp takes, its fingerprint is the one Holger prints, and
    /// it is the same bytes each time the key is read back.
    #[test]
    fn the_public_key_is_an_openpgp_key_with_a_verified_self_signature() {
        let k = key();
        let p = public(&k);
        assert_eq!(format!("{:X}", p.fingerprint()), k.fingerprint_hex());
        assert_eq!(p.details.users[0].id.id(), k.user_id().as_bytes());
        let again = RepoKey::from_secret(k.secret_armor().as_bytes()).unwrap();
        assert_eq!(again.public_armor(), k.public_armor());
        assert_eq!(again.fingerprint_hex(), k.fingerprint_hex());
    }

    /// ★ `Release.gpg` / `repomd.xml.asc`: a detached signature over the bytes verifies, and
    /// over a changed byte it does not.
    #[test]
    fn a_detached_signature_verifies_and_a_tampered_body_does_not() {
        let k = key();
        let p = public(&k);
        let body = b"<repomd>\n  <revision>1</revision>\n</repomd>\n";
        let asc = k.sign_detached(body, 1_760_000_100);
        let (sig, _) = DetachedSignature::from_armor_single(asc.as_bytes()).unwrap();
        sig.verify(&p, body).expect("the signature verifies");
        let mut tampered = body.to_vec();
        tampered[22] = b'2';
        assert!(
            sig.verify(&p, &tampered).is_err(),
            "a changed body is refused"
        );
    }

    /// ★ `InRelease`: the clear-signed Release verifies, carries the Release text, and a
    /// changed line is refused; a line starting with `-` is dash-escaped.
    #[test]
    fn a_cleartext_signature_verifies_and_a_tampered_text_does_not() {
        let k = key();
        let p = public(&k);
        let release = "Origin: Holger\nSuite: stable\nSHA256:\n abc 12 main/binary-amd64/Packages\n-dash: line\n";
        let signed = k.sign_cleartext(release, 1_760_000_200);
        assert!(signed.contains("\n- -dash: line\n"), "{signed}");
        let (msg, _) = CleartextSignedMessage::from_string(&signed).unwrap();
        msg.verify(&p).expect("the clear-signed text verifies");
        assert_eq!(
            msg.signed_text(),
            release.trim_end_matches('\n').replace('\n', "\r\n")
        );
        let tampered = signed.replace("Origin: Holger", "Origin: Hxlger");
        let (bad, _) = CleartextSignedMessage::from_string(&tampered).unwrap();
        assert!(bad.verify(&p).is_err(), "a changed line is refused");
    }

    /// The key store: made 0600, refused a second time, read back the same; a group-readable
    /// one refuses the load by name; none is `None`.
    #[test]
    fn the_key_store_makes_a_private_file_once_and_reads_it_back() {
        use std::os::unix::fs::PermissionsExt as _;
        let d = tempfile::tempdir().unwrap();
        assert!(load(d.path()).unwrap().is_none());
        let k = create(d.path(), DEFAULT_USER_ID).unwrap();
        let mode = std::fs::metadata(secret_path(d.path()))
            .unwrap()
            .permissions()
            .mode();
        assert_eq!(mode & 0o777, 0o600);
        let err = create(d.path(), DEFAULT_USER_ID).unwrap_err().to_string();
        assert!(err.contains("never replaced"), "{err}");
        let back = load(d.path()).unwrap().unwrap();
        assert_eq!(back.fingerprint_hex(), k.fingerprint_hex());
        std::fs::set_permissions(
            secret_path(d.path()),
            std::fs::Permissions::from_mode(0o640),
        )
        .unwrap();
        let err = load(d.path()).unwrap_err().to_string();
        assert!(err.contains("holger-key-1"), "{err}");
    }

    /// ★ HOL-SIGN-2 review: a temporary left in the key store (a create that died, or a
    /// file anyone put there) never carries the key out: a 0644 one is not reused (the key
    /// is 0600), and a symlink is not written through.
    #[test]
    fn a_left_over_temporary_never_carries_the_key_out_of_the_store() {
        use std::os::unix::fs::PermissionsExt as _;
        let d = tempfile::tempdir().unwrap();
        let dir = d.path().join(DIR);
        std::fs::create_dir_all(&dir).unwrap();
        let tmp = dir.join(format!("{SECRET_FILE}.tmp"));
        std::fs::write(&tmp, b"stale").unwrap();
        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).unwrap();
        create(d.path(), DEFAULT_USER_ID).unwrap();
        let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
        assert_eq!(mode(&secret_path(d.path())), 0o600, "the key is 0600");
        assert!(load(d.path()).unwrap().is_some());
        assert!(!tmp.exists(), "no temporary is left");

        std::fs::remove_file(secret_path(d.path())).unwrap();
        let outside = d.path().join("outside.txt");
        std::fs::write(&outside, b"untouched").unwrap();
        std::fs::set_permissions(&outside, std::fs::Permissions::from_mode(0o644)).unwrap();
        std::os::unix::fs::symlink(&outside, &tmp).unwrap();
        create(d.path(), DEFAULT_USER_ID).unwrap();
        assert_eq!(
            std::fs::read(&outside).unwrap(),
            b"untouched",
            "not written through"
        );
        let meta = std::fs::symlink_metadata(secret_path(d.path())).unwrap();
        assert!(meta.file_type().is_file(), "the key is a file, not a link");
        assert_eq!(mode(&secret_path(d.path())), 0o600);
    }

    /// ★ HOL-SIGN-2 review: a key that appears while a create runs is never replaced.
    #[test]
    fn a_new_private_file_never_replaces_one_that_is_there() {
        let d = tempfile::tempdir().unwrap();
        let file = d.path().join("secret.asc");
        std::fs::write(&file, b"the first key").unwrap();
        let err = write_new_private(&file, b"a second key").unwrap_err();
        assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists, "{err}");
        assert_eq!(std::fs::read(&file).unwrap(), b"the first key");
        assert!(!d.path().join("secret.asc.tmp").exists());
    }

    /// A packet length past the end of the file is a truncated packet, never an overflow.
    #[test]
    fn a_packet_longer_than_the_file_is_truncated() {
        let huge = [0xC5, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 1, 2, 3];
        let err = read_packet(&huge).unwrap_err().to_string();
        assert!(err.contains("truncated"), "{err}");
        let old = [0x96, 0xFF, 0xFF, 0xFF, 0xFF, 1];
        assert!(read_packet(&old).is_err());
    }

    /// A passphrase-protected or a non-Ed25519 key is refused by name.
    #[test]
    fn a_key_holger_cannot_sign_with_is_refused() {
        let k = key();
        let mut body = k.public_body();
        body.push(254);
        let err = RepoKey::from_secret(&packet(5, &body))
            .unwrap_err()
            .to_string();
        assert!(err.contains("passphrase"), "{err}");
        let mut rsa = k.public_body();
        rsa[5] = 1;
        let err = RepoKey::from_secret(&packet(5, &rsa))
            .unwrap_err()
            .to_string();
        assert!(err.contains("Ed25519"), "{err}");
    }

    #[test]
    fn crc24_is_the_openpgp_one() {
        // RFC 4880 §6.1: the CRC of nothing is the init value.
        assert_eq!(crc24(b""), 0x00B7_04CE);
        assert_eq!(dearmor(armor("X", b"hello").as_bytes()).unwrap(), b"hello");
    }
}