@@ -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+
371461fn 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-----\n MIIEowIBAAKCAQEA\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-----\n MIIEowIBAAKCAQEA\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-----\n MIIEowIBAAKCAQEA" ) . is_empty( ) ) ;
942+ assert ! (
943+ scan( "-----BEGIN PRIVATE KEY-----\n MIIEowIBAAKCAQEA\n -----END RSA PRIVATE KEY-----" )
944+ . is_empty( )
945+ ) ;
946+ assert ! (
947+ scan( "-----BEGIN CERTIFICATE-----\n MIIEowIBAAKCAQEA\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-----\n not 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