use chrono::{DateTime, TimeZone, Utc};
use crate::authn::ids::{DeviceId, TenantId, UserId};
use crate::device::store::{BindingsUpdate, OwnerClaim, SweepConfig, TrustTransition};
use crate::device::types::{Device, DeviceBinding, DeviceTrustLevel, FingerprintHash};
use crate::session::crypto::{BoundFormat, CryptoError, SessionCrypto};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
#[derive(Clone)]
pub(crate) struct BindingsCodec {
crypto: Option<SessionCrypto>,
}
impl BindingsCodec {
pub fn encrypted(crypto: SessionCrypto) -> Self {
Self {
crypto: Some(crypto),
}
}
pub fn plaintext() -> Self {
Self { crypto: None }
}
pub fn encode(
&self,
tenant_id: &str,
device_id: &str,
bindings: &[DeviceBinding],
) -> Result<String, SqlDeviceStoreError> {
let bytes = rmp_serde::to_vec_named(bindings)?;
let payload = match &self.crypto {
Some(crypto) => crypto.seal_bound(
BoundFormat::DEVICE_BINDINGS,
&row_id(tenant_id, device_id),
&bytes,
)?,
None => bytes,
};
Ok(BASE64.encode(payload))
}
pub fn decode(
&self,
tenant_id: &str,
device_id: &str,
stored: &str,
) -> Result<Vec<DeviceBinding>, SqlDeviceStoreError> {
if stored.is_empty() {
return Ok(Vec::new());
}
let payload = BASE64
.decode(stored)
.map_err(|_| SqlDeviceStoreError::Crypto(CryptoError))?;
let plaintext = match &self.crypto {
Some(crypto) => {
let opened = crypto.open_bound(
BoundFormat::DEVICE_BINDINGS,
&row_id(tenant_id, device_id),
&payload,
)?;
if opened.legacy {
tracing::debug!("bindings blob predates row binding; re-sealed on next save");
}
opened.plaintext
}
None => payload,
};
Ok(rmp_serde::from_slice(&plaintext)?)
}
}
fn row_id(tenant_id: &str, device_id: &str) -> Vec<u8> {
let mut id = Vec::with_capacity(tenant_id.len() + 1 + device_id.len());
id.extend_from_slice(tenant_id.as_bytes());
id.push(0);
id.extend_from_slice(device_id.as_bytes());
id
}
pub(crate) const FAMILY_INSERT_CHUNK: usize = 500;
pub(crate) fn refresh_family_ids(bindings: &[DeviceBinding]) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
bindings
.iter()
.filter_map(|b| match b {
DeviceBinding::Refresh { family_id, .. } => Some(family_id),
_ => None,
})
.filter(|family_id| seen.insert(family_id.as_str()))
.cloned()
.collect()
}
pub(crate) mod trust_level_codec {
use crate::device::types::DeviceTrustLevel;
pub fn to_str(level: DeviceTrustLevel) -> &'static str {
match level {
DeviceTrustLevel::Unknown => "Unknown",
DeviceTrustLevel::Seen => "Seen",
DeviceTrustLevel::Trusted => "Trusted",
DeviceTrustLevel::Revoked => "Revoked",
}
}
pub fn from_str(s: &str) -> Option<DeviceTrustLevel> {
match s {
"Unknown" => Some(DeviceTrustLevel::Unknown),
"Seen" => Some(DeviceTrustLevel::Seen),
"Trusted" => Some(DeviceTrustLevel::Trusted),
"Revoked" => Some(DeviceTrustLevel::Revoked),
_ => None,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum SqlDeviceStoreError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("device not found: tenant={tenant_id} id={device_id}")]
NotFound {
tenant_id: String,
device_id: String,
},
#[error("device-bindings MessagePack encoding failed: {0}")]
Encode(#[from] rmp_serde::encode::Error),
#[error("device-bindings MessagePack decoding failed: {0}")]
Decode(#[from] rmp_serde::decode::Error),
#[error("encryption/decryption error: {0}")]
Crypto(#[from] CryptoError),
#[error("unrecognised trust_level value: {0}")]
UnknownTrustLevel(String),
#[error("malformed stored value: {0}")]
MalformedRow(String),
}
pub(crate) fn unix_to_utc(secs: i64) -> Result<DateTime<Utc>, SqlDeviceStoreError> {
Utc.timestamp_opt(secs, 0).single().ok_or_else(|| {
SqlDeviceStoreError::MalformedRow(format!("unrepresentable Unix timestamp: {secs}"))
})
}
pub(crate) fn utc_to_unix(dt: DateTime<Utc>) -> i64 {
dt.timestamp()
}
macro_rules! select_devices {
($tail:literal) => {
concat!(
"SELECT tenant_id, id, user_id, trust_level, fingerprint_hash, \
first_seen_at, last_seen_at, revoked_at, demoted_at, bindings \
FROM devices ",
$tail
)
};
}
pub(crate) use select_devices;
#[derive(sqlx::FromRow)]
pub(crate) struct DeviceRow {
tenant_id: String,
id: String,
user_id: Option<String>,
trust_level: String,
fingerprint_hash: Vec<u8>,
first_seen_at: i64,
last_seen_at: i64,
revoked_at: Option<i64>,
demoted_at: Option<i64>,
bindings: String,
}
impl DeviceRow {
pub(crate) fn into_device(self, codec: &BindingsCodec) -> Result<Device, SqlDeviceStoreError> {
let Self {
tenant_id,
id,
user_id,
trust_level,
fingerprint_hash,
first_seen_at,
last_seen_at,
revoked_at,
demoted_at,
bindings,
} = self;
let tenant = TenantId::try_new(&tenant_id)
.map_err(|e| SqlDeviceStoreError::MalformedRow(format!("tenant_id: {e}")))?;
let device_id = DeviceId::try_new(&id)
.map_err(|e| SqlDeviceStoreError::MalformedRow(format!("device id: {e}")))?;
let user = match user_id {
Some(u) => Some(
UserId::try_new(&u)
.map_err(|e| SqlDeviceStoreError::MalformedRow(format!("user_id: {e}")))?,
),
None => None,
};
let trust = trust_level_codec::from_str(&trust_level)
.ok_or(SqlDeviceStoreError::UnknownTrustLevel(trust_level))?;
let fp_bytes: [u8; 32] = fingerprint_hash
.try_into()
.map_err(|_| SqlDeviceStoreError::MalformedRow("fingerprint_hash length".into()))?;
let first = unix_to_utc(first_seen_at)?;
let last = unix_to_utc(last_seen_at)?;
let revoked = revoked_at.map(unix_to_utc).transpose()?;
let demoted = demoted_at.map(unix_to_utc).transpose()?;
let bindings = codec.decode(&tenant_id, &id, &bindings)?;
Ok(Device {
id: device_id,
tenant_id: tenant,
user_id: user,
trust_level: trust,
fingerprint_hash: FingerprintHash::from_bytes(fp_bytes),
first_seen_at: first,
last_seen_at: last,
revoked_at: revoked,
demoted_at: demoted,
bindings,
})
}
}
pub(crate) fn decode_optional(
codec: &BindingsCodec,
row: Option<DeviceRow>,
) -> Result<Option<Device>, SqlDeviceStoreError> {
row.map(|r| r.into_device(codec)).transpose()
}
pub(crate) fn decode_all(
codec: &BindingsCodec,
rows: Vec<DeviceRow>,
) -> Result<Vec<Device>, SqlDeviceStoreError> {
rows.into_iter().map(|r| r.into_device(codec)).collect()
}
pub(crate) fn parse_stored_trust(
stored: Option<String>,
) -> Result<Option<DeviceTrustLevel>, SqlDeviceStoreError> {
match stored {
None => Ok(None),
Some(s) => trust_level_codec::from_str(&s)
.map(Some)
.ok_or(SqlDeviceStoreError::UnknownTrustLevel(s)),
}
}
pub(crate) fn require_row(
rows_affected: u64,
tenant: String,
device_id: String,
) -> Result<(), SqlDeviceStoreError> {
if rows_affected == 0 {
return Err(SqlDeviceStoreError::NotFound {
tenant_id: tenant,
device_id,
});
}
Ok(())
}
pub(crate) struct DeviceWrite {
pub tenant: String,
pub id: String,
pub user_id: Option<String>,
pub trust: &'static str,
pub fingerprint: Vec<u8>,
pub first_seen_at: i64,
pub last_seen_at: i64,
pub revoked_at: Option<i64>,
pub demoted_at: Option<i64>,
pub bindings: String,
pub family_ids: Vec<String>,
}
impl DeviceWrite {
pub(crate) fn new(codec: &BindingsCodec, device: &Device) -> Result<Self, SqlDeviceStoreError> {
let tenant = device.tenant_id.to_string();
let id = device.id.to_string();
Ok(Self {
bindings: codec.encode(&tenant, &id, &device.bindings)?,
trust: trust_level_codec::to_str(device.trust_level),
fingerprint: device.fingerprint_hash.as_bytes().to_vec(),
user_id: device.user_id.as_ref().map(|u| u.to_string()),
first_seen_at: utc_to_unix(device.first_seen_at),
last_seen_at: utc_to_unix(device.last_seen_at),
revoked_at: device.revoked_at.map(utc_to_unix),
demoted_at: device.demoted_at.map(utc_to_unix),
family_ids: refresh_family_ids(&device.bindings),
tenant,
id,
})
}
}
pub(crate) struct TrustWrite {
pub trust: &'static str,
pub revoked_at: Option<i64>,
pub to_seen: i64,
pub now: i64,
}
impl TrustWrite {
pub(crate) fn new(level: DeviceTrustLevel, now: DateTime<Utc>) -> Self {
let now = utc_to_unix(now);
Self {
trust: trust_level_codec::to_str(level),
revoked_at: (level == DeviceTrustLevel::Revoked).then_some(now),
to_seen: i64::from(level == DeviceTrustLevel::Seen),
now,
}
}
}
pub(crate) struct GuardedTrustWrite {
pub expected: &'static str,
pub new: &'static str,
pub revoked_at: Option<i64>,
pub keep_demoted: i64,
pub demoted_at: Option<i64>,
}
impl GuardedTrustWrite {
pub(crate) fn new(
expected: DeviceTrustLevel,
level: DeviceTrustLevel,
now: DateTime<Utc>,
) -> Self {
let now = utc_to_unix(now);
let to_seen = level == DeviceTrustLevel::Seen;
Self {
expected: trust_level_codec::to_str(expected),
new: trust_level_codec::to_str(level),
revoked_at: (level == DeviceTrustLevel::Revoked).then_some(now),
keep_demoted: i64::from(to_seen && expected != DeviceTrustLevel::Trusted),
demoted_at: (to_seen && expected == DeviceTrustLevel::Trusted).then_some(now),
}
}
}
pub(crate) fn transition_after_miss(
current: Option<DeviceTrustLevel>,
expected: DeviceTrustLevel,
) -> TrustTransition {
match current {
None => TrustTransition::Missing,
Some(current) if current == expected => TrustTransition::Applied,
Some(current) => TrustTransition::Refused { current },
}
}
pub(crate) enum SwapPlan {
Settled(BindingsUpdate),
Write(BindingsSwap),
}
pub(crate) struct BindingsSwap {
pub stored: String,
pub blob: String,
pub family_ids: Vec<String>,
}
pub(crate) fn plan_bindings_swap(
codec: &BindingsCodec,
tenant: &str,
device_id: &str,
row: Option<(String, String)>,
expected: &[DeviceBinding],
bindings: &[DeviceBinding],
) -> Result<SwapPlan, SqlDeviceStoreError> {
let Some((level, stored)) = row else {
return Ok(SwapPlan::Settled(BindingsUpdate::Missing));
};
match trust_level_codec::from_str(&level) {
Some(DeviceTrustLevel::Revoked) => {
return Ok(SwapPlan::Settled(BindingsUpdate::Revoked));
}
Some(_) => {}
None => return Err(SqlDeviceStoreError::UnknownTrustLevel(level)),
}
if codec.decode(tenant, device_id, &stored)? != expected {
return Ok(SwapPlan::Settled(BindingsUpdate::Conflict));
}
Ok(SwapPlan::Write(BindingsSwap {
blob: codec.encode(tenant, device_id, bindings)?,
family_ids: refresh_family_ids(bindings),
stored,
}))
}
pub(crate) fn bindings_update_after_miss(current: Option<DeviceTrustLevel>) -> BindingsUpdate {
match current {
None => BindingsUpdate::Missing,
Some(DeviceTrustLevel::Revoked) => BindingsUpdate::Revoked,
Some(_) => BindingsUpdate::Conflict,
}
}
pub(crate) fn claim_after_miss(owner: Option<Option<String>>, user: &str) -> OwnerClaim {
match owner {
None => OwnerClaim::Missing,
Some(Some(owner)) if owner == user => OwnerClaim::Claimed,
Some(_) => OwnerClaim::OwnedByOther,
}
}
pub(crate) struct SweepCutoffs {
pub now: i64,
pub trusted: i64,
pub seen: i64,
pub grace: i64,
}
impl SweepCutoffs {
pub(crate) fn new(cfg: &SweepConfig, now: DateTime<Utc>) -> Self {
let now = utc_to_unix(now);
Self {
now,
trusted: now - cfg.trusted_idle.num_seconds(),
seen: now - cfg.seen_idle.num_seconds(),
grace: now - cfg.revoked_grace.num_seconds(),
}
}
}
pub(crate) fn push_family_rows<DB>(
insert: &mut sqlx::QueryBuilder<DB>,
chunk: &[String],
tenant: &str,
device_id: &str,
) where
DB: sqlx::Database,
for<'q> &'q str: sqlx::Encode<'q, DB> + sqlx::Type<DB>,
{
insert.push_values(chunk, |mut row, family_id| {
row.push_bind(tenant)
.push_bind(device_id)
.push_bind(family_id.as_str());
});
}
#[cfg(test)]
mod device_sql_common_tests {
use super::*;
use crate::device::types::{
AttestationClass, DeviceBinding, DeviceTrustLevel, FingerprintHash,
};
use chrono::Utc;
#[test]
fn bindings_codec_round_trips_non_empty_vector() {
let codec = BindingsCodec::plaintext();
let now = Utc::now();
let bindings = vec![
DeviceBinding::Cookie {
token_hash: FingerprintHash::from_bytes([0xAA; 32]),
issued_at: now,
last_used_at: now,
},
DeviceBinding::WebAuthn {
credential_id: "cred-AX028".to_string(),
attestation_class: AttestationClass::None,
bound_at: now,
last_used_at: now,
},
];
let encoded = codec.encode("t", "d", &bindings).expect("encode");
let decoded = codec.decode("t", "d", &encoded).expect("decode");
assert_eq!(
decoded.len(),
2,
"decode must round-trip 2 bindings, not return empty"
);
assert_eq!(decoded, bindings, "round-trip must preserve content");
}
#[test]
fn trust_level_to_str_pins_variant_strings() {
assert_eq!(
trust_level_codec::to_str(DeviceTrustLevel::Unknown),
"Unknown"
);
assert_eq!(trust_level_codec::to_str(DeviceTrustLevel::Seen), "Seen");
assert_eq!(
trust_level_codec::to_str(DeviceTrustLevel::Trusted),
"Trusted"
);
assert_eq!(
trust_level_codec::to_str(DeviceTrustLevel::Revoked),
"Revoked"
);
}
#[test]
fn trust_level_from_str_round_trips_each_variant() {
assert_eq!(
trust_level_codec::from_str("Unknown"),
Some(DeviceTrustLevel::Unknown)
);
assert_eq!(
trust_level_codec::from_str("Seen"),
Some(DeviceTrustLevel::Seen)
);
assert_eq!(
trust_level_codec::from_str("Trusted"),
Some(DeviceTrustLevel::Trusted)
);
assert_eq!(
trust_level_codec::from_str("Revoked"),
Some(DeviceTrustLevel::Revoked)
);
assert_eq!(
trust_level_codec::from_str("not-a-level"),
None,
"unknown input must yield None, not Some(Default)"
);
}
#[test]
fn refresh_family_ids_are_distinct_and_keep_first_seen_order() {
let now = Utc::now();
let refresh = |f: &str| DeviceBinding::Refresh {
family_id: f.to_string(),
issued_at: now,
last_used_at: now,
};
let bindings = vec![refresh("b"), refresh("a"), refresh("b"), refresh("a")];
assert_eq!(refresh_family_ids(&bindings), vec!["b", "a"]);
assert!(refresh_family_ids(&[]).is_empty());
}
#[test]
fn trust_level_codec_is_a_bijection_on_known_variants() {
for level in [
DeviceTrustLevel::Unknown,
DeviceTrustLevel::Seen,
DeviceTrustLevel::Trusted,
DeviceTrustLevel::Revoked,
] {
let s = trust_level_codec::to_str(level);
assert_eq!(
trust_level_codec::from_str(s),
Some(level),
"{level:?} must round-trip via to_str/from_str"
);
}
}
}
#[cfg(test)]
mod binding_tests;