use crate::cbor::Value;
use crate::seal;
use super::{procedure_org, signed_by_some_key, RecordError, RecordType};
const TOMBSTONE_FIELDS: &[&str] = &["withdrawn_type", "withdrawn_version", "reason", "detail"];
const TOMBSTONE_REASONS: &[&str] = &["shutdown", "moved", "revoked"];
pub const MAX_SERVING_STATIONS: usize = 8;
const MAX_STATION_HOSTNAME_BYTES: usize = 253;
const MAX_STATION_ROLE_BYTES: usize = 64;
const STATION_AUTHORIZATION_MAX_WINDOW_MS: i128 = 30 * 60 * 1000;
const MAX_APP_TEXT_BYTES: usize = 64;
const CONTENT_ID_TAG_SHA384: u8 = 2;
pub(super) fn payload_ok(t: RecordType, payload: &Value) -> bool {
let Value::Map(pairs) = payload else {
return false;
};
let field = |name: &str| payload.get(name);
match t {
RecordType::NODE_RECORD => is_id(field("node_id")),
RecordType::REALM_DIRECTORY | RecordType::REALM_STATIONS => is_id(field("realm_id")),
RecordType::REALM_MEMBER_ENDORSEMENT => {
is_id(field("realm_id")) && is_id(field("member_node"))
}
RecordType::PROCEDURE_ADVERTISEMENT => advertisement_ok(payload, pairs.len()),
RecordType::TOMBSTONE => tombstone_ok(payload, pairs),
RecordType::FOUNDATION_SEED_LIST | RecordType::STATION_ENDPOINT => true,
RecordType::FOUNDATION_REALM_TRUST_LIST => trust_list_ok(payload, pairs.len()),
RecordType::STATION_AUTHORIZATION => station_authorization_ok(payload),
RecordType::APP_RECORD => app_ok(payload, pairs.len()),
RecordType::FOUNDATION_PARAMETER => is_text(field("param_name")),
RecordType::FOUNDATION_T3_ATTESTATION => is_id(field("station_id")),
RecordType::CONTENT_ANNOUNCEMENT => {
is_id(field("announcer_node"))
&& is_content_id_shaped(field("mcid"))
&& is_id(field("realm_id"))
&& is_id(field("serving_station"))
&& matches!(field("procedure"), Some(Value::Text(p)) if !p.is_empty())
}
RecordType::ORG_DIRECTORY => {
is_id(field("realm_id")) && is_text(field("org_name")) && is_id(field("org_key"))
}
RecordType::PROCEDURE_DELEGATION => is_id(field("org_key")) && is_id(field("advertiser")),
t => t >= RecordType::DOMAIN_MIN,
}
}
fn advertisement_ok(payload: &Value, size: usize) -> bool {
if !is_id(payload.get("realm_id"))
|| !is_text(payload.get("procedure"))
|| !is_id(payload.get("advertiser_node"))
|| !serving_stations_ok(payload.get("serving_stations"))
{
return false;
}
let authorization = match payload.get("authorization") {
None => 0,
Some(Value::Map(_)) => 1,
Some(_) => return false,
};
kem_key_pair(payload).is_some_and(|pair| size == 4 + authorization + pair)
}
pub(super) fn serving_stations_ok(v: Option<&Value>) -> bool {
let Some(Value::List(items)) = v else {
return false;
};
let ids: Option<Vec<&Vec<u8>>> = items
.iter()
.map(|item| match item {
Value::Bytes(b) if b.len() == 32 => Some(b),
_ => None,
})
.collect();
ids.is_some_and(|ids| {
(1..=MAX_SERVING_STATIONS).contains(&ids.len()) && ids.windows(2).all(|w| w[0] < w[1])
})
}
fn trust_list_ok(payload: &Value, size: usize) -> bool {
let Some(Value::List(trusted)) = payload.get("realms_trusted") else {
return false;
};
size == 1
&& trusted.iter().all(|entry| {
matches!(entry, Value::Map(fields) if fields.len() == 2)
&& is_id(entry.get("realm_id"))
&& is_id(entry.get("realm_key_id"))
})
}
fn station_authorization_ok(payload: &Value) -> bool {
let field = |name: &str| payload.get(name);
let hostname = matches!(field("hostname"), Some(Value::Text(h)) if !h.is_empty() && h.len() <= MAX_STATION_HOSTNAME_BYTES);
let roles = matches!(field("roles"), Some(Value::List(roles)) if roles.iter().all(|r| matches!(r, Value::Text(t) if !t.is_empty() && t.len() <= MAX_STATION_ROLE_BYTES)));
let window = match (field("valid_from"), field("valid_until")) {
(Some(Value::Int(from)), Some(Value::Int(until))) => {
protocol_int(*from)
&& protocol_int(*until)
&& until >= from
&& until - from <= STATION_AUTHORIZATION_MAX_WINDOW_MS
}
_ => false,
};
let attestation = match (field("attestation_required"), field("attestation_key_id")) {
(None, None) => true,
(None | Some(Value::Int(1)), key_id @ Some(_)) => is_id(key_id),
_ => false,
};
is_id(field("realm_id"))
&& is_id(field("station_node"))
&& hostname
&& roles
&& window
&& attestation
}
fn protocol_int(n: i128) -> bool {
(0..1 << 53).contains(&n)
}
fn app_ok(payload: &Value, size: usize) -> bool {
let field = |name: &str| payload.get(name);
let (Some(Value::Text(org)), Some(Value::Text(app)), Some(Value::Text(version))) =
(field("org_name"), field("app_name"), field("version"))
else {
return false;
};
let authorization = match field("authorization") {
Some(Value::Map(fields)) => {
fields.len() == 1
&& matches!(
field("authorization").and_then(|a| a.get("org_directory")),
Some(Value::Bytes(_))
)
}
_ => false,
};
let services = match field("services") {
Some(Value::List(services)) => app_services_ok(services, org),
_ => false,
};
size == 7
&& is_id(field("realm_id"))
&& is_id(field("org_key"))
&& app_org(org)
&& app_text(app)
&& !app.contains('/')
&& app_text(version)
&& services
&& authorization
}
fn app_org(org: &str) -> bool {
org != "_" && !org.starts_with('~') && app_text(org) && !org.contains('/')
}
fn app_text(text: &str) -> bool {
!text.is_empty() && text.len() <= MAX_APP_TEXT_BYTES
}
fn app_services_ok(services: &[Value], org: &str) -> bool {
let mut names = std::collections::HashSet::with_capacity(services.len());
services.iter().all(|service| {
let (Some(Value::Text(name)), Some(Value::List(procedures))) =
(service.get("name"), service.get("procedures"))
else {
return false;
};
matches!(service, Value::Map(fields) if fields.len() == 2)
&& app_text(name)
&& names.insert(name.clone())
&& procedures
.iter()
.all(|p| matches!(p, Value::Text(p) if procedure_org(p) == Ok(Some(org))))
})
}
fn kem_key_pair(payload: &Value) -> Option<usize> {
match (payload.get("kem_key"), payload.get("kem_key_id")) {
(None, None) => Some(0),
(Some(Value::Bytes(key)), Some(Value::Bytes(id)))
if seal::is_carried_key_size(key.len()) && seal::key_id(key).as_slice() == id =>
{
Some(2)
}
_ => None,
}
}
fn tombstone_ok(payload: &Value, pairs: &[(Value, Value)]) -> bool {
let (Some(Value::Int(withdrawn)), Some(Value::Bytes(version)), Some(Value::Text(reason))) = (
payload.get("withdrawn_type"),
payload.get("withdrawn_version"),
payload.get("reason"),
) else {
return false;
};
if version.len() != 16 {
return false;
}
let slot: Vec<&(Value, Value)> = pairs
.iter()
.filter(|(k, _)| !matches!(k, Value::Text(n) if TOMBSTONE_FIELDS.contains(&n.as_str())))
.collect();
TOMBSTONE_REASONS.contains(&reason.as_str())
&& withdrawable(*withdrawn)
&& payload
.get("detail")
.is_none_or(|d| matches!(d, Value::Text(_)))
&& slot_ok(*withdrawn, &slot)
}
fn withdrawable(t: i128) -> bool {
match t {
0x20..=0xFF => true,
1..=0x1F => t != 0x0C && signed_by_some_key(t),
_ => false,
}
}
fn slot_ok(t: i128, slot: &[&(Value, Value)]) -> bool {
if t >= 0x20 {
return match slot {
[] => true,
[(Value::Text(name), Value::Bytes(subject))] => {
name == "subject" && !subject.is_empty()
}
_ => false,
};
}
let names = slot_field_names(t);
slot.len() == names.len()
&& slot.iter().all(|(k, v)| matches!(k, Value::Text(n) if names.contains(&n.as_str()) && slot_value_ok(n, v)))
}
pub(super) fn slot_field_names(t: i128) -> &'static [&'static str] {
match t {
0x03 | 0x04 => &["realm_id"],
0x05 => &["realm_id", "member_node"],
0x06 => &["realm_id", "procedure"],
0x0E => &["param_name"],
0x10 => &["station_id"],
0x11 => &["mcid"],
0x13 => &["realm_id", "station_node"],
0x15 => &["realm_id", "org_name"],
0x16 => &["advertiser"],
0x17 => &["realm_id", "org_name", "app_name"],
_ => &[],
}
}
fn slot_value_ok(name: &str, v: &Value) -> bool {
match name {
"procedure" | "param_name" | "org_name" | "app_name" => matches!(v, Value::Text(_)),
"mcid" => is_content_id(Some(v)),
_ => is_id(Some(v)),
}
}
pub(super) fn is_id(v: Option<&Value>) -> bool {
matches!(v, Some(Value::Bytes(b)) if b.len() == 32)
}
fn is_text(v: Option<&Value>) -> bool {
matches!(v, Some(Value::Text(_)))
}
pub(super) fn is_content_id(v: Option<&Value>) -> bool {
matches!(v, Some(Value::Bytes(b)) if b.len() == 50 && b[0] == CONTENT_ID_TAG_SHA384)
}
pub(super) fn is_content_id_shaped(v: Option<&Value>) -> bool {
matches!(v, Some(Value::Bytes(b)) if b.len() > 2 && (b[0] != CONTENT_ID_TAG_SHA384 || b.len() == 50))
}
pub(super) fn content_id_tag_known(t: RecordType, payload: &Value) -> Result<(), RecordError> {
match (t, payload.get("mcid")) {
(RecordType::CONTENT_ANNOUNCEMENT, Some(Value::Bytes(m)))
if m.first() != Some(&CONTENT_ID_TAG_SHA384) =>
{
Err(RecordError::McidTagUnknown)
}
_ => Ok(()),
}
}