Skip to main content

systemprompt_users/services/
device_cert_service.rs

1//! Device-certificate fingerprint registration and lookup.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::sync::Arc;
7use systemprompt_identifiers::{DeviceCertId, UserId};
8
9use crate::error::{Result, UserError};
10use crate::models::UserDeviceCert;
11use crate::repository::{EnrollDeviceCertParams, UserRepository};
12
13const FINGERPRINT_LEN: usize = 64;
14
15pub const DEVICE_FINGERPRINT_FOREIGN_USER: &str = "device fingerprint is enrolled to another user";
16
17#[derive(Debug, Clone)]
18pub struct EnrollParams<'a> {
19    pub user_id: &'a UserId,
20    pub fingerprint: &'a str,
21    pub label: &'a str,
22}
23
24#[derive(Debug, Clone)]
25pub struct DeviceCertService {
26    repository: Arc<UserRepository>,
27}
28
29impl DeviceCertService {
30    pub const fn new(repository: Arc<UserRepository>) -> Self {
31        Self { repository }
32    }
33
34    pub async fn enroll(&self, params: EnrollParams<'_>) -> Result<UserDeviceCert> {
35        let label = params.label.trim();
36        if label.is_empty() {
37            return Err(UserError::Validation(
38                "device cert label must not be empty".into(),
39            ));
40        }
41        let fingerprint = normalize_fingerprint(params.fingerprint)?;
42        let id = DeviceCertId::generate();
43        self.repository
44            .enroll_device_cert(EnrollDeviceCertParams {
45                id: &id,
46                user_id: params.user_id,
47                fingerprint: &fingerprint,
48                label,
49            })
50            .await
51    }
52
53    pub async fn enroll_or_reuse(&self, params: EnrollParams<'_>) -> Result<UserDeviceCert> {
54        let fingerprint = normalize_fingerprint(params.fingerprint)?;
55        match self
56            .repository
57            .find_active_device_cert_by_fingerprint(&fingerprint)
58            .await?
59        {
60            Some(existing) if existing.user_id == *params.user_id => Ok(existing),
61            Some(_) => Err(UserError::Validation(
62                DEVICE_FINGERPRINT_FOREIGN_USER.into(),
63            )),
64            None => self.enroll(params).await,
65        }
66    }
67
68    pub async fn verify(&self, fingerprint: &str) -> Result<Option<UserDeviceCert>> {
69        let normalized = normalize_fingerprint(fingerprint)?;
70        self.repository
71            .find_active_device_cert_by_fingerprint(&normalized)
72            .await
73    }
74
75    pub async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<UserDeviceCert>> {
76        self.repository.list_device_certs_for_user(user_id).await
77    }
78
79    pub async fn revoke(&self, id: &DeviceCertId, user_id: &UserId) -> Result<bool> {
80        self.repository.revoke_device_cert(id, user_id).await
81    }
82}
83
84fn normalize_fingerprint(fingerprint: &str) -> Result<String> {
85    let trimmed = fingerprint.trim().to_ascii_lowercase();
86    if trimmed.len() != FINGERPRINT_LEN {
87        return Err(UserError::Validation(format!(
88            "device cert fingerprint must be {FINGERPRINT_LEN} hex chars (SHA-256)",
89        )));
90    }
91    if !trimmed.bytes().all(|b| b.is_ascii_hexdigit()) {
92        return Err(UserError::Validation(
93            "device cert fingerprint must be hex".into(),
94        ));
95    }
96    Ok(trimmed)
97}