Skip to main content

rahti_native/
secret.rs

1//! The per-installation session key.
2//!
3//! `rahti::auth` signs its session cookie with `AUTH_SECRET`, read from the
4//! process environment and, in a web project, put there by an ignored `.env`.
5//! A packaged application has no `.env` — and must not have one, because a
6//! file shipped inside an installer is a file every copy of the application
7//! shares, which for a signing key means every installation can forge every
8//! other installation's sessions.
9//!
10//! It also cannot go without. `rahti::auth` invents a key when none is set,
11//! once per process — so a packaged application with no secret signs you in,
12//! and signs you out again the next time it starts.
13//!
14//! So the key is generated **on the device, at first launch**, and kept in
15//! application storage. It is per installation: nothing in the repository,
16//! nothing in the installer, and nothing shared between two users of the same
17//! machine.
18//!
19//! ## Where it is kept
20//!
21//! **Windows.** Encrypted with DPAPI (`CryptProtectData`) before it is
22//! written. DPAPI derives its key from the signed-in user account, so the file
23//! is readable by that user on that machine and by nobody else: copied to
24//! another machine, or read by another account, it decrypts to nothing. The
25//! file itself is in `%LOCALAPPDATA%`, which is already per-user.
26//!
27//! **Android.** In the application's internal files directory, which is the
28//! platform's own per-application sandbox — a directory owned by a UID that
29//! only this application runs as, unreadable by every other installed app on a
30//! non-rooted device. That is the protection Android provides to files; a
31//! Keystore-backed wrapper on top of it needs Kotlin and a plugin, and this
32//! crate does not claim to have one. See `native-packaging.md`.
33//!
34//! ## What is not done with it
35//!
36//! It is never printed, never written to a log, never returned to the WebView,
37//! and never a native command's return value. [`session_secret`] is called
38//! once at startup and its result goes straight into the environment.
39
40use std::path::Path;
41
42use crate::error::NativeError;
43use crate::paths::AppPaths;
44
45const AUTH_SECRET_ENV: &str = "AUTH_SECRET";
46const AUTH_COOKIE_ENV: &str = "AUTH_COOKIE_NAME";
47
48/// The file, under [`AppPaths::data`].
49pub const SECRET_FILE: &str = "session.key";
50
51/// How many random bytes the key is. 32 bytes, written as 64 hex characters —
52/// comfortably past the length `rahti::auth` refuses below.
53const SECRET_BYTES: usize = 32;
54
55/// This installation's session key, generating one on first launch.
56///
57/// Idempotent across launches by construction: a key that already exists is
58/// read, and only a missing or unreadable one is replaced. That is the whole
59/// point — a key that changed per launch would sign every user out at every
60/// restart, which looks exactly like a broken login.
61pub fn session_secret(paths: &AppPaths) -> Result<String, NativeError> {
62    let file = paths.secret_file();
63
64    if let Some(existing) = read_secret(&file)? {
65        return Ok(existing);
66    }
67
68    let secret = generate()?;
69    write_secret(&file, &secret)?;
70    Ok(secret)
71}
72
73/// Put the session key, and the project's cookie name, where `rahti::auth`
74/// will read them.
75///
76/// Called before `initialize_application`, because the auth policy is built
77/// from the environment as the router goes up.
78///
79/// `cookie_name` is `auth.cookieName` from `rahti.native.json`. Without it the
80/// framework default applies, which works — a native application's cookie jar
81/// belongs to that application — but means a package and its web deployment
82/// disagree about the name for no reason.
83pub fn install_session_secret(
84    paths: &AppPaths,
85    cookie_name: Option<&str>,
86) -> Result<(), NativeError> {
87    let secret = session_secret(paths)?;
88
89    // SAFETY: called by the native host before any task is spawned and before
90    // the router is built — the same single-threaded moment `main` sets
91    // anything else.
92    unsafe {
93        std::env::set_var(AUTH_SECRET_ENV, secret);
94        if let Some(name) = cookie_name {
95            std::env::set_var(AUTH_COOKIE_ENV, name);
96        }
97    }
98    Ok(())
99}
100
101/// A new key, from the operating system's randomness.
102fn generate() -> Result<String, NativeError> {
103    let mut bytes = [0u8; SECRET_BYTES];
104    getrandom::fill(&mut bytes).map_err(|e| {
105        NativeError::new(
106            "secret",
107            format!("the operating system would not provide randomness for a session key: {e}"),
108        )
109    })?;
110    Ok(bytes.iter().map(|b| format!("{b:02x}")).collect())
111}
112
113/// The stored key, or `None` when there is not a usable one.
114///
115/// A file that exists and cannot be decrypted counts as absent rather than as
116/// an error: the realistic cause is a Windows profile that was restored or a
117/// user who was recreated, and the correct response to "this key is not
118/// readable by me" is a new key and a signed-out user, not an application that
119/// refuses to open.
120fn read_secret(file: &Path) -> Result<Option<String>, NativeError> {
121    let stored = match std::fs::read(file) {
122        Ok(bytes) => bytes,
123        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
124        Err(e) => return Err(NativeError::io("secret", file, e)),
125    };
126
127    let Some(plain) = unprotect(&stored) else {
128        return Ok(None);
129    };
130
131    let secret = String::from_utf8(plain).ok().filter(|s| s.len() >= 32);
132    Ok(secret)
133}
134
135fn write_secret(file: &Path, secret: &str) -> Result<(), NativeError> {
136    if let Some(parent) = file.parent() {
137        std::fs::create_dir_all(parent).map_err(|e| NativeError::io("secret", parent, e))?;
138    }
139
140    let protected = protect(secret.as_bytes())?;
141    std::fs::write(file, protected).map_err(|e| NativeError::io("secret", file, e))?;
142    restrict(file)?;
143    Ok(())
144}
145
146/// Owner-only, where the filesystem has a word for it.
147///
148/// Android's internal files directory is already per-application, so this is
149/// belt and braces there. It is not on the "other" platforms this crate
150/// compiles for so that a developer running the tests on Linux does not leave
151/// a world-readable key behind.
152#[cfg(unix)]
153fn restrict(file: &Path) -> Result<(), NativeError> {
154    use std::os::unix::fs::PermissionsExt;
155    std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o600))
156        .map_err(|e| NativeError::io("secret", file, e))
157}
158
159/// Windows has no mode bits. `%LOCALAPPDATA%` is per-user, and DPAPI is what
160/// actually protects the contents.
161#[cfg(not(unix))]
162fn restrict(_file: &Path) -> Result<(), NativeError> {
163    Ok(())
164}
165
166// ------------------------------------------------------------------ DPAPI
167
168#[cfg(windows)]
169mod dpapi {
170    use windows_sys::Win32::Foundation::LocalFree;
171    use windows_sys::Win32::Security::Cryptography::{
172        CRYPT_INTEGER_BLOB, CryptProtectData, CryptUnprotectData,
173    };
174
175    /// Encrypt for the signed-in user.
176    ///
177    /// `None` for the entropy argument deliberately: a second secret to
178    /// protect the first one would have to be stored beside it, which protects
179    /// nothing. The user account *is* the key.
180    pub fn protect(plain: &[u8]) -> Option<Vec<u8>> {
181        let input = blob(plain);
182        let mut output = CRYPT_INTEGER_BLOB {
183            cbData: 0,
184            pbData: std::ptr::null_mut(),
185        };
186
187        // SAFETY: `input` points at `plain` for the duration of the call, and
188        // every optional parameter is null, which the API documents as
189        // "absent". `output` is written by the call and freed below.
190        let ok = unsafe {
191            CryptProtectData(
192                &input,
193                std::ptr::null(),
194                std::ptr::null_mut(),
195                std::ptr::null_mut(),
196                std::ptr::null_mut(),
197                0,
198                &mut output,
199            )
200        };
201        take(ok, output)
202    }
203
204    pub fn unprotect(sealed: &[u8]) -> Option<Vec<u8>> {
205        let input = blob(sealed);
206        let mut output = CRYPT_INTEGER_BLOB {
207            cbData: 0,
208            pbData: std::ptr::null_mut(),
209        };
210
211        // SAFETY: as above.
212        let ok = unsafe {
213            CryptUnprotectData(
214                &input,
215                std::ptr::null_mut(),
216                std::ptr::null_mut(),
217                std::ptr::null_mut(),
218                std::ptr::null_mut(),
219                0,
220                &mut output,
221            )
222        };
223        take(ok, output)
224    }
225
226    fn blob(bytes: &[u8]) -> CRYPT_INTEGER_BLOB {
227        CRYPT_INTEGER_BLOB {
228            cbData: bytes.len() as u32,
229            pbData: bytes.as_ptr() as *mut u8,
230        }
231    }
232
233    /// Copy what the API allocated, and give it back.
234    /// `ok` is a Win32 `BOOL`: zero is failure, anything else is success.
235    fn take(ok: i32, output: CRYPT_INTEGER_BLOB) -> Option<Vec<u8>> {
236        if ok == 0 || output.pbData.is_null() {
237            return None;
238        }
239        // SAFETY: a successful call leaves `cbData` bytes at `pbData`, which
240        // is a `LocalAlloc` allocation the caller owns.
241        let copied =
242            unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() };
243        unsafe {
244            LocalFree(output.pbData as _);
245        }
246        Some(copied)
247    }
248}
249
250#[cfg(windows)]
251fn protect(plain: &[u8]) -> Result<Vec<u8>, NativeError> {
252    dpapi::protect(plain).ok_or_else(|| {
253        NativeError::new(
254            "secret",
255            "Windows would not encrypt the session key for this user account (DPAPI).",
256        )
257    })
258}
259
260#[cfg(windows)]
261fn unprotect(sealed: &[u8]) -> Option<Vec<u8>> {
262    dpapi::unprotect(sealed)
263}
264
265/// Everywhere else the key is stored as it is, protected by the filesystem.
266///
267/// On Android that is the application sandbox, which is the platform's actual
268/// answer for application-private files. On a developer machine running these
269/// tests it is mode `0600`. Neither is DPAPI, and neither pretends to be.
270#[cfg(not(windows))]
271fn protect(plain: &[u8]) -> Result<Vec<u8>, NativeError> {
272    Ok(plain.to_vec())
273}
274
275#[cfg(not(windows))]
276fn unprotect(sealed: &[u8]) -> Option<Vec<u8>> {
277    Some(sealed.to_vec())
278}