From 1267fc5c2626e4d19e76b66db97fbf542fdcb401 Mon Sep 17 00:00:00 2001 From: Benjamin Bruun Date: Fri, 7 Aug 2026 14:19:47 +0200 Subject: [PATCH 1/3] feat: append a CRC32 record to the exported profile A profile reaches a device over an AT command or a UART with no integrity check, so a flipped character silently provisions the wrong key. The record is last, covers everything in front of it, and is emitted by default; --no-crc leaves it out. A SoftSIM that predates the record ignores it, so older devices are unaffected. --- README.md | 10 ++++- src/config.rs | 4 ++ src/main.rs | 8 ++-- src/models/profile/encoder.rs | 85 ++++++++++++++++++++++++++++++----- 4 files changed, 90 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 119d979..0890194 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,11 @@ Options: --no-smsc Do not include SMSC TLV in output when present in profile. This can reduce profile size for SoftSIMs that do not support SMS + --no-crc + Do not append the CRC32 TLV that lets a SoftSIM detect a profile + corrupted in transit. Older SoftSIMs ignore the record --format[=] - Output format [default: hex] [possible values: hex, json] + Output format [default: hex] [possible values: hex, json, raw] -h, --help Print help ``` @@ -135,8 +138,11 @@ The SoftSIM profile is represented in the following format when fetched from Ono ``` Following a successful decryption and formatting of the encrypted SoftSIM profile, the CLI tool exports the profile in the following format. It is this and only this format that is accepted by SoftSIM-enabled devices by Onomondo: ``` -01120809101010325406360214980010325476981032140320000000000000000000000000000000000420000102030405060708090A0B0C0D0E0F0520000102030405060708090A0B0C0D0E0F0620000102030405060708090A0B0C0D0E0F +01120809101010325406360214980010325476981032140320000000000000000000000000000000000420000102030405060708090A0B0C0D0E0F0520000102030405060708090A0B0C0D0E0F0620000102030405060708090A0B0C0D0E0Ffe083016ba59 ``` +The trailing `fe08` record is a CRC32 of everything before it, so a SoftSIM can tell that the profile +reached it intact. It is always the last record. A SoftSIM that predates the record ignores it, and +`--no-crc` leaves it out entirely. ### Example Write hex encoded profiles to stdout. Optionally, this can be piped directly to a device if the device is ready to receive a profile in this specific format. diff --git a/src/config.rs b/src/config.rs index 805f8d8..2e95b61 100644 --- a/src/config.rs +++ b/src/config.rs @@ -53,6 +53,10 @@ pub enum SubCommand { /// This can reduce profile size for SoftSIMs that do not support SMS #[arg(long = "no-smsc")] no_smsc: bool, + /// Do not append the CRC32 TLV that lets a SoftSIM detect a profile + /// corrupted in transit. Older SoftSIMs ignore the record + #[arg(long = "no-crc")] + no_crc: bool, }, } diff --git a/src/main.rs b/src/main.rs index 48acfab..f32c55f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,7 +56,8 @@ async fn main() { format, smsp, no_smsc, - } => next(&private_key, &base_path.unwrap(), format, smsp, !no_smsc), + no_crc, + } => next(&private_key, &base_path.unwrap(), format, smsp, !no_smsc, !no_crc), }; if let Err(res) = res { @@ -206,6 +207,7 @@ fn next( format: config::Format, smsp: bool, smsc: bool, + crc: bool, ) -> Result<(), Box> { let key = match models::profile::crypto::Key::new(key_path) { Ok(k) => k, @@ -222,11 +224,11 @@ fn next( match format { config::Format::Hex => { - std::io::stdout().write_all(profile.to_hex(smsp, smsc).as_bytes())?; + std::io::stdout().write_all(profile.to_hex(smsp, smsc, crc).as_bytes())?; } config::Format::Json => { - std::io::stdout().write_all(profile.to_json(smsp, smsc)?.as_bytes())?; + std::io::stdout().write_all(profile.to_json(smsp, smsc, crc)?.as_bytes())?; } config::Format::Raw => { diff --git a/src/models/profile/encoder.rs b/src/models/profile/encoder.rs index 74858a8..3e8c12d 100644 --- a/src/models/profile/encoder.rs +++ b/src/models/profile/encoder.rs @@ -13,8 +13,27 @@ enum Tags { Adm = 10, Puk = 11, Smsc = 12, + // Records that describe the profile rather than carry a field live at the + // top of the range: 0x01..=0xef is profile data, 0xf0..=0xff is structural. + Crc32 = 0xfe, End = 0xff, } + +/// CRC-32/ISO-HDLC, as used by zlib: reflected polynomial 0xedb88320, initial +/// and final inversion. Hand-rolled so the same handful of lines can sit in the +/// decoder as well and the two cannot drift apart. +fn crc32(data: &[u8]) -> u32 { + let mut crc = u32::MAX; + + for byte in data { + crc ^= *byte as u32; + for _ in 0..8 { + crc = (crc >> 1) ^ if crc & 1 != 0 { 0xedb8_8320 } else { 0 }; + } + } + + !crc +} #[derive(Serialize)] struct AdditionField { name: String, @@ -28,15 +47,15 @@ struct ExtendedProfile { } impl Profile { - pub fn to_json(&self, include_smsp: bool, include_smsc: bool) -> Result> { - to_json(self, include_smsp, include_smsc) + pub fn to_json(&self, include_smsp: bool, include_smsc: bool, include_crc: bool) -> Result> { + to_json(self, include_smsp, include_smsc, include_crc) } - pub fn to_hex(&self, include_smsp: bool, include_smsc: bool) -> String { - to_hex(self, include_smsp, include_smsc) + pub fn to_hex(&self, include_smsp: bool, include_smsc: bool, include_crc: bool) -> String { + to_hex(self, include_smsp, include_smsc, include_crc) } } -fn to_json(p: &Profile, include_smsp: bool, include_smsc: bool) -> Result> { +fn to_json(p: &Profile, include_smsp: bool, include_smsc: bool, include_crc: bool) -> Result> { let mut profile = ExtendedProfile { profile: p.clone(), additional_fields: Vec::new(), @@ -85,14 +104,14 @@ fn to_json(p: &Profile, include_smsp: bool, include_smsc: bool) -> Result String { +pub fn to_hex(p: &Profile, include_smsp: bool, include_smsc: bool, include_crc: bool) -> String { let mut ret = String::new(); if let Some(imsi) = &p.imsi { @@ -146,6 +165,13 @@ pub fn to_hex(p: &Profile, include_smsp: bool, include_smsc: bool) -> String { let encoded_adm = hex::encode(adm.as_bytes()); ret.push_str(&encoded_adm.encode_tlv(Tags::Adm)); } + + // Must stay last: it covers every character in front of it, and that is also + // the only position an older decoder skips an unknown record safely in. + if include_crc { + let crc = format!("{:08x}", crc32(ret.as_bytes())); + ret.push_str(&crc.encode_tlv(Tags::Crc32)); + } ret } @@ -289,7 +315,9 @@ mod tests { "98001032547698103214", swap_nibbles(p.iccid.as_deref().unwrap()) ); - assert_eq!(p.to_hex(true, false), "01120809101010325406360214980010325476981032140320000000000000000000000000000000000420000102030405060708090A0B0C0D0E0F0520000102030405060708090A0B0C0D0E0F0620000102030405060708090A0B0C0D0E0F") + assert_eq!(p.to_hex(true, false, false), "01120809101010325406360214980010325476981032140320000000000000000000000000000000000420000102030405060708090A0B0C0D0E0F0520000102030405060708090A0B0C0D0E0F0620000102030405060708090A0B0C0D0E0F"); + // The decoder is pinned to this exact pair, see profile_decode_test.c. + assert_eq!(p.to_hex(true, false, true), "01120809101010325406360214980010325476981032140320000000000000000000000000000000000420000102030405060708090A0B0C0D0E0F0520000102030405060708090A0B0C0D0E0F0620000102030405060708090A0B0C0D0E0Ffe083016ba59") } #[test] @@ -309,11 +337,11 @@ mod tests { }; // when enabled, default tag 7 should be present at start of tlv for smsp: 07 04 abcd - let encoded_default = p.to_hex(true, false); + let encoded_default = p.to_hex(true, false, false); assert!(encoded_default.contains("0704abcd")); // when disabled, smsp should not be included - let encoded_custom = p.to_hex(false, false); + let encoded_custom = p.to_hex(false, false, false); assert!(!encoded_custom.contains("abcd")); } @@ -334,7 +362,7 @@ mod tests { }; // when enabled, expected SMSC TLV: tag 0c length 18 hex (24) then content starting with 07 91 - let encoded_default = p.to_hex(false, true); + let encoded_default = p.to_hex(false, true, false); assert!(encoded_default.contains("0c18")); assert!(encoded_default.contains("0791447779078484ffffffff")); } @@ -356,8 +384,41 @@ mod tests { }; // when enabled, expected SMSC TLV: tag 0c length 18 hex (24) then content starting with 07 91 - let encoded_default = p.to_hex(false, true); + let encoded_default = p.to_hex(false, true, false); assert!(encoded_default.contains("0c18")); assert!(encoded_default.contains("07914477790784f4ffffffff")); } + + #[test] + fn test_crc32_check_value() { + // The check value every CRC-32/ISO-HDLC implementation agrees on. The + // decoder pins the same one, so the two cannot drift apart unnoticed. + assert_eq!(crc32(b"123456789"), 0xcbf4_3926); + assert_eq!(crc32(b""), 0); + } + + #[test] + fn test_crc_flag() { + let p = Profile { + iccid: Some(String::from("89000123456789012341")), + imsi: Some(String::from("001010123456063")), + opc: Some(String::from("00000000000000000000000000000000")), + k: Some(String::from("000102030405060708090A0B0C0D0E0F")), + kic: Some(String::from("000102030405060708090A0B0C0D0E0F")), + kid: Some(String::from("000102030405060708090A0B0C0D0E0F")), + pin: None, + puk: None, + adm: None, + smsp: None, + smsc: None, + }; + + // The record has to be last, and it covers exactly what precedes it. + let with_crc = p.to_hex(true, false, true); + let without_crc = p.to_hex(true, false, false); + assert!(with_crc.ends_with("fe083016ba59")); + // Length, not a substring search: "fe08" can occur by chance inside key material. + assert_eq!(with_crc.len(), without_crc.len() + 12); + assert_eq!(with_crc, format!("{}fe08{:08x}", without_crc, crc32(without_crc.as_bytes()))); + } } From c639da4a9ff644b94e50ebd4367a3c130ffc8961 Mon Sep 17 00:00:00 2001 From: Benjamin Bruun Date: Tue, 18 Aug 2026 13:58:20 +0200 Subject: [PATCH 2/3] fix: fold hex case before computing the profile CRC32 Hex case carries no meaning to a SoftSIM, so re-casing in transit must not turn a good profile into a hard reject. Signed-off-by: Benjamin Bruun --- README.md | 7 ++++--- src/models/profile/encoder.rs | 18 ++++++++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0890194..f8ec379 100644 --- a/README.md +++ b/README.md @@ -138,11 +138,12 @@ The SoftSIM profile is represented in the following format when fetched from Ono ``` Following a successful decryption and formatting of the encrypted SoftSIM profile, the CLI tool exports the profile in the following format. It is this and only this format that is accepted by SoftSIM-enabled devices by Onomondo: ``` -01120809101010325406360214980010325476981032140320000000000000000000000000000000000420000102030405060708090A0B0C0D0E0F0520000102030405060708090A0B0C0D0E0F0620000102030405060708090A0B0C0D0E0Ffe083016ba59 +01120809101010325406360214980010325476981032140320000000000000000000000000000000000420000102030405060708090A0B0C0D0E0F0520000102030405060708090A0B0C0D0E0F0620000102030405060708090A0B0C0D0E0Ffe08610658d0 ``` The trailing `fe08` record is a CRC32 of everything before it, so a SoftSIM can tell that the profile -reached it intact. It is always the last record. A SoftSIM that predates the record ignores it, and -`--no-crc` leaves it out entirely. +reached it intact. It is always the last record, and it is computed over the lowercased characters, so +a transport that re-cases the hex does not invalidate it. A SoftSIM that predates the record ignores +it, and `--no-crc` leaves it out entirely. ### Example Write hex encoded profiles to stdout. Optionally, this can be piped directly to a device if the device is ready to receive a profile in this specific format. diff --git a/src/models/profile/encoder.rs b/src/models/profile/encoder.rs index 3e8c12d..9d91cdc 100644 --- a/src/models/profile/encoder.rs +++ b/src/models/profile/encoder.rs @@ -168,8 +168,10 @@ pub fn to_hex(p: &Profile, include_smsp: bool, include_smsc: bool, include_crc: // Must stay last: it covers every character in front of it, and that is also // the only position an older decoder skips an unknown record safely in. + // Folded to lowercase first: a transport that re-cases the hex must not + // invalidate the profile, so case never enters the CRC. if include_crc { - let crc = format!("{:08x}", crc32(ret.as_bytes())); + let crc = format!("{:08x}", crc32(ret.to_ascii_lowercase().as_bytes())); ret.push_str(&crc.encode_tlv(Tags::Crc32)); } ret @@ -317,7 +319,7 @@ mod tests { ); assert_eq!(p.to_hex(true, false, false), "01120809101010325406360214980010325476981032140320000000000000000000000000000000000420000102030405060708090A0B0C0D0E0F0520000102030405060708090A0B0C0D0E0F0620000102030405060708090A0B0C0D0E0F"); // The decoder is pinned to this exact pair, see profile_decode_test.c. - assert_eq!(p.to_hex(true, false, true), "01120809101010325406360214980010325476981032140320000000000000000000000000000000000420000102030405060708090A0B0C0D0E0F0520000102030405060708090A0B0C0D0E0F0620000102030405060708090A0B0C0D0E0Ffe083016ba59") + assert_eq!(p.to_hex(true, false, true), "01120809101010325406360214980010325476981032140320000000000000000000000000000000000420000102030405060708090A0B0C0D0E0F0520000102030405060708090A0B0C0D0E0F0620000102030405060708090A0B0C0D0E0Ffe08610658d0") } #[test] @@ -395,6 +397,11 @@ mod tests { // decoder pins the same one, so the two cannot drift apart unnoticed. assert_eq!(crc32(b"123456789"), 0xcbf4_3926); assert_eq!(crc32(b""), 0); + // The record's CRC is computed over the lowercased characters. + assert_eq!( + crc32("0A0B0C0D".to_ascii_lowercase().as_bytes()), + crc32(b"0a0b0c0d") + ); } #[test] @@ -416,9 +423,12 @@ mod tests { // The record has to be last, and it covers exactly what precedes it. let with_crc = p.to_hex(true, false, true); let without_crc = p.to_hex(true, false, false); - assert!(with_crc.ends_with("fe083016ba59")); + assert!(with_crc.ends_with("fe08610658d0")); // Length, not a substring search: "fe08" can occur by chance inside key material. assert_eq!(with_crc.len(), without_crc.len() + 12); - assert_eq!(with_crc, format!("{}fe08{:08x}", without_crc, crc32(without_crc.as_bytes()))); + assert_eq!( + with_crc, + format!("{}fe08{:08x}", without_crc, crc32(without_crc.to_ascii_lowercase().as_bytes())) + ); } } From 14eecd02a7d12f165bd7f13539f69d56112676b4 Mon Sep 17 00:00:00 2001 From: Benjamin Bruun Date: Thu, 20 Aug 2026 08:06:16 +0200 Subject: [PATCH 3/3] fix: emit the exported profile hex in lowercase Case carries no meaning to any decoder; fold once at assembly so the export matches the fold the CRC is already computed over. --- README.md | 2 +- src/models/profile/encoder.rs | 23 +++++++++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f8ec379..df1dd29 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ The SoftSIM profile is represented in the following format when fetched from Ono ``` Following a successful decryption and formatting of the encrypted SoftSIM profile, the CLI tool exports the profile in the following format. It is this and only this format that is accepted by SoftSIM-enabled devices by Onomondo: ``` -01120809101010325406360214980010325476981032140320000000000000000000000000000000000420000102030405060708090A0B0C0D0E0F0520000102030405060708090A0B0C0D0E0F0620000102030405060708090A0B0C0D0E0Ffe08610658d0 +01120809101010325406360214980010325476981032140320000000000000000000000000000000000420000102030405060708090a0b0c0d0e0f0520000102030405060708090a0b0c0d0e0f0620000102030405060708090a0b0c0d0e0ffe08610658d0 ``` The trailing `fe08` record is a CRC32 of everything before it, so a SoftSIM can tell that the profile reached it intact. It is always the last record, and it is computed over the lowercased characters, so diff --git a/src/models/profile/encoder.rs b/src/models/profile/encoder.rs index 9d91cdc..94c5b90 100644 --- a/src/models/profile/encoder.rs +++ b/src/models/profile/encoder.rs @@ -85,7 +85,7 @@ fn to_json(p: &Profile, include_smsp: bool, include_smsc: bool, include_crc: boo let a001 = AdditionField { name: String::from("Key material for attaching to network"), file: String::from("/3f00/a001"), - content: format!("{}{}00", k, o), + content: format!("{}{}00", k, o).to_ascii_lowercase(), }; profile.additional_fields.push(a001); @@ -95,7 +95,8 @@ fn to_json(p: &Profile, include_smsp: bool, include_smsc: bool, include_crc: boo let a004 = AdditionField { name: String::from("Key material for OTA related functions"), file: String::from("/3f00/a004"), - content: format!("b00011060101{}{}{}", kic, kid, rpad("", 2 * 76, None)), + content: format!("b00011060101{}{}{}", kic, kid, rpad("", 2 * 76, None)) + .to_ascii_lowercase(), }; profile.additional_fields.push(a004); @@ -166,12 +167,15 @@ pub fn to_hex(p: &Profile, include_smsp: bool, include_smsc: bool, include_crc: ret.push_str(&encoded_adm.encode_tlv(Tags::Adm)); } + // Emit lowercase throughout: case carries no meaning to any decoder, and the + // CRC below then covers the string verbatim. A transport that re-cases the + // hex is still safe because the decoder folds again before verifying. + let mut ret = ret.to_ascii_lowercase(); + // Must stay last: it covers every character in front of it, and that is also // the only position an older decoder skips an unknown record safely in. - // Folded to lowercase first: a transport that re-cases the hex must not - // invalidate the profile, so case never enters the CRC. if include_crc { - let crc = format!("{:08x}", crc32(ret.to_ascii_lowercase().as_bytes())); + let crc = format!("{:08x}", crc32(ret.as_bytes())); ret.push_str(&crc.encode_tlv(Tags::Crc32)); } ret @@ -317,9 +321,12 @@ mod tests { "98001032547698103214", swap_nibbles(p.iccid.as_deref().unwrap()) ); - assert_eq!(p.to_hex(true, false, false), "01120809101010325406360214980010325476981032140320000000000000000000000000000000000420000102030405060708090A0B0C0D0E0F0520000102030405060708090A0B0C0D0E0F0620000102030405060708090A0B0C0D0E0F"); + assert_eq!(p.to_hex(true, false, false), "01120809101010325406360214980010325476981032140320000000000000000000000000000000000420000102030405060708090a0b0c0d0e0f0520000102030405060708090a0b0c0d0e0f0620000102030405060708090a0b0c0d0e0f"); // The decoder is pinned to this exact pair, see profile_decode_test.c. - assert_eq!(p.to_hex(true, false, true), "01120809101010325406360214980010325476981032140320000000000000000000000000000000000420000102030405060708090A0B0C0D0E0F0520000102030405060708090A0B0C0D0E0F0620000102030405060708090A0B0C0D0E0Ffe08610658d0") + let hex = p.to_hex(true, false, true); + assert_eq!(hex, "01120809101010325406360214980010325476981032140320000000000000000000000000000000000420000102030405060708090a0b0c0d0e0f0520000102030405060708090a0b0c0d0e0f0620000102030405060708090a0b0c0d0e0ffe08610658d0"); + // The whole export is lowercase, including pass-through key material. + assert!(!hex.bytes().any(|b| b.is_ascii_uppercase())); } #[test] @@ -428,7 +435,7 @@ mod tests { assert_eq!(with_crc.len(), without_crc.len() + 12); assert_eq!( with_crc, - format!("{}fe08{:08x}", without_crc, crc32(without_crc.to_ascii_lowercase().as_bytes())) + format!("{}fe08{:08x}", without_crc, crc32(without_crc.as_bytes())) ); } }