Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[=<FORMAT>]
Output format [default: hex] [possible values: hex, json]
Output format [default: hex] [possible values: hex, json, raw]
-h, --help
Print help
```
Expand All @@ -135,8 +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:
```
01120809101010325406360214980010325476981032140320000000000000000000000000000000000420000102030405060708090A0B0C0D0E0F0520000102030405060708090A0B0C0D0E0F0620000102030405060708090A0B0C0D0E0F
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
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.
Expand Down
4 changes: 4 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}

Expand Down
8 changes: 5 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -206,6 +207,7 @@ fn next(
format: config::Format,
smsp: bool,
smsc: bool,
crc: bool,
) -> Result<(), Box<dyn Error>> {
let key = match models::profile::crypto::Key::new(key_path) {
Ok(k) => k,
Expand All @@ -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 => {
Expand Down
106 changes: 92 additions & 14 deletions src/models/profile/encoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,15 +47,15 @@ struct ExtendedProfile {
}

impl Profile {
pub fn to_json(&self, include_smsp: bool, include_smsc: bool) -> Result<String, Box<dyn std::error::Error>> {
to_json(self, include_smsp, include_smsc)
pub fn to_json(&self, include_smsp: bool, include_smsc: bool, include_crc: bool) -> Result<String, Box<dyn std::error::Error>> {
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<String, Box<dyn std::error::Error>> {
fn to_json(p: &Profile, include_smsp: bool, include_smsc: bool, include_crc: bool) -> Result<String, Box<dyn std::error::Error>> {
let mut profile = ExtendedProfile {
profile: p.clone(),
additional_fields: Vec::new(),
Expand Down Expand Up @@ -66,7 +85,7 @@ fn to_json(p: &Profile, include_smsp: bool, include_smsc: bool) -> Result<String
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);
Expand All @@ -76,7 +95,8 @@ fn to_json(p: &Profile, include_smsp: bool, include_smsc: bool) -> Result<String
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);
Expand All @@ -85,14 +105,14 @@ fn to_json(p: &Profile, include_smsp: bool, include_smsc: bool) -> Result<String
profile.additional_fields.push(AdditionField {
name: String::from("Hex encoded profile"),
file: String::from("n/a"),
content: to_hex(p, include_smsp, include_smsc),
content: to_hex(p, include_smsp, include_smsc, include_crc),
});

let t = serde_json::to_string(&profile)?;
Ok(t)
}

pub fn to_hex(p: &Profile, include_smsp: bool, include_smsc: bool) -> 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 {
Expand Down Expand Up @@ -146,6 +166,18 @@ 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));
}

// 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.
if include_crc {
let crc = format!("{:08x}", crc32(ret.as_bytes()));
ret.push_str(&crc.encode_tlv(Tags::Crc32));
}
ret
}

Expand Down Expand Up @@ -289,7 +321,12 @@ 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.
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]
Expand All @@ -309,11 +346,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"));
}

Expand All @@ -334,7 +371,7 @@ mod tests {
};

// when enabled, expected SMSC TLV: tag 0c length 18 hex (24) then content starting with 07 91 <swapped digits>
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"));
}
Expand All @@ -356,8 +393,49 @@ mod tests {
};

// when enabled, expected SMSC TLV: tag 0c length 18 hex (24) then content starting with 07 91 <swapped digits>
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);
// The record's CRC is computed over the lowercased characters.
assert_eq!(
crc32("0A0B0C0D".to_ascii_lowercase().as_bytes()),
crc32(b"0a0b0c0d")
);
}

#[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("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()))
);
}
}