uselesskey_core/srp/negative/
pem.rs1use alloc::string::{String, ToString};
36use alloc::vec::Vec;
37
38use crate::srp::hash::hash32;
39
40#[derive(Clone, Copy, Debug)]
42pub enum CorruptPem {
43 BadHeader,
45 BadFooter,
47 BadBase64,
49 Truncate {
51 bytes: usize,
53 },
54 ExtraBlankLine,
56}
57
58pub fn corrupt_pem(pem: &str, how: CorruptPem) -> String {
60 match how {
61 CorruptPem::BadHeader => replace_first_line(pem, "-----BEGIN CORRUPTED KEY-----"),
62 CorruptPem::BadFooter => replace_last_line(pem, "-----END CORRUPTED KEY-----"),
63 CorruptPem::BadBase64 => inject_bad_base64_line(pem),
64 CorruptPem::Truncate { bytes } => truncate_utf8_at_byte_boundary(pem, bytes),
65 CorruptPem::ExtraBlankLine => inject_blank_line(pem),
66 }
67}
68
69pub fn corrupt_pem_deterministic(pem: &str, variant: &str) -> String {
73 let digest = hash32(variant.as_bytes());
74 let bytes = digest.as_bytes();
75
76 match bytes[0] % 5 {
77 0 => corrupt_pem(pem, CorruptPem::BadHeader),
78 1 => corrupt_pem(pem, CorruptPem::BadFooter),
79 2 => corrupt_pem(pem, CorruptPem::BadBase64),
80 3 => corrupt_pem(pem, CorruptPem::ExtraBlankLine),
81 _ => {
82 let bytes = derived_truncate_len(pem, bytes);
83 corrupt_pem(pem, CorruptPem::Truncate { bytes })
84 }
85 }
86}
87
88fn derived_truncate_len(pem: &str, digest: &[u8; 32]) -> usize {
89 let total_bytes = pem.len();
90 if total_bytes <= 1 {
91 return 0;
92 }
93
94 let span = total_bytes - 1;
95 1 + (u16::from_be_bytes([digest[1], digest[2]]) as usize % span)
96}
97
98fn truncate_utf8_at_byte_boundary(input: &str, max_bytes: usize) -> String {
99 if max_bytes >= input.len() {
100 return input.to_string();
101 }
102
103 let end = input
104 .char_indices()
105 .map(|(idx, _)| idx)
106 .take_while(|idx| *idx <= max_bytes)
107 .last()
108 .unwrap_or(0);
109 input[..end].to_string()
110}
111
112fn replace_first_line(pem: &str, replacement: &str) -> String {
113 let sep = line_separator(pem);
114 let mut lines = pem.lines();
115 let _first = lines.next();
116
117 let mut out = String::new();
118 push_line(&mut out, replacement, sep);
119
120 for l in lines {
121 push_line(&mut out, l, sep);
122 }
123
124 out
125}
126
127fn replace_last_line(pem: &str, replacement: &str) -> String {
128 let mut all: Vec<&str> = pem.lines().collect();
129 if all.is_empty() {
130 return replacement.to_string();
131 }
132 let last_idx = all.len() - 1;
133 all[last_idx] = replacement;
134
135 join_lines(&all, line_separator(pem))
136}
137
138fn inject_bad_base64_line(pem: &str) -> String {
139 let sep = line_separator(pem);
140 let mut lines: Vec<&str> = pem.lines().collect();
141 if lines.len() < 3 {
142 return alloc::format!("{pem}{sep}THIS_IS_NOT_BASE64!!!{sep}");
143 }
144
145 lines.insert(1, "THIS_IS_NOT_BASE64!!!");
146
147 join_lines(&lines, sep)
148}
149
150fn inject_blank_line(pem: &str) -> String {
151 let sep = line_separator(pem);
152 let mut lines: Vec<&str> = pem.lines().collect();
153 if lines.len() < 3 {
154 return alloc::format!("{pem}{sep}{sep}");
155 }
156 lines.insert(1, "");
157
158 join_lines(&lines, sep)
159}
160
161fn line_separator(input: &str) -> &'static str {
162 if input.contains("\r\n") { "\r\n" } else { "\n" }
163}
164
165fn join_lines(lines: &[&str], sep: &str) -> String {
166 let mut out = String::new();
167 for line in lines {
168 push_line(&mut out, line, sep);
169 }
170 out
171}
172
173fn push_line(out: &mut String, line: &str, sep: &str) {
174 out.push_str(line);
175 out.push_str(sep);
176}
177
178#[cfg(all(test, feature = "std"))]
179mod tests {
180 use super::*;
181 use std::collections::HashSet;
182
183 #[test]
184 fn bad_header_replaces_first_line() {
185 let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
186 let out = corrupt_pem(pem, CorruptPem::BadHeader);
187 assert!(out.starts_with("-----BEGIN CORRUPTED KEY-----\n"));
188 }
189
190 #[test]
191 fn bad_header_on_empty_input_is_single_replacement_line() {
192 let out = corrupt_pem("", CorruptPem::BadHeader);
193 assert_eq!(out, "-----BEGIN CORRUPTED KEY-----\n");
194 }
195
196 #[test]
197 fn truncate_utf8_exact_boundary_keeps_full_character() {
198 let pem = "éx"; let out = corrupt_pem(pem, CorruptPem::Truncate { bytes: 2 });
200 assert_eq!(out, "é");
201 }
202
203 #[test]
204 fn truncate_utf8_zero_budget_returns_empty() {
205 let out = corrupt_pem("abc", CorruptPem::Truncate { bytes: 0 });
206 assert_eq!(out, "");
207 }
208 #[test]
209 fn bad_footer_replaces_last_line() {
210 let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
211 let out = corrupt_pem(pem, CorruptPem::BadFooter);
212 assert!(out.contains("-----END CORRUPTED KEY-----\n"));
213 }
214
215 #[test]
216 fn bad_footer_on_empty_input_returns_replacement() {
217 let out = corrupt_pem("", CorruptPem::BadFooter);
218 assert_eq!(out, "-----END CORRUPTED KEY-----");
219 }
220
221 #[test]
222 fn bad_base64_short_input_inserts_line() {
223 let out = corrupt_pem("x", CorruptPem::BadBase64);
224 assert_eq!(out, "x\nTHIS_IS_NOT_BASE64!!!\n");
225 }
226
227 #[test]
228 fn extra_blank_line_short_input_appends_newlines() {
229 let out = corrupt_pem("x", CorruptPem::ExtraBlankLine);
230 assert_eq!(out, "x\n\n");
231 }
232
233 #[test]
234 fn bad_header_preserves_crlf_line_endings() {
235 let pem = "-----BEGIN TEST-----\r\nAAA=\r\n-----END TEST-----\r\n";
236 let out = corrupt_pem(pem, CorruptPem::BadHeader);
237
238 assert!(out.starts_with("-----BEGIN CORRUPTED KEY-----\r\n"));
239 assert!(!out.contains("-----BEGIN CORRUPTED KEY-----\nAAA="));
240 assert_eq!(out.matches("\r\n").count(), 3);
241 }
242
243 #[test]
244 fn injected_corruptions_preserve_crlf_line_endings() {
245 let pem = "-----BEGIN TEST-----\r\nAAA=\r\n-----END TEST-----\r\n";
246
247 let bad_base64 = corrupt_pem(pem, CorruptPem::BadBase64);
248 assert!(bad_base64.contains("-----BEGIN TEST-----\r\nTHIS_IS_NOT_BASE64!!!\r\nAAA="));
249 assert!(!bad_base64.contains("THIS_IS_NOT_BASE64!!!\nAAA="));
250
251 let blank_line = corrupt_pem(pem, CorruptPem::ExtraBlankLine);
252 assert!(blank_line.contains("-----BEGIN TEST-----\r\n\r\nAAA="));
253 assert!(!blank_line.contains("-----BEGIN TEST-----\n\nAAA="));
254 }
255
256 #[test]
257 fn short_input_corruptions_preserve_crlf_line_endings() {
258 let pem = "line1\r\nline2";
259
260 let bad_base64 = corrupt_pem(pem, CorruptPem::BadBase64);
261 assert_eq!(bad_base64, "line1\r\nline2\r\nTHIS_IS_NOT_BASE64!!!\r\n");
262
263 let blank_line = corrupt_pem(pem, CorruptPem::ExtraBlankLine);
264 assert_eq!(blank_line, "line1\r\nline2\r\n\r\n");
265 }
266
267 #[test]
268 fn truncate_variant_limits_length() {
269 let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
270 let out = corrupt_pem(pem, CorruptPem::Truncate { bytes: 10 });
271 assert_eq!(out.len(), 10);
272 }
273
274 #[test]
275 fn truncate_variant_uses_byte_budget_without_breaking_utf8() {
276 let pem = "🔐KEY";
277 let out = corrupt_pem(pem, CorruptPem::Truncate { bytes: 3 });
278 assert_eq!(out, "");
279
280 let out = corrupt_pem(pem, CorruptPem::Truncate { bytes: 4 });
281 assert_eq!(out, "🔐");
282 }
283
284 #[test]
285 fn deterministic_truncate_never_exceeds_input_byte_length() {
286 let pem = "🔐A";
287 let variant = (0..256)
288 .map(|i| alloc::format!("corrupt:truncate:{i}"))
289 .find(|candidate| hash32(candidate.as_bytes()).as_bytes()[0] % 5 == 4)
290 .expect("a truncate variant should exist");
291 let out = corrupt_pem_deterministic(pem, &variant);
292 assert!(out.len() <= pem.len());
293 assert!(pem.starts_with(&out));
294 }
295
296 #[test]
297 fn deterministic_corruption_is_stable_for_same_variant() {
298 let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
299 let first = corrupt_pem_deterministic(pem, "corrupt:variant-a");
300 let second = corrupt_pem_deterministic(pem, "corrupt:variant-a");
301 assert_eq!(first, second);
302 }
303
304 #[test]
305 fn deterministic_corruption_produces_multiple_shapes_across_variants() {
306 let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
307 let variants = ["a", "b", "c", "d", "e", "f", "g", "h"];
308 let mut outputs = HashSet::new();
309 for v in variants {
310 outputs.insert(corrupt_pem_deterministic(pem, v));
311 }
312 assert!(outputs.len() >= 2);
313 }
314
315 fn find_variant(target: u8) -> String {
316 for i in 0u64.. {
317 let v = format!("v{i}");
318 if hash32(v.as_bytes()).as_bytes()[0] % 5 == target {
319 return v;
320 }
321 }
322 unreachable!()
323 }
324
325 #[test]
326 fn deterministic_pem_arm0_bad_header() {
327 let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
328 let out = corrupt_pem_deterministic(pem, &find_variant(0));
329 assert!(out.contains("BEGIN CORRUPTED KEY"));
330 }
331
332 #[test]
333 fn deterministic_pem_arm1_bad_footer() {
334 let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
335 let out = corrupt_pem_deterministic(pem, &find_variant(1));
336 assert!(out.contains("END CORRUPTED KEY"));
337 }
338
339 #[test]
340 fn deterministic_pem_arm2_bad_base64() {
341 let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
342 let out = corrupt_pem_deterministic(pem, &find_variant(2));
343 assert!(out.contains("THIS_IS_NOT_BASE64!!!"));
344 }
345
346 #[test]
347 fn deterministic_pem_arm3_blank_line() {
348 let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
349 let out = corrupt_pem_deterministic(pem, &find_variant(3));
350 assert!(out.contains("BEGIN TEST-----\n\n"));
351 }
352
353 #[test]
354 fn deterministic_pem_arm4_truncate() {
355 let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
356 let out = corrupt_pem_deterministic(pem, &find_variant(4));
357 assert!(out.len() < pem.len());
358 }
359
360 #[test]
361 fn bad_base64_inserts_after_header_in_normal_pem() {
362 let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
365 let out = corrupt_pem(pem, CorruptPem::BadBase64);
366 let lines: Vec<&str> = out.lines().collect();
367 assert_eq!(lines.len(), 4);
368 assert_eq!(lines[0], "-----BEGIN TEST-----");
369 assert_eq!(lines[1], "THIS_IS_NOT_BASE64!!!");
370 assert_eq!(lines[2], "AAA=");
371 }
372
373 #[test]
374 fn bad_base64_two_line_pem_appends() {
375 let pem = "line1\nline2";
378 let out = corrupt_pem(pem, CorruptPem::BadBase64);
379 assert_eq!(out, "line1\nline2\nTHIS_IS_NOT_BASE64!!!\n");
380 }
381
382 #[test]
383 fn blank_line_inserts_after_header_in_normal_pem() {
384 let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
386 let out = corrupt_pem(pem, CorruptPem::ExtraBlankLine);
387 let lines: Vec<&str> = out.lines().collect();
388 assert_eq!(lines.len(), 4);
389 assert_eq!(lines[0], "-----BEGIN TEST-----");
390 assert_eq!(lines[1], "");
391 assert_eq!(lines[2], "AAA=");
392 }
393
394 #[test]
395 fn blank_line_two_line_pem_appends() {
396 let pem = "line1\nline2";
397 let out = corrupt_pem(pem, CorruptPem::ExtraBlankLine);
398 assert_eq!(out, "line1\nline2\n\n");
399 }
400
401 #[test]
402 fn derived_truncate_len_exact_arithmetic() {
403 let mut digest = [0u8; 32];
406 digest[1] = 0x0A;
407 digest[2] = 0x0B;
408 assert_eq!(derived_truncate_len("0123456789", &digest), 7);
410 }
411
412 #[test]
413 fn derived_truncate_len_empty_returns_zero() {
414 let digest = [0u8; 32];
415 assert_eq!(derived_truncate_len("", &digest), 0);
416 }
417
418 #[test]
419 fn derived_truncate_len_single_char_returns_zero() {
420 let digest = [0xFF; 32];
421 assert_eq!(derived_truncate_len("x", &digest), 0);
422 }
423}