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

nordisk/holger

server/lib/src/time_floor.rs

//! ★ **The monotone time floor, and the signed offline time token** (lane HOL-NCSC, NCSC P3).
//!
//! An airgap box judges a bundle's expiry, its maximum age and its certificates' validity
//! against its clock, and its clock is a battery that someone with the console or the BIOS
//! can roll back. So every judgement through [`FlooredTime`] is taken at
//! `max(clock, floor)`, where the floor (`<data_root>/holger-time/floor`, [`TimeFloor`]) is
//! the highest TRUSTED time the box has seen:
//!
//! * a verified clock (the estate's signed clock, [`crate::import_door::EstateTime`] trusted),
//!   at every judgement;
//! * the signed `created_at` of every bundle it accepted (and an intel bundle's signed
//!   snapshot time);
//! * a signed time token: a `holger.time-token/v1` bundle ([`TIME_TOKEN_PRODUCT`]), made on
//!   the online side by `holger time-token make` and carried in like any bundle
//!   (`holger bundle import`, the inbox, the doors), verified against the trust policy.
//!
//! The floor never moves back. An UNTRUSTED clock never raises it: a host clock set into the
//! future would leave a floor nothing can lower. A clock found behind the floor by more than
//! [`TOLERANCE_MS`] is judged at the floor, said by name and audited (`holger-time-1`). A
//! floor file that cannot be read refuses (`holger-time-3`): it never reads as zero.
//!
//! The token is no new envelope: it is an ordinary signed update bundle (approvals, release
//! signature, trust policy, expiry), whose one payload says the time; it carries no
//! anti-rollback floor of its own (the time floor is its own monotone check), so it needs no
//! first-import grant.

use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

use chrono::{DateTime, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use update_bundle::time::{TimeError, TrustedTime};

use crate::lock::LockOrRecover as _;

/// The floor's directory under the data root.
pub const DIR: &str = "holger-time";
/// The floor's file in [`DIR`].
pub const FILE: &str = "floor";
/// The update-bundle product of a signed time token.
pub const TIME_TOKEN_PRODUCT: &str = "holger.time-token/v1";
/// The token's one payload (`payload/time-token.json`).
pub const TOKEN_REL: &str = "time-token.json";
/// How far behind the floor a clock may read before it is said and audited (5 minutes).
pub const TOLERANCE_MS: i64 = 5 * 60 * 1000;

#[derive(Serialize, Deserialize, Clone, Debug, Default)]
struct Kept {
    unix_ms: i64,
    #[serde(default)]
    source: String,
}

/// **The floor**, kept on the data disk.
pub struct TimeFloor {
    path: PathBuf,
    kept: Mutex<Kept>,
}

impl TimeFloor {
    /// The floor under `data_root` (none yet: 0). A file that is there and cannot be read
    /// refuses (`holger-time-3`).
    pub fn open(data_root: &Path) -> Result<Self, String> {
        let path = data_root.join(DIR).join(FILE);
        let kept = match std::fs::read(&path) {
            Ok(bytes) => serde_json::from_slice::<Kept>(&bytes).map_err(|e| {
                holger_errcode::TIME_FLOOR_UNREADABLE.tag(format!("{} is not a time floor ({e}); a floor that cannot be read is not a floor of zero", path.display()))
            })?,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Kept::default(),
            Err(e) => return Err(holger_errcode::TIME_FLOOR_UNREADABLE.tag(format!("{}: {e}", path.display()))),
        };
        Ok(Self {
            path,
            kept: Mutex::new(kept),
        })
    }

    /// Where it is kept.
    pub fn path(&self) -> &Path {
        &self.path
    }

    /// The floor, unix milliseconds (0: none yet).
    pub fn floor_ms(&self) -> i64 {
        self.kept.lock_or_recover().unix_ms
    }

    /// What raised it last.
    pub fn source(&self) -> String {
        self.kept.lock_or_recover().source.clone()
    }

    /// **Raise the floor to `unix_ms`** (a trusted time, from `source`); `Ok(false)` when it
    /// stands at or above it already. Written before it is believed (atomically, synced).
    pub fn raise(&self, unix_ms: i64, source: &str) -> Result<bool, String> {
        let mut k = self.kept.lock_or_recover();
        if unix_ms <= k.unix_ms {
            return Ok(false);
        }
        let next = Kept {
            unix_ms,
            source: source.to_string(),
        };
        if let Some(dir) = self.path.parent() {
            std::fs::create_dir_all(dir).map_err(|e| {
                holger_errcode::TIME_FLOOR_UNREADABLE.tag(format!("{}: {e}", dir.display()))
            })?;
        }
        let body = serde_json::to_vec_pretty(&next).map_err(|e| e.to_string())?;
        update_bundle::container::atomic_write(&self.path, &body).map_err(|e| {
            holger_errcode::TIME_FLOOR_UNREADABLE.tag(format!("{}: {e}", self.path.display()))
        })?;
        *k = next;
        Ok(true)
    }
}

/// **A clock judged against the floor**: `max(clock, floor)`, the clock's name and trust.
pub struct FlooredTime<'a> {
    floor: &'a TimeFloor,
    clock: &'a dyn TrustedTime,
    audit: Option<Arc<dyn crate::audit::AuditLog>>,
}

impl<'a> FlooredTime<'a> {
    /// `clock` over `floor`; a clock found behind the floor is written to `audit`.
    pub fn new(
        floor: &'a TimeFloor,
        clock: &'a dyn TrustedTime,
        audit: Option<Arc<dyn crate::audit::AuditLog>>,
    ) -> Self {
        Self {
            floor,
            clock,
            audit,
        }
    }

    fn behind(&self, clock_ms: i64, floor_ms: i64) {
        let said = holger_errcode::TIME_BEHIND_FLOOR.tag(format!(
            "the clock ({}) reads {} behind the time floor ({}, set by {}); judged at the floor",
            self.clock.source_name(),
            words(floor_ms - clock_ms),
            iso(floor_ms),
            self.floor.source()
        ));
        log::warn!("{said}");
        if let Some(log) = &self.audit {
            let ev = crate::audit::AuditEvent::new(
                "holger",
                crate::audit::AuditAction::Other,
                "",
                "time floor",
                "",
                409,
                0,
            )
            .with_detail(said.clone());
            if let Err(e) = log.record(ev).and_then(|()| log.flush()) {
                log::error!("{said} — and its audit row could not be written: {e:?}");
            }
        }
    }
}

fn iso(ms: i64) -> String {
    Utc.timestamp_millis_opt(ms)
        .single()
        .map(|t| t.to_rfc3339())
        .unwrap_or_else(|| ms.to_string())
}

fn words(ms: i64) -> String {
    let s = ms / 1000;
    match s {
        s if s >= 86_400 => format!("{} d {} h", s / 86_400, (s % 86_400) / 3600),
        s if s >= 3600 => format!("{} h {} min", s / 3600, (s % 3600) / 60),
        s => format!("{} min {} s", s / 60, s % 60),
    }
}

impl TrustedTime for FlooredTime<'_> {
    fn source_name(&self) -> &'static str {
        self.clock.source_name()
    }
    fn is_trusted(&self) -> bool {
        self.clock.is_trusted()
    }
    fn now(&self) -> Result<DateTime<Utc>, TimeError> {
        let clock = self.clock.now()?;
        let clock_ms = clock.timestamp_millis();
        if self.clock.is_trusted() {
            if let Err(e) = self.floor.raise(
                clock_ms,
                &format!("the verified clock ({})", self.clock.source_name()),
            ) {
                log::error!("the time floor could not follow the verified clock: {e}");
            }
        }
        let floor_ms = self.floor.floor_ms();
        if clock_ms >= floor_ms {
            return Ok(clock);
        }
        if floor_ms - clock_ms > TOLERANCE_MS {
            self.behind(clock_ms, floor_ms);
        }
        Utc.timestamp_millis_opt(floor_ms)
            .single()
            .ok_or(TimeError::OutOfRange(floor_ms))
    }
}

/// The token's payload.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct TokenBody {
    /// [`TIME_TOKEN_PRODUCT`].
    pub schema: String,
    /// The time it says, unix milliseconds (equal to the bundle's signed `created_at`).
    pub unix_ms: i64,
    /// Chosen by the maker; makes two tokens of one second distinct.
    pub nonce: String,
}

/// **The time a verified token says**, or why it is no token (`holger-time-2`). The members
/// are the VERIFIED bundle's; the payload's time must be the signed `created_at`.
pub fn token_time(
    members: &update_bundle::MemberMap,
    core: &update_bundle::BundleCore,
) -> Result<i64, String> {
    let refuse = |why: String| holger_errcode::TIME_TOKEN_REFUSED.tag(why);
    if core.product != TIME_TOKEN_PRODUCT {
        return Err(refuse(format!(
            "product {:?} is not {TIME_TOKEN_PRODUCT}",
            core.product
        )));
    }
    let rel = format!("{}{TOKEN_REL}", update_bundle::schema::PAYLOAD_PREFIX);
    let bytes = members
        .get(&rel)
        .ok_or_else(|| refuse(format!("the token carries no {rel}")))?;
    let body: TokenBody =
        serde_json::from_slice(bytes).map_err(|e| refuse(format!("{rel}: {e}")))?;
    if body.schema != TIME_TOKEN_PRODUCT {
        return Err(refuse(format!("{rel}: schema {:?}", body.schema)));
    }
    if body.unix_ms != core.created_at.timestamp_millis() {
        return Err(refuse(format!(
            "{rel} says {} and the signed created_at says {}",
            iso(body.unix_ms),
            core.created_at.to_rfc3339()
        )));
    }
    Ok(body.unix_ms)
}

/// **Make a time token** (the online side, `holger time-token make`): a signed bundle at
/// `at`, valid for `valid`, signed by the three identities under `signer_dir` (minted once,
/// as `holger export-push` keeps its signer). Returns the pinned trust policy the airgap
/// side verifies it with (it trusts exactly this signer, for this product only).
#[cfg(feature = "export-push")]
pub fn make_token(
    out: &Path,
    signer_dir: &Path,
    at: DateTime<Utc>,
    valid: chrono::Duration,
    supplier: &str,
) -> anyhow::Result<update_bundle::TrustPolicy> {
    use update_bundle::core::{PolicyResult, SourceProvenance};
    use update_bundle::digest::sha256_hex;
    use update_bundle::prepare::{PayloadInput, Preparation};
    let signer = crate::export_push::Signer::open_or_mint(signer_dir)?;
    let policy = signer.policy(TIME_TOKEN_PRODUCT);
    let mut nonce = [0u8; 16];
    ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
        .map_err(|_| anyhow::anyhow!("no randomness for the token's nonce"))?;
    let nonce: String = nonce.iter().map(|b| format!("{b:02x}")).collect();
    let at = Utc
        .timestamp_millis_opt(at.timestamp_millis())
        .single()
        .ok_or_else(|| anyhow::anyhow!("the time {at} is not representable"))?;
    let body = serde_json::to_vec_pretty(&TokenBody {
        schema: TIME_TOKEN_PRODUCT.into(),
        unix_ms: at.timestamp_millis(),
        nonce: nonce.clone(),
    })?;
    let prep = Preparation {
        bundle_id: format!("holger-time-token-{}-{}", at.timestamp(), &nonce[..8]),
        sequence: u64::try_from(at.timestamp()).unwrap_or(0),
        created_at: at,
        expires_at: Some(at + valid),
        producer: format!("holger {} time-token make", env!("CARGO_PKG_VERSION")),
        supplier: supplier.to_string(),
        product: TIME_TOKEN_PRODUCT.to_string(),
        release_version: at.to_rfc3339(),
        supported_hardware_models: vec![],
        supported_hardware_revisions: vec![],
        source_provenance: SourceProvenance {
            source_kind: "time-token".into(),
            source_uri: None,
            source_repository: None,
            source_asset_path: None,
            fetched_at: None,
            reported_size: Some(body.len() as u64),
            vendor_checksum: None,
            vendor_signature_status: None,
            tls_origin: None,
            transfer_receipt_digest: None,
        },
        policy_result: PolicyResult {
            policy_digest: sha256_hex(policy.to_ron()?.as_bytes()),
            advisory_snapshot_digest: None,
            verdict: "pass".into(),
            non_production: false,
        },
        supersedes: vec![],
        withdraws: vec![],
        payloads: vec![PayloadInput {
            relative_path: TOKEN_REL.into(),
            bytes: body,
            media_type: "application/json".into(),
        }],
        evidence: vec![],
    };
    let mut members = prep
        .build()
        .map_err(|e| anyhow::anyhow!("prepare the time token: {e}"))?
        .members;
    signer.sign(&mut members, &policy)?;
    update_bundle::pack_bundle(&members, out)
        .map_err(|e| anyhow::anyhow!("pack {}: {e}", out.display()))?;
    Ok(policy)
}