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

nordisk/holger

sync/src/report.rs

//! **A downstream's sync report, signed** (lane OX-WGDS2).
//!
//! A downstream's mirror tells its upstream how far it has synced (holger-server-lib's
//! `holger.grow.v1.DownstreamReport`, one per mirror cycle). A paired downstream is named by
//! its mutual-TLS certificate; a SYNC downstream (the console's enrolment, `via: Front` or
//! `via: Wireguard`) is named by this proof instead: the report's text hashed, the
//! downstream and the upstream named, signed with the downstream's sync key, the key the
//! upstream enrolled. The road it came by does not matter, so the same proof holds through
//! the front's public door and through its WireGuard sync door.
//!
//! ```text
//! downstream (mirror cycle)                               upstream (main)
//! report text ─► sha256 ─► Signed{kind: report} ──header──► open_report(enrolled, its name,
//!                                                            the text it got, now): the
//!                                                            downstream's report is counted
//! ```

use serde::{Deserialize, Serialize};
use sha2::Digest as _;

use crate::{Kind, Signed, SyncError, SyncKey, TrustedPeer};

/// The proof's schema.
pub const REPORT_SCHEMA: &str = "holger.downstream-report/v1";
/// How far a proof's clock may be from the upstream's, either way (seconds).
pub const REPORT_SKEW_SECS: i64 = 600;

/// **The proof**: who reports, to whom, the hash of what it reports, and when.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReportProof {
    pub schema: String,
    /// The downstream's sync name (the envelope's signer).
    pub downstream: String,
    /// The upstream it reports to.
    pub upstream: String,
    /// sha256 (hex) of the report's text.
    pub body_sha256: String,
    /// Unix seconds.
    pub issued_at: i64,
}

fn sha256_hex(body: &[u8]) -> String {
    hex::encode(sha2::Sha256::digest(body))
}

impl SyncKey {
    /// **Sign a report** of `body` as `downstream`, to `upstream`, at `now`.
    pub fn sign_report(
        &self,
        downstream: &str,
        upstream: &str,
        body: &[u8],
        now: i64,
    ) -> Result<Signed, SyncError> {
        let p = ReportProof {
            schema: REPORT_SCHEMA.into(),
            downstream: downstream.into(),
            upstream: upstream.into(),
            body_sha256: sha256_hex(body),
            issued_at: now,
        };
        Signed::seal(Kind::Report, downstream, self, &p)
    }
}

/// ★ **Open a report's proof** under `enrolled` (the upstream's live downstreams): the
/// signer enrolled with its key, the kind a report, the schema this one, the proof naming
/// its signer and `upstream`, the hash the one of `body`, and issued within
/// [`REPORT_SKEW_SECS`] of `now`. An unknown signer is [`SyncError::UnknownDownstream`].
pub fn open_report(
    bytes: &[u8],
    enrolled: &[TrustedPeer],
    upstream: &str,
    body: &[u8],
    now: i64,
) -> Result<ReportProof, SyncError> {
    let s = Signed::from_bytes(bytes)?;
    if !enrolled.iter().any(|p| p.name == s.signer) {
        return Err(SyncError::UnknownDownstream(s.signer));
    }
    let p: ReportProof = s.open(Kind::Report, enrolled)?;
    if p.schema != REPORT_SCHEMA {
        return Err(SyncError::Malformed(format!(
            "report schema '{}', expected '{REPORT_SCHEMA}'",
            p.schema
        )));
    }
    if p.downstream != s.signer {
        return Err(SyncError::Malformed(format!(
            "the report names downstream '{}' but '{}' signed it",
            p.downstream, s.signer
        )));
    }
    if p.upstream != upstream {
        return Err(SyncError::Malformed(format!(
            "the report is for upstream '{}', not '{upstream}'",
            p.upstream
        )));
    }
    if p.body_sha256 != sha256_hex(body) {
        return Err(SyncError::Malformed(
            "the report's text is not the one its proof was signed for".into(),
        ));
    }
    if (now - p.issued_at).abs() > REPORT_SKEW_SECS {
        return Err(SyncError::Malformed(format!("the report was signed at {} and it is {now} here: more than {REPORT_SKEW_SECS} s apart", p.issued_at)));
    }
    Ok(p)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// ★ A report's proof opens for an enrolled downstream, to this upstream, over exactly
    /// the text it signed, near now; anything else is refused by name.
    #[test]
    fn a_report_proof_opens_only_for_an_enrolled_downstream_over_the_text_it_signed() {
        let down = SyncKey::from_seed([3; 32]);
        let enrolled = vec![TrustedPeer {
            name: "lab-1".into(),
            key: down.public_hex(),
        }];
        let body = b"every 60\ncrates\t7\t10\t5000\n";
        let bytes = down
            .sign_report("lab-1", "cloud", body, 1_000)
            .unwrap()
            .to_bytes();
        let p = open_report(&bytes, &enrolled, "cloud", body, 1_100).unwrap();
        assert_eq!(
            (p.downstream.as_str(), p.upstream.as_str(), p.issued_at),
            ("lab-1", "cloud", 1_000)
        );
        assert!(open_report(
            &bytes,
            &enrolled,
            "cloud",
            b"every 60\ncrates\t7\t11\t5000\n",
            1_100
        )
        .unwrap_err()
        .to_string()
        .contains("not the one"));
        assert!(open_report(&bytes, &enrolled, "dmz", body, 1_100)
            .unwrap_err()
            .to_string()
            .contains("not 'dmz'"));
        assert!(open_report(
            &bytes,
            &enrolled,
            "cloud",
            body,
            1_000 + REPORT_SKEW_SECS + 1
        )
        .unwrap_err()
        .to_string()
        .contains("apart"));
        assert!(
            matches!(open_report(&bytes, &[], "cloud", body, 1_100), Err(SyncError::UnknownDownstream(n)) if n == "lab-1")
        );
        let other = vec![TrustedPeer {
            name: "lab-1".into(),
            key: SyncKey::from_seed([4; 32]).public_hex(),
        }];
        assert!(matches!(
            open_report(&bytes, &other, "cloud", body, 1_100),
            Err(SyncError::UntrustedSigner { .. })
        ));
        // An ack is never a report: the kind is in the signed bytes.
        let ack = crate::SyncAck {
            schema: crate::ACK_SCHEMA.into(),
            downstream: "lab-1".into(),
            upstream: "cloud".into(),
            generation: 1,
            applied_at: 1,
            packages_added: 0,
            invalidations_applied: vec![],
            quarantined: vec![],
            carrier: "pull".into(),
        };
        let ab = down.sign_ack(&ack).unwrap().to_bytes();
        assert!(open_report(&ab, &enrolled, "cloud", body, 1_100)
            .unwrap_err()
            .to_string()
            .contains("'ack' document where a 'report'"));
    }
}