1use {
38 crate::signing::{freeze_signing_bytes, freeze_signing_input},
39 krabka_audit::operator_keys::{OperatorKeyEntry, OperatorKeys},
40 krabka_protocol::krabka::freeze::DescribedTopicFreeze,
41 std::{
42 fs,
43 path::{Path, PathBuf},
44 },
45};
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Unproved {
50 Unsigned,
53 UnknownKeyId,
56 SignatureDidNotVerify,
60}
61
62impl Unproved {
63 #[must_use]
65 pub fn reason(self) -> &'static str {
66 match self {
67 Self::Unsigned => "unsigned, so the broker's word is all that stands behind it",
68 Self::UnknownKeyId => {
69 "no local operator key carries that key_id, so this entry cannot be checked here"
70 }
71 Self::SignatureDidNotVerify => {
72 "the signature does not verify against the local operator key"
73 }
74 }
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct CheckedEntry {
81 pub scope: String,
83 pub pattern_type: i8,
85 pub set_by: String,
87 pub key_id: String,
89 pub unproved: Option<Unproved>,
91}
92
93#[derive(Debug, Clone, Default, PartialEq, Eq)]
95pub struct VerifyOutcome {
96 pub entries: Vec<CheckedEntry>,
98}
99
100impl VerifyOutcome {
101 #[must_use]
103 pub fn any_signature_failed(&self) -> bool { self.has(Unproved::SignatureDidNotVerify) }
104
105 #[must_use]
107 pub fn any_key_is_unknown(&self) -> bool { self.has(Unproved::UnknownKeyId) }
108
109 #[must_use]
111 pub fn proved(&self) -> usize {
112 self.entries
113 .iter()
114 .filter(|entry| entry.unproved.is_none())
115 .count()
116 }
117
118 fn has(&self, unproved: Unproved) -> bool {
119 self.entries
120 .iter()
121 .any(|entry| entry.unproved == Some(unproved))
122 }
123}
124
125#[must_use]
131pub fn verify_registry(
132 cluster_id: &str,
133 keys: &OperatorKeys,
134 freezes: &[DescribedTopicFreeze],
135) -> VerifyOutcome {
136 VerifyOutcome {
137 entries: freezes
138 .iter()
139 .map(|freeze| CheckedEntry {
140 scope: freeze.scope.clone(),
141 pattern_type: freeze.pattern_type,
142 set_by: freeze.set_by.clone(),
143 key_id: freeze.key_id.clone(),
144 unproved: check_entry(cluster_id, keys, freeze),
145 })
146 .collect(),
147 }
148}
149
150fn check_entry(
152 cluster_id: &str,
153 keys: &OperatorKeys,
154 freeze: &DescribedTopicFreeze,
155) -> Option<Unproved> {
156 if freeze.key_id.is_empty() && freeze.signature.is_empty() {
157 return Some(Unproved::Unsigned);
158 }
159 if keys.get(&freeze.key_id).is_none() {
160 return Some(Unproved::UnknownKeyId);
161 }
162 let message = freeze_signing_bytes(
163 &freeze_signing_input()
164 .cluster_id(cluster_id)
165 .entry(freeze)
166 .frozen(true)
167 .call(),
168 );
169 if keys.verify(&freeze.key_id, &freeze.set_by, &message, &freeze.signature) {
173 None
174 } else {
175 Some(Unproved::SignatureDidNotVerify)
176 }
177}
178
179pub fn load_trust_set(path: &Path) -> Result<OperatorKeys, String> {
192 let text = fs::read_to_string(path)
193 .map_err(|error| format!("cannot read {}: {error}", path.display()))?;
194 let file: TrustFile = toml::from_str(&text)
195 .map_err(|error| format!("cannot read {} as operator keys: {error}", path.display()))?;
196 let entries: Vec<OperatorKeyEntry> = file
197 .operator_keys
198 .into_iter()
199 .map(|key| OperatorKeyEntry {
200 key_id: key.key_id,
201 principal: key.principal,
202 public_key_path: key.public_key_path,
203 })
204 .collect();
205 if entries.is_empty() {
206 return Err(format!(
207 "{} declares an empty [[operator_keys]] array, so nothing can be verified",
208 path.display()
209 ));
210 }
211 OperatorKeys::load(&entries).map_err(|error| format!("{}: {error}", path.display()))
212}
213
214#[derive(serde::Deserialize)]
216struct TrustFile {
217 operator_keys: Vec<TrustFileKey>,
221}
222
223#[derive(serde::Deserialize)]
225struct TrustFileKey {
226 key_id: String,
228 principal: String,
230 public_key_path: PathBuf,
232}
233
234#[cfg(test)]
235mod tests {
236 use {
237 super::*,
238 assert2::check,
239 krabka_audit::{FileEd25519Signer, operator_keys::OperatorKey},
240 krabka_protocol::primitives::uuid::Uuid,
241 ring::{rand::SystemRandom, signature::Ed25519KeyPair},
242 std::slice,
243 tempfile::TempDir,
244 };
245
246 const CLUSTER: &str = "krabka-test";
247 const ALICE: &str = "User:alice";
248
249 #[derive(Clone, Copy)]
250 enum UnprovedEntryCase {
251 Unsigned,
252 UnknownKey,
253 OtherKey,
254 ChangedScope,
255 ChangedAuthor,
256 }
257
258 #[derive(Clone, Copy)]
259 enum TrustFileCase {
260 Missing,
261 InvalidToml,
262 MissingBlock,
263 EmptyBlock,
264 MissingPublicKey,
265 }
266
267 struct TestSigner {
269 signer: FileEd25519Signer,
270 public: Vec<u8>,
271 }
272
273 fn fresh_signer(key_id: &str) -> TestSigner {
274 let rng = SystemRandom::new();
275 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).expect("generate pkcs8");
276 let signer = FileEd25519Signer::from_pkcs8_bytes(pkcs8.as_ref(), key_id.to_owned())
277 .expect("parse pkcs8");
278 let public = signer.public_key();
279 TestSigner { signer, public }
280 }
281
282 fn trust_set(dir: &TempDir, key_id: &str, principal: &str, public: &[u8]) -> OperatorKeys {
284 let path = dir.path().join(format!("{key_id}.pub"));
285 fs::write(&path, public).expect("write public key");
286 OperatorKeys::load(&[OperatorKeyEntry {
287 key_id: key_id.to_owned(),
288 principal: principal.to_owned(),
289 public_key_path: path,
290 }])
291 .expect("load trust set")
292 }
293
294 fn signed_entry(signer: &FileEd25519Signer, key_id: &str, scope: &str) -> DescribedTopicFreeze {
296 let mut entry = DescribedTopicFreeze {
297 scope: scope.to_owned(),
298 pattern_type: 3,
299 reason: "DR cutover".to_owned(),
300 set_by: ALICE.to_owned(),
301 set_at_ms: 1_770_000_000_000,
302 proposal_id: Uuid::ZERO,
303 key_id: key_id.to_owned(),
304 ..DescribedTopicFreeze::default()
305 };
306 entry.signature = signer.sign(&freeze_signing_bytes(
307 &freeze_signing_input()
308 .cluster_id(CLUSTER)
309 .entry(&entry)
310 .frozen(true)
311 .call(),
312 ));
313 entry
314 }
315
316 struct SignedFixture {
318 dir: TempDir,
319 keys: OperatorKeys,
320 entry: DescribedTopicFreeze,
321 }
322
323 fn signed_fixture() -> SignedFixture {
324 let dir = TempDir::new().expect("tempdir");
325 let TestSigner { signer, public } = fresh_signer("alice-yubi");
326 let keys = trust_set(&dir, "alice-yubi", ALICE, &public);
327 let entry = signed_entry(&signer, "alice-yubi", "orders");
328 SignedFixture { dir, keys, entry }
329 }
330
331 #[test]
332 fn a_signature_that_the_local_key_makes_verifies_locally() {
333 let SignedFixture {
334 dir: _dir,
335 keys,
336 entry,
337 } = signed_fixture();
338
339 let outcome = verify_registry(CLUSTER, &keys, slice::from_ref(&entry));
340
341 check!(
342 outcome
343 == VerifyOutcome {
344 entries: vec![CheckedEntry {
345 scope: "orders".to_owned(),
346 pattern_type: 3,
347 set_by: ALICE.to_owned(),
348 key_id: "alice-yubi".to_owned(),
349 unproved: None,
350 }],
351 }
352 );
353 check!(outcome.proved() == 1);
354 check!(!outcome.any_signature_failed());
355 check!(!outcome.any_key_is_unknown());
356 }
357
358 #[yare::parameterized(
365 unsigned = { UnprovedEntryCase::Unsigned, Unproved::Unsigned },
366 unknown_key = { UnprovedEntryCase::UnknownKey, Unproved::UnknownKeyId },
367 other_key_signature = { UnprovedEntryCase::OtherKey, Unproved::SignatureDidNotVerify },
368 changed_scope = { UnprovedEntryCase::ChangedScope, Unproved::SignatureDidNotVerify },
369 changed_author = { UnprovedEntryCase::ChangedAuthor, Unproved::SignatureDidNotVerify },
370 )]
371 fn an_entry_the_local_trust_set_cannot_prove_is_reported(
372 case: UnprovedEntryCase,
373 expected: Unproved,
374 ) {
375 let dir = TempDir::new().expect("tempdir");
376 let TestSigner { signer, public } = fresh_signer("alice-yubi");
377 let TestSigner { signer: other, .. } = fresh_signer("mallory-yubi");
378 let keys = trust_set(&dir, "alice-yubi", ALICE, &public);
379
380 let entry = match case {
381 UnprovedEntryCase::Unsigned => DescribedTopicFreeze {
382 scope: "orders".to_owned(),
383 set_by: ALICE.to_owned(),
384 ..DescribedTopicFreeze::default()
385 },
386 UnprovedEntryCase::UnknownKey => signed_entry(&signer, "bob-yubi", "orders"),
387 UnprovedEntryCase::OtherKey => signed_entry(&other, "alice-yubi", "orders"),
388 UnprovedEntryCase::ChangedScope => {
389 let mut entry = signed_entry(&signer, "alice-yubi", "orders");
390 entry.scope = "payments".to_owned();
391 entry
392 }
393 UnprovedEntryCase::ChangedAuthor => {
394 let mut entry = signed_entry(&signer, "alice-yubi", "orders");
395 entry.set_by = "User:bob".to_owned();
396 entry
397 }
398 };
399 let outcome = verify_registry(CLUSTER, &keys, &[entry]);
400 check!(outcome.entries[0].unproved == Some(expected));
401
402 let good = signed_entry(&signer, "alice-yubi", "orders");
405 check!(verify_registry(CLUSTER, &keys, &[good]).proved() == 1);
406 }
407
408 #[test]
411 fn a_signature_made_for_another_cluster_does_not_verify() {
412 let SignedFixture {
413 dir: _dir,
414 keys,
415 entry,
416 } = signed_fixture();
417
418 let outcome = verify_registry("another-cluster", &keys, &[entry]);
419
420 check!(outcome.any_signature_failed());
421 check!(outcome.proved() == 0);
422 }
423
424 #[test]
425 fn a_trust_file_reads_the_operator_keys_block_of_a_broker_file() {
426 let dir = TempDir::new().expect("tempdir");
427 let TestSigner { public, .. } = fresh_signer("alice-yubi");
428 let public_path = dir.path().join("alice.pub");
429 fs::write(&public_path, &public).expect("write public key");
430 let file = dir.path().join("broker.toml");
431 let encoded_public_path =
432 toml::Value::String(public_path.display().to_string()).to_string();
433 fs::write(
434 &file,
435 format!(
436 "node_id = 1\n\n[[operator_keys]]\nkey_id = \"alice-yubi\"\nprincipal = \
437 \"{ALICE}\"\npublic_key_path = {encoded_public_path}\n",
438 ),
439 )
440 .expect("write trust file");
441
442 let keys = load_trust_set(&file).expect("load trust set");
443
444 check!(keys.len() == 1);
445 check!(keys.get("alice-yubi").map(OperatorKey::principal) == Some(ALICE));
446 }
447
448 #[yare::parameterized(
451 missing = { TrustFileCase::Missing },
452 invalid_toml = { TrustFileCase::InvalidToml },
453 missing_block = { TrustFileCase::MissingBlock },
454 empty_block = { TrustFileCase::EmptyBlock },
455 missing_public_key = { TrustFileCase::MissingPublicKey },
456 )]
457 fn a_trust_file_that_proves_nothing_is_refused(case: TrustFileCase) {
458 let dir = TempDir::new().expect("tempdir");
459 let missing = dir.path().join("absent.toml");
460 let not_toml = dir.path().join("not-toml.toml");
461 fs::write(¬_toml, "this is not toml").expect("write file");
462 let no_block = dir.path().join("no-block.toml");
463 fs::write(&no_block, "node_id = 1\n").expect("write file");
464 let empty_block = dir.path().join("empty-block.toml");
465 fs::write(&empty_block, "operator_keys = []\n").expect("write file");
466 let bad_key = dir.path().join("bad-key.toml");
467 fs::write(
468 &bad_key,
469 "[[operator_keys]]\nkey_id = \"k\"\nprincipal = \"User:k\"\npublic_key_path = \
470 \"/nonexistent/k.pub\"\n",
471 )
472 .expect("write file");
473
474 let path = match case {
475 TrustFileCase::Missing => missing,
476 TrustFileCase::InvalidToml => not_toml,
477 TrustFileCase::MissingBlock => no_block,
478 TrustFileCase::EmptyBlock => empty_block,
479 TrustFileCase::MissingPublicKey => bad_key,
480 };
481 assert2::assert!(load_trust_set(&path).is_err());
482 }
483}