返回 CodeWhale
verify.rs
根目录 / crates / config / src / cloud_facts / verify.rs
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
421 lines RUST