Rename all crate directories, package names, binary names, proto package/module paths, ALPN strings, env var prefixes, config filenames, mDNS service names, and plugin ABI symbols from quicproquo/qpq to quicprochat/qpc.
180 lines
5.8 KiB
Rust
180 lines
5.8 KiB
Rust
//! Cached session token stored next to the state file.
|
|
//!
|
|
//! File format (no password): two lines — username and hex-encoded session token.
|
|
//! File format (with password): QPCE-encrypted version of the above.
|
|
//! The token has a server-side 24h TTL; no client-side expiry tracking.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::Context;
|
|
|
|
use super::state::{decrypt_state, encrypt_state, is_encrypted_state};
|
|
|
|
pub struct CachedSession {
|
|
pub username: String,
|
|
pub token_hex: String,
|
|
}
|
|
|
|
/// Derive the session cache path: `{state_path}.session`.
|
|
fn session_cache_path(state_path: &Path) -> PathBuf {
|
|
state_path.with_extension("session")
|
|
}
|
|
|
|
/// Parse the two-line format (username + token_hex) from plaintext bytes.
|
|
fn parse_session_lines(text: &str) -> Option<CachedSession> {
|
|
let mut lines = text.lines();
|
|
let username = lines.next()?.trim().to_string();
|
|
let token_hex = lines.next()?.trim().to_string();
|
|
if username.is_empty() || token_hex.is_empty() {
|
|
return None;
|
|
}
|
|
if hex::decode(&token_hex).is_err() {
|
|
return None;
|
|
}
|
|
Some(CachedSession { username, token_hex })
|
|
}
|
|
|
|
/// Load a cached session token. Returns None if file is missing or malformed.
|
|
/// Decrypts if the file is QPCE-encrypted (requires `password`).
|
|
pub fn load_cached_session(state_path: &Path, password: Option<&str>) -> Option<CachedSession> {
|
|
let path = session_cache_path(state_path);
|
|
let raw = std::fs::read(&path).ok()?;
|
|
|
|
if is_encrypted_state(&raw) {
|
|
let pw = password?;
|
|
let plaintext = decrypt_state(pw, &raw).ok()?;
|
|
let text = String::from_utf8(plaintext).ok()?;
|
|
parse_session_lines(&text)
|
|
} else {
|
|
let text = String::from_utf8(raw).ok()?;
|
|
parse_session_lines(&text)
|
|
}
|
|
}
|
|
|
|
/// Save a session token to the cache file (mode 0o600 on Unix).
|
|
/// Encrypts with QPCE if `password` is provided.
|
|
pub fn save_cached_session(
|
|
state_path: &Path,
|
|
username: &str,
|
|
token_hex: &str,
|
|
password: Option<&str>,
|
|
) -> anyhow::Result<()> {
|
|
let path = session_cache_path(state_path);
|
|
let contents = format!("{username}\n{token_hex}\n");
|
|
|
|
let bytes = match password {
|
|
Some(pw) => encrypt_state(pw, contents.as_bytes())?,
|
|
None => {
|
|
#[cfg(not(unix))]
|
|
tracing::warn!(
|
|
"storing session token as plaintext (no password set); \
|
|
file permissions cannot be restricted on this platform"
|
|
);
|
|
contents.into_bytes()
|
|
}
|
|
};
|
|
|
|
std::fs::write(&path, bytes).with_context(|| format!("write session cache {path:?}"))?;
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let perms = std::fs::Permissions::from_mode(0o600);
|
|
std::fs::set_permissions(&path, perms).ok();
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove the cached session file.
|
|
pub fn clear_cached_session(state_path: &Path) {
|
|
let path = session_cache_path(state_path);
|
|
std::fs::remove_file(&path).ok();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn plaintext_round_trip() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let state_path = dir.path().join("state.bin");
|
|
|
|
let token = hex::encode(b"session-token-bytes");
|
|
save_cached_session(&state_path, "alice", &token, None).unwrap();
|
|
let loaded = load_cached_session(&state_path, None).unwrap();
|
|
assert_eq!(loaded.username, "alice");
|
|
assert_eq!(loaded.token_hex, token);
|
|
}
|
|
|
|
#[test]
|
|
fn encrypted_round_trip() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let state_path = dir.path().join("state.bin");
|
|
let password = "strong-password";
|
|
|
|
let token = hex::encode(b"encrypted-token");
|
|
save_cached_session(&state_path, "bob", &token, Some(password)).unwrap();
|
|
|
|
// Encrypted file should start with QPCE magic
|
|
let raw = std::fs::read(session_cache_path(&state_path)).unwrap();
|
|
assert_eq!(&raw[..4], b"QPCE");
|
|
|
|
let loaded = load_cached_session(&state_path, Some(password)).unwrap();
|
|
assert_eq!(loaded.username, "bob");
|
|
assert_eq!(loaded.token_hex, token);
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_password_returns_none() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let state_path = dir.path().join("state.bin");
|
|
|
|
let token = hex::encode(b"secret-token");
|
|
save_cached_session(&state_path, "carol", &token, Some("correct")).unwrap();
|
|
let result = load_cached_session(&state_path, Some("wrong"));
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn missing_file_returns_none() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let state_path = dir.path().join("nonexistent.bin");
|
|
assert!(load_cached_session(&state_path, None).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn clear_removes_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let state_path = dir.path().join("state.bin");
|
|
|
|
let token = hex::encode(b"to-be-deleted");
|
|
save_cached_session(&state_path, "dave", &token, None).unwrap();
|
|
assert!(session_cache_path(&state_path).exists());
|
|
|
|
clear_cached_session(&state_path);
|
|
assert!(!session_cache_path(&state_path).exists());
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_content_returns_none() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let state_path = dir.path().join("state.bin");
|
|
let cache_path = session_cache_path(&state_path);
|
|
|
|
// Not valid hex on second line
|
|
std::fs::write(&cache_path, "alice\nnot-hex-data\n").unwrap();
|
|
assert!(load_cached_session(&state_path, None).is_none());
|
|
|
|
// Only one line
|
|
std::fs::write(&cache_path, "alice\n").unwrap();
|
|
assert!(load_cached_session(&state_path, None).is_none());
|
|
|
|
// Empty file
|
|
std::fs::write(&cache_path, "").unwrap();
|
|
assert!(load_cached_session(&state_path, None).is_none());
|
|
}
|
|
}
|