repo: el índice firmado ancla el digest del .swm, y --repo acepta lista de orígenes
La cadena de confianza estaba rota en el último eslabón y no se veía. La firma del índice cubre la lista de entradas, pero `file` es una RUTA, no un contenido: quien sirviera los bytes podía devolver otro .swm bajo el mismo nombre y la firma seguía casando. La red de aguas abajo no alcanza — `expected_hash` es opcional y el .swm se lee mucho antes (el gate de colisiones de `install` ya decide con su contenido). `PackageEntry::digest` (BLAKE3, la misma función que sella artefactos) se sella al publicar y se verifica antes de escribir un byte: raíz → firma del índice → digest → bytes. Con eso el origen deja de necesitar confianza, que es la condición para replicar en N espejos. Ausencia ⇒ se sigue al siguiente origen. Contenido distinto ⇒ ABORTA, no cae al siguiente: el fallback ahí convertiría una manipulación en silencio, con el paquete instalándose desde el espejo bueno y nadie enterándose de que uno miente. Campo Option con skip_serializing_if ⇒ un índice ya firmado serializa idéntico y su firma sigue siendo válida (test). Sin digests informa CUÁNTOS no verificó, para que un índice viejo servido desde un espejo ajeno no parezca verificado. El mensaje nombra el origen que sirvió de verdad, no la lista entera. 2 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CK6HpSoHcN9M4GBpqRSusR
This commit is contained in:
@@ -242,7 +242,9 @@ enum Cmd {
|
||||
Install {
|
||||
/// Nombre del paquete a instalar (clave del índice del repo).
|
||||
name: String,
|
||||
/// Repositorio: un directorio local (con `index.json`) o una URL HTTP(S) (`http(s)://…`).
|
||||
/// Repositorio: un directorio local (con `index.json`) o una LISTA de orígenes HTTP(S)
|
||||
/// separada por comas (`https://a,https://b`), que se prueban en orden. Los `.swm` se
|
||||
/// verifican contra el `digest` del índice firmado, así que un origen no necesita confianza.
|
||||
#[arg(long, default_value = DEFAULT_REPO)]
|
||||
repo: String,
|
||||
/// Re-rootea las mutaciones bajo este prefix (tests/staging) en vez de abrir overlay.
|
||||
@@ -1786,6 +1788,9 @@ fn run_pack(
|
||||
file: file_name,
|
||||
distro_version: distro_version.to_string(),
|
||||
expected_hash: expected_hash.clone(),
|
||||
// El digest se sella al publicar y lo cubre la firma del índice: es lo que permite
|
||||
// servir los `.swm` desde espejos que no controlamos (ADR 0014).
|
||||
digest: Some(hammer_core::repo::digest_swm(yaml.as_bytes())),
|
||||
signed_by: swm.signature.as_ref().map(|s| s.by.clone()),
|
||||
deps: recipe.deps.build.clone(),
|
||||
});
|
||||
@@ -1810,38 +1815,78 @@ fn run_pack(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// De dónde viene un repo: un directorio local, o una URL HTTP(S) (el repo son ficheros
|
||||
/// estáticos — `index.json` + los `.swm` —, así que cualquier servidor estático lo sirve).
|
||||
/// De dónde viene un repo: un directorio local, o una **lista** de orígenes HTTP(S) (el repo son
|
||||
/// ficheros estáticos — `index.json` + los `.swm` —, así que cualquier servidor estático lo sirve).
|
||||
///
|
||||
/// # Por qué una lista y no un origen (ADR 0014)
|
||||
///
|
||||
/// Un solo origen es un punto único de fallo para toda la distro: si esa máquina o esa cuenta se
|
||||
/// cae, nadie instala nada. Y no hace falta que sea uno: el índice va **firmado** y ancla el
|
||||
/// `digest` de cada `.swm`, así que un origen no aporta confianza — sólo bytes. Se separan con
|
||||
/// comas y se prueban en orden hasta que uno responda.
|
||||
enum RepoSource {
|
||||
Local(PathBuf),
|
||||
Http(String), // base sin barra final
|
||||
Http(Vec<String>), // bases sin barra final, en orden de preferencia
|
||||
}
|
||||
|
||||
impl RepoSource {
|
||||
fn parse(s: &str) -> RepoSource {
|
||||
if s.starts_with("http://") || s.starts_with("https://") {
|
||||
RepoSource::Http(s.trim_end_matches('/').to_string())
|
||||
} else {
|
||||
let bases: Vec<String> = s
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|b| b.starts_with("http://") || b.starts_with("https://"))
|
||||
.map(|b| b.trim_end_matches('/').to_string())
|
||||
.collect();
|
||||
if bases.is_empty() {
|
||||
RepoSource::Local(PathBuf::from(s))
|
||||
} else {
|
||||
RepoSource::Http(bases)
|
||||
}
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
match self {
|
||||
RepoSource::Local(d) => d.display().to_string(),
|
||||
RepoSource::Http(b) => b.clone(),
|
||||
RepoSource::Http(bases) => bases.join(", "),
|
||||
}
|
||||
}
|
||||
|
||||
/// Baja `rel` probando cada origen en orden. Devuelve los bytes **y el origen que los sirvió**:
|
||||
/// decir "bajado de \<la lista entera\>" cuando sólo uno respondió es una media verdad que
|
||||
/// esconde justo lo que hace falta para depurar un espejo caído.
|
||||
///
|
||||
/// El error que se propaga es el del ÚLTIMO intento, con la lista completa delante: un «404»
|
||||
/// suelto no dice cuántos sitios se probaron.
|
||||
fn fetch_desde_algun_origen<'a>(
|
||||
bases: &'a [String],
|
||||
rel: &str,
|
||||
) -> anyhow::Result<(Vec<u8>, &'a str)> {
|
||||
let mut ultimo = None;
|
||||
for base in bases {
|
||||
match hammer_build::download::fetch_url_bytes(&format!("{base}/{rel}")) {
|
||||
Ok(bytes) => return Ok((bytes, base.as_str())),
|
||||
Err(e) => ultimo = Some(e),
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"ningún origen sirvió '{rel}' ({} probado(s): {}). Último error: {}",
|
||||
bases.len(),
|
||||
bases.join(", "),
|
||||
ultimo
|
||||
.map(|e| e.to_string())
|
||||
.unwrap_or_else(|| "sin orígenes".into()),
|
||||
))
|
||||
}
|
||||
|
||||
/// Lee el índice del repo (local: `index.json`; http: `GET <base>/index.json`).
|
||||
fn read_index(&self) -> anyhow::Result<hammer_core::RepoIndex> {
|
||||
match self {
|
||||
RepoSource::Local(d) => Ok(hammer_core::RepoIndex::load(d)?),
|
||||
RepoSource::Http(base) => {
|
||||
let url = format!("{base}/index.json");
|
||||
let bytes = hammer_build::download::fetch_url_bytes(&url)?;
|
||||
RepoSource::Http(bases) => {
|
||||
let (bytes, origen) =
|
||||
Self::fetch_desde_algun_origen(bases, hammer_core::repo::INDEX_FILE)?;
|
||||
serde_json::from_slice(&bytes)
|
||||
.map_err(|e| anyhow::anyhow!("índice remoto {url}: {e}"))
|
||||
.map_err(|e| anyhow::anyhow!("índice remoto de {origen}: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1849,6 +1894,10 @@ impl RepoSource {
|
||||
/// Materializa un directorio de repo LOCAL del que el resto del flujo lee los `.swm`. Para
|
||||
/// `Local` es el propio directorio; para `Http` baja `index.json` + los `.swm` de `files` a un
|
||||
/// dir temporal (que el caller mantiene vivo) ⇒ el camino de instalación es idéntico.
|
||||
///
|
||||
/// **Cada `.swm` se verifica contra el `digest` del índice firmado antes de tocar el disco.**
|
||||
/// Sin eso, la lista de orígenes de arriba sería un agujero y no una mejora: cualquiera de los
|
||||
/// N espejos podría servir otro manifiesto bajo el mismo nombre de fichero.
|
||||
fn materialize(
|
||||
&self,
|
||||
index: &hammer_core::RepoIndex,
|
||||
@@ -1857,16 +1906,47 @@ impl RepoSource {
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
match self {
|
||||
RepoSource::Local(d) => Ok(d.clone()),
|
||||
RepoSource::Http(base) => {
|
||||
RepoSource::Http(bases) => {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let dir = tmp.path().to_path_buf();
|
||||
index.save(&dir)?; // index.json (con firma) re-cargable idéntico
|
||||
let mut sin_digest = 0usize;
|
||||
let mut servidores: Vec<String> = Vec::new();
|
||||
for f in files {
|
||||
let url = format!("{base}/{f}");
|
||||
let bytes = hammer_build::download::fetch_url_bytes(&url)?;
|
||||
let (bytes, origen) = Self::fetch_desde_algun_origen(bases, f)?;
|
||||
let entry = index
|
||||
.packages
|
||||
.iter()
|
||||
.find(|p| p.file == *f)
|
||||
.ok_or_else(|| anyhow::anyhow!("'{f}' no está en el índice"))?;
|
||||
// Contenido distinto del firmado ⇒ ABORTA, no pasa al siguiente origen. Un
|
||||
// fallback aquí convertiría una manipulación en silencio: el paquete acabaría
|
||||
// instalándose desde el espejo bueno y nadie se enteraría de que uno miente.
|
||||
// Ausencia (404, host caído) sí es motivo para seguir; contenido distinto no.
|
||||
hammer_core::repo::verify_digest(entry, &bytes)?;
|
||||
if entry.digest.is_none() {
|
||||
sin_digest += 1;
|
||||
}
|
||||
if !servidores.iter().any(|s| s == origen) {
|
||||
servidores.push(origen.to_string());
|
||||
}
|
||||
std::fs::write(dir.join(f), bytes)?;
|
||||
}
|
||||
eprintln!("repo: bajados {} .swm de {base}", files.len());
|
||||
eprintln!(
|
||||
"repo: bajados {} .swm de {}",
|
||||
files.len(),
|
||||
servidores.join(", ")
|
||||
);
|
||||
if sin_digest > 0 {
|
||||
// Se informa y no se bloquea (como el resto de verificaciones de la CLI), pero
|
||||
// se dice el número: un índice sin digests NO es seguro de servir desde un
|
||||
// espejo ajeno, y el silencio lo haría parecer verificado.
|
||||
eprintln!(
|
||||
"repo: ⚠ {sin_digest} de {} paquete(s) sin `digest` en el índice — \
|
||||
sus bytes NO se verificaron; republicá el repo para sellarlos",
|
||||
files.len()
|
||||
);
|
||||
}
|
||||
*tmp_holder = Some(tmp); // mantener vivo hasta el final del install
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,35 @@ use crate::swm::Signature;
|
||||
/// Nombre canónico del índice dentro del directorio del repo.
|
||||
pub const INDEX_FILE: &str = "index.json";
|
||||
|
||||
/// El `digest` de unos bytes de `.swm`, en el mismo formato `b3:…` que un [`crate::ArtifactHash`].
|
||||
///
|
||||
/// Length-prefijado como todo lo demás: la función es la misma que sella artefactos, no una segunda
|
||||
/// convención de hash conviviendo con la primera.
|
||||
pub fn digest_swm(bytes: &[u8]) -> String {
|
||||
crate::ArtifactHash::of_inputs(&[bytes])
|
||||
.as_str()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Comprueba que `bytes` casan con el `digest` que el índice **firmado** declara para esa entrada.
|
||||
///
|
||||
/// Un fallo acá no es "descarga corrupta": es que el origen entregó otro contenido del que el
|
||||
/// release firmó. Se trata como manipulación y aborta.
|
||||
pub fn verify_digest(entry: &PackageEntry, bytes: &[u8]) -> crate::Result<()> {
|
||||
let Some(declarado) = entry.digest.as_deref() else {
|
||||
return Ok(()); // índice anterior al campo: el llamador informa, no bloquea.
|
||||
};
|
||||
let real = digest_swm(bytes);
|
||||
if real != declarado {
|
||||
return Err(crate::Error::Other(anyhow::anyhow!(
|
||||
"digest de '{}' no coincide: el índice firmado declara {declarado}, los bytes servidos dan {real} \
|
||||
— el origen entregó un .swm distinto del que se firmó",
|
||||
entry.name,
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// El índice del repo: la lista de paquetes publicados. Un nombre aparece una sola vez
|
||||
/// (la última publicación gana, ver [`RepoIndex::upsert`]).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -53,6 +82,24 @@ pub struct PackageEntry {
|
||||
/// `expected_hash` del `source_patch` (si lo lleva): el ancla "verificar, no confiar".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_hash: Option<String>,
|
||||
/// BLAKE3 (`b3:…`) de los BYTES del `.swm`, para que el índice firmado ancle **todo** lo que
|
||||
/// el cliente descarga (ADR 0014).
|
||||
///
|
||||
/// # Por qué hace falta para servir desde espejos ajenos
|
||||
///
|
||||
/// La firma del índice cubre la lista de entradas — nombre, versión, `file`, deps — pero `file`
|
||||
/// es una RUTA, no un contenido: sin este campo, quien sirva los bytes puede devolver otro
|
||||
/// `.swm` bajo el mismo nombre y la firma sigue casando. Aguas abajo hay red (el `apply`
|
||||
/// reconstruye desde fuente y compara contra `expected_hash`), pero `expected_hash` es opcional
|
||||
/// y el `.swm` se lee ANTES de llegar ahí — el gate de colisiones de `install` ya decide con su
|
||||
/// contenido. Con `digest`, la cadena queda cerrada de punta a punta: clave raíz → firma del
|
||||
/// índice → digest → bytes, y **el origen deja de necesitar confianza**, que es justo la
|
||||
/// condición para poder replicar en N espejos que no controlamos.
|
||||
///
|
||||
/// `None` en índices anteriores a este campo: se informa y no se bloquea, igual que el resto de
|
||||
/// verificaciones de la CLI. Es opcional en el tipo, no en el protocolo.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub digest: Option<String>,
|
||||
/// Autor de la firma del `.swm` (si está firmado). `install --trust` lo valida de verdad
|
||||
/// contra la clave; este campo es sólo el metadato legible del índice.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -194,11 +241,42 @@ mod tests {
|
||||
file: format!("{name}-{ver}.swm"),
|
||||
distro_version: "dev".into(),
|
||||
expected_hash: None,
|
||||
digest: None,
|
||||
signed_by: None,
|
||||
deps: deps.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digest_detecta_un_swm_cambiado() {
|
||||
let mut e = entry("foo", "1");
|
||||
let bytes = b"contenido del .swm";
|
||||
// Sin digest (indice viejo): no bloquea. Es opcional en el tipo, no en el protocolo.
|
||||
assert!(verify_digest(&e, bytes).is_ok());
|
||||
|
||||
e.digest = Some(digest_swm(bytes));
|
||||
assert!(verify_digest(&e, bytes).is_ok());
|
||||
|
||||
// Un solo byte distinto bajo el MISMO nombre de fichero — que es exactamente lo que puede
|
||||
// hacer un espejo que no controlamos — y la firma del índice sigue casando sin este campo.
|
||||
let err = verify_digest(&e, b"contenido del .swm!")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("no coincide"), "mensaje inesperado: {err}");
|
||||
assert!(
|
||||
err.contains("foo"),
|
||||
"el error debe nombrar el paquete: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digest_ausente_no_cambia_la_serializacion() {
|
||||
// `skip_serializing_if` ⇒ un índice ya firmado antes de este campo serializa IDÉNTICO y su
|
||||
// firma sigue siendo válida. Añadir el campo no invalida releases existentes.
|
||||
let json = serde_json::to_string(&entry("foo", "1")).unwrap();
|
||||
assert!(!json.contains("digest"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_missing_index_is_empty_repo() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user