use serde_json::Value;
use sqlx::Row;
use sqlx::sqlite::SqliteRow;
use tracing::{debug, info};
use uuid::Uuid;
use crate::random::random_bytes;
use crate::sqlite::db::Database;
use crate::sqlite::nonce::now_secs;
use crate::sqlite::order::rfc3339;
#[derive(Debug)]
pub struct Eab {
pub kid: Uuid,
pub secret: Vec<u8>,
pub label: Option<String>,
pub profile: Option<String>,
pub status: String,
pub created_at: i64,
}
const SECRET_LEN: usize = 32;
impl Eab {
fn from_row(row: SqliteRow) -> Result<Self, sqlx::Error> {
Ok(Eab {
kid: row.try_get("kid")?,
secret: row.try_get("secret")?,
label: row.try_get("label")?,
profile: row.try_get("profile")?,
status: row.try_get("status")?,
created_at: row.try_get("created_at")?,
})
}
pub async fn create(
label: Option<String>,
profile: Option<String>,
database: &Database,
) -> Result<Eab, sqlx::Error> {
let eab = Eab {
kid: crate::sqlite::id::mint(),
secret: random_bytes::<SECRET_LEN>().to_vec(),
label,
profile,
status: "active".to_string(),
created_at: now_secs(),
};
debug!(event = "db_eab_create_started", outcome = "progress", kid = ?eab.kid, profile = ?eab.profile);
sqlx::query(
"INSERT INTO eab_keys (kid, secret, label, profile, status, created_at) \
VALUES (?, ?, ?, ?, ?, ?);",
)
.bind(eab.kid)
.bind(&eab.secret)
.bind(&eab.label)
.bind(&eab.profile)
.bind(&eab.status)
.bind(eab.created_at)
.execute(&database.pool)
.await?;
info!(event = "db_eab_created", outcome = "success", kid = ?eab.kid);
Ok(eab)
}
pub async fn find_by_kid(
kid: &str,
profile: &str,
database: &Database,
) -> Result<Option<Eab>, sqlx::Error> {
debug!(event = "db_eab_find_by_kid_started", outcome = "progress", kid = ?kid, profile = %profile);
let Some(kid) = crate::sqlite::id::parse(kid) else {
return Ok(None);
};
let row = sqlx::query(
"SELECT kid, secret, label, profile, status, created_at FROM eab_keys \
WHERE kid = ? AND (profile IS NULL OR profile = ?);",
)
.bind(kid)
.bind(profile)
.fetch_optional(&database.pool)
.await?;
row.map(Eab::from_row).transpose()
}
pub async fn find_any_by_kid(
kid: &str,
database: &Database,
) -> Result<Option<Eab>, sqlx::Error> {
debug!(event = "db_eab_find_any_by_kid_started", outcome = "progress", kid = ?kid);
let Some(kid) = crate::sqlite::id::parse(kid) else {
return Ok(None);
};
let row = sqlx::query(
"SELECT kid, secret, label, profile, status, created_at FROM eab_keys WHERE kid = ?;",
)
.bind(kid)
.fetch_optional(&database.pool)
.await?;
row.map(Eab::from_row).transpose()
}
pub async fn search(
limit: i64,
offset: i64,
database: &Database,
) -> Result<(Vec<Eab>, i64), sqlx::Error> {
debug!(
event = "db_eab_search_started",
outcome = "progress",
limit = limit,
offset = offset
);
let rows = sqlx::query(
"SELECT kid, secret, label, profile, status, created_at FROM eab_keys \
ORDER BY created_at DESC, kid DESC LIMIT ? OFFSET ?;",
)
.bind(limit)
.bind(offset)
.fetch_all(&database.pool)
.await?;
let total: i64 = sqlx::query("SELECT COUNT(*) FROM eab_keys;")
.fetch_one(&database.pool)
.await?
.try_get(0)?;
let keys = rows
.into_iter()
.map(Eab::from_row)
.collect::<Result<_, _>>()?;
Ok((keys, total))
}
pub async fn revoke(kid: &str, database: &Database) -> Result<bool, sqlx::Error> {
debug!(event = "db_eab_revoke_started", outcome = "progress", kid = ?kid);
let Some(kid) = crate::sqlite::id::parse(kid) else {
return Ok(false);
};
let result = sqlx::query("UPDATE eab_keys SET status = 'revoked' WHERE kid = ?;")
.bind(kid)
.execute(&database.pool)
.await?;
let updated = result.rows_affected() > 0;
if updated {
info!(event = "db_eab_revoked", outcome = "success", kid = ?kid);
} else {
debug!(event = "db_eab_revoke_missing", outcome = "success", kid = ?kid);
}
Ok(updated)
}
#[must_use]
pub fn is_active(&self) -> bool {
self.status == "active"
}
#[must_use]
pub fn to_json(&self) -> Value {
serde_json::json!({
"kid": self.kid,
"label": self.label,
"profile": self.profile,
"status": self.status,
"createdAt": rfc3339(self.created_at),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[tokio::test]
async fn create_persists_an_active_key_with_a_32_byte_secret() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let eab = Eab::create(Some("team-a".to_string()), None, &db)
.await
.unwrap();
assert_eq!(eab.status, "active");
assert_eq!(eab.secret.len(), 32);
assert_eq!(eab.label.as_deref(), Some("team-a"));
}
#[tokio::test]
async fn find_by_kid_round_trip() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let created = Eab::create(None, None, &db).await.unwrap();
let found = Eab::find_by_kid(created.kid.to_string().as_str(), "default", &db)
.await
.unwrap()
.unwrap();
assert_eq!(found.secret, created.secret);
assert!(found.label.is_none());
}
#[tokio::test]
async fn find_by_kid_of_unknown_returns_none() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
assert!(
Eab::find_by_kid("nope", "default", &db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn search_returns_every_key_newest_first_and_empty_is_empty() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
assert!(Eab::search(50, 0, &db).await.unwrap().0.is_empty());
let first = Eab::create(None, None, &db).await.unwrap();
let second = Eab::create(None, None, &db).await.unwrap();
let (all, total) = Eab::search(50, 0, &db).await.unwrap();
assert_eq!((all.len(), total), (2, 2));
let kids: Vec<String> = all.iter().map(|eab| eab.kid.to_string()).collect();
assert_eq!(kids, [second.kid.to_string(), first.kid.to_string()]);
}
#[tokio::test]
async fn search_pages_without_overlap_and_reports_the_unpaged_total() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
assert_eq!(Eab::search(50, 0, &db).await.unwrap().1, 0);
let created: Vec<String> = {
let mut kids = Vec::new();
for _ in 0..5 {
kids.push(Eab::create(None, None, &db).await.unwrap().kid);
}
kids.into_iter().map(|v| v.to_string()).collect()
};
let (first, total) = Eab::search(2, 0, &db).await.unwrap();
let (second, also_total) = Eab::search(2, 2, &db).await.unwrap();
let (third, _) = Eab::search(2, 4, &db).await.unwrap();
assert_eq!(total, 5);
assert_eq!(also_total, 5, "the total is the table, not the page");
assert_eq!((first.len(), second.len(), third.len()), (2, 2, 1));
let walked: Vec<String> = first
.iter()
.chain(second.iter())
.chain(third.iter())
.map(|eab| eab.kid.to_string())
.collect();
assert_eq!(walked.len(), created.len());
for kid in &created {
assert_eq!(
walked.iter().filter(|seen| *seen == kid).count(),
1,
"{kid} was not on exactly one page"
);
}
}
#[tokio::test]
async fn revoke_marks_revoked_reports_true_and_is_idempotent() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let eab = Eab::create(None, None, &db).await.unwrap();
assert!(
Eab::revoke(eab.kid.to_string().as_str(), &db)
.await
.unwrap()
);
assert!(
!Eab::find_by_kid(eab.kid.to_string().as_str(), "default", &db)
.await
.unwrap()
.unwrap()
.is_active()
);
assert!(
Eab::revoke(eab.kid.to_string().as_str(), &db)
.await
.unwrap()
);
}
#[tokio::test]
async fn revoke_of_unknown_kid_reports_false() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
assert!(!Eab::revoke("nope", &db).await.unwrap());
}
#[tokio::test]
async fn to_json_never_includes_the_secret() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let eab = Eab::create(Some("x".to_string()), None, &db).await.unwrap();
let json = eab.to_json();
assert!(json.get("secret").is_none());
assert!(json.get("hmacKey").is_none());
assert_eq!(json["kid"], eab.kid.to_string());
assert_eq!(json["status"], "active");
assert_eq!(json["label"], "x");
}
}