1use {
7 crate::{
8 ids::{EpochMs, Seq},
9 sink::AuditError,
10 },
11 krabka_protocol::krabka::freeze::DescribedTopicFreeze,
12 ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey},
13 std::{fs, path::Path},
14};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct FreezeSigningInput<'a> {
20 pub cluster_id: &'a str,
21 pub pattern_type: i8,
22 pub scope: &'a str,
23 pub frozen: bool,
24 pub reason: &'a str,
25 pub set_by: &'a str,
26 pub set_at_ms: i64,
27 pub proposal_id: [u8; 16],
28}
29
30#[must_use]
32#[bon::builder]
33pub fn freeze_signing_input<'a>(
34 cluster_id: &'a str,
35 entry: &'a DescribedTopicFreeze,
36 frozen: bool,
37) -> FreezeSigningInput<'a> {
38 FreezeSigningInput {
39 cluster_id,
40 pattern_type: entry.pattern_type,
41 scope: &entry.scope,
42 frozen,
43 reason: &entry.reason,
44 set_by: &entry.set_by,
45 set_at_ms: entry.set_at_ms,
46 proposal_id: entry.proposal_id.0,
47 }
48}
49
50pub const CHECKPOINT_DOMAIN: &[u8] = b"krabka-audit-ckpt-v1\0";
52
53#[derive(derive_more::Debug)]
55pub struct FileEd25519Signer {
56 key_id: String,
57 #[debug(skip)]
58 key_pair: Ed25519KeyPair,
59 #[debug(skip)]
60 public_key: Vec<u8>,
61}
62
63impl FileEd25519Signer {
64 #[tracing::instrument(level = "debug", skip_all, fields(key_id = %key_id), err)]
66 pub fn from_pkcs8_file(path: impl AsRef<Path>, key_id: String) -> Result<Self, AuditError> {
70 let der =
71 fs::read(path.as_ref()).map_err(|e| AuditError::Key(format!("read key file: {e}")))?;
72 Self::from_pkcs8_bytes(&der, key_id)
73 }
74
75 #[tracing::instrument(level = "debug", skip_all, fields(key_id = %key_id, bytes = der.len()), err)]
77 pub fn from_pkcs8_bytes(der: &[u8], key_id: String) -> Result<Self, AuditError> {
80 let key_pair = Ed25519KeyPair::from_pkcs8(der)
81 .map_err(|_| AuditError::Key("invalid PKCS#8 Ed25519 key".to_owned()))?;
82 let public_key = key_pair.public_key().as_ref().to_vec();
83 Ok(Self {
84 key_id,
85 key_pair,
86 public_key,
87 })
88 }
89}
90
91impl FileEd25519Signer {
92 #[must_use]
95 pub fn key_id(&self) -> &str { &self.key_id }
96
97 #[must_use]
99 pub fn sign(&self, msg: &[u8]) -> Vec<u8> { self.key_pair.sign(msg).as_ref().to_vec() }
100
101 #[must_use]
103 pub fn public_key(&self) -> Vec<u8> { self.public_key.clone() }
104}
105
106#[must_use]
111pub fn checkpoint_signing_bytes(
112 key_id: &str,
113 seq_high: Seq,
114 head: &[u8; 32],
115 time_ms: EpochMs,
116) -> Vec<u8> {
117 let kid = key_id.as_bytes();
118 let kid_len = u16::try_from(kid.len()).unwrap_or(u16::MAX);
119 let mut v = Vec::with_capacity(CHECKPOINT_DOMAIN.len() + 2 + kid.len() + 8 + 32 + 8);
120 v.extend_from_slice(CHECKPOINT_DOMAIN);
121 v.extend_from_slice(&kid_len.to_be_bytes());
122 v.extend_from_slice(kid);
123 v.extend_from_slice(&seq_high.0.to_be_bytes());
124 v.extend_from_slice(head);
125 v.extend_from_slice(&time_ms.0.to_be_bytes());
126 v
127}
128
129#[must_use]
133pub fn verify_signature(public_key: &[u8], msg: &[u8], sig: &[u8]) -> bool {
134 UnparsedPublicKey::new(&ED25519, public_key)
135 .verify(msg, sig)
136 .is_ok()
137}
138
139#[cfg(test)]
140mod tests {
141 use {
142 super::*,
143 crate::{
144 sink::AuditError,
145 test_support::{TestSigner, signer_with_key_id},
146 },
147 assert2::check,
148 };
149
150 #[yare::parameterized(
151 valid_signature = { false, false, true },
152 tampered_message = { false, true, false },
153 wrong_key = { true, false, false },
154 )]
155 fn sign_then_verify_roundtrips(use_other_key: bool, tamper_message: bool, expected: bool) {
156 let TestSigner {
157 signer,
158 public_key: pubkey,
159 } = signer_with_key_id("k1");
160 check!((signer.key_id(), signer.public_key()) == ("k1", pubkey.clone()));
161 let msg = b"the quick brown fox";
162 let sig = signer.sign(msg);
163 let TestSigner {
164 public_key: other_pub,
165 ..
166 } = signer_with_key_id("k2");
167 let key = if use_other_key { &other_pub } else { &pubkey };
168 let message = if tamper_message {
169 b"the quick brown FOX".as_slice()
170 } else {
171 msg.as_slice()
172 };
173 check!(verify_signature(key, message, &sig) == expected);
174 }
175
176 #[yare::parameterized(
177 identical_fields = { "k1", Seq(42), [9u8; 32], EpochMs(1_700_000_000_000), true },
178 different_key = { "k2", Seq(42), [9u8; 32], EpochMs(1_700_000_000_000), false },
179 different_sequence = { "k1", Seq(43), [9u8; 32], EpochMs(1_700_000_000_000), false },
180 different_head = { "k1", Seq(42), [8u8; 32], EpochMs(1_700_000_000_000), false },
181 different_time = { "k1", Seq(42), [9u8; 32], EpochMs(1), false },
182 )]
183 fn checkpoint_bytes_are_canonical_and_field_sensitive(
184 key_id: &str,
185 seq: Seq,
186 candidate_head: [u8; 32],
187 time: EpochMs,
188 expected_equal: bool,
189 ) {
190 let base = checkpoint_signing_bytes("k1", Seq(42), &[9u8; 32], EpochMs(1_700_000_000_000));
191 check!(base.starts_with(CHECKPOINT_DOMAIN));
192 check!(
193 (checkpoint_signing_bytes(key_id, seq, &candidate_head, time) == base)
194 == expected_equal
195 );
196 }
197
198 #[test]
199 fn bad_pkcs8_is_a_key_error() {
200 let err = FileEd25519Signer::from_pkcs8_bytes(b"not-a-key", "k".into());
201 check!(matches!(err, Err(AuditError::Key(_))));
202 }
203
204 #[test]
205 fn freeze_input_preserves_every_signed_field() {
206 use krabka_protocol::{krabka::freeze::DescribedTopicFreeze, primitives::uuid::Uuid};
207
208 let entry = DescribedTopicFreeze {
209 pattern_type: 4,
210 scope: "tenant-a.".to_owned(),
211 reason: "maintenance".to_owned(),
212 set_by: "User:alice".to_owned(),
213 set_at_ms: 0x0102_0304_0506_0708,
214 proposal_id: Uuid([7; 16]),
215 ..DescribedTopicFreeze::default()
216 };
217
218 for frozen in [true, false] {
219 check!(
220 freeze_signing_input()
221 .cluster_id("cluster-a")
222 .entry(&entry)
223 .frozen(frozen)
224 .call()
225 == FreezeSigningInput {
226 cluster_id: "cluster-a",
227 pattern_type: 4,
228 scope: "tenant-a.",
229 frozen,
230 reason: "maintenance",
231 set_by: "User:alice",
232 set_at_ms: 0x0102_0304_0506_0708,
233 proposal_id: [7; 16],
234 }
235 );
236 }
237 }
238}