1use {
8 crate::BackupError,
9 clap::Args,
10 krabka_client_core::security::{
11 ClientSecurity, KeyStore, SaslCredentials, TlsConnectorConfig, TrustStore,
12 },
13 krabka_security::{ListenerProtocol, SaslMechanism},
14 std::{collections::BTreeMap, io::Error, mem, path::PathBuf},
15 tokio::fs,
16};
17
18#[derive(Debug, Args, Clone, Default)]
20pub struct SecurityArgs {
21 #[arg(long, value_name = "FILE")]
23 pub command_config: Option<PathBuf>,
24}
25
26impl SecurityArgs {
27 pub async fn load(&self) -> Result<Option<ClientSecurity>, BackupError> {
34 let Some(path) = &self.command_config else {
35 return Ok(None);
36 };
37 let text = fs::read_to_string(path).await.map_err(|source| {
38 BackupError::Io(Error::new(
39 source.kind(),
40 format!("read command config {}: {source}", path.display()),
41 ))
42 })?;
43 let properties = parse_properties(&text)?;
44 security(&properties)
45 }
46}
47
48fn parse_properties(text: &str) -> Result<BTreeMap<String, String>, BackupError> {
49 let mut logical = Vec::new();
50 let mut pending = String::new();
51 for line in text.lines() {
52 let trailing = line
53 .chars()
54 .rev()
55 .take_while(|character| *character == '\\')
56 .count();
57 pending.push_str(line.trim_start());
58 if trailing % 2 == 1 {
59 pending.pop();
60 } else {
61 logical.push(mem::take(&mut pending));
62 }
63 }
64 if !pending.is_empty() {
65 logical.push(pending);
66 }
67
68 let mut properties = BTreeMap::new();
69 for line in logical {
70 let trimmed = line.trim_start();
71 if trimmed.is_empty() || trimmed.starts_with(['#', '!']) {
72 continue;
73 }
74 let split = property_split(trimmed);
75 let (key, value) = trimmed.split_at(split);
76 let value = value.trim_start_matches(|character: char| {
77 character.is_whitespace() || character == '=' || character == ':'
78 });
79 properties.insert(unescape(key.trim_end())?, unescape(value)?);
80 }
81 Ok(properties)
82}
83
84fn property_split(line: &str) -> usize {
85 let mut escaped = false;
86 for (index, character) in line.char_indices() {
87 if escaped {
88 escaped = false;
89 } else if character == '\\' {
90 escaped = true;
91 } else if character == '=' || character == ':' || character.is_whitespace() {
92 return index;
93 }
94 }
95 line.len()
96}
97
98fn unescape(value: &str) -> Result<String, BackupError> {
99 let mut characters = value.chars();
100 let mut unescaped = String::new();
101 while let Some(character) = characters.next() {
102 if character != '\\' {
103 unescaped.push(character);
104 continue;
105 }
106 match characters.next() {
107 Some('t') => unescaped.push('\t'),
108 Some('n') => unescaped.push('\n'),
109 Some('r') => unescaped.push('\r'),
110 Some('u') => {
111 let digits: String = characters.by_ref().take(4).collect();
112 let code = u32::from_str_radix(&digits, 16).map_err(|_| {
113 BackupError::InvalidArgument("invalid unicode escape in command config".into())
114 })?;
115 unescaped.push(char::from_u32(code).ok_or_else(|| {
116 BackupError::InvalidArgument(
117 "invalid unicode code point in command config".into(),
118 )
119 })?);
120 }
121 Some(other) => unescaped.push(other),
122 None => unescaped.push('\\'),
123 }
124 }
125 Ok(unescaped)
126}
127
128fn security(properties: &BTreeMap<String, String>) -> Result<Option<ClientSecurity>, BackupError> {
129 let protocol = match properties
130 .get("security.protocol")
131 .map_or("PLAINTEXT", String::as_str)
132 {
133 "PLAINTEXT" => ListenerProtocol::Plaintext,
134 "SSL" => ListenerProtocol::Ssl,
135 "SASL_PLAINTEXT" => ListenerProtocol::SaslPlaintext,
136 "SASL_SSL" => ListenerProtocol::SaslSsl,
137 value => return invalid(&format!("unsupported security.protocol {value}")),
138 };
139 if !protocol.requires_sasl()
140 && let Some(key) = properties.keys().find(|key| key.starts_with("sasl."))
141 {
142 return invalid(&format!("{key} requires a SASL security.protocol"));
143 }
144 if !protocol.requires_tls()
145 && let Some(key) = properties.keys().find(|key| key.starts_with("ssl."))
146 {
147 return invalid(&format!("{key} requires SSL or SASL_SSL"));
148 }
149 if protocol == ListenerProtocol::Plaintext {
150 return Ok(None);
151 }
152
153 let tls = protocol
154 .requires_tls()
155 .then(|| tls_config(properties))
156 .transpose()?;
157 let sasl = protocol
158 .requires_sasl()
159 .then(|| sasl_credentials(properties))
160 .transpose()?;
161 Ok(Some(ClientSecurity {
162 protocol,
163 tls,
164 sasl,
165 sasl_host: properties.get("sasl.kerberos.service.host").cloned(),
166 }))
167}
168
169fn tls_config(properties: &BTreeMap<String, String>) -> Result<TlsConnectorConfig, BackupError> {
170 for key in ["ssl.truststore.type", "ssl.keystore.type"] {
171 if let Some(kind) = properties.get(key)
172 && kind != "PEM"
173 {
174 return invalid(&format!("{key}={kind} is unsupported; expected PEM"));
175 }
176 }
177 let key_store = match (
178 properties.get("ssl.keystore.location"),
179 properties.get("ssl.key.location"),
180 ) {
181 (Some(cert), Some(key)) => Some(KeyStore::PemFiles {
182 certificate_chain: PathBuf::from(cert),
183 private_key: PathBuf::from(key),
184 key_password: None,
185 }),
186 (None, None) => None,
187 _ => {
188 return invalid("ssl.keystore.location and ssl.key.location must be provided together");
189 }
190 };
191 let mut tls = TlsConnectorConfig::default();
192 if let Some(location) = properties.get("ssl.truststore.location") {
193 tls.trust_store = TrustStore::PemFile(PathBuf::from(location));
194 }
195 tls.key_store = key_store;
196 tls.server_name = properties
197 .get("ssl.server.name")
198 .cloned()
199 .unwrap_or_default();
200 Ok(tls)
201}
202
203fn sasl_credentials(properties: &BTreeMap<String, String>) -> Result<SaslCredentials, BackupError> {
204 let mechanism = required(properties, "sasl.mechanism")?;
205 let options = properties
206 .get("sasl.jaas.config")
207 .map_or_default(|value| jaas_options(value));
208 match mechanism.as_str() {
209 "PLAIN" => Ok(SaslCredentials::Plain {
210 username: required(&options, "username")?,
211 password: required(&options, "password")?,
212 }),
213 "SCRAM-SHA-256" | "SCRAM-SHA-512" => Ok(SaslCredentials::Scram {
214 mechanism: if mechanism == "SCRAM-SHA-256" {
215 SaslMechanism::ScramSha256
216 } else {
217 SaslMechanism::ScramSha512
218 },
219 username: required(&options, "username")?,
220 password: required(&options, "password")?,
221 delegation_token: options
224 .get("tokenauth")
225 .is_some_and(|value| value.eq_ignore_ascii_case("true")),
226 }),
227 value => invalid(&format!("unsupported sasl.mechanism {value}")),
228 }
229}
230
231fn jaas_options(value: &str) -> BTreeMap<String, String> {
232 let mut options = BTreeMap::new();
233 let mut characters = value.trim_end_matches(';').chars().peekable();
234 while let Some(character) = characters.next() {
235 if character.is_whitespace() {
236 continue;
237 }
238 let mut key = String::from(character);
239 while let Some(&character) = characters.peek() {
240 if character == '=' || character.is_whitespace() {
241 break;
242 }
243 key.push(character);
244 characters.next();
245 }
246 while characters
247 .peek()
248 .is_some_and(|character| character.is_whitespace())
249 {
250 characters.next();
251 }
252 if characters.next() != Some('=') {
253 while characters
254 .peek()
255 .is_some_and(|character| !character.is_whitespace())
256 {
257 characters.next();
258 }
259 continue;
260 }
261 while characters
262 .peek()
263 .is_some_and(|character| character.is_whitespace())
264 {
265 characters.next();
266 }
267 let quote = characters
268 .peek()
269 .copied()
270 .filter(|character| *character == '"' || *character == '\'');
271 if quote.is_some() {
272 characters.next();
273 }
274 let mut parsed = String::new();
275 let mut escaped = false;
276 for character in characters.by_ref() {
277 if escaped {
278 parsed.push(character);
279 escaped = false;
280 } else if character == '\\' {
281 escaped = true;
282 } else if quote == Some(character) || (quote.is_none() && character.is_whitespace()) {
283 break;
284 } else {
285 parsed.push(character);
286 }
287 }
288 options.insert(key, parsed);
289 }
290 options
291}
292
293fn required(values: &BTreeMap<String, String>, key: &str) -> Result<String, BackupError> {
294 values
295 .get(key)
296 .cloned()
297 .ok_or_else(|| BackupError::InvalidArgument(format!("command config is missing {key}")))
298}
299
300fn invalid<T>(message: &str) -> Result<T, BackupError> {
301 Err(BackupError::InvalidArgument(format!(
302 "invalid command config: {message}"
303 )))
304}
305
306#[cfg(test)]
307mod tests {
308 use {
309 super::*,
310 assert2::{assert, check},
311 };
312
313 #[test]
314 fn properties_support_comments_escapes_and_continuations() {
315 let parsed = parse_properties("! ignored\na\\:b = one\\\n two\\u0033\n")
316 .expect("valid Java properties");
317 check!(parsed == BTreeMap::from([("a:b".into(), "onetwo3".into())]));
318 }
319
320 #[test]
321 fn sasl_ssl_maps_to_the_shared_client_policy() {
322 let properties = parse_properties(
323 "security.protocol=SASL_SSL\n\
324 ssl.truststore.type=PEM\n\
325 ssl.truststore.location=/etc/krabka/ca.pem\n\
326 ssl.server.name=broker.example\n\
327 sasl.mechanism=SCRAM-SHA-512\n\
328 sasl.jaas.config=x required username=backup password=not-logged;\n",
329 )
330 .expect("valid properties");
331 let policy = security(&properties)
332 .expect("valid policy")
333 .expect("secured policy");
334
335 check!(policy.protocol == ListenerProtocol::SaslSsl);
336 let tls = policy.tls.expect("TLS policy");
337 check!(tls.trust_store == TrustStore::PemFile(PathBuf::from("/etc/krabka/ca.pem")));
338 check!(tls.server_name == "broker.example");
339 assert!(let Some(SaslCredentials::Scram {
340 mechanism: SaslMechanism::ScramSha512,
341 username,
342 password,
343 delegation_token: false,
344 }) = policy.sasl);
345 check!(username == "backup");
346 check!(password == "not-logged");
347 }
348
349 #[yare::parameterized(
352 absent = { "x required username=id password=hmac;", false },
353 true_lowercase = { "x required username=id password=hmac tokenauth=true;", true },
354 true_uppercase = { "x required username=id password=hmac tokenauth=TRUE;", true },
355 false_value = { "x required username=id password=hmac tokenauth=false;", false },
356 )]
357 fn tokenauth_marks_a_delegation_token_login(jaas: &str, expected: bool) {
358 let properties = parse_properties(&format!(
359 "security.protocol=SASL_PLAINTEXT\n\
360 sasl.mechanism=SCRAM-SHA-256\n\
361 sasl.jaas.config={jaas}\n"
362 ))
363 .expect("valid properties");
364 let policy = security(&properties)
365 .expect("valid policy")
366 .expect("secured policy");
367 assert2::assert!(
368 let Some(SaslCredentials::Scram {
369 delegation_token, ..
370 }) = policy.sasl,
371 "{jaas}"
372 );
373 check!(delegation_token == expected, "{jaas}");
374 }
375
376 #[test]
377 fn invalid_config_errors_do_not_echo_credentials() {
378 let properties = parse_properties(
379 "security.protocol=SASL_PLAINTEXT\n\
380 sasl.mechanism=SCRAM-SHA-256\n\
381 sasl.jaas.config=x required password=hunter2;\n",
382 )
383 .expect("valid properties");
384 let error = security(&properties)
385 .expect_err("missing username must fail")
386 .to_string();
387 check!(!error.contains("hunter2"), "got: {error}");
388 check!(error.contains("username"), "got: {error}");
389 }
390
391 #[test]
392 fn tls_refuses_non_pem_stores() {
393 let properties = parse_properties(
394 "security.protocol=SSL\nssl.truststore.type=JKS\nssl.truststore.location=a.jks\n",
395 )
396 .expect("valid properties");
397 let error = security(&properties)
398 .expect_err("the shared client reads PEM, not JKS")
399 .to_string();
400 check!(error.contains("expected PEM"), "got: {error}");
401 }
402
403 #[yare::parameterized(
404 sasl_mechanism = { "sasl.mechanism", "SCRAM-SHA-512" },
405 tls_location = { "ssl.truststore.location", "/etc/krabka/ca.pem" },
406 )]
407 fn security_properties_cannot_be_silently_ignored(property: &str, value: &str) {
408 let properties = BTreeMap::from([(property.into(), value.into())]);
409 let error = security(&properties)
410 .expect_err("PLAINTEXT must reject secured-client properties")
411 .to_string();
412 check!(error.contains(property), "got: {error}");
413 }
414
415 #[test]
416 fn tls_sni_defaults_to_each_connection_target() {
417 let properties =
418 parse_properties("security.protocol=SSL\nssl.truststore.location=/etc/krabka/ca.pem\n")
419 .expect("valid properties");
420 let policy = security(&properties)
421 .expect("valid policy")
422 .expect("secured policy");
423 check!(policy.tls.expect("TLS policy").server_name.is_empty());
424 }
425}