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 crate::telemetry::inc_bootstrap_tokens_issued(profile);
97 Ok(SetupCode {
98 url: url.to_string(),
99 bootstrap_token: token,
100 expires_at,
101 })
102 }
103
104 pub fn verify(&self, token: &str) -> Result<TokenClaims, PairingError> {
109 let (claims_b64, sig_b64) = token
110 .split_once('.')
111 .ok_or(PairingError::Invalid("bootstrap token format"))?;
112 let claims_bytes = URL_SAFE_NO_PAD
113 .decode(claims_b64)
114 .map_err(|_| PairingError::Invalid("bootstrap token claims b64"))?;
115 let sig = URL_SAFE_NO_PAD
116 .decode(sig_b64)
117 .map_err(|_| PairingError::Invalid("bootstrap token sig b64"))?;
118 let mut mac = HmacSha256::new_from_slice(&self.secret)
119 .map_err(|e| PairingError::Invalid(Box::leak(e.to_string().into_boxed_str())))?;
120 mac.update(&claims_bytes);
121 let expected = mac.finalize().into_bytes();
122 if !bool::from(sig.ct_eq(&expected)) {
123 return Err(PairingError::InvalidSignature);
124 }
125 let claims: TokenClaims = serde_json::from_slice(&claims_bytes)
126 .map_err(|_| PairingError::Invalid("bootstrap token claims json"))?;
127 if claims.expires_at < Utc::now() {
128 return Err(PairingError::Expired);
129 }
130 Ok(claims)
131 }
132}
133
134pub fn encode_setup_code(payload: &SetupCode) -> Result<String, PairingError> {
137 let json = serde_json::to_vec(payload).map_err(|e| PairingError::Storage(e.to_string()))?;
138 Ok(URL_SAFE_NO_PAD.encode(json))
139}
140
141pub fn decode_setup_code(code: &str) -> Result<SetupCode, PairingError> {
143 let bytes = URL_SAFE_NO_PAD
144 .decode(code)
145 .map_err(|_| PairingError::Invalid("setup-code b64"))?;
146 serde_json::from_slice(&bytes).map_err(|_| PairingError::Invalid("setup-code json"))
147}
148
149pub fn token_expires_at(token: &str) -> Option<DateTime<Utc>> {
151 let claims_b64 = token.split_once('.')?.0;
152 let claims_bytes = URL_SAFE_NO_PAD.decode(claims_b64).ok()?;
153 let claims: TokenClaims = serde_json::from_slice(&claims_bytes).ok()?;
154 Some(claims.expires_at)
155}
156
157pub fn token_device_label(token: &str) -> Option<String> {
161 let claims_b64 = token.split_once('.')?.0;
162 let claims_bytes = URL_SAFE_NO_PAD.decode(claims_b64).ok()?;
163 let claims: TokenClaims = serde_json::from_slice(&claims_bytes).ok()?;
164 claims.device_label
165}