Skip to main content

nexo_auth/
handle.rs

1use std::fmt;
2use std::sync::Arc;
3
4use sha2::{Digest, Sha256};
5
6pub type AgentId = Arc<str>;
7pub type Channel = &'static str;
8
9pub const WHATSAPP: Channel = "whatsapp";
10pub const TELEGRAM: Channel = "telegram";
11pub const GOOGLE: Channel = "google";
12pub const EMAIL: Channel = "email";
13
14/// 8-byte prefix of `sha256(account_id)`. Stable across boots and
15/// safe to emit to logs, metrics, and transcripts — unlike the raw
16/// account id it never discloses a phone number or email.
17#[derive(Copy, Clone, PartialEq, Eq, Hash)]
18pub struct Fingerprint([u8; 8]);
19
20impl Fingerprint {
21    pub fn of(value: &str) -> Self {
22        let mut hasher = Sha256::new();
23        hasher.update(value.as_bytes());
24        let digest = hasher.finalize();
25        let mut out = [0u8; 8];
26        out.copy_from_slice(&digest[..8]);
27        Self(out)
28    }
29
30    pub fn as_bytes(&self) -> &[u8; 8] {
31        &self.0
32    }
33
34    pub fn to_hex(&self) -> String {
35        hex::encode(self.0)
36    }
37}
38
39impl fmt::Debug for Fingerprint {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        f.write_str(&self.to_hex())
42    }
43}
44
45impl fmt::Display for Fingerprint {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        f.write_str(&self.to_hex())
48    }
49}
50
51/// Opaque handle to an issued credential. The raw `account_id` is
52/// intentionally not exposed via `Debug` / `Display` — callers that
53/// need to log must use [`CredentialHandle::fingerprint`]. This keeps
54/// phone numbers and emails out of transcripts, audit logs, and the
55/// prompt cache.
56#[derive(Clone)]
57pub struct CredentialHandle {
58    channel: Channel,
59    account_id: Arc<str>,
60    agent_id: AgentId,
61    fingerprint: Fingerprint,
62}
63
64impl CredentialHandle {
65    pub fn new(channel: Channel, account_id: &str, agent_id: &str) -> Self {
66        Self {
67            channel,
68            account_id: Arc::from(account_id),
69            agent_id: Arc::from(agent_id),
70            fingerprint: Fingerprint::of(account_id),
71        }
72    }
73
74    pub fn channel(&self) -> Channel {
75        self.channel
76    }
77
78    pub fn agent_id(&self) -> &str {
79        &self.agent_id
80    }
81
82    pub fn fingerprint(&self) -> Fingerprint {
83        self.fingerprint
84    }
85
86    /// Raw account id — only call from the owning store when issuing
87    /// a network request. Never log this value; prefer [`Self::fingerprint`].
88    pub fn account_id_raw(&self) -> &str {
89        &self.account_id
90    }
91}
92
93impl fmt::Debug for CredentialHandle {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        f.debug_struct("CredentialHandle")
96            .field("channel", &self.channel)
97            .field("agent", &self.agent_id)
98            .field("fp", &self.fingerprint)
99            .finish()
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn fingerprint_is_stable() {
109        let a = Fingerprint::of("ana@gmail.com");
110        let b = Fingerprint::of("ana@gmail.com");
111        assert_eq!(a, b);
112    }
113
114    #[test]
115    fn fingerprint_differs_between_ids() {
116        let a = Fingerprint::of("ana@gmail.com");
117        let b = Fingerprint::of("kate@gmail.com");
118        assert_ne!(a, b);
119    }
120
121    #[test]
122    fn handle_debug_does_not_leak_account_id() {
123        let h = CredentialHandle::new(WHATSAPP, "+573001234567", "ana");
124        let rendered = format!("{:?}", h);
125        assert!(!rendered.contains("573001234567"));
126        assert!(rendered.contains("whatsapp"));
127        assert!(rendered.contains("ana"));
128        assert!(rendered.contains(&h.fingerprint().to_hex()));
129    }
130
131    #[test]
132    fn fingerprint_display_is_hex() {
133        let fp = Fingerprint::of("x");
134        assert_eq!(fp.to_hex().len(), 16);
135        assert!(fp.to_hex().chars().all(|c| c.is_ascii_hexdigit()));
136    }
137}