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);
}
}