use std::net::{Ipv4Addr, Ipv6Addr}; use std::path::Path; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use crate::data_model::aspa::AspaObject; use crate::data_model::crl::RpkixCrl; use crate::data_model::manifest::ManifestObject; use crate::data_model::rc::{ AccessDescription, RcExtensions, ResourceCertificate, SubjectInfoAccess, }; use crate::data_model::roa::{IpPrefix as RoaIpPrefix, RoaAfi, RoaObject}; use crate::data_model::signed_object::{ ResourceEeCertificate, RpkiSignedObject, SignedAttrsProfiled, SignerInfoProfiled, }; use crate::data_model::ta::TaCertificate; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ObjectType { Auto, Cer, Mft, Crl, Roa, Aspa, } impl ObjectType { pub fn parse(value: &str) -> Result { match value.to_ascii_lowercase().as_str() { "auto" => Ok(Self::Auto), "cer" | ".cer" | "cert" | "certificate" => Ok(Self::Cer), "mft" | ".mft" | "manifest" => Ok(Self::Mft), "crl" | ".crl" => Ok(Self::Crl), "roa" | ".roa" => Ok(Self::Roa), "asa" | ".asa" | "aspa" => Ok(Self::Aspa), _ => Err(format!("unsupported object type: {value}")), } } pub fn label(self) -> &'static str { object_type_label(self) } } pub fn resolve_object_type(object_type: ObjectType, path: &Path) -> Result { if object_type != ObjectType::Auto { return Ok(object_type); } match path .extension() .and_then(|v| v.to_str()) .map(|v| v.to_ascii_lowercase()) .as_deref() { Some("cer") => Ok(ObjectType::Cer), Some("mft") => Ok(ObjectType::Mft), Some("crl") => Ok(ObjectType::Crl), Some("roa") => Ok(ObjectType::Roa), Some("asa") | Some("aspa") => Ok(ObjectType::Aspa), _ => Err(format!( "cannot infer object type from path: {}", path.display() )), } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ObjectProjectionRecord { pub schema_version: u32, pub sha256: String, pub object_type: String, pub parse_status: String, pub error_summary: Option, pub projection: Value, } pub fn build_object_projection( object_type: ObjectType, input_path: &Path, bytes: &[u8], entry_limit: usize, ) -> ObjectProjectionRecord { let resolved = match resolve_object_type(object_type, input_path) { Ok(value) => value, Err(err) => { return ObjectProjectionRecord { schema_version: 1, sha256: sha256_hex(bytes), object_type: "unknown".to_string(), parse_status: "error".to_string(), error_summary: Some(err), projection: json!({"decode": {"profileValid": false}}), }; } }; let projection = parse_object_json(resolved, input_path, bytes, entry_limit); let parse_status = if projection .get("object") .and_then(|v| v.get("decode")) .and_then(|v| v.get("profileValid")) .and_then(Value::as_bool) .unwrap_or(false) { "ok" } else { "error" }; let error_summary = projection .get("object") .and_then(|v| v.get("decode")) .and_then(|v| v.get("error")) .and_then(Value::as_str) .map(str::to_string); ObjectProjectionRecord { schema_version: 1, sha256: sha256_hex(bytes), object_type: resolved.label().to_string(), parse_status: parse_status.to_string(), error_summary, projection, } } pub fn parse_object_json( object_type: ObjectType, input_path: &Path, bytes: &[u8], entry_limit: usize, ) -> Value { let object = match object_type { ObjectType::Auto => unreachable!("auto must be resolved"), ObjectType::Cer => parse_cer_json(bytes), ObjectType::Mft => parse_mft_json(bytes, entry_limit), ObjectType::Crl => parse_crl_json(bytes, entry_limit), ObjectType::Roa => parse_roa_json(bytes, entry_limit), ObjectType::Aspa => parse_aspa_json(bytes, entry_limit), }; json!({ "tool": "rpki_object_parse", "schemaVersion": 1, "input": { "path": input_path.display().to_string(), "type": object_type_label(object_type), "bytes": bytes_summary(bytes), }, "object": object, }) } pub fn parse_cer_json(bytes: &[u8]) -> Value { match ResourceCertificate::decode_der(bytes) { Ok(cert) => { let ta_profile = match TaCertificate::decode_der(bytes) { Ok(ta) => json!({ "valid": true, "selfSignature": result_json(ta.verify_self_signature().map_err(|e| e.to_string())), }), Err(err) => json!({ "valid": false, "error": err.to_string(), }), }; json!({ "type": "cer", "decode": {"profileValid": true}, "resourceCertificate": resource_certificate_json(&cert), "trustAnchorProfile": ta_profile, }) } Err(err) => json!({ "type": "cer", "decode": {"profileValid": false, "error": err.to_string()}, }), } } pub fn parse_mft_json(bytes: &[u8], entry_limit: usize) -> Value { match ManifestObject::decode_der(bytes) { Ok(mft) => { let files = mft.manifest.parse_files(); let (file_sample, file_list_error) = match files { Ok(entries) => ( json!({ "count": entries.len(), "truncated": entries.len() > entry_limit, "entries": entries.iter().take(entry_limit).map(|item| { json!({"fileName": item.file_name, "hashHex": hex::encode(item.hash_bytes)}) }).collect::>(), }), Value::Null, ), Err(err) => (Value::Null, json!(err.to_string())), }; json!({ "type": "mft", "decode": {"profileValid": true}, "eContentType": mft.econtent_type, "signedObject": signed_object_json(&mft.signed_object), "manifest": { "version": mft.manifest.version, "manifestNumberHex": mft.manifest.manifest_number.to_hex_upper(), "thisUpdate": format_time(mft.manifest.this_update), "nextUpdate": format_time(mft.manifest.next_update), "fileHashAlg": mft.manifest.file_hash_alg, "fileCount": mft.manifest.file_count(), "fileList": file_sample, "fileListError": file_list_error, }, "embeddedEeProfile": result_json(mft.validate_embedded_ee_cert().map_err(|e| e.to_string())), "cmsSignature": result_json(mft.signed_object.verify_signature().map_err(|e| e.to_string())), }) } Err(err) => json!({ "type": "mft", "decode": {"profileValid": false, "error": err.to_string()}, }), } } pub fn parse_crl_json(bytes: &[u8], entry_limit: usize) -> Value { match RpkixCrl::decode_der(bytes) { Ok(crl) => json!({ "type": "crl", "decode": {"profileValid": true}, "rawDer": bytes_summary(&crl.raw_der), "version": crl.version, "issuer": crl.issuer_dn, "signatureAlgorithm": crl.signature_algorithm_oid, "thisUpdate": format_time(crl.this_update.utc), "nextUpdate": format_time(crl.next_update.utc), "extensions": { "authorityKeyIdentifier": hex::encode(&crl.extensions.authority_key_identifier), "crlNumberHex": crl.extensions.crl_number.to_hex_upper(), "crlNumber": crl.extensions.crl_number.to_u64(), }, "revokedCertificates": { "count": crl.revoked_certs.len(), "truncated": crl.revoked_certs.len() > entry_limit, "entries": crl.revoked_certs.iter().take(entry_limit).map(|item| { json!({ "serialNumberHex": item.serial_number.to_hex_upper(), "serialNumber": item.serial_number.to_u64(), "revocationDate": format_time(item.revocation_date.utc), }) }).collect::>(), }, }), Err(err) => json!({ "type": "crl", "decode": {"profileValid": false, "error": err.to_string()}, }), } } pub fn manifest_file_entries_page( bytes: &[u8], offset: usize, limit: usize, ) -> Result<(usize, Vec), String> { let mft = ManifestObject::decode_der(bytes).map_err(|err| err.to_string())?; let entries = mft.manifest.parse_files().map_err(|err| err.to_string())?; let total = entries.len(); let end = (offset + limit).min(total); let page = entries[offset.min(total)..end] .iter() .map(|item| json!({"fileName": item.file_name, "hashHex": hex::encode(&item.hash_bytes)})) .collect::>(); Ok((total, page)) } pub fn crl_revoked_entries_page( bytes: &[u8], offset: usize, limit: usize, ) -> Result<(usize, Vec), String> { let crl = RpkixCrl::decode_der(bytes).map_err(|err| err.to_string())?; let total = crl.revoked_certs.len(); let end = (offset + limit).min(total); let page = crl.revoked_certs[offset.min(total)..end] .iter() .map(|item| { json!({ "serialNumberHex": item.serial_number.to_hex_upper(), "serialNumber": item.serial_number.to_u64(), "revocationDate": format_time(item.revocation_date.utc), }) }) .collect::>(); Ok((total, page)) } pub fn parse_roa_json(bytes: &[u8], entry_limit: usize) -> Value { match RoaObject::decode_der(bytes) { Ok(roa) => json!({ "type": "roa", "decode": {"profileValid": true}, "eContentType": roa.econtent_type, "signedObject": signed_object_json(&roa.signed_object), "roa": { "version": roa.roa.version, "asId": roa.roa.as_id, "ipAddressFamilies": roa.roa.ip_addr_blocks.iter().map(|family| { json!({ "afi": format!("{:?}", family.afi), "addressCount": family.addresses.len(), "truncated": family.addresses.len() > entry_limit, "addresses": family.addresses.iter().take(entry_limit).map(|entry| { json!({ "prefix": roa_prefix_string(&entry.prefix), "maxLength": entry.max_length, }) }).collect::>(), }) }).collect::>(), }, "embeddedEeProfile": result_json(roa.validate_embedded_ee_cert().map_err(|e| e.to_string())), "cmsSignature": result_json(roa.signed_object.verify_signature().map_err(|e| e.to_string())), }), Err(err) => json!({ "type": "roa", "decode": {"profileValid": false, "error": err.to_string()}, }), } } pub fn parse_aspa_json(bytes: &[u8], entry_limit: usize) -> Value { match AspaObject::decode_der(bytes) { Ok(aspa) => json!({ "type": "aspa", "decode": {"profileValid": true}, "eContentType": aspa.econtent_type, "signedObject": signed_object_json(&aspa.signed_object), "aspa": { "version": aspa.aspa.version, "customerAsId": aspa.aspa.customer_as_id, "providerCount": aspa.aspa.provider_as_ids.len(), "providersTruncated": aspa.aspa.provider_as_ids.len() > entry_limit, "providerAsIds": aspa.aspa.provider_as_ids.iter().take(entry_limit).copied().collect::>(), }, "embeddedEeProfile": result_json(aspa.validate_embedded_ee_cert().map_err(|e| e.to_string())), "cmsSignature": result_json(aspa.signed_object.verify_signature().map_err(|e| e.to_string())), }), Err(err) => json!({ "type": "aspa", "decode": {"profileValid": false, "error": err.to_string()}, }), } } fn resource_certificate_json(cert: &ResourceCertificate) -> Value { let tbs = &cert.tbs; json!({ "rawDer": bytes_summary(&cert.raw_der), "kind": format!("{:?}", cert.kind), "version": tbs.version, "serialNumberHex": hex::encode(tbs.serial_number.to_bytes_be()), "signatureAlgorithm": tbs.signature_algorithm, "issuer": tbs.issuer_name.to_string(), "subject": tbs.subject_name.to_string(), "validity": { "notBefore": format_time(tbs.validity_not_before), "notAfter": format_time(tbs.validity_not_after), }, "subjectPublicKeyInfo": bytes_summary(&tbs.subject_public_key_info), "extensions": rc_extensions_json(&tbs.extensions), }) } fn rc_extensions_json(ext: &RcExtensions) -> Value { json!({ "basicConstraintsCa": ext.basic_constraints_ca, "subjectKeyIdentifier": ext.subject_key_identifier.as_ref().map(|v| hex::encode(v)), "authorityKeyIdentifier": ext.authority_key_identifier.as_ref().map(|v| hex::encode(v)), "crlDistributionPointsUris": ext.crl_distribution_points_uris, "caIssuersUris": ext.ca_issuers_uris, "subjectInfoAccess": subject_info_access_json(ext.subject_info_access.as_ref()), "certificatePoliciesOid": ext.certificate_policies_oid, "ipResources": serde_json::to_value(&ext.ip_resources).unwrap_or(Value::Null), "asResources": serde_json::to_value(&ext.as_resources).unwrap_or(Value::Null), }) } fn subject_info_access_json(value: Option<&SubjectInfoAccess>) -> Value { match value { None => Value::Null, Some(SubjectInfoAccess::Ca(ca)) => json!({ "kind": "ca", "accessDescriptions": ca.access_descriptions.iter().map(access_description_json).collect::>(), }), Some(SubjectInfoAccess::Ee(ee)) => json!({ "kind": "ee", "signedObjectUris": ee.signed_object_uris, "accessDescriptions": ee.access_descriptions.iter().map(access_description_json).collect::>(), }), } } fn access_description_json(value: &AccessDescription) -> Value { json!({ "accessMethodOid": value.access_method_oid, "accessLocation": value.access_location, }) } fn signed_object_json(signed_object: &RpkiSignedObject) -> Value { let signed_data = &signed_object.signed_data; json!({ "rawDer": bytes_summary(&signed_object.raw_der), "contentInfoContentType": signed_object.content_info_content_type, "signedData": { "version": signed_data.version, "digestAlgorithms": signed_data.digest_algorithms, "encapContentInfo": { "eContentType": signed_data.encap_content_info.econtent_type, "eContent": bytes_summary(&signed_data.encap_content_info.econtent), }, "certificates": signed_data.certificates.iter().map(ee_certificate_json).collect::>(), "crlsPresent": signed_data.crls_present, "signerInfos": signed_data.signer_infos.iter().map(signer_info_json).collect::>(), }, }) } fn ee_certificate_json(cert: &ResourceEeCertificate) -> Value { json!({ "rawDer": bytes_summary(&cert.raw_der), "subjectKeyIdentifier": hex::encode(&cert.subject_key_identifier), "spkiDer": bytes_summary(&cert.spki_der), "rsaPublicKey": { "modulus": bytes_summary(&cert.rsa_public_modulus), "exponent": bytes_summary(&cert.rsa_public_exponent), }, "tbsCertificate": bytes_summary(&cert.tbs_certificate_der), "certificateSignature": bytes_summary(&cert.signature_bytes), "keyUsageSummary": format!("{:?}", cert.key_usage_summary), "siaSignedObjectUris": cert.sia_signed_object_uris, "resourceCertificate": resource_certificate_json(&cert.resource_cert), }) } fn signer_info_json(info: &SignerInfoProfiled) -> Value { json!({ "version": info.version, "sidSki": hex::encode(&info.sid_ski), "digestAlgorithm": info.digest_algorithm, "signatureAlgorithm": info.signature_algorithm, "signedAttrs": signed_attrs_json(&info.signed_attrs), "unsignedAttrsPresent": info.unsigned_attrs_present, "signature": bytes_summary(&info.signature), "signedAttrsDerForSignature": bytes_summary(&info.signed_attrs_der_for_signature), }) } fn signed_attrs_json(attrs: &SignedAttrsProfiled) -> Value { json!({ "contentType": attrs.content_type, "messageDigest": hex::encode(&attrs.message_digest), "signingTime": { "utc": format_time(attrs.signing_time.utc), "encoding": format!("{:?}", attrs.signing_time.encoding), }, "otherAttrsPresent": attrs.other_attrs_present, }) } fn result_json(result: Result<(), String>) -> Value { match result { Ok(()) => json!({"valid": true}), Err(err) => json!({"valid": false, "error": err}), } } fn object_type_label(object_type: ObjectType) -> &'static str { match object_type { ObjectType::Auto => "auto", ObjectType::Cer => "cer", ObjectType::Mft => "mft", ObjectType::Crl => "crl", ObjectType::Roa => "roa", ObjectType::Aspa => "aspa", } } fn bytes_summary(bytes: &[u8]) -> Value { let head_len = bytes.len().min(16); let tail_len = bytes.len().min(16); json!({ "len": bytes.len(), "sha256": sha256_hex(bytes), "headHex": hex::encode(&bytes[..head_len]), "tailHex": hex::encode(&bytes[bytes.len().saturating_sub(tail_len)..]), }) } fn sha256_hex(bytes: &[u8]) -> String { hex::encode(Sha256::digest(bytes)) } fn format_time(value: time::OffsetDateTime) -> String { value .to_offset(time::UtcOffset::UTC) .format(&time::format_description::well_known::Rfc3339) .unwrap_or_else(|_| value.unix_timestamp().to_string()) } fn roa_prefix_string(prefix: &RoaIpPrefix) -> String { let bytes = prefix.addr_bytes(); match prefix.afi { RoaAfi::Ipv4 => { let octets = [bytes[0], bytes[1], bytes[2], bytes[3]]; format!("{}/{}", Ipv4Addr::from(octets), prefix.prefix_len) } RoaAfi::Ipv6 => { let mut octets = [0u8; 16]; octets.copy_from_slice(bytes); format!("{}/{}", Ipv6Addr::from(octets), prefix.prefix_len) } } } #[cfg(test)] mod tests { use std::path::Path; use super::*; #[test] fn object_type_parser_and_resolver_cover_aliases() { assert_eq!(ObjectType::parse("auto").unwrap(), ObjectType::Auto); assert_eq!(ObjectType::parse(".cer").unwrap(), ObjectType::Cer); assert_eq!(ObjectType::parse("certificate").unwrap(), ObjectType::Cer); assert_eq!(ObjectType::parse("manifest").unwrap(), ObjectType::Mft); assert_eq!(ObjectType::parse(".crl").unwrap(), ObjectType::Crl); assert_eq!(ObjectType::parse("roa").unwrap(), ObjectType::Roa); assert_eq!(ObjectType::parse("aspa").unwrap(), ObjectType::Aspa); assert_eq!(ObjectType::parse(".asa").unwrap(), ObjectType::Aspa); assert!(ObjectType::parse("unknown").is_err()); assert_eq!(ObjectType::Aspa.label(), "aspa"); assert_eq!( resolve_object_type(ObjectType::Auto, Path::new("repo/a.cer")).unwrap(), ObjectType::Cer ); assert_eq!( resolve_object_type(ObjectType::Auto, Path::new("repo/a.mft")).unwrap(), ObjectType::Mft ); assert_eq!( resolve_object_type(ObjectType::Auto, Path::new("repo/a.crl")).unwrap(), ObjectType::Crl ); assert_eq!( resolve_object_type(ObjectType::Auto, Path::new("repo/a.roa")).unwrap(), ObjectType::Roa ); assert_eq!( resolve_object_type(ObjectType::Auto, Path::new("repo/a.asa")).unwrap(), ObjectType::Aspa ); assert_eq!( resolve_object_type(ObjectType::Roa, Path::new("repo/a.bin")).unwrap(), ObjectType::Roa ); assert!(resolve_object_type(ObjectType::Auto, Path::new("repo/a.bin")).is_err()); } #[test] fn invalid_der_returns_error_projection_for_all_object_types() { let bytes = b"not der"; for object_type in [ ObjectType::Cer, ObjectType::Mft, ObjectType::Crl, ObjectType::Roa, ObjectType::Aspa, ] { let value = parse_object_json(object_type, Path::new("bad.der"), bytes, 1); assert_eq!( value["object"]["decode"]["profileValid"].as_bool(), Some(false) ); assert!(value["object"]["decode"]["error"].as_str().is_some()); } let record = build_object_projection(ObjectType::Auto, Path::new("bad.bin"), bytes, 1); assert_eq!(record.object_type, "unknown"); assert_eq!(record.parse_status, "error"); assert!(record.error_summary.is_some()); } #[test] fn parses_fixture_objects_into_human_readable_projection() { let cases = [ ( ObjectType::Cer, "tests/fixtures/ta/apnic-ta.cer", "cer", "resourceCertificate", ), ( ObjectType::Mft, "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", "mft", "manifest", ), ( ObjectType::Crl, "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.crl", "crl", "revokedCertificates", ), ( ObjectType::Roa, "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa", "roa", "roa", ), ( ObjectType::Aspa, "tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/5m80fwYws_3FiFD7JiQjAqZ1RYQ.asa", "aspa", "aspa", ), ]; for (object_type, path, expected_type, expected_section) in cases { let bytes = std::fs::read(path).expect("fixture"); let record = build_object_projection(object_type, Path::new(path), &bytes, 1); assert_eq!(record.object_type, expected_type); assert_eq!(record.parse_status, "ok"); assert_eq!( record.projection["object"]["decode"]["profileValid"].as_bool(), Some(true) ); assert!(record.projection["object"][expected_section].is_object()); } } #[test] fn large_projection_lists_are_paged_from_raw_bytes() { let mft_bytes = std::fs::read( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ) .expect("mft"); let (total, page) = manifest_file_entries_page(&mft_bytes, 1, 3).expect("mft page"); assert!(total >= 3); assert_eq!(page.len(), 3); assert!(page[0]["fileName"].as_str().is_some()); let (_, empty_page) = manifest_file_entries_page(&mft_bytes, total + 10, 3).expect("empty page"); assert!(empty_page.is_empty()); let crl_bytes = std::fs::read( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.crl", ) .expect("crl"); let (total, page) = crl_revoked_entries_page(&crl_bytes, 0, 5).expect("crl page"); assert!(page.len() <= total); let (_, empty_page) = crl_revoked_entries_page(&crl_bytes, total + 10, 5).expect("empty crl page"); assert!(empty_page.is_empty()); } }