use base64ct::{Base64, Encoding};
use std::time::{SystemTime, UNIX_EPOCH};
use qso_graph_trust_awslc::{Error, random};
use crate::lotw;
use x509_cert::der::DateTime;
pub const UPLOAD_URL: &str = "https://lotw.arrl.org/lotw/upload";
pub const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
pub const MAX_MESSAGE_BYTES: usize = 4096;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NotASignedLog;
impl std::fmt::Display for NotASignedLog {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("not a signed log (.tq8)")
}
}
impl std::error::Error for NotASignedLog {}
pub const MAX_SIGNED_LOG_BYTES: usize = 64 * 1024 * 1024;
pub fn check_signed_log(tq8: &[u8]) -> Result<Vec<u8>, NotASignedLog> {
let tq7 = crate::gzip::decompress(tq8, MAX_SIGNED_LOG_BYTES).map_err(|_| NotASignedLog)?;
let mut fields = Fields { text: &tq7, at: 0 };
match fields.next()? {
Some(Token::Field("TQSL_IDENT", v)) if printable(v, 256) => {}
_ => return Err(NotASignedLog),
}
let mut contacts = 0usize;
let mut certs = std::collections::HashMap::new();
let mut stations: std::collections::HashMap<&[u8], Record<'_>> = Default::default();
let mut record: Option<(Kind, Record<'_>)> = None;
while let Some(token) = fields.next()? {
match (&mut record, token) {
(None, Token::Field(name, value)) if name.eq_ignore_ascii_case("Rec_Type") => {
let kind = match value {
b"tCERT" => Kind::Cert,
b"tSTATION" => Kind::Station,
b"tCONTACT" => Kind::Contact,
_ => return Err(NotASignedLog),
};
record = Some((kind, Vec::new()));
}
(Some((kind, got)), Token::Field(name, value)) => {
if !allowed(*kind, name, value) || got.iter().any(|(n, _)| *n == name) {
return Err(NotASignedLog);
}
got.push((name, value));
}
(Some(_), Token::End) => {
let Some((kind, got)) = record.take() else {
return Err(NotASignedLog);
};
let get = |n: &str| got.iter().find(|(f, _)| *f == n).map(|(_, v)| *v);
match kind {
Kind::Cert => {
let uid = get("CERT_UID")
.filter(|v| !v.is_empty())
.ok_or(NotASignedLog)?;
let len = rsa_signature_len(get("CERTIFICATE").ok_or(NotASignedLog)?)?;
if certs.insert(uid, len).is_some() {
return Err(NotASignedLog);
}
}
Kind::Station => {
let uid = get("STATION_UID").ok_or(NotASignedLog)?;
stations.insert(uid, got);
}
Kind::Contact => {
if certs.is_empty() || get(lotw::SIGNATURE_FIELD).is_none() {
return Err(NotASignedLog);
}
let station = get("STATION_UID")
.and_then(|uid| stations.get(uid))
.ok_or(NotASignedLog)?;
let len = station
.iter()
.find(|(f, _)| *f == "CERT_UID")
.and_then(|(_, uid)| certs.get(uid))
.ok_or(NotASignedLog)?;
let signature: String = get(lotw::SIGNATURE_FIELD)
.ok_or(NotASignedLog)?
.iter()
.filter(|b| !b.is_ascii_whitespace())
.map(|b| char::from(*b))
.collect();
let decoded = Base64::decode_vec(&signature).map_err(|_| NotASignedLog)?;
if decoded.is_empty() || decoded.len() != *len {
return Err(NotASignedLog);
}
let mut expected = Vec::new();
for name in lotw::SIGN_STATION {
if let Some((_, v)) = station.iter().find(|(f, _)| f == name) {
expected.extend_from_slice(v);
}
}
for (name, _) in lotw::SIGN_CONTACT {
expected.extend_from_slice(get(name).unwrap_or_default());
}
expected.make_ascii_uppercase();
if get("SIGNDATA") != Some(expected.as_slice()) {
return Err(NotASignedLog);
}
contacts += 1;
}
}
}
_ => return Err(NotASignedLog),
}
}
if record.is_some() || contacts == 0 {
return Err(NotASignedLog);
}
Ok(crate::signed_log::tq8(&tq7))
}
fn rsa_signature_len(value: &[u8]) -> Result<usize, NotASignedLog> {
use pkcs8::SubjectPublicKeyInfoRef;
use pkcs8::der::{
Decode, Reader,
asn1::{AnyRef, ObjectIdentifier, UintRef},
};
let pem = format!(
"-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----\n",
std::str::from_utf8(value)
.map_err(|_| NotASignedLog)?
.trim()
);
let cert = crate::certificate::Certificate::from_pem_or_der(pem.as_bytes())
.map_err(|_| NotASignedLog)?;
let spki =
SubjectPublicKeyInfoRef::from_der(cert.public_key_der()).map_err(|_| NotASignedLog)?;
if spki.algorithm.oid != ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.1") {
return Err(NotASignedLog);
}
let key = spki.subject_public_key.as_bytes().ok_or(NotASignedLog)?;
AnyRef::from_der(key)
.and_then(|key| {
key.sequence(|reader| {
let modulus = reader.decode::<UintRef<'_>>()?;
let _exponent = reader.decode::<UintRef<'_>>()?;
Ok(modulus.as_bytes().len())
})
})
.map_err(|_| NotASignedLog)
}
type Record<'a> = Vec<(&'a str, &'a [u8])>;
#[derive(Clone, Copy)]
enum Kind {
Cert,
Station,
Contact,
}
enum Token<'a> {
Field(&'a str, &'a [u8]),
End,
}
struct Fields<'a> {
text: &'a [u8],
at: usize,
}
impl<'a> Fields<'a> {
fn next(&mut self) -> Result<Option<Token<'a>>, NotASignedLog> {
while self.text.get(self.at).is_some_and(u8::is_ascii_whitespace) {
self.at += 1;
}
if self.at == self.text.len() {
return Ok(None);
}
if self.text[self.at] != b'<' {
return Err(NotASignedLog);
}
let close = self.text[self.at..]
.iter()
.take(80)
.position(|&b| b == b'>')
.ok_or(NotASignedLog)?;
let tag = std::str::from_utf8(&self.text[self.at + 1..self.at + close])
.map_err(|_| NotASignedLog)?;
self.at += close + 1;
if tag.eq_ignore_ascii_case("eor") {
return Ok(Some(Token::End));
}
let mut parts = tag.split(':');
let name = parts.next().filter(|n| {
!n.is_empty()
&& n.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.')
});
let len = parts.next().and_then(|l| l.parse::<usize>().ok());
let typed = parts.next();
let (Some(name), Some(len), None) = (name, len, parts.next()) else {
return Err(NotASignedLog);
};
if typed.is_some_and(|t| t.is_empty() || !t.bytes().all(|b| b.is_ascii_alphanumeric())) {
return Err(NotASignedLog);
}
let end = self
.at
.checked_add(len)
.filter(|&e| e <= self.text.len())
.ok_or(NotASignedLog)?;
let value = &self.text[self.at..end];
self.at = end;
Ok(Some(Token::Field(name, value)))
}
}
fn printable(value: &[u8], max: usize) -> bool {
value.len() <= max
&& std::str::from_utf8(value)
.is_ok_and(|t| t.chars().all(|c| !c.is_control() || c == '\n' || c == '\r'))
}
const CONTACT_FIELDS: &[&str] = &[
"STATION_UID",
"CALL",
"BAND",
"MODE",
"FREQ",
"FREQ_RX",
"PROP_MODE",
"SAT_NAME",
"BAND_RX",
"QSO_DATE",
"QSO_TIME",
"SIGNDATA",
];
fn allowed(kind: Kind, name: &str, value: &[u8]) -> bool {
match kind {
Kind::Cert => match name {
"CERT_UID" => value.len() <= 10 && value.iter().all(u8::is_ascii_digit),
"CERTIFICATE" => {
printable(value, 8 * 1024)
&& std::str::from_utf8(value).is_ok_and(|b64| {
let pem = format!(
"-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----\n",
b64.trim()
);
crate::certificate::Certificate::from_pem_or_der(pem.as_bytes()).is_ok()
})
}
_ => false,
},
Kind::Station => match name {
"STATION_UID" | "CERT_UID" => value.len() <= 10 && value.iter().all(u8::is_ascii_digit),
_ => lotw::loc_field(name).is_some() && printable(value, crate::station::MAX_LEN),
},
Kind::Contact => {
if name == lotw::SIGNATURE_FIELD {
printable(value, crate::certificate::MAX_CERTIFICATE_LEN)
} else if name == "SIGNDATA" {
printable(value, 1024)
} else {
CONTACT_FIELDS.contains(&name) && printable(value, 128)
}
}
}
}
pub struct UploadRequest {
headers: [(&'static str, String); 2],
body: Vec<u8>,
}
impl UploadRequest {
pub fn new(tq8: &[u8]) -> Self {
Self::try_new(tq8).expect("could not generate an upload boundary")
}
pub fn try_new(tq8: &[u8]) -> Result<Self, Error> {
Self::try_named(tq8, "log.tq8")
}
pub fn try_named(tq8: &[u8], basename: &str) -> Result<Self, Error> {
let boundary = boundary_with(tq8, random)?;
let filename = filename(SystemTime::now(), &upload_name(basename));
let mut body = format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"upfile\"; filename=\"{filename}\"\r\nContent-Type: application/octet-stream\r\n\r\n"
).into_bytes();
body.extend_from_slice(tq8);
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
Ok(Self {
headers: [
(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
),
("User-Agent", format!("QSO Graph Trust/{}", crate::VERSION)),
],
body,
})
}
pub fn method(&self) -> &'static str {
"POST"
}
pub fn url(&self) -> &'static str {
UPLOAD_URL
}
pub fn headers(&self) -> &[(&'static str, String)] {
&self.headers
}
pub fn body(&self) -> &[u8] {
&self.body
}
}
fn boundary_with(
tq8: &[u8],
mut fill: impl FnMut(&mut [u8]) -> Result<(), Error>,
) -> Result<String, Error> {
loop {
let mut bytes = [0; 16];
fill(&mut bytes)?;
let mut boundary = String::from("qgt-");
for byte in bytes {
use std::fmt::Write;
write!(boundary, "{byte:02x}").expect("write to String");
}
if !tq8
.windows(boundary.len())
.any(|w| w == boundary.as_bytes())
{
return Ok(boundary);
}
}
}
fn upload_name(basename: &str) -> String {
let name = basename.rsplit(['/', '\\']).next().unwrap_or("");
let mut name: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
c
} else {
'_'
}
})
.take(64)
.collect();
if name.trim_matches(['.', '_']).is_empty() {
name = "log.tq8".to_string();
}
if !name.to_ascii_lowercase().ends_with(".tq8") {
name.push_str(".tq8");
}
name
}
fn filename(now: SystemTime, basename: &str) -> String {
let date = now
.duration_since(UNIX_EPOCH)
.ok()
.and_then(|d| DateTime::from_unix_duration(d).ok())
.unwrap_or(DateTime::from_unix_duration(std::time::Duration::ZERO).expect("Unix epoch"));
format!(
"<TQSLUpl {:04}{:02}{:02}-{:02}{:02}> {basename}",
date.year(),
date.month(),
date.day(),
date.hour(),
date.minutes()
)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum UploadOutcome {
Accepted {
message: Option<String>,
},
Rejected {
message: Option<String>,
},
Unknown,
}
impl UploadOutcome {
pub fn tqsl_exit_code(&self) -> u8 {
match self {
Self::Accepted { .. } => 0,
Self::Rejected { .. } => 2,
Self::Unknown => 3,
}
}
}
pub fn parse_response(_status: u16, body: &[u8]) -> UploadOutcome {
if body.len() > MAX_RESPONSE_BYTES {
return UploadOutcome::Unknown;
}
let Ok(text) = std::str::from_utf8(body) else {
return UploadOutcome::Unknown;
};
if text.contains('\0') {
return UploadOutcome::Unknown;
}
let Some(status) = capture(text, "UPL", false) else {
return UploadOutcome::Unknown;
};
let message = capture(text, "UPLMESSAGE", true).map(clean_message);
if status.trim().eq_ignore_ascii_case("accepted") {
UploadOutcome::Accepted { message }
} else {
UploadOutcome::Rejected { message }
}
}
fn capture<'a>(text: &'a str, marker: &str, message: bool) -> Option<&'a str> {
let last_close = if message { text.rfind("-->") } else { None };
for (start, _) in text.match_indices("<!-- ") {
let mut chars = text[start + 5..].chars();
chars.next(); let Some(tail) = chars.as_str().strip_prefix(marker) else {
continue;
};
let mut chars = tail.chars();
if chars.next().is_none() {
continue;
}
let value = chars.as_str();
let end = if message {
last_close.and_then(|end| end.checked_sub(text.len() - value.len()))
} else {
value.find('-')
};
if let Some(end) = end
&& end > 0
&& value[end..].starts_with("-->")
{
return Some(&value[..end]);
}
}
None
}
fn clean_message(raw: &str) -> String {
let mut result = String::new();
for ch in raw.trim().chars().filter(|c| !c.is_control()) {
if result.len() + ch.len_utf8() > MAX_MESSAGE_BYTES {
break;
}
result.push(ch);
}
result
}
#[cfg(test)]
mod tests;