Skip to main content

nexo_pairing/
setup_code.rs

1//! HMAC-SHA256 bearer-token issuer.
2//!
3//! `agent pair start` builds a JSON `TokenClaims` blob, signs it with
4//! the secret in `~/.nexo/secret/pairing.key`, and ships
5//! `b64u(claims) + "." + b64u(sig)` to the companion. The companion
6//! presents this as a `Bearer` token to the daemon's gateway, which
7//! verifies HMAC + expiry.
8
9use 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    /// Open the secret file at `path`, or generate it if missing. On
30    /// Unix the new file gets 0600 perms; on other platforms we log
31    /// and proceed (paridad con OpenClaw).
32    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    /// Verify a previously-issued token. Returns the claims on
105    /// success. Constant-time compare on the HMAC. Any tampering
106    /// (modified claims, wrong sig, expired) returns the appropriate
107    /// `PairingError` variant.
108    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
134/// Encoded form: `b64url(JSON({url, bootstrap_token, expires_at}))`.
135/// QR-friendly: short, URL-safe, no padding.
136pub 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
141/// Inverse of [`encode_setup_code`] for the companion side.
142pub 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
149/// Convenience: returns the `expires_at` as a UTC timestamp.
150pub 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
157/// Convenience: reads the `device_label` from an unverified token payload.
158/// Used by the companion to label the persisted session file without needing
159/// the HMAC secret (the daemon verifies the signature on its side).
160pub 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}