Skip to main content

ugos_client/
auth.rs

1//! Authentication flow for the UGOS API.
2//!
3//! Implements the three-step auth handshake:
4//! 1. Fetch RSA public key via `POST /verify/check`
5//! 2. Encrypt password with PKCS1v1.5
6//! 3. Login via `POST /verify/login` → obtain session token + cookies
7
8use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
9use rsa::{Pkcs1v15Encrypt, RsaPublicKey, pkcs1::DecodeRsaPublicKey, pkcs8::DecodePublicKey};
10use serde::{Deserialize, Serialize};
11
12use crate::error::{Result, UgosError};
13
14/// Credentials needed to authenticate with a UGOS NAS.
15/// Login credentials for a UGOS NAS.
16#[derive(Clone)]
17pub struct Credentials {
18    /// Username (e.g. "fabian").
19    pub username: String,
20    /// Plaintext password.
21    pub password: String,
22}
23
24impl std::fmt::Debug for Credentials {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        f.debug_struct("Credentials")
27            .field("username", &self.username)
28            .field("password", &"[REDACTED]")
29            .finish()
30    }
31}
32
33/// Session data returned after successful login.
34#[derive(Clone, Serialize, Deserialize)]
35pub struct Session {
36    /// The session token appended as `?token=` to every request.
37    pub token: String,
38    /// PEM public key the NAS returns at login, used to wrap the per-request
39    /// AES key for encrypted endpoints. Absent from cached sessions written
40    /// by older versions.
41    #[serde(default)]
42    pub public_key: String,
43}
44
45impl std::fmt::Debug for Session {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.debug_struct("Session")
48            .field("token", &"[REDACTED]")
49            .finish()
50    }
51}
52
53/// Login response payload from `/verify/login`.
54#[derive(Debug, Deserialize)]
55struct LoginData {
56    token: String,
57    /// Base64-encoded PEM, used for the request encryption scheme.
58    #[serde(default)]
59    public_key: String,
60}
61
62/// Fetch the RSA public key from the NAS.
63///
64/// # Errors
65///
66/// Returns [`UgosError::Encryption`] if the key header is missing or malformed.
67/// Returns [`UgosError::Http`] on network failure.
68pub async fn fetch_rsa_key(
69    client: &reqwest::Client,
70    base_url: &str,
71    username: &str,
72) -> Result<RsaPublicKey> {
73    let url = format!("{base_url}/v1/verify/check");
74    let body = serde_json::json!({"username": username});
75
76    let resp = client.post(&url).json(&body).send().await?;
77
78    let header = resp
79        .headers()
80        .get("x-rsa-token")
81        .and_then(|v| v.to_str().ok())
82        .ok_or_else(|| UgosError::Encryption("missing x-rsa-token header".into()))?;
83
84    let pem_bytes = B64
85        .decode(header)
86        .map_err(|e| UgosError::Encryption(format!("base64 decode: {e}")))?;
87    let pem_str = String::from_utf8(pem_bytes)
88        .map_err(|e| UgosError::Encryption(format!("PEM not UTF-8: {e}")))?;
89
90    parse_public_key(&pem_str)
91}
92
93/// Parse a PEM public key, tolerating the mislabelled variant UGOS sends.
94///
95/// # Errors
96///
97/// Returns [`UgosError::Encryption`] when the PEM cannot be parsed.
98pub fn parse_public_key(pem: &str) -> Result<RsaPublicKey> {
99    RsaPublicKey::from_pkcs1_pem(pem)
100        .or_else(|_| RsaPublicKey::from_public_key_pem(pem))
101        .or_else(|_| {
102            // UGOS sometimes wraps SPKI DER in a "BEGIN RSA PUBLIC KEY" header.
103            // Re-label and try as SPKI.
104            let relabeled = pem
105                .replace("BEGIN RSA PUBLIC KEY", "BEGIN PUBLIC KEY")
106                .replace("END RSA PUBLIC KEY", "END PUBLIC KEY");
107            RsaPublicKey::from_public_key_pem(&relabeled)
108        })
109        .map_err(|e| UgosError::Encryption(format!("RSA key parse: {e}")))
110}
111
112/// Encrypt a password with the given RSA public key using PKCS1v1.5 padding.
113///
114/// # Errors
115///
116/// Returns [`UgosError::Encryption`] if RSA encryption fails.
117pub fn encrypt_password(key: &RsaPublicKey, password: &str) -> Result<String> {
118    let mut rng = rsa::rand_core::OsRng;
119    let ciphertext = key
120        .encrypt(&mut rng, Pkcs1v15Encrypt, password.as_bytes())
121        .map_err(|e| UgosError::Encryption(format!("RSA encrypt: {e}")))?;
122    Ok(B64.encode(ciphertext))
123}
124
125/// Perform the full login flow and return a [`Session`].
126///
127/// The `client` must have `cookie_store(true)` so cookies are captured
128/// automatically from the `Set-Cookie` response headers.
129///
130/// # Errors
131///
132/// Returns [`UgosError::AuthFailed`] on bad credentials.
133/// Returns [`UgosError::Encryption`] on RSA failures.
134/// Returns [`UgosError::Http`] on network failures.
135pub async fn login(
136    client: &reqwest::Client,
137    base_url: &str,
138    creds: &Credentials,
139) -> Result<Session> {
140    // Step 1: fetch RSA public key
141    let pubkey = fetch_rsa_key(client, base_url, &creds.username).await?;
142
143    // Step 2: encrypt password
144    let encrypted = encrypt_password(&pubkey, &creds.password)?;
145
146    // Step 3: POST /verify/login
147    let url = format!("{base_url}/v1/verify/login");
148    let body = serde_json::json!({
149        "username": creds.username,
150        "password": encrypted,
151        "keepalive": true,
152        "otp": false,
153    });
154
155    let resp = client.post(&url).json(&body).send().await?;
156
157    // Check the status code before decoding: a rejected login carries no
158    // token, and decoding first would report "missing field `token`" instead
159    // of what actually went wrong.
160    let api: crate::types::common::ApiResponse<serde_json::Value> = resp.json().await?;
161    let data: LoginData = serde_json::from_value(api.into_result()?)?;
162
163    // The key arrives base64-encoded; decode it here so callers see PEM.
164    let public_key = if data.public_key.is_empty() {
165        String::new()
166    } else {
167        B64.decode(&data.public_key)
168            .ok()
169            .and_then(|b| String::from_utf8(b).ok())
170            .unwrap_or_default()
171    };
172
173    Ok(Session {
174        token: data.token,
175        public_key,
176    })
177}