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)
}