1pub use krabka_audit::{FreezeSigningInput, freeze_signing_input};
50use {krabka_audit::FileEd25519Signer, std::path::Path};
51
52pub const FREEZE_DOMAIN: &[u8] = b"krabka-topic-freeze-v1\0";
57
58pub const BREAK_GLASS_DOMAIN: &[u8] = b"krabka-break-glass-v1\0";
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct ApprovalSigningInput<'a> {
64 pub proposal_id: [u8; 16],
66 pub action: i8,
68 pub target: &'a str,
70 pub proposer: &'a str,
72 pub created_at_ms: i64,
74 pub expires_at_ms: i64,
76}
77
78#[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#[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
129pub 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
142const LEN_PREFIX: usize = size_of::<u32>();
144
145fn 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 #[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 check!(expected.len() == 102);
211 }
212
213 #[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 check!(expected.len() == 79);
232 }
233
234 #[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 #[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 #[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}