use hmac::{Hmac, KeyInit, Mac};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use vta_sdk::protocols::device_management::{UV_PIN_CHANGE_PREFIX, UvKeyMaterial};
use vti_common::acl::UvKey;
use vti_common::error::AppError;
use vti_common::store::{Batch, KeyspaceHandle};
pub const MAX_ATTEMPTS: u32 = 5;
pub const FIRST_LOCK_SECS: i64 = 15 * 60;
pub const MAX_LOCK_SECS: i64 = 24 * 60 * 60;
const WRITE_ATTEMPTS: usize = 32;
fn guard_key(device_did: &str) -> String {
format!("uv-pin:{device_did}")
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PinGuard {
pub enrolled_at: String,
pub failed_attempts: u32,
pub locks: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub locked_until: Option<i64>,
}
impl PinGuard {
fn fresh(enrolled_at: &str) -> Self {
Self {
enrolled_at: enrolled_at.to_string(),
..Self::default()
}
}
#[must_use]
pub fn locked_at(&self, now: i64) -> Option<i64> {
self.locked_until.filter(|t| *t > now)
}
#[must_use]
pub fn attempts_remaining(&self) -> u32 {
if self.locks > 0 {
0
} else {
MAX_ATTEMPTS.saturating_sub(self.failed_attempts)
}
}
fn after_wrong(&self, now: i64) -> Self {
let mut next = self.clone();
next.failed_attempts = next.failed_attempts.saturating_add(1);
let lock_now = next.locks > 0 || next.failed_attempts >= MAX_ATTEMPTS;
if lock_now {
next.locks = next.locks.saturating_add(1);
next.locked_until = Some(now + lock_secs(next.locks));
}
next
}
}
#[must_use]
pub fn lock_secs(n: u32) -> i64 {
let shift = n.saturating_sub(1).min(16);
FIRST_LOCK_SECS
.saturating_mul(1i64 << shift)
.min(MAX_LOCK_SECS)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct PinStatus {
pub failed_attempts: u32,
pub locked_until: Option<i64>,
}
#[derive(Debug)]
#[non_exhaustive]
pub enum PinRefusal {
Locked {
locked_until: i64,
},
Wrong {
attempts_remaining: u32,
failed_attempts: u32,
locked_until: Option<i64>,
},
Malformed,
NotAPin,
App(AppError),
}
impl From<AppError> for PinRefusal {
fn from(e: AppError) -> Self {
PinRefusal::App(e)
}
}
pub fn change_message(new_enrolment: &Value) -> Result<String, AppError> {
let canonical = serde_json_canonicalizer::to_string(new_enrolment)
.map_err(|e| AppError::Internal(format!("uv-key JCS canonicalization failed: {e}")))?;
Ok(format!(
"{UV_PIN_CHANGE_PREFIX}{}",
hex::encode(Sha256::digest(canonical.as_bytes()))
))
}
async fn read(
acl_ks: &KeyspaceHandle,
device_did: &str,
enrolled_at: &str,
) -> Result<(Option<Vec<u8>>, PinGuard), AppError> {
let raw = acl_ks.get_raw(guard_key(device_did)).await?;
let guard = match raw.as_deref().map(serde_json::from_slice::<PinGuard>) {
Some(Ok(g)) if g.enrolled_at == enrolled_at => g,
_ => PinGuard::fresh(enrolled_at),
};
Ok((raw, guard))
}
async fn write_if_unchanged(
acl_ks: &KeyspaceHandle,
device_did: &str,
expected: Option<Vec<u8>>,
next: &PinGuard,
) -> Result<bool, AppError> {
let key = guard_key(device_did);
let mut batch = Batch::new();
match expected {
Some(raw) => batch.require_unchanged(key.clone(), raw),
None => batch.require_absent(key.clone()),
};
batch.insert(key, next)?;
Ok(acl_ks.apply_batch(batch).await?.is_applied())
}
pub async fn status(acl_ks: &KeyspaceHandle, device_did: &str, key: &UvKey, now: i64) -> PinStatus {
match read(acl_ks, device_did, &key.enrolled_at).await {
Ok((_, g)) => PinStatus {
failed_attempts: g.failed_attempts,
locked_until: g.locked_at(now),
},
Err(_) => PinStatus::default(),
}
}
pub async fn check(
acl_ks: &KeyspaceHandle,
device_did: &str,
key: &UvKey,
message: &str,
proof_b64: &str,
now: i64,
) -> Result<(), PinRefusal> {
let UvKeyMaterial::VtaPin { verifier, .. } = &key.enrolment.key else {
return Err(PinRefusal::NotAPin);
};
let k = verifier.key_bytes().ok_or(PinRefusal::NotAPin)?;
for _ in 0..WRITE_ATTEMPTS {
let (raw, guard) = read(acl_ks, device_did, &key.enrolled_at).await?;
if let Some(locked_until) = guard.locked_at(now) {
return Err(PinRefusal::Locked { locked_until });
}
use base64::Engine as _;
let proof = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(proof_b64.trim_end_matches('='))
.ok()
.filter(|p| p.len() == 32)
.ok_or(PinRefusal::Malformed)?;
let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(&k)
.map_err(|e| AppError::Internal(format!("hmac key: {e}")))?;
mac.update(message.as_bytes());
if mac.verify_slice(&proof).is_ok() {
let clean = PinGuard::fresh(&key.enrolled_at);
if guard == clean {
return Ok(());
}
if write_if_unchanged(acl_ks, device_did, raw, &clean).await? {
return Ok(());
}
continue;
}
let next = guard.after_wrong(now);
if write_if_unchanged(acl_ks, device_did, raw, &next).await? {
return Err(PinRefusal::Wrong {
attempts_remaining: next.attempts_remaining(),
failed_attempts: next.failed_attempts,
locked_until: next.locked_at(now),
});
}
}
Err(PinRefusal::App(AppError::Conflict(format!(
"the PIN guard for {device_did} kept changing; retry"
))))
}
pub async fn audit_refusal(
audit: &vta_audit::SharedAuditSink,
device_did: &str,
purpose: &str,
refusal: &PinRefusal,
) {
let (action, outcome, detail) = match refusal {
PinRefusal::Wrong {
attempts_remaining,
failed_attempts,
locked_until,
} => {
let detail = serde_json::json!({
"purpose": purpose,
"failedAttempts": failed_attempts,
"attemptsRemaining": attempts_remaining,
"lockedUntil": locked_until,
})
.to_string();
vta_audit::record_with_detail_best_effort(
audit,
"device.uv-pin.failed",
device_did,
Some(device_did),
"denied",
None,
None,
Some(&detail),
)
.await;
match locked_until {
Some(_) => ("device.uv-pin.locked", "locked", detail),
None => return,
}
}
PinRefusal::Locked { locked_until } => (
"device.uv-pin.locked",
"denied",
serde_json::json!({ "purpose": purpose, "lockedUntil": locked_until }).to_string(),
),
_ => return,
};
vta_audit::record_with_detail_best_effort(
audit,
action,
device_did,
Some(device_did),
outcome,
None,
None,
Some(&detail),
)
.await;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn locks_double_from_fifteen_minutes_to_a_day() {
assert_eq!(lock_secs(1), 15 * 60);
assert_eq!(lock_secs(2), 30 * 60);
assert_eq!(lock_secs(3), 60 * 60);
assert_eq!(lock_secs(7), 16 * 60 * 60);
assert_eq!(lock_secs(8), 24 * 60 * 60);
assert_eq!(lock_secs(40), 24 * 60 * 60);
}
#[test]
fn five_wrong_lock_and_each_wrong_after_a_lock_locks_again_for_longer() {
let mut g = PinGuard::fresh("t");
for n in 1..=4 {
g = g.after_wrong(1_000);
assert_eq!(g.locked_until, None);
assert_eq!(g.attempts_remaining(), MAX_ATTEMPTS - n);
}
g = g.after_wrong(1_000);
assert_eq!(g.locked_until, Some(1_000 + 15 * 60));
assert_eq!(g.attempts_remaining(), 0);
let later = 1_000 + 15 * 60 + 1;
assert_eq!(g.locked_at(later), None);
g = g.after_wrong(later);
assert_eq!(g.locked_until, Some(later + 30 * 60));
}
}