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}