Skip to main content

uselesskey_core/srp/negative/
pem.rs

1//! PEM-focused negative fixture corruption helpers for test fixtures.
2//!
3//! Use [`corrupt_pem`] to apply a specific corruption strategy, or
4//! [`corrupt_pem_deterministic`] to let the variant string choose the
5//! strategy deterministically.
6//!
7//! # Examples
8//!
9//! ```
10//! use uselesskey_core::srp::negative::pem::{corrupt_pem, CorruptPem};
11//!
12//! let pem = "-----BEGIN RSA PRIVATE KEY-----\nABC=\n-----END RSA PRIVATE KEY-----\n";
13//!
14//! // Replace the header with an invalid one
15//! let bad = corrupt_pem(pem, CorruptPem::BadHeader);
16//! assert!(bad.starts_with("-----BEGIN CORRUPTED KEY-----"));
17//!
18//! // Inject invalid base64 so decoders reject it
19//! let bad = corrupt_pem(pem, CorruptPem::BadBase64);
20//! assert!(bad.contains("THIS_IS_NOT_BASE64!!!"));
21//! ```
22//!
23//! Deterministic corruption picks a strategy from the variant string,
24//! producing the same output every time:
25//!
26//! ```
27//! use uselesskey_core::srp::negative::pem::corrupt_pem_deterministic;
28//!
29//! let pem = "-----BEGIN PUBLIC KEY-----\nABC=\n-----END PUBLIC KEY-----\n";
30//! let a = corrupt_pem_deterministic(pem, "corrupt:test-v1");
31//! let b = corrupt_pem_deterministic(pem, "corrupt:test-v1");
32//! assert_eq!(a, b); // same variant ⇒ same corruption
33//! ```
34
35use alloc::string::{String, ToString};
36use alloc::vec::Vec;
37
38use crate::srp::hash::hash32;
39
40/// Strategies for corrupting PEM-encoded data.
41#[derive(Clone, Copy, Debug)]
42pub enum CorruptPem {
43    /// Replace the `-----BEGIN …-----` line with an invalid header.
44    BadHeader,
45    /// Replace the `-----END …-----` line with an invalid footer.
46    BadFooter,
47    /// Inject a non-base64 line into the body so decoders reject the payload.
48    BadBase64,
49    /// Keep at most the first `bytes` bytes of the PEM string.
50    Truncate {
51        /// Maximum byte budget to keep without splitting a UTF-8 character.
52        bytes: usize,
53    },
54    /// Insert a blank line after the header, breaking strict PEM parsers.
55    ExtraBlankLine,
56}
57
58/// Apply a specific [`CorruptPem`] corruption strategy to the given PEM string.
59pub 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
69/// Choose a corruption strategy deterministically from `variant` and apply it to `pem`.
70///
71/// The same `(pem, variant)` pair always produces the same corrupted output.
72pub 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"; // 'é' is two bytes
199        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        // Catches `< 3` → `== 3` and `<= 3`: those would take the early-return
363        // path for a 3-line PEM, appending at end instead of inserting after line 1.
364        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        // Catches `< 3` → `> 3`: with `> 3`, a 2-line input would insert
376        // instead of taking the early-return append path.
377        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        // Same boundary check as inject_bad_base64_line.
385        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        // Catches `return 0`, `return 1`, `+ → *`, and arithmetic mutations
404        // on the span / modulo computation.
405        let mut digest = [0u8; 32];
406        digest[1] = 0x0A;
407        digest[2] = 0x0B;
408        // bytes=10, span=9, u16=0x0A0B=2571, 2571%9=6, result=1+6=7
409        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}