Something went wrong. Try again.
A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453//! 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<Secrets, String> { 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<Secrets, String> { 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<Secrets, String> { 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::<u64>() )); 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<Secrets, String> { 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<Secrets> = 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}"); }}