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 mut lines = pem.lines();
114    let _first = lines.next();
115
116    let mut out = String::new();
117    out.push_str(replacement);
118    out.push('\n');
119
120    for l in lines {
121        out.push_str(l);
122        out.push('\n');
123    }
124
125    out
126}
127
128fn replace_last_line(pem: &str, replacement: &str) -> String {
129    let mut all: Vec<&str> = pem.lines().collect();
130    if all.is_empty() {
131        return replacement.to_string();
132    }
133    let last_idx = all.len() - 1;
134    all[last_idx] = replacement;
135
136    let mut out = String::new();
137    for l in all {
138        out.push_str(l);
139        out.push('\n');
140    }
141    out
142}
143
144fn inject_bad_base64_line(pem: &str) -> String {
145    let mut lines: Vec<&str> = pem.lines().collect();
146    if lines.len() < 3 {
147        return alloc::format!("{pem}\nTHIS_IS_NOT_BASE64!!!\n");
148    }
149
150    lines.insert(1, "THIS_IS_NOT_BASE64!!!");
151
152    let mut out = String::new();
153    for l in lines {
154        out.push_str(l);
155        out.push('\n');
156    }
157    out
158}
159
160fn inject_blank_line(pem: &str) -> String {
161    let mut lines: Vec<&str> = pem.lines().collect();
162    if lines.len() < 3 {
163        return alloc::format!("{pem}\n\n");
164    }
165    lines.insert(1, "");
166
167    let mut out = String::new();
168    for l in lines {
169        out.push_str(l);
170        out.push('\n');
171    }
172    out
173}
174
175#[cfg(all(test, feature = "std"))]
176mod tests {
177    use super::*;
178    use std::collections::HashSet;
179
180    #[test]
181    fn bad_header_replaces_first_line() {
182        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
183        let out = corrupt_pem(pem, CorruptPem::BadHeader);
184        assert!(out.starts_with("-----BEGIN CORRUPTED KEY-----\n"));
185    }
186
187    #[test]
188    fn bad_footer_replaces_last_line() {
189        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
190        let out = corrupt_pem(pem, CorruptPem::BadFooter);
191        assert!(out.contains("-----END CORRUPTED KEY-----\n"));
192    }
193
194    #[test]
195    fn bad_footer_on_empty_input_returns_replacement() {
196        let out = corrupt_pem("", CorruptPem::BadFooter);
197        assert_eq!(out, "-----END CORRUPTED KEY-----");
198    }
199
200    #[test]
201    fn bad_base64_short_input_inserts_line() {
202        let out = corrupt_pem("x", CorruptPem::BadBase64);
203        assert_eq!(out, "x\nTHIS_IS_NOT_BASE64!!!\n");
204    }
205
206    #[test]
207    fn extra_blank_line_short_input_appends_newlines() {
208        let out = corrupt_pem("x", CorruptPem::ExtraBlankLine);
209        assert_eq!(out, "x\n\n");
210    }
211
212    #[test]
213    fn truncate_variant_limits_length() {
214        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
215        let out = corrupt_pem(pem, CorruptPem::Truncate { bytes: 10 });
216        assert_eq!(out.len(), 10);
217    }
218
219    #[test]
220    fn truncate_variant_uses_byte_budget_without_breaking_utf8() {
221        let pem = "🔐KEY";
222        let out = corrupt_pem(pem, CorruptPem::Truncate { bytes: 3 });
223        assert_eq!(out, "");
224
225        let out = corrupt_pem(pem, CorruptPem::Truncate { bytes: 4 });
226        assert_eq!(out, "🔐");
227    }
228
229    #[test]
230    fn deterministic_truncate_never_exceeds_input_byte_length() {
231        let pem = "🔐A";
232        let variant = (0..256)
233            .map(|i| alloc::format!("corrupt:truncate:{i}"))
234            .find(|candidate| hash32(candidate.as_bytes()).as_bytes()[0] % 5 == 4)
235            .expect("a truncate variant should exist");
236        let out = corrupt_pem_deterministic(pem, &variant);
237        assert!(out.len() <= pem.len());
238        assert!(pem.starts_with(&out));
239    }
240
241    #[test]
242    fn deterministic_corruption_is_stable_for_same_variant() {
243        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
244        let first = corrupt_pem_deterministic(pem, "corrupt:variant-a");
245        let second = corrupt_pem_deterministic(pem, "corrupt:variant-a");
246        assert_eq!(first, second);
247    }
248
249    #[test]
250    fn deterministic_corruption_produces_multiple_shapes_across_variants() {
251        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
252        let variants = ["a", "b", "c", "d", "e", "f", "g", "h"];
253        let mut outputs = HashSet::new();
254        for v in variants {
255            outputs.insert(corrupt_pem_deterministic(pem, v));
256        }
257        assert!(outputs.len() >= 2);
258    }
259
260    fn find_variant(target: u8) -> String {
261        for i in 0u64.. {
262            let v = format!("v{i}");
263            if hash32(v.as_bytes()).as_bytes()[0] % 5 == target {
264                return v;
265            }
266        }
267        unreachable!()
268    }
269
270    #[test]
271    fn deterministic_pem_arm0_bad_header() {
272        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
273        let out = corrupt_pem_deterministic(pem, &find_variant(0));
274        assert!(out.contains("BEGIN CORRUPTED KEY"));
275    }
276
277    #[test]
278    fn deterministic_pem_arm1_bad_footer() {
279        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
280        let out = corrupt_pem_deterministic(pem, &find_variant(1));
281        assert!(out.contains("END CORRUPTED KEY"));
282    }
283
284    #[test]
285    fn deterministic_pem_arm2_bad_base64() {
286        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
287        let out = corrupt_pem_deterministic(pem, &find_variant(2));
288        assert!(out.contains("THIS_IS_NOT_BASE64!!!"));
289    }
290
291    #[test]
292    fn deterministic_pem_arm3_blank_line() {
293        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
294        let out = corrupt_pem_deterministic(pem, &find_variant(3));
295        assert!(out.contains("BEGIN TEST-----\n\n"));
296    }
297
298    #[test]
299    fn deterministic_pem_arm4_truncate() {
300        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
301        let out = corrupt_pem_deterministic(pem, &find_variant(4));
302        assert!(out.len() < pem.len());
303    }
304
305    #[test]
306    fn bad_base64_inserts_after_header_in_normal_pem() {
307        // Catches `< 3` → `== 3` and `<= 3`: those would take the early-return
308        // path for a 3-line PEM, appending at end instead of inserting after line 1.
309        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
310        let out = corrupt_pem(pem, CorruptPem::BadBase64);
311        let lines: Vec<&str> = out.lines().collect();
312        assert_eq!(lines.len(), 4);
313        assert_eq!(lines[0], "-----BEGIN TEST-----");
314        assert_eq!(lines[1], "THIS_IS_NOT_BASE64!!!");
315        assert_eq!(lines[2], "AAA=");
316    }
317
318    #[test]
319    fn bad_base64_two_line_pem_appends() {
320        // Catches `< 3` → `> 3`: with `> 3`, a 2-line input would insert
321        // instead of taking the early-return append path.
322        let pem = "line1\nline2";
323        let out = corrupt_pem(pem, CorruptPem::BadBase64);
324        assert_eq!(out, "line1\nline2\nTHIS_IS_NOT_BASE64!!!\n");
325    }
326
327    #[test]
328    fn blank_line_inserts_after_header_in_normal_pem() {
329        // Same boundary check as inject_bad_base64_line.
330        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
331        let out = corrupt_pem(pem, CorruptPem::ExtraBlankLine);
332        let lines: Vec<&str> = out.lines().collect();
333        assert_eq!(lines.len(), 4);
334        assert_eq!(lines[0], "-----BEGIN TEST-----");
335        assert_eq!(lines[1], "");
336        assert_eq!(lines[2], "AAA=");
337    }
338
339    #[test]
340    fn blank_line_two_line_pem_appends() {
341        let pem = "line1\nline2";
342        let out = corrupt_pem(pem, CorruptPem::ExtraBlankLine);
343        assert_eq!(out, "line1\nline2\n\n");
344    }
345
346    #[test]
347    fn derived_truncate_len_exact_arithmetic() {
348        // Catches `return 0`, `return 1`, `+ → *`, and arithmetic mutations
349        // on the span / modulo computation.
350        let mut digest = [0u8; 32];
351        digest[1] = 0x0A;
352        digest[2] = 0x0B;
353        // bytes=10, span=9, u16=0x0A0B=2571, 2571%9=6, result=1+6=7
354        assert_eq!(derived_truncate_len("0123456789", &digest), 7);
355    }
356
357    #[test]
358    fn derived_truncate_len_empty_returns_zero() {
359        let digest = [0u8; 32];
360        assert_eq!(derived_truncate_len("", &digest), 0);
361    }
362
363    #[test]
364    fn derived_truncate_len_single_char_returns_zero() {
365        let digest = [0xFF; 32];
366        assert_eq!(derived_truncate_len("x", &digest), 0);
367    }
368}