nexo_pairing/
setup_code.rs1use std::path::Path;
10use std::time::Duration;
11
12use base64::engine::general_purpose::URL_SAFE_NO_PAD;
13use base64::Engine;
14use chrono::{DateTime, Utc};
15use hmac::{Hmac, Mac};
16use rand::RngCore;
17use sha2::Sha256;
18use subtle::ConstantTimeEq;
19
20use crate::types::{PairingError, SetupCode, TokenClaims};
21
22type HmacSha256 = Hmac<Sha256>;
23
24pub struct SetupCodeIssuer {
25 secret: [u8; 32],
26}
27
28impl SetupCodeIssuer {
29 pub fn open_or_create(path: &Path) -> Result<Self, PairingError> {
33 match std::fs::read(path) {
34 Ok(bytes) if bytes.len() == 32 => {
35 let mut secret = [0u8; 32];
36 secret.copy_from_slice(&bytes);
37 Ok(Self { secret })
38 }
39 Ok(_) => Err(PairingError::Invalid("pairing secret has wrong length")),
40 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::generate(path),
41 Err(e) => Err(PairingError::Io(e.to_string())),
42 }
43 }
44
45 fn generate(path: &Path) -> Result<Self, PairingError> {
46 let mut secret = [0u8; 32];
47 rand::thread_rng().fill_bytes(&mut secret);
48 if let Some(parent) = path.parent() {
49 std::fs::create_dir_all(parent).map_err(|e| PairingError::Io(e.to_string()))?;
50 }
51 std::fs::write(path, secret).map_err(|e| PairingError::Io(e.to_string()))?;
52 #[cfg(unix)]
53 {
54 use std::os::unix::fs::PermissionsExt;
55 let mut perms = std::fs::metadata(path)
56 .map_err(|e| PairingError::Io(e.to_string()))?
57 .permissions();
58 perms.set_mode(0o600);
59 std::fs::set_permissions(path, perms).map_err(|e| PairingError::Io(e.to_string()))?;
60 }
61 Ok(Self { secret })
62 }
63
64 pub fn issue(
65 &self,
66 url: &str,
67 profile: &str,
68 ttl: Duration,
69 device_label: Option<&str>,
70 ) -> Result<SetupCode, PairingError> {
71 if url.trim().is_empty() {
72 return Err(PairingError::Invalid("setup-code url is empty"));
73 }
74 let expires_at = Utc::now()
75 + chrono::Duration::from_std(ttl)
76 .map_err(|_| PairingError::Invalid("setup-code ttl out of range"))?;
77 let mut nonce_bytes = [0u8; 16];
78 rand::thread_rng().fill_bytes(&mut nonce_bytes);
79 let claims = TokenClaims {
80 profile: profile.to_string(),
81 expires_at,
82 nonce: hex::encode(nonce_bytes),
83 device_label: device_label.map(str::to_string),
84 };
85 let claims_json =
86 serde_json::to_vec(&claims).map_err(|e| PairingError::Storage(e.to_string()))?;
87 let mut mac = HmacSha256::new_from_slice(&self.secret)
88 .map_err(|e| PairingError::Invalid(Box::leak(e.to_string().into_boxed_str())))?;
89 mac.update(&claims_json);
90 let sig = mac.finalize().into_bytes();
91 let token = format!(
92 "{}.{}",
93 URL_SAFE_NO_PAD.encode(&claims_json),
94 URL_SAFE_NO_PAD.encode(sig)
95 );
96 Ok(SetupCode {
97 url: url.to_string(),
98 bootstrap_token: token,
99 expires_at,
100 })
101 }
102
103 pub fn verify(&self, token: &str) -> Result<TokenClaims, PairingError> {
108 let (claims_b64, sig_b64) = token
109 .split_once('.')
110 .ok_or(PairingError::Invalid("bootstrap token format"))?;
111 let claims_bytes = URL_SAFE_NO_PAD
112 .decode(claims_b64)
113 .map_err(|_| PairingError::Invalid("bootstrap token claims b64"))?;
114 let sig = URL_SAFE_NO_PAD
115 .decode(sig_b64)
116 .map_err(|_| PairingError::Invalid("bootstrap token sig b64"))?;
117 let mut mac = HmacSha256::new_from_slice(&self.secret)
118 .map_err(|e| PairingError::Invalid(Box::leak(e.to_string().into_boxed_str())))?;
119 mac.update(&claims_bytes);
120 let expected = mac.finalize().into_bytes();
121 if !bool::from(sig.ct_eq(&expected)) {
122 return Err(PairingError::InvalidSignature);
123 }
124 let claims: TokenClaims = serde_json::from_slice(&claims_bytes)
125 .map_err(|_| PairingError::Invalid("bootstrap token claims json"))?;
126 if claims.expires_at < Utc::now() {
127 return Err(PairingError::Expired);
128 }
129 Ok(claims)
130 }
131}
132
133pub fn encode_setup_code(payload: &SetupCode) -> Result<String, PairingError> {
136 let json = serde_json::to_vec(payload).map_err(|e| PairingError::Storage(e.to_string()))?;
137 Ok(URL_SAFE_NO_PAD.encode(json))
138}
139
140pub fn decode_setup_code(code: &str) -> Result<SetupCode, PairingError> {
142 let bytes = URL_SAFE_NO_PAD
143 .decode(code)
144 .map_err(|_| PairingError::Invalid("setup-code b64"))?;
145 serde_json::from_slice(&bytes).map_err(|_| PairingError::Invalid("setup-code json"))
146}
147
148pub fn token_expires_at(token: &str) -> Option<DateTime<Utc>> {
150 let claims_b64 = token.split_once('.')?.0;
151 let claims_bytes = URL_SAFE_NO_PAD.decode(claims_b64).ok()?;
152 let claims: TokenClaims = serde_json::from_slice(&claims_bytes).ok()?;
153 Some(claims.expires_at)
154}