use crate::admin::totp_store::{self as store, DeviceTable, Stored};
use crate::sql::{ExecError, Pool};
use crate::totp::TotpSecret;
use crate::Model;
#[derive(Model, Clone)]
#[rustango(table = "rustango_operator_totp", managed = false, scope = "registry")]
#[allow(dead_code)]
pub struct OperatorTotp {
#[rustango(primary_key)]
pub operator_id: i64,
#[rustango(secret, max_length = 255)]
secret: String,
#[rustango(default = "false")]
pub confirmed: bool,
#[rustango(default = "now()")]
pub created_at: chrono::DateTime<chrono::Utc>,
pub last_used_step: Option<i64>,
#[rustango(secret, max_length = 255)]
pending_secret: Option<String>,
}
impl std::fmt::Debug for OperatorTotp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OperatorTotp")
.field("operator_id", &self.operator_id)
.field("confirmed", &self.confirmed)
.field("created_at", &self.created_at)
.field("last_used_step", &self.last_used_step)
.finish_non_exhaustive()
}
}
const SEALED_V1: &str = "enc:v1:";
fn aad(operator_id: i64) -> Vec<u8> {
format!("rustango:operator-totp:v1:{operator_id}").into_bytes()
}
impl DeviceTable for OperatorTotp {
const OWNER: &'static str = "operator_id";
const SECRET: &'static str = "secret";
const PENDING: &'static str = "pending_secret";
fn seal(owner: i64, secret: &TotpSecret) -> Result<String, ExecError> {
crate::aead::encrypt(&secret.0, &aad(owner))
.map(|b64| format!("{SEALED_V1}{b64}"))
.map_err(|_| {
ExecError::Driver(sqlx::Error::Configuration(
"RUSTANGO_SECRET_KEY is not set; cannot seal a TOTP secret".into(),
))
})
}
fn open(owner: i64, stored: &str) -> Option<TotpSecret> {
let b64 = stored.strip_prefix(SEALED_V1)?;
let bytes = crate::aead::decrypt(b64, &aad(owner)).ok()?;
(bytes.len() >= crate::totp::MIN_SECRET_BYTES).then_some(TotpSecret(bytes))
}
fn upgrade(pool: &Pool) -> impl std::future::Future<Output = Result<(), sqlx::Error>> + Send {
ensure_table(pool)
}
}
pub(crate) fn reseal(operator_id: i64, stored: &str) -> Result<String, String> {
let b64 = stored.strip_prefix(SEALED_V1).ok_or("not a sealed value")?;
let plain = crate::aead::decrypt(b64, &aad(operator_id)).map_err(|e| format!("{e:?}"))?;
crate::aead::encrypt(&plain, &aad(operator_id))
.map(|b64| format!("{SEALED_V1}{b64}"))
.map_err(|e| format!("cannot seal: {e:?}"))
}
pub async fn ensure_table(pool: &Pool) -> Result<(), sqlx::Error> {
crate::migrate::apply_idempotent(pool, &bootstrap_snapshot()).await
}
pub(crate) fn bootstrap_snapshot() -> crate::migrate::SchemaSnapshot {
use crate::core::Model as _;
crate::migrate::SchemaSnapshot::from_models_forced(&[OperatorTotp::SCHEMA])
}
pub(crate) async fn confirmed(pool: &Pool, operator_id: i64) -> Result<Option<Stored>, ExecError> {
store::confirmed_in::<OperatorTotp>(pool, operator_id).await
}
pub(crate) async fn pending(pool: &Pool, operator_id: i64) -> Result<Option<Stored>, ExecError> {
store::pending_in::<OperatorTotp>(pool, operator_id).await
}
pub(crate) async fn start(
pool: &Pool,
operator_id: i64,
secret: &TotpSecret,
) -> Result<(), ExecError> {
store::start_in::<OperatorTotp>(pool, operator_id, secret).await
}
pub(crate) async fn redeem(
pool: &Pool,
operator_id: i64,
secret: &Stored,
code: &str,
) -> Result<bool, ExecError> {
store::redeem_in::<OperatorTotp>(pool, operator_id, secret, code, false).await
}
pub(crate) async fn confirm(
pool: &Pool,
operator_id: i64,
secret: &Stored,
code: &str,
) -> Result<bool, ExecError> {
store::redeem_in::<OperatorTotp>(pool, operator_id, secret, code, true).await
}
pub(crate) async fn remove(
pool: &Pool,
operator_id: i64,
secret: &Stored,
) -> Result<bool, ExecError> {
store::remove_in::<OperatorTotp>(pool, operator_id, secret).await
}
pub(crate) async fn reset(
pool: &Pool,
operator: &crate::tenancy::auth::Operator,
) -> Result<u64, ExecError> {
let id = operator.id.get().copied().unwrap_or_default();
crate::session::revoke_sessions::<crate::tenancy::auth::Operator>(
pool,
id,
operator.sessions_revoked_at,
0,
)
.await?;
store::reset_in::<OperatorTotp>(pool, id).await
}