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