Skip to content

Commit 1c8bc54

Browse files
committed
feat: detect PEM private keys
1 parent 089d6dc commit 1c8bc54

1 file changed

Lines changed: 130 additions & 0 deletions

File tree

crates/core/src/lib.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ enum Label {
2323
Phone,
2424
Ssn,
2525
CreditCard,
26+
PrivateKey,
2627
IpAddress,
2728
Date,
2829
ZipCode,
@@ -36,6 +37,7 @@ impl Label {
3637
Label::Phone => "PHONE",
3738
Label::Ssn => "SSN",
3839
Label::CreditCard => "CREDIT_CARD",
40+
Label::PrivateKey => "PRIVATE_KEY",
3941
Label::IpAddress => "IP_ADDRESS",
4042
Label::Date => "DATE",
4143
Label::ZipCode => "ZIP_CODE",
@@ -58,6 +60,7 @@ pub fn scan(text: &str) -> Vec<Entity> {
5860
detect_phone(text, &mut candidates);
5961
detect_ssn(text, &mut candidates);
6062
detect_credit_card(text, &mut candidates);
63+
detect_private_key(text, &mut candidates);
6164
detect_date(text, &mut candidates);
6265
detect_zip_code(text, &mut candidates);
6366
detect_ip_address(text, &mut candidates);
@@ -368,6 +371,93 @@ fn detect_credit_card(text: &str, candidates: &mut Vec<Candidate>) {
368371
}
369372
}
370373

374+
const PRIVATE_KEY_TYPES: [&[u8]; 5] = [
375+
b"PRIVATE KEY",
376+
b"RSA PRIVATE KEY",
377+
b"EC PRIVATE KEY",
378+
b"OPENSSH PRIVATE KEY",
379+
b"ENCRYPTED PRIVATE KEY",
380+
];
381+
382+
fn is_base64_byte(byte: u8) -> bool {
383+
byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=')
384+
}
385+
386+
fn has_private_key_body(bytes: &[u8]) -> bool {
387+
let mut has_content = false;
388+
389+
for line in bytes.split(|byte| *byte == b'\n') {
390+
let line = line.strip_suffix(b"\r").unwrap_or(line);
391+
392+
if line.is_empty() {
393+
continue;
394+
}
395+
396+
if !line.iter().all(|byte| is_base64_byte(*byte)) {
397+
return false;
398+
}
399+
400+
has_content = true;
401+
}
402+
403+
has_content
404+
}
405+
406+
fn private_key_parts_at(bytes: &[u8], start: usize) -> Option<(usize, usize)> {
407+
for key_type in PRIVATE_KEY_TYPES {
408+
let begin = [b"-----BEGIN ".as_slice(), key_type, b"-----"].concat();
409+
410+
if !bytes[start..].starts_with(&begin) {
411+
continue;
412+
}
413+
414+
let body_start = start + begin.len();
415+
let end_marker = [b"-----END ".as_slice(), key_type, b"-----"].concat();
416+
let end_start =
417+
(body_start..=bytes.len().saturating_sub(end_marker.len())).find(|end_start| {
418+
bytes[*end_start..].starts_with(&end_marker)
419+
&& (*end_start == body_start || bytes[*end_start - 1] == b'\n')
420+
})?;
421+
let end = end_start + end_marker.len();
422+
423+
if has_private_key_body(&bytes[body_start..end_start]) {
424+
return Some((end, body_start));
425+
}
426+
}
427+
428+
None
429+
}
430+
431+
fn detect_private_key(text: &str, candidates: &mut Vec<Candidate>) {
432+
let bytes = text.as_bytes();
433+
let mut start = 0;
434+
435+
while start < bytes.len() {
436+
if !bytes[start..].starts_with(b"-----BEGIN ")
437+
|| (start > 0 && bytes[start - 1].is_ascii_alphanumeric())
438+
{
439+
start += 1;
440+
continue;
441+
}
442+
443+
let Some((end, _body_start)) = private_key_parts_at(bytes, start) else {
444+
start += 1;
445+
continue;
446+
};
447+
448+
if end == bytes.len() || !bytes[end].is_ascii_alphanumeric() {
449+
candidates.push(Candidate {
450+
label: Label::PrivateKey,
451+
start_byte: start,
452+
end_byte: end,
453+
});
454+
start = end;
455+
} else {
456+
start += 1;
457+
}
458+
}
459+
}
460+
371461
fn is_leap_year(year: u16) -> bool {
372462
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
373463
}
@@ -823,6 +913,46 @@ mod tests {
823913
assert!(scan("x4111-1111-1111-1111y").is_empty()); // embedded
824914
}
825915

916+
#[test]
917+
fn detects_generic_private_key_pem_block() {
918+
let key = "-----BEGIN PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n-----END PRIVATE KEY-----";
919+
920+
assert_eq!(
921+
scan(&format!("Key:\n{key}")),
922+
vec![Entity {
923+
label: "PRIVATE_KEY".to_owned(),
924+
text: key.to_owned(),
925+
start: 5,
926+
end: 5 + key.chars().count(),
927+
}]
928+
);
929+
}
930+
931+
#[test]
932+
fn detects_typed_private_key_pem_block_with_unicode_offset() {
933+
let key =
934+
"-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n-----END RSA PRIVATE KEY-----";
935+
936+
assert_eq!(scan(&format!("🔒 {key}"))[0].start, 2);
937+
}
938+
939+
#[test]
940+
fn rejects_invalid_private_key_pem_blocks() {
941+
assert!(scan("-----BEGIN PRIVATE KEY-----\nMIIEowIBAAKCAQEA").is_empty());
942+
assert!(
943+
scan("-----BEGIN PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n-----END RSA PRIVATE KEY-----")
944+
.is_empty()
945+
);
946+
assert!(
947+
scan("-----BEGIN CERTIFICATE-----\nMIIEowIBAAKCAQEA\n-----END CERTIFICATE-----")
948+
.is_empty()
949+
);
950+
assert!(scan("-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----").is_empty());
951+
assert!(
952+
scan("-----BEGIN PRIVATE KEY-----\nnot base64\n-----END PRIVATE KEY-----").is_empty()
953+
);
954+
}
955+
826956
#[test]
827957
fn detects_numeric_and_named_dates() {
828958
assert_eq!(

0 commit comments

Comments
 (0)