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/signed_index.rs

//! ★ **Signed repository indexes** (lane HOL-SIGN-2, after HOL-SIGNED-APT-DNF).
//!
//! A deb or rpm repository that a box's repository key ([`crate::repo_sign`]) signs serves,
//! beside its index, the signature over it:
//!
//! | Path (under the repository) | Body |
//! |---|---|
//! | deb `dists/<suite>/InRelease` | `dists/<suite>/Release`, clear-signed |
//! | deb `dists/<suite>/Release.gpg` | a detached armored signature over `Release` |
//! | rpm `repodata/repomd.xml.asc` | a detached armored signature over `repomd.xml` |
//!
//! [`IndexSigner`] answers those paths for any backend: it asks the backend for the document
//! the client will check the signature against, and signs exactly those bytes. A signature
//! is made once per document: it is kept by the document's SHA-256 and made again when the
//! document changes, so the signature always follows what the repository serves. Two shapes
//! use it:
//!
//! * the chain views ([`crate::chain_view::ChainView`]), whose index follows the chain's
//!   AVAILABLE set;
//! * an archive-backed deb or rpm repository (`ron_archive_path`), wrapped in
//!   [`SignedIndexes`] at boot.
//!
//! **What the key puts its name to.** The signer signs only an index document of its own
//! repository type, at a suite name that is a plain Debian suite word ([`plain_suite`]):
//! the suite is part of the URL, the deb backend writes it into `Release` (`Suite:`,
//! `Codename:`), and a box key must never sign text a client chose. A chain view also leaves
//! the format's index tree out of its set ([`is_index_path`]), so its indexes are always the
//! ones Holger synthesized over the AVAILABLE packages, never a file that came through the
//! chain at an index path (a replayed `InRelease`, a hand-made `Release`).

use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};

use traits::{
    Access, ArtifactFormat, ArtifactId, Bytes, Caps, Decorator, HttpBytesReply, HttpReply,
    RepositoryBackendTrait,
};

use crate::lock::LockOrRecover as _;
use crate::repo_sign::RepoKey;

/// How a signed path signs its document.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SigShape {
    /// apt's `InRelease`: the document clear-signed.
    Clear,
    /// apt's `Release.gpg`, rpm-md's `repomd.xml.asc`: a detached armored signature.
    Detached,
}

/// At most this many signed documents are kept per repository: a suite name is the
/// client's, so the cache is bounded; past it a signature is made per request (one Ed25519
/// signature) and not kept.
pub const SIGNED_KEPT: usize = 32;

/// **A suite name the key signs a `Release` for**: what Debian's archive tools allow in a
/// suite or codename (`stable`, `trixie-security`, `1.0`, `noble+1`), at most 64 bytes. A
/// suite is not `.` or `..`, and nothing else (no `%`, no space, no control byte) reaches
/// the `Suite:` line of a signed `Release`.
pub fn plain_suite(suite: &str) -> bool {
    !suite.is_empty()
        && suite.len() <= 64
        && suite != "."
        && suite != ".."
        && suite
            .bytes()
            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'+' | b'~'))
}

/// Is `rel` (a path below the repository) in the index tree that `repo_type` synthesizes:
/// deb `dists/`, rpm `repodata/`? A chain view leaves those paths out of its set.
pub fn is_index_path(repo_type: &str, rel: &str) -> bool {
    let rel = rel.trim_start_matches('/');
    match repo_type {
        "deb" => rel.starts_with("dists/"),
        "rpm" => rel.starts_with("repodata/"),
        _ => false,
    }
}

/// **The document a signed path signs, and how**: `(the document's request path, shape,
/// content type)` for repository `name` of `repo_type`. `None` for every other path, for a
/// suite that is not [`plain_suite`], and for every path of another repository type.
pub fn signed_doc(
    name: &str,
    repo_type: &str,
    suburl: &str,
) -> Option<(String, SigShape, &'static str)> {
    let path = suburl
        .split('?')
        .next()
        .unwrap_or(suburl)
        .trim_start_matches('/');
    let rel = path.strip_prefix(name)?.strip_prefix('/')?;
    match repo_type {
        "deb" => {
            let parts: Vec<&str> = rel.split('/').collect();
            match parts.as_slice() {
                ["dists", suite, "InRelease"] if plain_suite(suite) => Some((
                    format!("/{name}/dists/{suite}/Release"),
                    SigShape::Clear,
                    "text/plain",
                )),
                ["dists", suite, "Release.gpg"] if plain_suite(suite) => Some((
                    format!("/{name}/dists/{suite}/Release"),
                    SigShape::Detached,
                    "application/pgp-signature",
                )),
                _ => None,
            }
        }
        "rpm" => (rel == "repodata/repomd.xml.asc").then(|| {
            (
                format!("/{name}/repodata/repomd.xml"),
                SigShape::Detached,
                "application/pgp-signature",
            )
        }),
        _ => None,
    }
}

/// One kept signature: the SHA-256 of the document it signs, the signature, its type.
struct Held {
    doc_sha256: [u8; 32],
    signed: Bytes,
    ctype: &'static str,
}

/// **The signer of one repository's indexes** under the box's repository key.
pub struct IndexSigner {
    name: String,
    repo_type: String,
    key: Arc<RepoKey>,
    held: Mutex<BTreeMap<String, Held>>,
}

impl IndexSigner {
    pub fn new(name: &str, repo_type: &str, key: Arc<RepoKey>) -> Self {
        Self {
            name: name.to_string(),
            repo_type: repo_type.to_string(),
            key,
            held: Mutex::new(BTreeMap::new()),
        }
    }

    /// The key it signs with.
    pub fn key(&self) -> &RepoKey {
        &self.key
    }

    /// Is `suburl` one of the signed paths ([`signed_doc`])?
    pub fn signs(&self, suburl: &str) -> bool {
        signed_doc(&self.name, &self.repo_type, suburl).is_some()
    }

    /// **A signed path's answer**, or `None` when `suburl` is no signed path. The signature
    /// is over the document `backend` serves now; a document that is not there (a 404 from
    /// the backend) is the backend's own answer, and nothing is signed.
    pub fn answer(
        &self,
        suburl: &str,
        backend: &dyn RepositoryBackendTrait,
    ) -> Option<anyhow::Result<HttpBytesReply>> {
        let (doc, shape, ctype) = signed_doc(&self.name, &self.repo_type, suburl)?;
        let path = suburl.split('?').next().unwrap_or(suburl).to_string();
        Some((|| {
            let (status, headers, body) = backend.handle_http2_request_bytes("GET", &doc, &[])?;
            if status != 200 {
                return Ok((status, headers, body));
            }
            let mut digest = [0u8; 32];
            digest.copy_from_slice(ring::digest::digest(&ring::digest::SHA256, &body).as_ref());
            if let Some(h) = self.held.lock_or_recover().get(&path) {
                if h.doc_sha256 == digest {
                    return Ok(reply(h.ctype, h.signed.clone()));
                }
            }
            let at = crate::repo_sign::now();
            let signed = match shape {
                SigShape::Clear => {
                    let text = std::str::from_utf8(&body).map_err(|_| {
                        anyhow::anyhow!("{doc} is not text, so it is not clear-signed")
                    })?;
                    self.key.sign_cleartext(text, at)
                }
                SigShape::Detached => self.key.sign_detached(&body, at),
            };
            let signed = Bytes::from(signed);
            let mut held = self.held.lock_or_recover();
            if held.len() < SIGNED_KEPT || held.contains_key(&path) {
                held.insert(
                    path,
                    Held {
                        doc_sha256: digest,
                        signed: signed.clone(),
                        ctype,
                    },
                );
            }
            Ok(reply(ctype, signed))
        })())
    }
}

fn reply(ctype: &'static str, body: Bytes) -> HttpBytesReply {
    (200, vec![("Content-Type".into(), ctype.into())], body)
}

/// **An archive-backed deb or rpm repository, its indexes signed** (lane HOL-SIGN-2): every
/// call is the inner backend's, except a `GET`/`HEAD` of a signed path, which
/// [`IndexSigner`] answers over the inner backend's document. A signed path maps to no
/// coordinate, so no door streams a file of the archive in its place.
pub struct SignedIndexes {
    inner: Arc<dyn RepositoryBackendTrait>,
    signer: IndexSigner,
}

impl SignedIndexes {
    /// `inner` (named `inner.name()`, of `repo_type`) signed with `key`.
    pub fn new(inner: Arc<dyn RepositoryBackendTrait>, repo_type: &str, key: Arc<RepoKey>) -> Self {
        let signer = IndexSigner::new(inner.name(), repo_type, key);
        Self { inner, signer }
    }
}

impl Decorator for SignedIndexes {
    fn name(&self) -> &str {
        self.inner.name()
    }

    fn format(&self) -> ArtifactFormat {
        self.inner.format()
    }

    fn is_writable(&self) -> bool {
        self.inner.is_writable()
    }

    fn caps(&self) -> Caps {
        self.inner.caps()
    }

    fn around<T>(
        &self,
        _access: Access,
        call: impl FnOnce(&dyn RepositoryBackendTrait) -> anyhow::Result<T>,
    ) -> anyhow::Result<T> {
        call(self.inner.as_ref())
    }

    fn handle_http2_request(
        &self,
        method: &str,
        suburl: &str,
        body: &[u8],
    ) -> anyhow::Result<HttpReply> {
        if matches!(method, "GET" | "HEAD") && self.signer.signs(suburl) {
            return Decorator::handle_http2_request_bytes(self, method, suburl, body)
                .map(|(s, h, b)| (s, h, b.to_vec()));
        }
        self.inner.handle_http2_request(method, suburl, body)
    }

    fn handle_http2_request_bytes(
        &self,
        method: &str,
        suburl: &str,
        body: &[u8],
    ) -> anyhow::Result<HttpBytesReply> {
        if matches!(method, "GET" | "HEAD") {
            if let Some(answer) = self.signer.answer(suburl, self.inner.as_ref()) {
                return if method == "HEAD" {
                    answer.map(traits::backend::head_of_bytes)
                } else {
                    answer
                };
            }
        }
        self.inner.handle_http2_request_bytes(method, suburl, body)
    }

    fn coordinate_for_path(&self, suburl: &str) -> anyhow::Result<Option<ArtifactId>> {
        if self.signer.signs(suburl) {
            return Ok(None);
        }
        self.inner.coordinate_for_path(suburl)
    }
}

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

    /// A deb backend stand-in: `Release` is whatever the test sets; an archive file sits at
    /// `dists/stable/InRelease`, which the signer must never let through.
    struct Fake {
        release: Mutex<String>,
    }

    impl RepositoryBackendTrait for Fake {
        fn name(&self) -> &str {
            "apt"
        }
        fn format(&self) -> ArtifactFormat {
            ArtifactFormat::Raw
        }
        fn is_writable(&self) -> bool {
            false
        }
        fn fetch(&self, _: &ArtifactId) -> anyhow::Result<Option<Bytes>> {
            Ok(None)
        }
        fn put(&self, _: &ArtifactId, _: &[u8]) -> anyhow::Result<()> {
            anyhow::bail!("read-only")
        }
        fn handle_http2_request(
            &self,
            _method: &str,
            suburl: &str,
            _body: &[u8],
        ) -> anyhow::Result<HttpReply> {
            Ok(match suburl {
                "/apt/dists/stable/Release" => (
                    200,
                    vec![],
                    self.release.lock_or_recover().clone().into_bytes(),
                ),
                "/apt/dists/stable/InRelease" => {
                    (200, vec![], b"an archive's own InRelease".to_vec())
                }
                _ => (404, vec![], b"Not found".to_vec()),
            })
        }
        fn caps(&self) -> Caps {
            Caps::NONE
        }
    }

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

    fn public(k: &RepoKey) -> SignedPublicKey {
        SignedPublicKey::from_armor_single(k.public_armor().as_bytes())
            .unwrap()
            .0
    }

    /// ★ Only the index paths of the repository's own type, at a plain suite, are signed:
    /// a suite the client spelled with `%`, a space, `..` or a control byte never reaches
    /// a signed `Release`.
    #[test]
    fn the_key_signs_only_index_paths_at_a_plain_suite() {
        assert_eq!(
            signed_doc("apt", "deb", "/apt/dists/stable/InRelease"),
            Some((
                "/apt/dists/stable/Release".into(),
                SigShape::Clear,
                "text/plain"
            ))
        );
        assert!(signed_doc("apt", "deb", "/apt/dists/trixie-security/Release.gpg").is_some());
        assert!(signed_doc("yum", "rpm", "/yum/repodata/repomd.xml.asc").is_some());
        for refused in [
            "/apt/dists/st%0Aable/InRelease",
            "/apt/dists/st able/InRelease",
            "/apt/dists/../InRelease",
            "/apt/dists/./Release.gpg",
            "/apt/dists/stable\nSHA256:/InRelease",
            "/apt/dists//InRelease",
            "/apt/pool/InRelease",
            "/aptx/dists/stable/InRelease",
        ] {
            assert_eq!(signed_doc("apt", "deb", refused), None, "{refused:?}");
        }
        assert_eq!(
            signed_doc("apt", "rpm", "/apt/dists/stable/InRelease"),
            None
        );
        assert_eq!(
            signed_doc("apt", "helm", "/apt/repodata/repomd.xml.asc"),
            None
        );
        assert!(is_index_path("deb", "dists/stable/InRelease"));
        assert!(is_index_path("rpm", "/repodata/repomd.xml"));
        assert!(!is_index_path(
            "deb",
            "pool/main/h/hello/hello_1.0_amd64.deb"
        ));
        assert!(!is_index_path("helm", "dists/x"));
    }

    /// ★ An archive-backed repository's signed paths are the signer's: `InRelease` is the
    /// archive's `Release` clear-signed (never the archive's own `InRelease` file), a changed
    /// `Release` is signed again, `HEAD` answers like `GET`, and a signed path maps to no
    /// coordinate.
    #[test]
    fn an_archive_repository_serves_its_release_signed_and_follows_it() {
        let k = key();
        let p = public(&k);
        let fake = Arc::new(Fake {
            release: Mutex::new("Origin: Holger\nSuite: stable\n".into()),
        });
        let signed = SignedIndexes::new(fake.clone(), "deb", Arc::clone(&k));
        let get = |path: &str| {
            RepositoryBackendTrait::handle_http2_request_bytes(&signed, "GET", path, &[]).unwrap()
        };
        let (s, _, b) = get("/apt/dists/stable/InRelease");
        assert_eq!(s, 200);
        let (msg, _) =
            CleartextSignedMessage::from_string(std::str::from_utf8(&b).unwrap()).unwrap();
        msg.verify(&p).expect("InRelease verifies");
        assert_eq!(msg.signed_text(), "Origin: Holger\r\nSuite: stable");
        assert_eq!(
            get("/apt/dists/stable/InRelease").2,
            b,
            "kept, not signed again"
        );

        *fake.release.lock_or_recover() = "Origin: Holger\nSuite: stable\nDate: later\n".into();
        let (_, _, b2) = get("/apt/dists/stable/InRelease");
        let (msg, _) =
            CleartextSignedMessage::from_string(std::str::from_utf8(&b2).unwrap()).unwrap();
        msg.verify(&p).expect("the new InRelease verifies");
        assert!(
            msg.signed_text().ends_with("Date: later"),
            "follows the Release"
        );

        let (s, _, asc) = get("/apt/dists/stable/Release.gpg");
        assert_eq!(s, 200);
        let (sig, _) = DetachedSignature::from_armor_single(&asc[..]).unwrap();
        sig.verify(&p, b"Origin: Holger\nSuite: stable\nDate: later\n")
            .expect("Release.gpg verifies over the Release");

        let (s, h, b) = RepositoryBackendTrait::handle_http2_request_bytes(
            &signed,
            "HEAD",
            "/apt/dists/stable/InRelease",
            &[],
        )
        .unwrap();
        assert_eq!(s, 200);
        assert!(b.is_empty(), "HEAD: no body");
        assert!(
            h.iter()
                .any(|(n, _)| n.eq_ignore_ascii_case("content-length")),
            "{h:?}"
        );
        assert_eq!(
            RepositoryBackendTrait::coordinate_for_path(&signed, "/apt/dists/stable/InRelease")
                .unwrap(),
            None
        );
        // A suite the signer does not sign for is the inner backend's answer.
        assert_eq!(get("/apt/dists/st%25able/InRelease").0, 404);
    }

    /// No document, no signature: a 404 `Release` is a 404 `InRelease`.
    #[test]
    fn no_document_is_no_signature() {
        struct Empty;
        impl RepositoryBackendTrait for Empty {
            fn name(&self) -> &str {
                "yum"
            }
            fn format(&self) -> ArtifactFormat {
                ArtifactFormat::Raw
            }
            fn is_writable(&self) -> bool {
                false
            }
            fn fetch(&self, _: &ArtifactId) -> anyhow::Result<Option<Bytes>> {
                Ok(None)
            }
            fn put(&self, _: &ArtifactId, _: &[u8]) -> anyhow::Result<()> {
                anyhow::bail!("read-only")
            }
            fn handle_http2_request(
                &self,
                _: &str,
                _: &str,
                _: &[u8],
            ) -> anyhow::Result<HttpReply> {
                Ok((404, vec![], b"Not found".to_vec()))
            }
            fn caps(&self) -> Caps {
                Caps::NONE
            }
        }
        let signed = SignedIndexes::new(Arc::new(Empty), "rpm", key());
        let (s, _, _) = RepositoryBackendTrait::handle_http2_request_bytes(
            &signed,
            "GET",
            "/yum/repodata/repomd.xml.asc",
            &[],
        )
        .unwrap();
        assert_eq!(s, 404);
    }
}