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