Skip to main content

krabka_guard/
signing.rs

1//! The canonical bytes an operator signs, built on the operator's own machine.
2//!
3//! `--sign-with` takes a PKCS#8 Ed25519 key file and never sends it. This
4//! module builds the signed payload here, signs it here, and hands the caller a
5//! `key_id` and a detached signature. Those two fields are the only part of the
6//! signature that reaches a broker. The private key never leaves the machine
7//! that runs the command.
8//!
9//! # Why the byte layout is repeated rather than called
10//!
11//! The audit crate owns the shared freeze field contract, and the broker
12//! re-exports it for compatibility. This module independently encodes those
13//! fields and the break-glass proposal fields; it does not call the broker's
14//! private encoders. The golden vectors below pin these bytes field by field,
15//! so an encoder drift fails here rather than during an operator's thaw.
16//!
17//! # The freeze layout
18//!
19//! ```text
20//! FREEZE_DOMAIN                     b"krabka-topic-freeze-v1\0"
21//! cluster_id      u32 big-endian length, then the UTF-8 bytes
22//! pattern_type    u8                3 literal, 4 prefixed
23//! scope           u32 big-endian length, then the UTF-8 bytes
24//! frozen          u8                1 freeze, 0 thaw
25//! reason          u32 big-endian length, then the UTF-8 bytes
26//! set_by          u32 big-endian length, then the UTF-8 bytes
27//! set_at_ms       i64 big-endian
28//! proposal_id     16 bytes
29//! ```
30//!
31//! # The break-glass approval layout
32//!
33//! ```text
34//! BREAK_GLASS_DOMAIN                b"krabka-break-glass-v1\0"
35//! proposal_id     16 bytes
36//! action          u8                the wire value of the action
37//! target          u32 big-endian length, then the UTF-8 bytes
38//! proposer        u32 big-endian length, then the UTF-8 bytes
39//! created_at_ms   i64 big-endian
40//! expires_at_ms   i64 big-endian
41//! ```
42//!
43//! An approval signs the proposal that the broker holds, and not a proposal the
44//! caller supplies. So `krabka-guard break-glass approve --sign-with` reads the
45//! proposal back first and signs what the broker stored. The approvals list is
46//! outside the signed bytes on purpose: every approver signs the same payload,
47//! and a payload that grew with each approval could never verify twice.
48
49pub use krabka_audit::{FreezeSigningInput, freeze_signing_input};
50use {krabka_audit::FileEd25519Signer, std::path::Path};
51
52/// Domain separator for a freeze record signature (KFC-9).
53///
54/// It differs from every other separator in the workspace, so a signature made
55/// for one purpose never verifies as another.
56pub const FREEZE_DOMAIN: &[u8] = b"krabka-topic-freeze-v1\0";
57
58/// Domain separator for a break-glass approval signature (KFC-9).
59pub const BREAK_GLASS_DOMAIN: &[u8] = b"krabka-break-glass-v1\0";
60
61/// The break-glass proposal fields that an approval signature covers.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct ApprovalSigningInput<'a> {
64    /// The proposal the approval names.
65    pub proposal_id: [u8; 16],
66    /// The wire value of the gated action.
67    pub action: i8,
68    /// What the transition applies to.
69    pub target: &'a str,
70    /// The principal that opened the proposal.
71    pub proposer: &'a str,
72    /// When the controller opened the proposal.
73    pub created_at_ms: i64,
74    /// When the proposal stops being usable.
75    pub expires_at_ms: i64,
76}
77
78/// Build the canonical bytes of a freeze record.
79///
80/// The layout is the one this module documents, and the broker rebuilds the
81/// same bytes to verify.
82#[must_use]
83pub fn freeze_signing_bytes(input: &FreezeSigningInput<'_>) -> Vec<u8> {
84    let mut bytes = Vec::with_capacity(
85        FREEZE_DOMAIN.len()
86            + 4 * LEN_PREFIX
87            + input.cluster_id.len()
88            + input.scope.len()
89            + input.reason.len()
90            + input.set_by.len()
91            + 2
92            + size_of::<i64>()
93            + input.proposal_id.len(),
94    );
95    bytes.extend_from_slice(FREEZE_DOMAIN);
96    put_len_prefixed(&mut bytes, input.cluster_id.as_bytes());
97    bytes.push(input.pattern_type.to_be_bytes()[0]);
98    put_len_prefixed(&mut bytes, input.scope.as_bytes());
99    bytes.push(u8::from(input.frozen));
100    put_len_prefixed(&mut bytes, input.reason.as_bytes());
101    put_len_prefixed(&mut bytes, input.set_by.as_bytes());
102    bytes.extend_from_slice(&input.set_at_ms.to_be_bytes());
103    bytes.extend_from_slice(&input.proposal_id);
104    bytes
105}
106
107/// Build the canonical bytes that an approval of one proposal signs.
108#[must_use]
109pub fn approval_signing_bytes(input: &ApprovalSigningInput<'_>) -> Vec<u8> {
110    let mut bytes = Vec::with_capacity(
111        BREAK_GLASS_DOMAIN.len()
112            + input.proposal_id.len()
113            + 1
114            + 2 * LEN_PREFIX
115            + input.target.len()
116            + input.proposer.len()
117            + 2 * size_of::<i64>(),
118    );
119    bytes.extend_from_slice(BREAK_GLASS_DOMAIN);
120    bytes.extend_from_slice(&input.proposal_id);
121    bytes.push(input.action.to_be_bytes()[0]);
122    put_len_prefixed(&mut bytes, input.target.as_bytes());
123    put_len_prefixed(&mut bytes, input.proposer.as_bytes());
124    bytes.extend_from_slice(&input.created_at_ms.to_be_bytes());
125    bytes.extend_from_slice(&input.expires_at_ms.to_be_bytes());
126    bytes
127}
128
129/// Read the PKCS#8 Ed25519 key at `path` as the key named `key_id`.
130///
131/// The key stays in this process. Only the signature it makes goes on the wire.
132///
133/// # Errors
134///
135/// Returns a message when the file cannot be read, and when it does not hold a
136/// PKCS#8 Ed25519 key.
137pub fn load_signer(path: &Path, key_id: &str) -> Result<FileEd25519Signer, String> {
138    FileEd25519Signer::from_pkcs8_file(path, key_id.to_owned())
139        .map_err(|error| format!("cannot sign with {}: {error}", path.display()))
140}
141
142/// Byte length of one `u32` big-endian length prefix.
143const LEN_PREFIX: usize = size_of::<u32>();
144
145/// Append `field` behind its `u32` big-endian length.
146///
147/// The prefixes are what keep two different records from building one byte
148/// string. Without them a scope of `"a"` with a reason of `"bc"` and a scope of
149/// `"ab"` with a reason of `"c"` would sign the same.
150fn put_len_prefixed(bytes: &mut Vec<u8>, field: &[u8]) {
151    let len = u32::try_from(field.len()).unwrap_or(u32::MAX);
152    bytes.extend_from_slice(&len.to_be_bytes());
153    bytes.extend_from_slice(field);
154}
155
156#[cfg(test)]
157mod tests {
158    use {super::*, assert2::check};
159
160    krabka_macros::break_glass_golden_id!(PROPOSAL_ID);
161
162    fn freeze_input() -> FreezeSigningInput<'static> {
163        FreezeSigningInput {
164            cluster_id: "krabka-test",
165            pattern_type: 3,
166            scope: "orders",
167            frozen: true,
168            reason: "DR cutover",
169            set_by: "User:alice",
170            set_at_ms: 1_770_000_000_000,
171            proposal_id: [0; 16],
172        }
173    }
174
175    fn approval_input() -> ApprovalSigningInput<'static> {
176        ApprovalSigningInput {
177            proposal_id: PROPOSAL_ID,
178            action: 6,
179            target: "doomed",
180            proposer: "User:alice",
181            created_at_ms: 1_770_000_000_000,
182            expires_at_ms: 1_770_000_180_000,
183        }
184    }
185
186    /// The layout the broker's `freeze::signing` module documents, built by
187    /// hand rather than by the code under test. This is the pin: the two
188    /// copies of the layout cannot drift while this vector is written out
189    /// field by field.
190    #[test]
191    fn the_freeze_bytes_match_the_documented_layout() {
192        let mut expected = Vec::new();
193        expected.extend_from_slice(b"krabka-topic-freeze-v1\0");
194        expected.extend_from_slice(&11u32.to_be_bytes());
195        expected.extend_from_slice(b"krabka-test");
196        expected.push(3);
197        expected.extend_from_slice(&6u32.to_be_bytes());
198        expected.extend_from_slice(b"orders");
199        expected.push(1);
200        expected.extend_from_slice(&10u32.to_be_bytes());
201        expected.extend_from_slice(b"DR cutover");
202        expected.extend_from_slice(&10u32.to_be_bytes());
203        expected.extend_from_slice(b"User:alice");
204        expected.extend_from_slice(&1_770_000_000_000i64.to_be_bytes());
205        expected.extend_from_slice(&[0u8; 16]);
206
207        check!(freeze_signing_bytes(&freeze_input()) == expected);
208        // 23 domain + 4 + 11 cluster + 1 pattern + 4 + 6 scope + 1 frozen
209        // + 4 + 10 reason + 4 + 10 set_by + 8 timestamp + 16 proposal.
210        check!(expected.len() == 102);
211    }
212
213    /// The layout the broker's `break_glass::signing` module documents, built
214    /// by hand for the same reason.
215    #[test]
216    fn the_approval_bytes_match_the_documented_layout() {
217        let mut expected = Vec::new();
218        expected.extend_from_slice(b"krabka-break-glass-v1\0");
219        expected.extend_from_slice(&PROPOSAL_ID);
220        expected.push(6);
221        expected.extend_from_slice(&6u32.to_be_bytes());
222        expected.extend_from_slice(b"doomed");
223        expected.extend_from_slice(&10u32.to_be_bytes());
224        expected.extend_from_slice(b"User:alice");
225        expected.extend_from_slice(&1_770_000_000_000i64.to_be_bytes());
226        expected.extend_from_slice(&1_770_000_180_000i64.to_be_bytes());
227
228        check!(approval_signing_bytes(&approval_input()) == expected);
229        // 22 domain + 16 id + 1 action + 4 + 6 target + 4 + 10 proposer
230        // + 8 created + 8 expires.
231        check!(expected.len() == 79);
232    }
233
234    /// A signature captured from a freeze must not authorize the thaw, and a
235    /// signature made for one cluster must not authorize the same record in
236    /// another. Both properties come from the field being inside the signed
237    /// bytes, so every signed field has to change the bytes.
238    #[yare::parameterized(
239        cluster = { FreezeSigningInput { cluster_id: "other-cluster", ..freeze_input() } },
240        pattern_type = { FreezeSigningInput { pattern_type: 4, ..freeze_input() } },
241        scope = { FreezeSigningInput { scope: "payments", ..freeze_input() } },
242        thaw = { FreezeSigningInput { frozen: false, ..freeze_input() } },
243        reason = { FreezeSigningInput { reason: "tenant offboarding", ..freeze_input() } },
244        author = { FreezeSigningInput { set_by: "User:mallory", ..freeze_input() } },
245        timestamp = { FreezeSigningInput { set_at_ms: 1_770_000_000_001, ..freeze_input() } },
246        proposal = { FreezeSigningInput { proposal_id: PROPOSAL_ID, ..freeze_input() } },
247    )]
248    fn a_change_to_any_signed_freeze_field_changes_the_bytes(input: FreezeSigningInput<'static>) {
249        let baseline = freeze_signing_bytes(&freeze_input());
250        check!(freeze_signing_bytes(&input) != baseline);
251    }
252
253    #[yare::parameterized(
254        proposal = { ApprovalSigningInput { proposal_id: [0; 16], ..approval_input() } },
255        action = { ApprovalSigningInput { action: 7, ..approval_input() } },
256        target = { ApprovalSigningInput { target: "orders", ..approval_input() } },
257        proposer = { ApprovalSigningInput { proposer: "User:mallory", ..approval_input() } },
258        creation_time = {
259            ApprovalSigningInput { created_at_ms: 1_770_000_000_001, ..approval_input() },
260        },
261        expiry = { ApprovalSigningInput { expires_at_ms: 1_770_000_180_001, ..approval_input() } },
262    )]
263    fn a_change_to_any_signed_approval_field_changes_the_bytes(
264        input: ApprovalSigningInput<'static>,
265    ) {
266        let baseline = approval_signing_bytes(&approval_input());
267        check!(approval_signing_bytes(&input) != baseline);
268    }
269
270    /// A length prefix in front of each text field is what stops two different
271    /// field splits from producing one byte string.
272    #[test]
273    fn a_field_split_cannot_produce_one_byte_string() {
274        let left = freeze_signing_bytes(&FreezeSigningInput {
275            scope: "a",
276            reason: "bc",
277            ..freeze_input()
278        });
279        let right = freeze_signing_bytes(&FreezeSigningInput {
280            scope: "ab",
281            reason: "c",
282            ..freeze_input()
283        });
284        check!(left != right);
285    }
286
287    /// A separator shared between two signature purposes lets a captured
288    /// signature be presented for a purpose the signer never agreed to. The
289    /// trailing `\0` is what stops one separator starting the other.
290    #[test]
291    fn the_two_domain_separators_differ_and_neither_starts_the_other() {
292        check!(FREEZE_DOMAIN != BREAK_GLASS_DOMAIN);
293        check!(!FREEZE_DOMAIN.starts_with(BREAK_GLASS_DOMAIN));
294        check!(!BREAK_GLASS_DOMAIN.starts_with(FREEZE_DOMAIN));
295    }
296}