| 1 | //! Envelope verification: size cap → shape → pinned key → Ed25519 → payload |
| 2 | //! parse → cross-checks → channel → schema → version scope → rollback → |
| 3 | //! expiry. Nothing in the payload is trusted before the signature verifies. |
| 4 | |
| 5 | use base64::Engine as _; |
| 6 | use base64::engine::general_purpose::STANDARD as BASE64; |
| 7 | use serde::{Deserialize, Serialize}; |
| 8 | |
| 9 | use super::keys::{ |
| 10 | DOMAIN, ENVELOPE_VERSION, KeyStatus, MAX_ENVELOPE_BYTES, MAX_PAYLOAD_BYTES, |
| 11 | SUPPORTED_SCHEMA_VERSION, TrustedKey, trusted_key, |
| 12 | }; |
| 13 | use super::types::CloudFacts; |
| 14 | |
| 15 | /// Grace after `not_after` before facts downgrade to `stale` (48 h). |
| 16 | pub const NOT_AFTER_GRACE_SECS: u64 = 48 * 60 * 60; |
| 17 | pub const FUTURE_CLOCK_TOLERANCE_SECS: u64 = 300; |
| 18 | const MAX_SIGNATURES: usize = 8; |
| 19 | |
| 20 | /// The transport envelope as served by `/api/facts/v1/<channel>`. |
| 21 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 22 | pub struct Envelope { |
| 23 | pub envelope: u64, |
| 24 | pub channel: String, |
| 25 | pub facts_version: u64, |
| 26 | #[serde(default)] |
| 27 | pub schema_version: Option<u32>, |
| 28 | pub key_id: String, |
| 29 | pub alg: String, |
| 30 | #[serde(default)] |
| 31 | pub applies_to: Option<String>, |
| 32 | #[serde(default)] |
| 33 | pub published_at: Option<String>, |
| 34 | pub payload_b64: String, |
| 35 | pub sig_b64: String, |
| 36 | #[serde(default)] |
| 37 | pub sigs: Vec<ExtraSignature>, |
| 38 | #[serde(default)] |
| 39 | pub sha256: Option<String>, |
| 40 | } |
| 41 | |
| 42 | /// Additional `(key_id, sig)` pairs carried during key rotation. |
| 43 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 44 | pub struct ExtraSignature { |
| 45 | pub key_id: String, |
| 46 | pub sig_b64: String, |
| 47 | } |
| 48 | |
| 49 | /// Why an envelope was not accepted. Every variant leaves bundled facts in use. |
| 50 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 51 | pub enum FactsRejection { |
| 52 | TooLarge { bytes: usize }, |
| 53 | BadEnvelope(String), |
| 54 | UnknownKey { key_id: String }, |
| 55 | RetiredKey { key_id: String }, |
| 56 | BadSignature, |
| 57 | BadPayload(String), |
| 58 | Mismatch(String), |
| 59 | WrongChannel { expected: String, got: String }, |
| 60 | SchemaTooNew { schema_version: u32 }, |
| 61 | BadVersionReq(String), |
| 62 | NotApplicable { applies_to: String }, |
| 63 | Rollback { got: u64, highest_seen: u64 }, |
| 64 | } |
| 65 | |
| 66 | impl std::fmt::Display for FactsRejection { |
| 67 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 68 | match self { |
| 69 | Self::TooLarge { bytes } => write!(f, "envelope too large ({bytes} bytes)"), |
| 70 | Self::BadEnvelope(msg) => write!(f, "bad envelope: {msg}"), |
| 71 | Self::UnknownKey { key_id } => write!(f, "unknown key {key_id}"), |
| 72 | Self::RetiredKey { key_id } => write!(f, "retired key {key_id}"), |
| 73 | Self::BadSignature => write!(f, "bad signature"), |
| 74 | Self::BadPayload(msg) => write!(f, "bad payload: {msg}"), |
| 75 | Self::Mismatch(msg) => write!(f, "envelope/payload mismatch: {msg}"), |
| 76 | Self::WrongChannel { expected, got } => { |
| 77 | write!(f, "wrong channel (expected {expected}, got {got})") |
| 78 | } |
| 79 | Self::SchemaTooNew { schema_version } => { |
| 80 | write!(f, "schema_version {schema_version} newer than supported") |
| 81 | } |
| 82 | Self::BadVersionReq(req) => write!(f, "unparseable applies_to {req:?}"), |
| 83 | Self::NotApplicable { applies_to } => { |
| 84 | write!(f, "not applicable to this binary ({applies_to})") |
| 85 | } |
| 86 | Self::Rollback { got, highest_seen } => { |
| 87 | write!(f, "rollback (v{got} < seen v{highest_seen})") |
| 88 | } |
| 89 | } |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | /// A payload that passed every check. |
| 94 | #[derive(Debug, Clone, PartialEq)] |
| 95 | pub struct VerifiedFacts { |
| 96 | pub facts: CloudFacts, |
| 97 | /// The pinned key that authenticated the payload. |
| 98 | pub key_id: String, |
| 99 | /// Hex SHA-256 of the exact signed bytes. |
| 100 | pub sha256: String, |
| 101 | pub raw_len: usize, |
| 102 | /// `not_after` has passed by more than the grace window. |
| 103 | pub stale: bool, |
| 104 | } |
| 105 | |
| 106 | fn hex(bytes: &[u8]) -> String { |
| 107 | use std::fmt::Write as _; |
| 108 | let mut out = String::with_capacity(bytes.len() * 2); |
| 109 | for byte in bytes { |
| 110 | let _ = write!(&mut out, "{byte:02x}"); |
| 111 | } |
| 112 | out |
| 113 | } |
| 114 | |
| 115 | /// The exact bytes an Ed25519 signature covers. |
| 116 | #[must_use] |
| 117 | pub fn signing_message(key_id: &str, payload: &[u8]) -> Vec<u8> { |
| 118 | let mut msg = Vec::with_capacity(DOMAIN.len() + key_id.len() + 1 + payload.len()); |
| 119 | msg.extend_from_slice(DOMAIN); |
| 120 | msg.extend_from_slice(key_id.as_bytes()); |
| 121 | msg.push(0); |
| 122 | msg.extend_from_slice(payload); |
| 123 | msg |
| 124 | } |
| 125 | |
| 126 | fn ed25519_ok(public_key: &[u8; 32], message: &[u8], signature: &[u8]) -> bool { |
| 127 | ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, public_key) |
| 128 | .verify(message, signature) |
| 129 | .is_ok() |
| 130 | } |
| 131 | |
| 132 | /// Parse an RFC 3339 UTC timestamp (`YYYY-MM-DDTHH:MM:SS[.fff]Z`) to unix seconds. |
| 133 | /// |
| 134 | /// Offsets other than `Z` are not accepted (returns `None`); the publisher |
| 135 | /// always writes UTC. |
| 136 | #[must_use] |
| 137 | pub fn parse_rfc3339_utc(value: &str) -> Option<u64> { |
| 138 | let value = value.trim(); |
| 139 | if !value.ends_with('Z') { |
| 140 | return None; |
| 141 | } |
| 142 | let parsed = chrono::DateTime::parse_from_rfc3339(value).ok()?; |
| 143 | u64::try_from(parsed.timestamp()).ok() |
| 144 | } |
| 145 | |
| 146 | /// Verify a raw envelope document. |
| 147 | /// |
| 148 | /// * `expected_channel` — the channel the client asked for. |
| 149 | /// * `current` — the running binary's version (`CARGO_PKG_VERSION`). |
| 150 | /// * `highest_seen` — highest `facts_version` this client previously accepted |
| 151 | /// for the channel (rollback protection); equal is accepted. |
| 152 | /// * `keys` — the pinned trust anchors (normally [`super::keys::TRUSTED_KEYS`]). |
| 153 | /// * `now_unix` — wall clock, injected for deterministic tests. |
| 154 | pub fn verify_envelope( |
| 155 | bytes: &[u8], |
| 156 | expected_channel: &str, |
| 157 | current: &semver::Version, |
| 158 | highest_seen: Option<u64>, |
| 159 | keys: &[TrustedKey], |
| 160 | now_unix: u64, |
| 161 | ) -> Result<VerifiedFacts, FactsRejection> { |
| 162 | if bytes.len() > MAX_ENVELOPE_BYTES { |
| 163 | return Err(FactsRejection::TooLarge { bytes: bytes.len() }); |
| 164 | } |
| 165 | let envelope: Envelope = serde_json::from_slice(bytes) |
| 166 | .map_err(|err| FactsRejection::BadEnvelope(err.to_string()))?; |
| 167 | if envelope.envelope != ENVELOPE_VERSION { |
| 168 | return Err(FactsRejection::BadEnvelope(format!( |
| 169 | "envelope version {} (supported {ENVELOPE_VERSION})", |
| 170 | envelope.envelope |
| 171 | ))); |
| 172 | } |
| 173 | if envelope.schema_version.is_none() |
| 174 | || envelope.applies_to.is_none() |
| 175 | || envelope.published_at.is_none() |
| 176 | || envelope.sha256.is_none() |
| 177 | { |
| 178 | return Err(FactsRejection::BadEnvelope( |
| 179 | "missing signed-payload metadata".into(), |
| 180 | )); |
| 181 | } |
| 182 | if envelope.alg != "ed25519" { |
| 183 | return Err(FactsRejection::BadEnvelope("unsupported alg".into())); |
| 184 | } |
| 185 | let valid_key_id = |id: &str| { |
| 186 | id.strip_prefix("cwf-").is_some_and(|suffix| { |
| 187 | !suffix.is_empty() |
| 188 | && suffix.len() <= 32 |
| 189 | && suffix |
| 190 | .bytes() |
| 191 | .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') |
| 192 | }) |
| 193 | }; |
| 194 | if envelope.sigs.len() >= MAX_SIGNATURES |
| 195 | || !valid_key_id(&envelope.key_id) |
| 196 | || envelope |
| 197 | .sigs |
| 198 | .iter() |
| 199 | .any(|signature| !valid_key_id(&signature.key_id)) |
| 200 | || envelope.sig_b64.len() > 88 |
| 201 | || envelope |
| 202 | .sigs |
| 203 | .iter() |
| 204 | .any(|signature| signature.sig_b64.len() > 88) |
| 205 | { |
| 206 | return Err(FactsRejection::BadEnvelope( |
| 207 | "invalid signature candidates".into(), |
| 208 | )); |
| 209 | } |
| 210 | if envelope.channel.len() > 32 |
| 211 | || expected_channel.len() > 32 |
| 212 | || !envelope |
| 213 | .channel |
| 214 | .bytes() |
| 215 | .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') |
| 216 | || envelope |
| 217 | .applies_to |
| 218 | .as_ref() |
| 219 | .is_some_and(|value| value.len() > 200) |
| 220 | || envelope |
| 221 | .published_at |
| 222 | .as_ref() |
| 223 | .is_some_and(|value| value.len() > 40) |
| 224 | || envelope |
| 225 | .sha256 |
| 226 | .as_ref() |
| 227 | .is_some_and(|value| value.len() != 64) |
| 228 | { |
| 229 | return Err(FactsRejection::BadEnvelope( |
| 230 | "invalid metadata bounds".into(), |
| 231 | )); |
| 232 | } |
| 233 | if envelope.payload_b64.len() > MAX_PAYLOAD_BYTES * 4 / 3 + 4 { |
| 234 | return Err(FactsRejection::TooLarge { |
| 235 | bytes: envelope.payload_b64.len(), |
| 236 | }); |
| 237 | } |
| 238 | let payload = BASE64 |
| 239 | .decode(envelope.payload_b64.as_bytes()) |
| 240 | .map_err(|err| FactsRejection::BadEnvelope(format!("payload_b64: {err}")))?; |
| 241 | if payload.is_empty() { |
| 242 | return Err(FactsRejection::BadEnvelope("empty payload".into())); |
| 243 | } |
| 244 | if payload.len() > MAX_PAYLOAD_BYTES { |
| 245 | return Err(FactsRejection::TooLarge { |
| 246 | bytes: payload.len(), |
| 247 | }); |
| 248 | } |
| 249 | |
| 250 | // Candidate signatures: the primary first, then rotation extras. Accept |
| 251 | // the first that verifies under a pinned, active key. |
| 252 | let mut candidates: Vec<(&str, &str)> = Vec::with_capacity(1 + envelope.sigs.len()); |
| 253 | candidates.push((envelope.key_id.as_str(), envelope.sig_b64.as_str())); |
| 254 | for extra in &envelope.sigs { |
| 255 | candidates.push((extra.key_id.as_str(), extra.sig_b64.as_str())); |
| 256 | } |
| 257 | let mut saw_known = false; |
| 258 | let mut saw_retired: Option<String> = None; |
| 259 | let mut verified_key: Option<String> = None; |
| 260 | for (key_id, sig_b64) in candidates { |
| 261 | let Some(key) = trusted_key(keys, key_id) else { |
| 262 | continue; |
| 263 | }; |
| 264 | if key.status == KeyStatus::Retired { |
| 265 | saw_retired.get_or_insert_with(|| key_id.to_string()); |
| 266 | continue; |
| 267 | } |
| 268 | saw_known = true; |
| 269 | let Ok(signature) = BASE64.decode(sig_b64.as_bytes()) else { |
| 270 | continue; |
| 271 | }; |
| 272 | if signature.len() != 64 { |
| 273 | continue; |
| 274 | } |
| 275 | let message = signing_message(key_id, &payload); |
| 276 | if ed25519_ok(&key.public_key, &message, &signature) { |
| 277 | verified_key = Some(key_id.to_string()); |
| 278 | break; |
| 279 | } |
| 280 | } |
| 281 | let Some(key_id) = verified_key else { |
| 282 | if saw_known { |
| 283 | return Err(FactsRejection::BadSignature); |
| 284 | } |
| 285 | if let Some(key_id) = saw_retired { |
| 286 | return Err(FactsRejection::RetiredKey { key_id }); |
| 287 | } |
| 288 | return Err(FactsRejection::UnknownKey { |
| 289 | key_id: envelope.key_id, |
| 290 | }); |
| 291 | }; |
| 292 | |
| 293 | // Only now is the payload trusted enough to parse. |
| 294 | let facts: CloudFacts = serde_json::from_slice(&payload) |
| 295 | .map_err(|err| FactsRejection::BadPayload(err.to_string()))?; |
| 296 | if facts.channel != envelope.channel { |
| 297 | return Err(FactsRejection::Mismatch("channel".into())); |
| 298 | } |
| 299 | if facts.facts_version != envelope.facts_version { |
| 300 | return Err(FactsRejection::Mismatch(format!( |
| 301 | "facts_version {} vs {}", |
| 302 | envelope.facts_version, facts.facts_version |
| 303 | ))); |
| 304 | } |
| 305 | if let Some(outer) = envelope.applies_to.as_deref() |
| 306 | && outer != facts.applies_to |
| 307 | { |
| 308 | return Err(FactsRejection::Mismatch("applies_to".into())); |
| 309 | } |
| 310 | if let Some(outer) = envelope.schema_version |
| 311 | && outer != facts.schema_version |
| 312 | { |
| 313 | return Err(FactsRejection::Mismatch(format!( |
| 314 | "schema_version {outer} vs {}", |
| 315 | facts.schema_version |
| 316 | ))); |
| 317 | } |
| 318 | if envelope |
| 319 | .published_at |
| 320 | .as_ref() |
| 321 | .is_some_and(|outer| outer != &facts.published_at) |
| 322 | { |
| 323 | return Err(FactsRejection::Mismatch("published_at".into())); |
| 324 | } |
| 325 | if facts.schema_version == 0 || facts.facts_version == 0 { |
| 326 | return Err(FactsRejection::BadPayload( |
| 327 | "schema and facts versions must be positive".into(), |
| 328 | )); |
| 329 | } |
| 330 | if facts.channel != expected_channel { |
| 331 | return Err(FactsRejection::WrongChannel { |
| 332 | expected: expected_channel.to_string(), |
| 333 | got: facts.channel, |
| 334 | }); |
| 335 | } |
| 336 | if facts.schema_version > SUPPORTED_SCHEMA_VERSION { |
| 337 | return Err(FactsRejection::SchemaTooNew { |
| 338 | schema_version: facts.schema_version, |
| 339 | }); |
| 340 | } |
| 341 | if !version_req_matches(&facts.applies_to, current)? { |
| 342 | return Err(FactsRejection::NotApplicable { |
| 343 | applies_to: facts.applies_to, |
| 344 | }); |
| 345 | } |
| 346 | if let Some(highest) = highest_seen |
| 347 | && facts.facts_version < highest |
| 348 | { |
| 349 | return Err(FactsRejection::Rollback { |
| 350 | got: facts.facts_version, |
| 351 | highest_seen: highest, |
| 352 | }); |
| 353 | } |
| 354 | let published_at = parse_rfc3339_utc(&facts.published_at) |
| 355 | .ok_or_else(|| FactsRejection::BadPayload("invalid published_at timestamp".into()))?; |
| 356 | if published_at > now_unix.saturating_add(FUTURE_CLOCK_TOLERANCE_SECS) { |
| 357 | return Err(FactsRejection::BadPayload( |
| 358 | "published_at is in the future".into(), |
| 359 | )); |
| 360 | } |
| 361 | let not_after = facts |
| 362 | .not_after |
| 363 | .as_deref() |
| 364 | .map(|value| { |
| 365 | parse_rfc3339_utc(value) |
| 366 | .ok_or_else(|| FactsRejection::BadPayload("invalid not_after timestamp".into())) |
| 367 | }) |
| 368 | .transpose()?; |
| 369 | if not_after.is_some_and(|expires| expires < published_at) { |
| 370 | return Err(FactsRejection::BadPayload( |
| 371 | "not_after precedes published_at".into(), |
| 372 | )); |
| 373 | } |
| 374 | let stale = |
| 375 | not_after.is_some_and(|expires| now_unix > expires.saturating_add(NOT_AFTER_GRACE_SECS)); |
| 376 | |
| 377 | use sha2::Digest as _; |
| 378 | let sha256 = hex(&sha2::Sha256::digest(&payload)); |
| 379 | if envelope |
| 380 | .sha256 |
| 381 | .as_ref() |
| 382 | .is_some_and(|outer| outer != &sha256) |
| 383 | { |
| 384 | return Err(FactsRejection::Mismatch("sha256".into())); |
| 385 | } |
| 386 | Ok(VerifiedFacts { |
| 387 | facts, |
| 388 | key_id, |
| 389 | sha256, |
| 390 | raw_len: payload.len(), |
| 391 | stale, |
| 392 | }) |
| 393 | } |
| 394 | |
| 395 | /// Evaluate a Cargo-style semver requirement against the running version. |
| 396 | /// |
| 397 | /// `*` matches everything; an empty requirement is invalid. Prerelease binaries (`0.9.12-beta.1`) only |
| 398 | /// match requirements that name a prerelease on the same `major.minor.patch`, |
| 399 | /// which is semver's rule; the beta channel must set `applies_to` explicitly. |
| 400 | pub fn version_req_matches(req: &str, current: &semver::Version) -> Result<bool, FactsRejection> { |
| 401 | let trimmed = req.trim(); |
| 402 | if trimmed.is_empty() || trimmed.len() > 200 { |
| 403 | return Err(FactsRejection::BadVersionReq(String::new())); |
| 404 | } |
| 405 | if trimmed == "*" { |
| 406 | return Ok(true); |
| 407 | } |
| 408 | let parsed = semver::VersionReq::parse(trimmed) |
| 409 | .map_err(|_| FactsRejection::BadVersionReq(trimmed.to_string()))?; |
| 410 | Ok(parsed.matches(current)) |
| 411 | } |
| 412 | |
| 413 | /// Lenient per-item variant: unparseable requirements simply do not match. |
| 414 | #[must_use] |
| 415 | pub fn item_applies(req: Option<&str>, current: &semver::Version) -> bool { |
| 416 | match req { |
| 417 | None => true, |
| 418 | Some(req) => version_req_matches(req, current).unwrap_or(false), |
| 419 | } |
| 420 | } |
| 421 |