sync/src/lib.rs
//! **Holger's sync protocol** (Holger v2.0 phase H4, `docs/HOLGER-V2.md`).
//!
//! ```text
//! UPSTREAM HOLGER DOWNSTREAM HOLGER
//! ledger: generation N, invalidations, applied: last generation per upstream
//! per downstream: acked gen, reached
//! │ ▲
//! │ SyncManifest (generation, added, invalidations) │ verify under its trust
//! ├── signed by the upstream's key ──► pull: GET /-/sync/manifest
//! │ USB: a file on the stick, or evidence/holger-sync.json
//! │ inside the airgap bundle
//! │ │ apply: every invalidated
//! │ │ package is QUARANTINED at once
//! │ SyncAck (generation, counts, invalidations applied) │
//! ◄── signed by the downstream's key ── pull: POST /-/sync/ack
//! USB: an ack file back on the stick
//! ```
//!
//! This crate is the protocol only: the documents, their signatures, the upstream's
//! [`ledger::Ledger`] (per downstream: last generation applied, lag, invalidations
//! reached, the alarm) and the downstream's [`applied::Applied`] record. It holds no
//! server, opens no socket and knows no package format; `holger-server-lib`'s `sync`
//! module runs it against the intake chain.
//!
//! # The signature
//!
//! Every document travels as a [`Signed`] envelope: the document's JSON bytes
//! (base64), the signer's name, its Ed25519 public key and the signature over
//! `holger-sync/v1 \n <kind> \n <payload bytes>`. A verifier accepts an envelope only
//! when the signer's NAME is in its trust list with exactly that KEY, the signature
//! verifies, and the document inside names the same signer (a manifest's
//! `upstream`, an ack's `downstream`). The kind is in the signed bytes, so a
//! manifest is never accepted as an ack or the other way round.
//!
//! # Invalidations are cumulative
//!
//! A manifest carries EVERY invalidation the upstream ever issued, each with the
//! generation it was issued in. A downstream that missed a stick, or pulled after a
//! long gap, applies them all in one step, and a re-applied invalidation is a no-op.
use std::path::Path;
use base64::Engine as _;
use ed25519_dalek::pkcs8::{DecodePrivateKey as _, EncodePrivateKey as _};
use ed25519_dalek::{Signer as _, SigningKey, Verifier as _, VerifyingKey};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::Digest as _;
pub mod applied;
/// The signed invite a WireGuard downstream enrols with, once (lane HOL-WGDS).
pub mod invite;
pub mod ledger;
/// A downstream's sync report, signed with its sync key (lane OX-WGDS2).
pub mod report;
/// The manifest's schema.
pub const MANIFEST_SCHEMA: &str = "holger.sync-manifest/v1";
/// The acknowledgement's schema.
pub const ACK_SCHEMA: &str = "holger.sync-ack/v1";
/// The signed envelope's schema.
pub const SIGNED_SCHEMA: &str = "holger.sync-signed/v1";
/// The board's schema.
pub const BOARD_SCHEMA: &str = "holger.sync-board/v1";
/// Where an airgap bundle carries its manifest, relative to the bundle's `evidence/`.
pub const BUNDLE_MANIFEST: &str = "holger-sync.json";
/// The domain separator at the head of every signed message.
const DOMAIN: &[u8] = b"holger-sync/v1\n";
/// Unix seconds now.
pub fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Why a package is invalidated.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Reason {
/// A vulnerability was published after the package was approved.
CveAfter,
/// Malware was found in it after it was approved.
Malware,
/// Its publisher pulled (yanked, unpublished) the release.
Pulled,
/// A policy changed and no longer allows it.
Policy,
}
impl Reason {
pub const ALL: [Reason; 4] = [
Reason::CveAfter,
Reason::Malware,
Reason::Pulled,
Reason::Policy,
];
pub fn as_str(self) -> &'static str {
match self {
Reason::CveAfter => "cve-after",
Reason::Malware => "malware",
Reason::Pulled => "pulled",
Reason::Policy => "policy",
}
}
/// `cve-after`, `malware`, `pulled` or `policy`.
pub fn parse(raw: &str) -> Option<Reason> {
Reason::ALL
.into_iter()
.find(|r| r.as_str() == raw.trim().to_ascii_lowercase())
}
/// The reason in words, for an audit line.
pub fn words(self) -> &'static str {
match self {
Reason::CveAfter => "a vulnerability published after its approval",
Reason::Malware => "malware found after its approval",
Reason::Pulled => "the release was pulled by its publisher",
Reason::Policy => "a policy no longer allows it",
}
}
}
impl std::fmt::Display for Reason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// One package, by its coordinate (the intake label, `ns/name@version` or a file
/// path) and the sha256 of its bytes.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct PackageRef {
pub coordinate: String,
pub sha256: String,
}
/// **One invalidation**: "this package is blocked, for this reason".
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Invalidation {
/// Stable id: `inv-<generation>-<12 hex of sha256(coordinate, sha256, reason)>`.
pub id: String,
pub coordinate: String,
/// The bytes it names. `None`: every package at the coordinate.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
pub reason: Reason,
/// One sentence: the CVE, the scanner hit, the publisher's notice.
#[serde(default)]
pub detail: String,
/// The generation it was issued in.
pub generation: u64,
/// Unix seconds.
pub issued_at: i64,
/// The principal who issued it on the upstream.
#[serde(default)]
pub issued_by: String,
}
impl Invalidation {
#[allow(clippy::too_many_arguments)]
pub fn new(
coordinate: impl Into<String>,
sha256: Option<String>,
reason: Reason,
detail: impl Into<String>,
generation: u64,
issued_at: i64,
issued_by: impl Into<String>,
) -> Self {
let coordinate = coordinate.into();
let sha256 = sha256
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let mut h = sha2::Sha256::new();
h.update(coordinate.as_bytes());
h.update(b"\0");
h.update(sha256.as_deref().unwrap_or("").as_bytes());
h.update(b"\0");
h.update(reason.as_str().as_bytes());
let digest = hex::encode(h.finalize());
Self {
id: format!("inv-{generation}-{}", &digest[..12]),
coordinate,
sha256,
reason,
detail: detail.into(),
generation,
issued_at,
issued_by: issued_by.into(),
}
}
/// Does this invalidation name the package `coordinate` with bytes `sha256`?
/// By the bytes when it names bytes, else by the coordinate.
pub fn matches(&self, coordinate: &str, sha256: &str) -> bool {
match &self.sha256 {
Some(s) => s.eq_ignore_ascii_case(sha256.trim()),
None => self.coordinate == coordinate,
}
}
/// `coordinate (reason)`, for a log line.
pub fn label(&self) -> String {
format!("{} ({})", self.coordinate, self.reason)
}
}
/// **The sync manifest**: what one generation of an upstream says.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyncManifest {
pub schema: String,
/// The upstream's name; the envelope's signer.
pub upstream: String,
/// Monotonic. A downstream refuses a generation below the one it applied.
pub generation: u64,
/// Unix seconds.
pub issued_at: i64,
/// What carries it: `pull`, or `bundle <id> seq <n>`.
#[serde(default)]
pub carrier: String,
/// The packages this sync adds.
#[serde(default)]
pub added: Vec<PackageRef>,
/// EVERY invalidation the upstream issued (cumulative).
#[serde(default)]
pub invalidations: Vec<Invalidation>,
}
/// **The acknowledgement** a downstream sends for each applied sync.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyncAck {
pub schema: String,
/// The downstream's name; the envelope's signer.
pub downstream: String,
/// The upstream whose manifest was applied.
pub upstream: String,
/// The generation applied.
pub generation: u64,
/// Unix seconds.
pub applied_at: i64,
/// How many packages the applied sync added.
pub packages_added: u64,
/// The ids of every invalidation in force on the downstream after the apply.
pub invalidations_applied: Vec<String>,
/// The coordinates the apply quarantined just now.
#[serde(default)]
pub quarantined: Vec<String>,
/// `pull` or `usb`.
#[serde(default)]
pub carrier: String,
}
/// What a [`Signed`] envelope holds.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Manifest,
Ack,
/// A WireGuard downstream's invite ([`invite`]).
Invite,
/// A downstream's sync report ([`report`]).
Report,
}
impl Kind {
pub fn as_str(self) -> &'static str {
match self {
Kind::Manifest => "manifest",
Kind::Ack => "ack",
Kind::Invite => "invite",
Kind::Report => "report",
}
}
}
/// A peer this side trusts: its name and its Ed25519 public key (hex, 32 bytes).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustedPeer {
pub name: String,
pub key: String,
}
/// Why a sync document was not accepted, or a record could not be kept.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SyncError {
/// Not a sync document, or one that contradicts itself.
Malformed(String),
/// The signer is not in the trust list with this key.
UntrustedSigner { signer: String, why: String },
/// The signature does not verify.
BadSignature(String),
/// A manifest older than the generation already applied.
Rollback {
upstream: String,
applied: u64,
offered: u64,
},
/// An ack from a downstream this upstream has not enrolled.
UnknownDownstream(String),
/// An ack for a generation this upstream never issued.
FutureGeneration { offered: u64, issued: u64 },
/// A state file could not be read or written.
Io(String),
}
impl SyncError {
/// The number of this error's `holger-sync-<n>` code.
pub fn number(&self) -> u32 {
match self {
SyncError::Malformed(_) => 1,
SyncError::UntrustedSigner { .. } => 2,
SyncError::BadSignature(_) => 3,
SyncError::Rollback { .. } => 4,
SyncError::UnknownDownstream(_) => 5,
SyncError::FutureGeneration { .. } => 6,
SyncError::Io(_) => 7,
}
}
}
impl std::fmt::Display for SyncError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SyncError::Malformed(m) => write!(f, "the sync document is malformed: {m}"),
SyncError::UntrustedSigner { signer, why } => write!(f, "the sync document's signer '{signer}' is not trusted here: {why}"),
SyncError::BadSignature(m) => write!(f, "the sync document's signature does not verify: {m}"),
SyncError::Rollback { upstream, applied, offered } => write!(
f,
"the manifest from '{upstream}' is generation {offered}, older than generation {applied} already applied here (a rollback)"
),
SyncError::UnknownDownstream(d) => write!(f, "'{d}' is not a downstream this upstream has enrolled"),
SyncError::FutureGeneration { offered, issued } => {
write!(f, "the ack names generation {offered}, but this upstream has issued only up to {issued}")
}
SyncError::Io(m) => write!(f, "the sync state could not be kept: {m}"),
}
}
}
impl std::error::Error for SyncError {}
/// **This Holger's sync key** (Ed25519). An upstream signs its manifests with it, a
/// downstream its acks.
pub struct SyncKey {
key: SigningKey,
}
impl SyncKey {
/// A fresh key from the operating system's randomness.
pub fn mint() -> Result<Self, SyncError> {
let mut seed = [0u8; 32];
getrandom::fill(&mut seed)
.map_err(|e| SyncError::Io(format!("no randomness for a new sync key: {e}")))?;
Ok(Self {
key: SigningKey::from_bytes(&seed),
})
}
/// A key from its 32 seed bytes (tests).
pub fn from_seed(seed: [u8; 32]) -> Self {
Self {
key: SigningKey::from_bytes(&seed),
}
}
/// The key in `path` (PKCS#8 DER); minted and written (mode 0600) when absent.
pub fn open_or_mint(path: &Path) -> Result<Self, SyncError> {
if path.exists() {
let der = std::fs::read(path)
.map_err(|e| SyncError::Io(format!("read {}: {e}", path.display())))?;
let key = SigningKey::from_pkcs8_der(&der).map_err(|e| {
SyncError::Malformed(format!(
"{} is not a PKCS#8 Ed25519 key: {e}",
path.display()
))
})?;
return Ok(Self { key });
}
let k = Self::mint()?;
let der = k
.key
.to_pkcs8_der()
.map_err(|e| SyncError::Io(format!("encode the sync key: {e}")))?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
.map_err(|e| SyncError::Io(format!("create {}: {e}", dir.display())))?;
}
write_private(path, der.as_bytes())?;
Ok(k)
}
/// The public key, hex: what the peer puts in its trust list.
pub fn public_hex(&self) -> String {
hex::encode(self.key.verifying_key().to_bytes())
}
/// Sign `manifest` as `manifest.upstream`.
pub fn sign_manifest(&self, manifest: &SyncManifest) -> Result<Signed, SyncError> {
Signed::seal(Kind::Manifest, &manifest.upstream, self, manifest)
}
/// Sign `ack` as `ack.downstream`.
pub fn sign_ack(&self, ack: &SyncAck) -> Result<Signed, SyncError> {
Signed::seal(Kind::Ack, &ack.downstream, self, ack)
}
}
#[cfg(unix)]
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), SyncError> {
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.map_err(|e| SyncError::Io(format!("create {}: {e}", path.display())))?;
f.write_all(bytes)
.and_then(|()| f.sync_all())
.map_err(|e| SyncError::Io(format!("write {}: {e}", path.display())))
}
#[cfg(not(unix))]
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), SyncError> {
std::fs::write(path, bytes).map_err(|e| SyncError::Io(format!("write {}: {e}", path.display())))
}
/// **A signed sync document.**
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Signed {
pub schema: String,
/// `manifest` or `ack`.
pub kind: String,
/// The signer's name.
pub signer: String,
/// The signer's Ed25519 public key, hex.
pub key: String,
/// The document's JSON bytes, base64.
pub payload: String,
/// The Ed25519 signature, hex.
pub signature: String,
}
fn message(kind: Kind, payload: &[u8]) -> Vec<u8> {
let mut m = Vec::with_capacity(DOMAIN.len() + 16 + payload.len());
m.extend_from_slice(DOMAIN);
m.extend_from_slice(kind.as_str().as_bytes());
m.push(b'\n');
m.extend_from_slice(payload);
m
}
impl Signed {
pub(crate) fn seal<T: Serialize>(
kind: Kind,
signer: &str,
key: &SyncKey,
body: &T,
) -> Result<Self, SyncError> {
let payload = serde_json::to_vec(body).map_err(|e| SyncError::Malformed(e.to_string()))?;
let sig = key.key.sign(&message(kind, &payload));
Ok(Self {
schema: SIGNED_SCHEMA.into(),
kind: kind.as_str().into(),
signer: signer.into(),
key: key.public_hex(),
payload: base64::engine::general_purpose::STANDARD.encode(&payload),
signature: hex::encode(sig.to_bytes()),
})
}
/// The envelope's JSON.
pub fn to_bytes(&self) -> Vec<u8> {
serde_json::to_vec_pretty(self).unwrap_or_default()
}
/// An envelope from its JSON.
pub fn from_bytes(bytes: &[u8]) -> Result<Self, SyncError> {
let s: Signed = serde_json::from_slice(bytes)
.map_err(|e| SyncError::Malformed(format!("not a signed sync document: {e}")))?;
if s.schema != SIGNED_SCHEMA {
return Err(SyncError::Malformed(format!(
"schema '{}', expected '{SIGNED_SCHEMA}'",
s.schema
)));
}
Ok(s)
}
/// **Verify and open.** The signer must be in `trusted` with this key, the kind
/// must be `kind`, and the signature must verify over the payload.
pub fn open<T: DeserializeOwned>(
&self,
kind: Kind,
trusted: &[TrustedPeer],
) -> Result<T, SyncError> {
if self.kind != kind.as_str() {
return Err(SyncError::Malformed(format!(
"a '{}' document where a '{}' was expected",
self.kind,
kind.as_str()
)));
}
let Some(peer) = trusted.iter().find(|p| p.name == self.signer) else {
return Err(SyncError::UntrustedSigner {
signer: self.signer.clone(),
why: "no peer of that name is in the trust list".into(),
});
};
if !peer.key.trim().eq_ignore_ascii_case(self.key.trim()) {
return Err(SyncError::UntrustedSigner {
signer: self.signer.clone(),
why: format!(
"it signed with key {}, and the trust list holds {}",
short(&self.key),
short(&peer.key)
),
});
}
let key_bytes: [u8; 32] = hex::decode(peer.key.trim())
.ok()
.and_then(|b| b.try_into().ok())
.ok_or_else(|| SyncError::UntrustedSigner {
signer: self.signer.clone(),
why: "the trusted key is not 32 hex bytes".into(),
})?;
let vk = VerifyingKey::from_bytes(&key_bytes).map_err(|e| SyncError::UntrustedSigner {
signer: self.signer.clone(),
why: e.to_string(),
})?;
let payload = base64::engine::general_purpose::STANDARD
.decode(self.payload.trim())
.map_err(|e| SyncError::Malformed(format!("the payload is not base64: {e}")))?;
let sig_bytes: [u8; 64] = hex::decode(self.signature.trim())
.ok()
.and_then(|b| b.try_into().ok())
.ok_or_else(|| SyncError::BadSignature("the signature is not 64 hex bytes".into()))?;
vk.verify(
&message(kind, &payload),
&ed25519_dalek::Signature::from_bytes(&sig_bytes),
)
.map_err(|e| SyncError::BadSignature(e.to_string()))?;
serde_json::from_slice(&payload)
.map_err(|e| SyncError::Malformed(format!("the signed payload: {e}")))
}
}
fn short(key: &str) -> String {
key.chars().take(16).collect::<String>() + "…"
}
/// **Open a signed manifest** from its bytes under `trusted` (the downstream's
/// trusted upstreams). The manifest must name the envelope's signer.
pub fn open_manifest(bytes: &[u8], trusted: &[TrustedPeer]) -> Result<SyncManifest, SyncError> {
let s = Signed::from_bytes(bytes)?;
let m: SyncManifest = s.open(Kind::Manifest, trusted)?;
if m.schema != MANIFEST_SCHEMA {
return Err(SyncError::Malformed(format!(
"manifest schema '{}', expected '{MANIFEST_SCHEMA}'",
m.schema
)));
}
if m.upstream != s.signer {
return Err(SyncError::Malformed(format!(
"the manifest names upstream '{}' but '{}' signed it",
m.upstream, s.signer
)));
}
Ok(m)
}
/// **Open a signed ack** from its bytes under `enrolled` (the upstream's enrolled
/// downstreams). An unknown signer is [`SyncError::UnknownDownstream`].
pub fn open_ack(bytes: &[u8], enrolled: &[TrustedPeer]) -> Result<SyncAck, SyncError> {
let s = Signed::from_bytes(bytes)?;
if !enrolled.iter().any(|p| p.name == s.signer) {
return Err(SyncError::UnknownDownstream(s.signer));
}
let a: SyncAck = s.open(Kind::Ack, enrolled)?;
if a.schema != ACK_SCHEMA {
return Err(SyncError::Malformed(format!(
"ack schema '{}', expected '{ACK_SCHEMA}'",
a.schema
)));
}
if a.downstream != s.signer {
return Err(SyncError::Malformed(format!(
"the ack names downstream '{}' but '{}' signed it",
a.downstream, s.signer
)));
}
Ok(a)
}
/// The file name of an ack on the stick: `holger-sync-ack-<downstream>-<generation>.json`.
pub fn ack_file_name(ack: &SyncAck) -> String {
format!(
"holger-sync-ack-{}-{}.json",
safe(&ack.downstream),
ack.generation
)
}
/// The file name of a manifest on the stick: `holger-sync-<upstream>-<generation>.json`.
pub fn manifest_file_name(m: &SyncManifest) -> String {
format!("holger-sync-{}-{}.json", safe(&m.upstream), m.generation)
}
fn safe(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
/// Read a JSON state file; its default when absent.
pub fn read_state<T: DeserializeOwned + Default>(path: &Path) -> Result<T, SyncError> {
match std::fs::read(path) {
Ok(b) => serde_json::from_slice(&b)
.map_err(|e| SyncError::Io(format!("{} is not readable state: {e}", path.display()))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
Err(e) => Err(SyncError::Io(format!("read {}: {e}", path.display()))),
}
}
/// Write a JSON state file atomically (a temporary file renamed over it).
pub fn write_state<T: Serialize>(path: &Path, value: &T) -> Result<(), SyncError> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
.map_err(|e| SyncError::Io(format!("create {}: {e}", dir.display())))?;
}
let body = serde_json::to_vec_pretty(value).map_err(|e| SyncError::Io(e.to_string()))?;
let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
std::fs::write(&tmp, &body)
.and_then(|()| std::fs::rename(&tmp, path))
.map_err(|e| SyncError::Io(format!("write {}: {e}", path.display())))
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest(upstream: &str, generation: u64) -> SyncManifest {
SyncManifest {
schema: MANIFEST_SCHEMA.into(),
upstream: upstream.into(),
generation,
issued_at: 100,
carrier: "pull".into(),
added: vec![PackageRef {
coordinate: "pkg@1".into(),
sha256: "aa".into(),
}],
invalidations: vec![Invalidation::new(
"pkg@0",
Some("BB".into()),
Reason::Malware,
"eicar",
generation,
99,
"sec",
)],
}
}
#[test]
fn a_manifest_signed_by_a_trusted_upstream_opens() {
let k = SyncKey::from_seed([1; 32]);
let signed = k.sign_manifest(&manifest("dmz", 3)).unwrap();
let trust = [TrustedPeer {
name: "dmz".into(),
key: k.public_hex(),
}];
let m = open_manifest(&signed.to_bytes(), &trust).unwrap();
assert_eq!(m.generation, 3);
assert_eq!(
m.invalidations[0].sha256.as_deref(),
Some("bb"),
"sha256 is kept lower-case"
);
}
#[test]
fn an_untrusted_or_changed_manifest_is_refused() {
let k = SyncKey::from_seed([1; 32]);
let other = SyncKey::from_seed([2; 32]);
let signed = k.sign_manifest(&manifest("dmz", 3)).unwrap();
// Unknown name.
let e = open_manifest(
&signed.to_bytes(),
&[TrustedPeer {
name: "elsewhere".into(),
key: k.public_hex(),
}],
)
.unwrap_err();
assert_eq!(e.number(), 2, "{e}");
// Known name, other key.
let e = open_manifest(
&signed.to_bytes(),
&[TrustedPeer {
name: "dmz".into(),
key: other.public_hex(),
}],
)
.unwrap_err();
assert_eq!(e.number(), 2, "{e}");
// A payload changed after signing.
let trust = [TrustedPeer {
name: "dmz".into(),
key: k.public_hex(),
}];
let mut forged = signed.clone();
let mut m = manifest("dmz", 3);
m.invalidations.clear();
forged.payload =
base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&m).unwrap());
assert_eq!(
open_manifest(&forged.to_bytes(), &trust)
.unwrap_err()
.number(),
3
);
// Signed by another key that claims the trusted key.
let mut swapped = other.sign_manifest(&manifest("dmz", 3)).unwrap();
swapped.key = k.public_hex();
assert_eq!(
open_manifest(&swapped.to_bytes(), &trust)
.unwrap_err()
.number(),
3
);
}
#[test]
fn a_manifest_is_never_an_ack_and_must_name_its_signer() {
let k = SyncKey::from_seed([1; 32]);
let trust = [TrustedPeer {
name: "dmz".into(),
key: k.public_hex(),
}];
let signed = k.sign_manifest(&manifest("dmz", 3)).unwrap();
assert_eq!(
open_ack(&signed.to_bytes(), &trust).unwrap_err().number(),
1
);
// A manifest naming another upstream, signed under "dmz".
let lie = Signed::seal(Kind::Manifest, "dmz", &k, &manifest("other", 3)).unwrap();
assert_eq!(
open_manifest(&lie.to_bytes(), &trust).unwrap_err().number(),
1
);
}
#[test]
fn an_ack_from_an_unknown_downstream_is_refused_by_name() {
let k = SyncKey::from_seed([3; 32]);
let ack = SyncAck {
schema: ACK_SCHEMA.into(),
downstream: "onprem".into(),
upstream: "dmz".into(),
generation: 1,
applied_at: 1,
packages_added: 0,
invalidations_applied: vec![],
quarantined: vec![],
carrier: "usb".into(),
};
let signed = k.sign_ack(&ack).unwrap();
let e = open_ack(&signed.to_bytes(), &[]).unwrap_err();
assert_eq!(e, SyncError::UnknownDownstream("onprem".into()));
let ok = open_ack(
&signed.to_bytes(),
&[TrustedPeer {
name: "onprem".into(),
key: k.public_hex(),
}],
)
.unwrap();
assert_eq!(ok, ack);
}
#[test]
fn the_key_file_is_minted_once_and_read_back() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("k/sync-key.pk8");
let a = SyncKey::open_or_mint(&p).unwrap();
let b = SyncKey::open_or_mint(&p).unwrap();
assert_eq!(a.public_hex(), b.public_hex());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
assert_eq!(
std::fs::metadata(&p).unwrap().permissions().mode() & 0o777,
0o600
);
}
}
#[test]
fn an_invalidation_matches_by_bytes_or_by_coordinate() {
let by_bytes = Invalidation::new("a@1", Some("AB".into()), Reason::CveAfter, "", 1, 0, "");
assert!(by_bytes.matches("renamed@1", "ab"));
assert!(!by_bytes.matches("a@1", "cd"));
let by_coord = Invalidation::new("a@1", None, Reason::Pulled, "", 1, 0, "");
assert!(by_coord.matches("a@1", "anything"));
assert!(!by_coord.matches("a@2", "anything"));
assert_eq!(Reason::parse("CVE-after"), Some(Reason::CveAfter));
assert_eq!(Reason::parse("nope"), None);
}
fn ack(downstream: &str) -> SyncAck {
SyncAck {
schema: ACK_SCHEMA.into(),
downstream: downstream.into(),
upstream: "dmz".into(),
generation: 4,
applied_at: 5,
packages_added: 1,
invalidations_applied: vec!["inv-1-abc".into()],
quarantined: vec![],
carrier: "pull".into(),
}
}
fn trusted(name: &str, k: &SyncKey) -> Vec<TrustedPeer> {
vec![TrustedPeer {
name: name.into(),
key: k.public_hex(),
}]
}
#[test]
fn an_envelope_that_is_not_one_is_refused_before_any_key_is_read() {
let k = SyncKey::from_seed([4; 32]);
let trust = trusted("dmz", &k);
assert_eq!(open_manifest(b"not json", &trust).unwrap_err().number(), 1);
let mut s = k.sign_manifest(&manifest("dmz", 1)).unwrap();
s.schema = "holger.sync-signed/v0".into();
let e = open_manifest(&s.to_bytes(), &trust).unwrap_err();
assert_eq!(e.number(), 1);
assert!(
e.to_string().contains("expected 'holger.sync-signed/v1'"),
"{e}"
);
}
#[test]
fn every_field_of_the_envelope_is_checked() {
let k = SyncKey::from_seed([5; 32]);
let trust = trusted("dmz", &k);
let good = k.sign_manifest(&manifest("dmz", 2)).unwrap();
assert!(open_manifest(&good.to_bytes(), &trust).is_ok());
// A trust list whose key is not 32 hex bytes trusts nobody.
let mut s = good.clone();
s.key = "zz".into();
let bad_trust = vec![TrustedPeer {
name: "dmz".into(),
key: "zz".into(),
}];
let e = open_manifest(&s.to_bytes(), &bad_trust).unwrap_err();
assert_eq!(e.number(), 2, "{e}");
assert!(e.to_string().contains("not 32 hex bytes"), "{e}");
// The key comparison ignores case and spaces around it.
let shouty = vec![TrustedPeer {
name: "dmz".into(),
key: format!(" {} ", k.public_hex().to_uppercase()),
}];
assert!(open_manifest(&good.to_bytes(), &shouty).is_ok());
let mut s = good.clone();
s.payload = "***".into();
let e = open_manifest(&s.to_bytes(), &trust).unwrap_err();
assert_eq!(e.number(), 1);
assert!(e.to_string().contains("not base64"), "{e}");
let mut s = good.clone();
s.signature = "abcd".into();
let e = open_manifest(&s.to_bytes(), &trust).unwrap_err();
assert_eq!(
e,
SyncError::BadSignature("the signature is not 64 hex bytes".into())
);
// A signed payload that is not a manifest.
let junk =
Signed::seal(Kind::Manifest, "dmz", &k, &serde_json::json!({"hello": 1})).unwrap();
let e = open_manifest(&junk.to_bytes(), &trust).unwrap_err();
assert!(
matches!(e, SyncError::Malformed(ref m) if m.starts_with("the signed payload")),
"{e}"
);
// A manifest of another schema version, signed correctly.
let mut old = manifest("dmz", 2);
old.schema = "holger.sync-manifest/v0".into();
let e = open_manifest(&k.sign_manifest(&old).unwrap().to_bytes(), &trust).unwrap_err();
assert!(e.to_string().contains("manifest schema"), "{e}");
}
#[test]
fn an_ack_must_be_an_ack_of_this_schema_naming_its_signer() {
let k = SyncKey::from_seed([6; 32]);
let enrolled = trusted("onprem", &k);
let mut old = ack("onprem");
old.schema = "holger.sync-ack/v0".into();
let e = open_ack(&k.sign_ack(&old).unwrap().to_bytes(), &enrolled).unwrap_err();
assert!(e.to_string().contains("ack schema"), "{e}");
// An ack naming another downstream, signed as "onprem".
let lie = Signed::seal(Kind::Ack, "onprem", &k, &ack("elsewhere")).unwrap();
let e = open_ack(&lie.to_bytes(), &enrolled).unwrap_err();
assert!(
e.to_string().contains("names downstream 'elsewhere'"),
"{e}"
);
// An enrolled name with another key.
let other = SyncKey::from_seed([7; 32]);
let e = open_ack(
&other.sign_ack(&ack("onprem")).unwrap().to_bytes(),
&enrolled,
)
.unwrap_err();
assert_eq!(e.number(), 2);
assert!(e.to_string().contains("signed with key"), "{e}");
}
#[test]
fn every_error_has_its_own_code_and_says_what_happened() {
let all = [
SyncError::Malformed("m".into()),
SyncError::UntrustedSigner {
signer: "s".into(),
why: "w".into(),
},
SyncError::BadSignature("b".into()),
SyncError::Rollback {
upstream: "dmz".into(),
applied: 9,
offered: 3,
},
SyncError::UnknownDownstream("d".into()),
SyncError::FutureGeneration {
offered: 8,
issued: 2,
},
SyncError::Io("i".into()),
];
let numbers: Vec<u32> = all.iter().map(SyncError::number).collect();
assert_eq!(numbers, (1..=7).collect::<Vec<_>>());
let words: Vec<String> = all.iter().map(|e| e.to_string()).collect();
assert!(
words[3].contains("generation 3")
&& words[3].contains("generation 9")
&& words[3].contains("rollback")
);
assert!(words[5].contains("generation 8") && words[5].contains("up to 2"));
assert!(words[4].contains("'d'"));
let mut uniq = words.clone();
uniq.sort();
uniq.dedup();
assert_eq!(uniq.len(), words.len(), "{words:?}");
}
#[test]
fn reasons_read_back_and_invalidations_have_stable_ids() {
for r in Reason::ALL {
assert_eq!(
Reason::parse(&format!(" {} ", r.as_str().to_uppercase())),
Some(r)
);
assert_eq!(r.to_string(), r.as_str());
assert!(!r.words().is_empty());
}
let a = Invalidation::new(
"a@1",
Some(" AB ".into()),
Reason::Malware,
"hit",
7,
1,
"sec",
);
let b = Invalidation::new(
"a@1",
Some("ab".into()),
Reason::Malware,
"other words",
7,
2,
"else",
);
assert_eq!(
a.id, b.id,
"the id is the coordinate, bytes and reason, not the prose"
);
assert!(
a.id.starts_with("inv-7-") && a.id.len() == "inv-7-".len() + 12,
"{}",
a.id
);
assert_ne!(
a.id,
Invalidation::new("a@1", Some("ab".into()), Reason::Pulled, "", 7, 1, "").id
);
assert_eq!(a.label(), "a@1 (malware)");
// An empty sha256 is no sha256: the invalidation names the coordinate.
let c = Invalidation::new("a@1", Some(" ".into()), Reason::Policy, "", 1, 1, "");
assert_eq!(c.sha256, None);
assert!(c.matches("a@1", "ff"));
}
#[test]
fn file_names_on_the_stick_are_safe() {
let mut m = manifest("dmz/../evil name", 12);
assert_eq!(
manifest_file_name(&m),
"holger-sync-dmz____evil_name-12.json"
);
m.upstream = "dmz".into();
assert_eq!(manifest_file_name(&m), "holger-sync-dmz-12.json");
assert_eq!(
ack_file_name(&ack("on/prem")),
"holger-sync-ack-on_prem-4.json"
);
}
#[test]
fn state_files_default_when_absent_round_trip_and_refuse_garbage() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("deep/dir/state.json");
let empty: Vec<String> = read_state(&p).unwrap();
assert!(empty.is_empty(), "an absent state file is the default");
write_state(&p, &vec!["a".to_string(), "b".to_string()]).unwrap();
let back: Vec<String> = read_state(&p).unwrap();
assert_eq!(back, vec!["a", "b"]);
let left: Vec<_> = std::fs::read_dir(p.parent().unwrap())
.unwrap()
.flatten()
.map(|e| e.file_name())
.collect();
assert_eq!(left.len(), 1, "no temporary file is left behind: {left:?}");
std::fs::write(&p, b"{ not json").unwrap();
let e = read_state::<Vec<String>>(&p).unwrap_err();
assert_eq!(e.number(), 7);
assert!(e.to_string().contains("not readable state"), "{e}");
// A directory where the file should be is an error, not a default.
let e = read_state::<Vec<String>>(d.path()).unwrap_err();
assert_eq!(e.number(), 7);
// A key file that is not a key is refused, not replaced.
let kp = d.path().join("sync-key.pk8");
std::fs::write(&kp, b"garbage").unwrap();
assert_eq!(
SyncKey::open_or_mint(&kp).err().map(|e| e.number()),
Some(1)
);
assert_eq!(std::fs::read(&kp).unwrap(), b"garbage");
}
}