Skip to main content

krabka_guard/
verify.rs

1//! Proves the freeze registry against operator public keys on this machine.
2//!
3//! `freeze list --verify-signatures` reads the registry from a broker and then
4//! checks every entry here, against a trust set on the operator's own disk.
5//! That is what makes the operator's laptop, and not the broker that served the
6//! rows, the thing that says the registry is authentic. A broker that minted a
7//! record naming somebody else cannot make a signature to go with it, because
8//! the signing key never reached it.
9//!
10//! # What a proof is worth
11//!
12//! A verified entry proves authorship. It proves that the person the entry
13//! names authored that entry, for that scope, in that cluster, at that time. It
14//! does not prove that any broker then refused a write, which is a different
15//! kind of evidence that the produce-path tests supply.
16//!
17//! # The mixture
18//!
19//! `freeze.require_signature` is off by default, because a freeze is the safe
20//! direction and an operator has to reach it in one command on a cluster where
21//! nobody installed key material yet. So a registry can hold proved entries and
22//! attested ones side by side. An attested entry carries no signature, and the
23//! broker's word is all that stands behind it.
24//!
25//! This is what `--verify-signatures` separates, so an unsigned entry is
26//! reported as unsigned rather than counted as a failure. An operator who wants
27//! the mixture gone sets `freeze.require_signature = true` on the brokers.
28//!
29//! # Three outcomes, not two
30//!
31//! An entry that names a `key_id` the local trust set does not hold is not the
32//! same as an entry whose signature is wrong. The first says the tool could not
33//! check. The second says the tool checked and the answer is wrong. KFC-5's
34//! verifier draws the same line, and the caller turns the two into different
35//! exit codes.
36
37use {
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/// Why one registry entry is not proved.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Unproved {
50    /// The entry carries no signature. The broker's word is all that stands
51    /// behind it.
52    Unsigned,
53    /// The local trust set holds no key under the entry's `key_id`, so the
54    /// tool could not check this entry at all.
55    UnknownKeyId,
56    /// The tool checked and the answer is wrong: the signature does not verify
57    /// over the entry's canonical bytes, or the key is not bound to the
58    /// principal the entry names as its author.
59    SignatureDidNotVerify,
60}
61
62impl Unproved {
63    /// The word a report prints for this outcome.
64    #[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/// One registry entry, and what the local trust set says about it.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct CheckedEntry {
81    /// The scope the entry freezes.
82    pub scope: String,
83    /// `3` literal, `4` prefixed.
84    pub pattern_type: i8,
85    /// The principal the entry names as its author.
86    pub set_by: String,
87    /// The operator key the entry names, empty when the entry is unsigned.
88    pub key_id: String,
89    /// `None` when the signature verified.
90    pub unproved: Option<Unproved>,
91}
92
93/// What one `--verify-signatures` pass found.
94#[derive(Debug, Clone, Default, PartialEq, Eq)]
95pub struct VerifyOutcome {
96    /// Every entry, in the order the broker returned them.
97    pub entries: Vec<CheckedEntry>,
98}
99
100impl VerifyOutcome {
101    /// Whether any entry's signature was checked and found wrong.
102    #[must_use]
103    pub fn any_signature_failed(&self) -> bool { self.has(Unproved::SignatureDidNotVerify) }
104
105    /// Whether any entry names a key the local trust set does not hold.
106    #[must_use]
107    pub fn any_key_is_unknown(&self) -> bool { self.has(Unproved::UnknownKeyId) }
108
109    /// How many entries verified.
110    #[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/// Check every returned registry entry against the local trust set.
126///
127/// A live registry entry is a freeze, so the rebuilt bytes carry `frozen` set
128/// and no proposal. A thaw removes the entry, so no thaw is ever in the list
129/// this checks.
130#[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
150/// Decide what the local trust set says about one entry.
151fn 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    // `verify` also checks that the key is bound to the principal the entry
170    // names, so a signature from Alice's key over a record that claims Bob
171    // wrote it does not pass.
172    if keys.verify(&freeze.key_id, &freeze.set_by, &message, &freeze.signature) {
173        None
174    } else {
175        Some(Unproved::SignatureDidNotVerify)
176    }
177}
178
179/// Read a trust set from a TOML file that carries `[[operator_keys]]` entries.
180///
181/// The block is the one the broker reads out of its own `broker.toml`, and
182/// every other key in the file is ignored. So an operator points this at the
183/// same file the brokers run on, and the tool and the cluster cannot disagree
184/// about which key belongs to whom.
185///
186/// # Errors
187///
188/// Returns a message when the file cannot be read, when it is not TOML, when it
189/// declares no `[[operator_keys]]` array, and when an entry names a public key
190/// file that is missing or does not hold a raw 32-byte Ed25519 public key.
191pub 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/// The part of an operator's TOML file that this tool reads.
215#[derive(serde::Deserialize)]
216struct TrustFile {
217    /// The `[[operator_keys]]` array. A file without it is refused, because a
218    /// verify against an empty trust set is a check that silently does
219    /// nothing.
220    operator_keys: Vec<TrustFileKey>,
221}
222
223/// One `[[operator_keys]]` entry, in the shape `broker.toml` writes it.
224#[derive(serde::Deserialize)]
225struct TrustFileKey {
226    /// Stable identifier that a signed record names.
227    key_id: String,
228    /// The principal this key speaks for.
229    principal: String,
230    /// Path to the raw 32-byte Ed25519 public key.
231    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    /// A signer and the raw public key that goes with it.
268    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    /// A trust set holding one key bound to one principal.
283    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    /// A registry entry signed by `signer` under `key_id`.
295    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    /// A trust set, its directory, and a registry entry that the trust set proves.
317    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    /// Each of these would let a registry read as authentic when it is not, so
359    /// each has to be reported. The three outcomes are kept apart because a
360    /// runbook branches on them differently: an unsigned entry is expected on a
361    /// cluster that does not demand signatures, an unknown key means the tool
362    /// could not check, and a failed signature means the tool checked and the
363    /// answer is wrong.
364    #[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        // The control: the table above rejects for real reasons, and not
403        // because every entry is rejected.
404        let good = signed_entry(&signer, "alice-yubi", "orders");
405        check!(verify_registry(CLUSTER, &keys, &[good]).proved() == 1);
406    }
407
408    /// The cluster id is inside the signed bytes, so a signature captured from
409    /// one cluster does not verify in another.
410    #[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    /// Each of these would otherwise leave the tool checking against an empty
449    /// or partial trust set, which is a check that silently does nothing.
450    #[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(&not_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}