//! The two secrets a dev instance needs, generated once per project rather //! than demanded. `TOKEN_ENCRYPTION_KEY` encrypts rows in the project's //! database, so losing the file loses those rows: it is reused, never //! regenerated over. use std::io::ErrorKind; use std::path::Path; use base64::Engine; pub const FILE_NAME: &str = "secrets"; pub struct Secrets { pub session_secret: String, pub token_encryption_key: String, } // Hand-written so a stray `{:?}` cannot print the key. impl std::fmt::Debug for Secrets { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Secrets").finish_non_exhaustive() } } pub fn load_or_create(state_dir: &Path) -> Result { create_private_dir(state_dir)?; let path = state_dir.join(FILE_NAME); match std::fs::symlink_metadata(&path) { Ok(metadata) => load_existing(&path, &metadata), Err(e) if e.kind() == ErrorKind::NotFound => create(state_dir, &path), Err(e) => Err(format!("could not read {}: {e}", path.display())), } } fn load_existing(path: &Path, metadata: &std::fs::Metadata) -> Result { if !metadata.is_file() { return Err(format!( "{} is not a regular file (a symlink is not followed); replace it with the secrets file itself", path.display() )); } check_private(path, metadata)?; read(path) } /// The keys are written to a temporary file and linked into place, so the /// secrets file is never visible half-written and the link failing with /// `AlreadyExists` settles a race between two first runs: the loser reads /// the winner's keys instead of replacing them. fn create(state_dir: &Path, path: &Path) -> Result { let secrets = Secrets { session_secret: base64::engine::general_purpose::STANDARD .encode(rand::random::<[u8; 48]>()), token_encryption_key: base64::engine::general_purpose::STANDARD .encode(rand::random::<[u8; 32]>()), }; let tmp = state_dir.join(format!( ".{FILE_NAME}.{}.{:016x}.tmp", std::process::id(), rand::random::() )); write_private( &tmp, &format!( "# Generated by `happyview dev` for this project's local instance.\n\ # TOKEN_ENCRYPTION_KEY encrypts data in .happyview/happyview.db; keep the\n\ # two together, and keep both out of version control.\n\ SESSION_SECRET={}\nTOKEN_ENCRYPTION_KEY={}\n", secrets.session_secret, secrets.token_encryption_key ), )?; let linked = std::fs::hard_link(&tmp, path); let _ = std::fs::remove_file(&tmp); match linked { Ok(()) => { sync_dir(state_dir)?; Ok(secrets) } Err(e) if e.kind() == ErrorKind::AlreadyExists => { let metadata = std::fs::symlink_metadata(path) .map_err(|e| format!("could not read {}: {e}", path.display()))?; load_existing(path, &metadata) } Err(e) => Err(format!("could not create {}: {e}", path.display())), } } #[cfg(unix)] fn sync_dir(dir: &Path) -> Result<(), String> { std::fs::File::open(dir) .and_then(|d| d.sync_all()) .map_err(|e| format!("could not sync {}: {e}", dir.display())) } #[cfg(not(unix))] fn sync_dir(_dir: &Path) -> Result<(), String> { Ok(()) } /// Deleting regenerates the keys, which orphans everything encrypted under /// the old ones, so the sentence says so rather than only what is wrong. fn unusable(path: &Path, problem: &str) -> String { format!( "{}: {problem}; deleting the file generates new keys, after which sessions and tokens stored under the old ones stop working", path.display() ) } /// Parsed by hand rather than through dotenv: its parse errors quote the /// offending line, and a line in this file may be a secret. fn read(path: &Path) -> Result { let text = std::fs::read_to_string(path) .map_err(|e| format!("could not read {}: {e}", path.display()))?; let mut session_secret = None; let mut token_encryption_key = None; for (index, line) in text.lines().enumerate() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } let Some((key, value)) = line.split_once('=') else { return Err(unusable( path, &format!("line {} is not KEY=VALUE", index + 1), )); }; match key.trim() { "SESSION_SECRET" => session_secret = Some(value.trim().to_string()), "TOKEN_ENCRYPTION_KEY" => token_encryption_key = Some(value.trim().to_string()), _ => {} } } let missing = |key: &str| unusable(path, &format!("it has no {key}")); let session_secret = session_secret.ok_or_else(|| missing("SESSION_SECRET"))?; let token_encryption_key = token_encryption_key.ok_or_else(|| missing("TOKEN_ENCRYPTION_KEY"))?; crate::config::validate_session_secret(&session_secret).map_err(|e| unusable(path, &e))?; match crate::config::parse_token_encryption_key(Some(&token_encryption_key)) { Ok(Some(_)) => {} Ok(None) => return Err(missing("TOKEN_ENCRYPTION_KEY")), Err(e) => return Err(unusable(path, &e)), } Ok(Secrets { session_secret, token_encryption_key, }) } /// The directory is HappyView's own state and holds the database, so one /// that arrives loose (a project scaffolder creates it before dev runs) is /// tightened rather than refused; one owned by someone else is not ours to /// change. A symlink is refused outright: tightening would chmod its /// target, and the secrets would land wherever it points. fn create_private_dir(dir: &Path) -> Result<(), String> { match std::fs::symlink_metadata(dir) { Ok(metadata) if metadata.file_type().is_symlink() => { return Err(format!( "{} is a symlink; replace it with a real directory", dir.display() )); } Ok(metadata) if !metadata.is_dir() => { return Err(format!("{} is not a directory", dir.display())); } Ok(_) => {} Err(e) if e.kind() == ErrorKind::NotFound => {} Err(e) => return Err(format!("could not read {}: {e}", dir.display())), } let mut builder = std::fs::DirBuilder::new(); builder.recursive(true); #[cfg(unix)] { use std::os::unix::fs::DirBuilderExt; builder.mode(0o700); } builder .create(dir) .map_err(|e| format!("could not create {}: {e}", dir.display()))?; #[cfg(unix)] { use crate::fs_private::{NotPrivate, private_to_this_user}; use std::os::unix::fs::PermissionsExt; let metadata = std::fs::symlink_metadata(dir) .map_err(|e| format!("could not read {}: {e}", dir.display()))?; match private_to_this_user(&metadata) { Ok(()) => {} Err(NotPrivate::OtherOwner { uid, euid }) => { return Err(format!( "{} is owned by uid {uid} rather than this process's uid {euid}", dir.display() )); } Err(NotPrivate::OpenMode { .. }) => { std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) .map_err(|e| format!("could not restrict {}: {e}", dir.display()))?; } } } Ok(()) } fn write_private(path: &Path, contents: &str) -> Result<(), String> { use std::io::Write; let mut options = std::fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } let mut file = options .open(path) .map_err(|e| format!("could not create {}: {e}", path.display()))?; file.write_all(contents.as_bytes()) .and_then(|()| file.sync_all()) .map_err(|e| format!("could not write {}: {e}", path.display())) } /// Windows has no mode bits to check. #[cfg(unix)] fn check_private(path: &Path, metadata: &std::fs::Metadata) -> Result<(), String> { use crate::fs_private::{NotPrivate, private_to_this_user}; private_to_this_user(metadata).map_err(|reason| match reason { NotPrivate::OtherOwner { uid, euid } => format!( "{} is owned by uid {uid} rather than this process's uid {euid}", path.display() ), NotPrivate::OpenMode { mode } => format!( "{} has mode {mode:o}, which lets other users read its secrets; run `chmod 600 {}`", path.display(), path.display() ), }) } #[cfg(not(unix))] fn check_private(_path: &Path, _metadata: &std::fs::Metadata) -> Result<(), String> { Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn creates_once_and_reuses() { let dir = tempfile::tempdir().unwrap(); let state = dir.path().join(".happyview"); let first = load_or_create(&state).unwrap(); let second = load_or_create(&state).unwrap(); assert_eq!(first.session_secret, second.session_secret); assert_eq!(first.token_encryption_key, second.token_encryption_key); assert!(crate::config::validate_session_secret(&first.session_secret).is_ok()); assert!(matches!( crate::config::parse_token_encryption_key(Some(&first.token_encryption_key)), Ok(Some(_)) )); } #[cfg(unix)] #[test] fn the_file_and_its_directory_are_private() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let state = dir.path().join(".happyview"); load_or_create(&state).unwrap(); let file_mode = std::fs::metadata(state.join(FILE_NAME)) .unwrap() .permissions() .mode() & 0o777; let dir_mode = std::fs::metadata(&state).unwrap().permissions().mode() & 0o777; assert_eq!(file_mode, 0o600); assert_eq!(dir_mode, 0o700); } #[cfg(unix)] #[test] fn tightens_a_state_directory_hpy_created_open() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let state = dir.path().join(".happyview"); std::fs::create_dir(&state).unwrap(); std::fs::set_permissions(&state, std::fs::Permissions::from_mode(0o755)).unwrap(); load_or_create(&state).unwrap(); let mode = std::fs::metadata(&state).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o700); } #[cfg(unix)] #[test] fn refuses_a_group_readable_file() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let state = dir.path().join(".happyview"); load_or_create(&state).unwrap(); let file = state.join(FILE_NAME); std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap(); let err = load_or_create(&state).unwrap_err(); assert!(err.contains("644"), "{err}"); assert!(err.contains("chmod 600"), "{err}"); assert!(err.contains(&file.display().to_string()), "{err}"); } #[test] fn refuses_a_file_missing_a_key() { let dir = tempfile::tempdir().unwrap(); let state = dir.path().join(".happyview"); std::fs::create_dir_all(&state).unwrap(); write_private( &state.join(FILE_NAME), &format!("SESSION_SECRET={}\n", "x".repeat(64)), ) .unwrap(); let err = load_or_create(&state).unwrap_err(); assert!(err.contains("TOKEN_ENCRYPTION_KEY"), "{err}"); } #[test] fn refuses_a_short_session_secret() { let dir = tempfile::tempdir().unwrap(); let state = dir.path().join(".happyview"); std::fs::create_dir_all(&state).unwrap(); let key = base64::engine::general_purpose::STANDARD.encode([7u8; 32]); write_private( &state.join(FILE_NAME), &format!("SESSION_SECRET=short\nTOKEN_ENCRYPTION_KEY={key}\n"), ) .unwrap(); let err = load_or_create(&state).unwrap_err(); assert!(err.contains("SESSION_SECRET"), "{err}"); } #[test] fn a_broken_file_names_the_remedy_and_is_never_replaced() { let dir = tempfile::tempdir().unwrap(); let state = dir.path().join(".happyview"); std::fs::create_dir_all(&state).unwrap(); let file = state.join(FILE_NAME); write_private(&file, "SESSION_SECRET=short\n").unwrap(); let err = load_or_create(&state).unwrap_err(); assert!(err.contains(&file.display().to_string()), "{err}"); assert!( err.contains("deleting the file generates new keys"), "{err}" ); assert!(err.contains("stop working"), "{err}"); assert_eq!( std::fs::read_to_string(&file).unwrap(), "SESSION_SECRET=short\n" ); } #[test] fn a_parse_error_names_the_line_and_never_quotes_it() { let dir = tempfile::tempdir().unwrap(); let state = dir.path().join(".happyview"); std::fs::create_dir_all(&state).unwrap(); write_private( &state.join(FILE_NAME), "# note\nhunter2-not-a-pair-aaaaaaaaaaaaaaaaaaaa\n", ) .unwrap(); let err = load_or_create(&state).unwrap_err(); assert!(err.contains("line 2"), "{err}"); assert!(!err.contains("hunter2"), "{err}"); } #[test] fn concurrent_first_runs_agree_on_one_set_of_keys() { let dir = tempfile::tempdir().unwrap(); let state = dir.path().join(".happyview"); let barrier = std::sync::Arc::new(std::sync::Barrier::new(8)); let handles: Vec<_> = (0..8) .map(|_| { let state = state.clone(); let barrier = barrier.clone(); std::thread::spawn(move || { barrier.wait(); load_or_create(&state).unwrap() }) }) .collect(); let all: Vec = handles.into_iter().map(|h| h.join().unwrap()).collect(); for s in &all { assert_eq!(s.session_secret, all[0].session_secret); assert_eq!(s.token_encryption_key, all[0].token_encryption_key); } let leftovers: Vec<_> = std::fs::read_dir(&state) .unwrap() .map(|e| e.unwrap().file_name()) .collect(); assert_eq!(leftovers, vec![std::ffi::OsString::from(FILE_NAME)]); } #[test] fn losing_the_link_reads_the_winners_keys() { let dir = tempfile::tempdir().unwrap(); let state = dir.path().join(".happyview"); let winner = load_or_create(&state).unwrap(); // `create` is what a process that saw no file runs; the file has // appeared since. let loser = create(&state, &state.join(FILE_NAME)).unwrap(); assert_eq!(winner.session_secret, loser.session_secret); assert_eq!(winner.token_encryption_key, loser.token_encryption_key); } #[cfg(unix)] #[test] fn refuses_a_symlinked_state_directory_without_touching_its_target() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let target = dir.path().join("elsewhere"); std::fs::create_dir(&target).unwrap(); std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap(); let state = dir.path().join(".happyview"); std::os::unix::fs::symlink(&target, &state).unwrap(); let err = load_or_create(&state).unwrap_err(); assert!(err.contains(&state.display().to_string()), "{err}"); assert!(err.contains("symlink"), "{err}"); let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o755); assert_eq!(std::fs::read_dir(&target).unwrap().count(), 0); } #[cfg(unix)] #[test] fn refuses_a_symlinked_secrets_file_even_when_dangling() { let dir = tempfile::tempdir().unwrap(); let state = dir.path().join(".happyview"); std::fs::create_dir(&state).unwrap(); let file = state.join(FILE_NAME); let real = dir.path().join("real-secrets"); std::os::unix::fs::symlink(&real, &file).unwrap(); let dangling = load_or_create(&state).unwrap_err(); assert!(dangling.contains(&file.display().to_string()), "{dangling}"); assert!(dangling.contains("symlink"), "{dangling}"); assert!(!real.exists()); let good = load_or_create(&dir.path().join("other")).unwrap(); write_private( &real, &format!( "SESSION_SECRET={}\nTOKEN_ENCRYPTION_KEY={}\n", good.session_secret, good.token_encryption_key ), ) .unwrap(); let live = load_or_create(&state).unwrap_err(); assert!(live.contains("symlink"), "{live}"); } }