Built on grom's explicit say-so, after a round where they were withheld and the reason recorded. Both halves of that decision stay in the doc, because the reasoning is what makes the second half a decision rather than a drift. ReadCustomFile/ImportFile (one operation, two upstream names), WriteCustomFile/ExportFile, CustomFileExists, ExecuteCustomLuaFile, StartItemExport, EncryptPassword, EncryptedServerLogin. **The fence is app-side and it is not a `..` scan.** `local_state::custom_file_path` admits one relative name whose every component is an ordinary name, inside `benilla-config/CustomFiles/`, and refuses a leaf that is already a symlink. Each escape a string check misses has its own rule and its own test row: an absolute path (`join` *replaces* the base rather than appending), a Windows prefix, a `..` nested inside a longer path, and a planted symlink — the one no amount of sanitising the string will catch. A subdirectory rather than the config root, because a verb that could create `config.toml` or `autologin.toml` by name would let an addon rewrite the client's own settings; that is a different power from "keep a file of your own". The VM side holds three closures and no paths, so no spelling of a `filename` reaches outside that directory. **The credential pair's deviation is forced, not chosen.** Both are registered by nampower under "Registering *Glue* Lua functions" and this client has no glue Lua VM at all — login, character-select and character-create are Rust. `EncryptPassword` is pure and works the same anywhere. `EncryptedServerLogin` cannot mean "submit the login form" in the only VM there is, so it stores the credential for the next login: `enabled` on, the plaintext field cleared, the blob kept, and `AutoLogin::effective_password` reads it back at the refill. What that protects is an `autologin.toml` or a SavedVariables file read by someone else or pasted into a forum: the blob is authenticated and keyed on 32 random bytes in a different file, mode 0600. What it does not protect is anything running as this user, which can read the key as easily as the blob — there is no user secret in the chain and there cannot be, because the point is logging in without typing one. Both halves are at the module. Encrypt-then-MAC with HMAC-SHA1 as the PRF for both halves, from the two primitives the tree already had (sha1 moves from a dev-dependency to a real one). The HMAC is tested against RFC 2202's own vectors, because a hand-written one still round-trips with itself when it is wrong — and a first draft of the key reader did exactly that kind of silent wrong: it returned raw bytes on creation and hex text on read, and the caller guessed between them with "32 bytes and not all hex digits". It guessed wrong the moment a random key came out all-hex. Returning one thing is the fix, and the note is at the function. **The existing plaintext path is untouched.** Storing the password in plaintext was grom's own explicit choice when the alternatives were a keyring or nothing, so this is additive and nothing migrates by itself. The machinery to make the Remember Password tick-box write a blob instead is now all here — one line — and that is his call. Base64 was missing from the whole tree; it is in `benilla-ui`'s `script/encoding.rs` with RFC 4648's own vectors, and `C_EncodingUtil.EncodeBase64` will want the same one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
383 lines
16 KiB
Rust
383 lines
16 KiB
Rust
//! nampower's `EncryptPassword`/`EncryptedServerLogin` pair: a password kept on disk as a blob
|
|
//! rather than as itself.
|
|
//!
|
|
//! **What this protects, and what it does not.** The threat it answers is the one that makes the
|
|
//! pair worth having at all: an addon's SavedVariables file, or a config file, read by someone
|
|
//! else or pasted into a forum post. The blob alone is useless — it is authenticated and keyed on
|
|
//! 32 random bytes in `benilla-config/credential.key`, mode `0600`, which is a *different file*.
|
|
//! It is **not** protection against anything running as this user: that process can read the key
|
|
//! file as easily as the blob. There is no user secret in the chain and there cannot be, because
|
|
//! the point of the pair is logging in without typing one.
|
|
//!
|
|
//! **Not wire-compatible with nampower's own blobs**, and it cannot be: that DLL's scheme is not
|
|
//! documented and not in its string pool, and a blob is per-machine configuration anyway — a key
|
|
//! file generated here would not decrypt one made there whatever the cipher. So a
|
|
//! `NampowerSettings` blob copied from a real install will not decrypt here, and this module's
|
|
//! own blobs will not decrypt there. [`decrypt`] answers `None` for one, which the verb reports
|
|
//! as nil rather than as a wrong password.
|
|
//!
|
|
//! **The construction**, from the two primitives this tree already has (`sha1` and `rand`):
|
|
//! encrypt-then-MAC with HMAC-SHA1 as the PRF for both halves. `nonce(16) || ciphertext ||
|
|
//! tag(20)`, keystream `HMAC(key, nonce || counter_be32)` in 20-byte blocks, tag
|
|
//! `HMAC(key, nonce || ciphertext)`. HMAC-SHA1 is not broken as a PRF or a MAC — SHA-1's
|
|
//! collision weakness is a different property from either — and rolling a cipher is not what is
|
|
//! happening here: this is a standard construction with standard parts.
|
|
|
|
use bevy::prelude::warn;
|
|
use sha1::{Digest, Sha1};
|
|
|
|
use crate::local_state;
|
|
|
|
/// HMAC-SHA1's block and output sizes.
|
|
const BLOCK: usize = 64;
|
|
const MAC: usize = 20;
|
|
/// The nonce, long enough that a fresh one per call never repeats in practice.
|
|
const NONCE: usize = 16;
|
|
/// The key file's size. 32 bytes is more entropy than SHA-1's output can distinguish, which is
|
|
/// the point: the key is not the weak link.
|
|
const KEY: usize = 32;
|
|
|
|
/// HMAC-SHA1 (RFC 2104), written out because the tree has `sha1` and no `hmac`.
|
|
fn hmac(key: &[u8], msg: &[u8]) -> [u8; MAC] {
|
|
// A key longer than the block is hashed first; a shorter one is zero-padded.
|
|
let mut k = [0u8; BLOCK];
|
|
if key.len() > BLOCK {
|
|
k[..MAC].copy_from_slice(&Sha1::digest(key));
|
|
} else {
|
|
k[..key.len()].copy_from_slice(key);
|
|
}
|
|
let mut inner = Sha1::new();
|
|
inner.update(k.iter().map(|b| b ^ 0x36).collect::<Vec<u8>>());
|
|
inner.update(msg);
|
|
let inner = inner.finalize();
|
|
let mut outer = Sha1::new();
|
|
outer.update(k.iter().map(|b| b ^ 0x5c).collect::<Vec<u8>>());
|
|
outer.update(inner);
|
|
outer.finalize().into()
|
|
}
|
|
|
|
/// XOR `data` with the keystream for `nonce`, in place. The same call encrypts and decrypts.
|
|
fn apply_keystream(key: &[u8], nonce: &[u8], data: &mut [u8]) {
|
|
for (block, chunk) in data.chunks_mut(MAC).enumerate() {
|
|
let mut msg = Vec::with_capacity(nonce.len() + 4);
|
|
msg.extend_from_slice(nonce);
|
|
// Big-endian, so the counter's own bytes are stable across machines; it never matters for
|
|
// correctness here (one machine, one key) and it costs nothing to pin.
|
|
msg.extend_from_slice(&u32::try_from(block).unwrap_or(u32::MAX).to_be_bytes());
|
|
let ks = hmac(key, &msg);
|
|
for (b, k) in chunk.iter_mut().zip(ks) {
|
|
*b ^= k;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The machine-local key as its 32 raw bytes, created on first use.
|
|
///
|
|
/// Stored as **hex text**, not raw bytes, for two reasons: the file is inspectable, and a stray
|
|
/// newline from an editor cannot silently change the key's length. Created with
|
|
/// [`local_state::write_atomic_private`], so it is `0600` **before any bytes reach it** — a key
|
|
/// briefly readable under the default umask is a key that was readable.
|
|
///
|
|
/// A file that does not hold exactly `KEY` bytes of hex is **replaced**, not used: a truncated or
|
|
/// edited one would still round-trip with itself and would quietly weaken every blob made with
|
|
/// it. Replacing it orphans the old blobs, which `decrypt` reports as nil — the honest outcome,
|
|
/// and the warning says so.
|
|
///
|
|
/// One function rather than a create/read pair, because the first shape of this had two: `key`
|
|
/// returned raw bytes on creation and hex text on read, and a caller had to guess which. It
|
|
/// guessed with "32 bytes and not all hex digits", and the round trip failed the moment a random
|
|
/// key came out all-hex. Returning one thing is the fix.
|
|
fn key_bytes() -> Option<Vec<u8>> {
|
|
use rand::RngCore as _;
|
|
|
|
let decode = |text: &str| -> Option<Vec<u8>> {
|
|
let text = text.trim();
|
|
(text.len() == KEY * 2)
|
|
.then(|| {
|
|
(0..KEY)
|
|
.map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).ok())
|
|
.collect::<Option<Vec<u8>>>()
|
|
})
|
|
.flatten()
|
|
};
|
|
|
|
let path = local_state::home()?.join("credential.key");
|
|
match std::fs::read_to_string(&path) {
|
|
Ok(text) => {
|
|
if let Some(bytes) = decode(&text) {
|
|
return Some(bytes);
|
|
}
|
|
warn!(
|
|
"credential: {} does not hold {KEY} bytes of hex — replacing it (blobs made with \
|
|
the old key will no longer decrypt)",
|
|
path.display()
|
|
);
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(e) => {
|
|
warn!("credential: cannot read {}: {e}", path.display());
|
|
return None;
|
|
}
|
|
}
|
|
let mut fresh = vec![0u8; KEY];
|
|
rand::thread_rng().fill_bytes(&mut fresh);
|
|
let hex: String = fresh.iter().map(|b| format!("{b:02x}")).collect();
|
|
if let Err(e) = local_state::write_atomic_private(&path, &hex) {
|
|
warn!("credential: cannot create {}: {e}", path.display());
|
|
return None;
|
|
}
|
|
Some(fresh)
|
|
}
|
|
|
|
/// `EncryptPassword(password)`: the base64 blob, or `None` with no config home (a capture).
|
|
pub(crate) fn encrypt(password: &str) -> Option<String> {
|
|
use rand::RngCore as _;
|
|
|
|
let key = key_bytes()?;
|
|
let mut nonce = [0u8; NONCE];
|
|
rand::thread_rng().fill_bytes(&mut nonce);
|
|
let mut body = password.as_bytes().to_vec();
|
|
apply_keystream(&key, &nonce, &mut body);
|
|
// Encrypt-then-MAC: the tag covers the ciphertext and the nonce, so neither can be changed
|
|
// without the tag failing. The other order authenticates the plaintext and leaves the
|
|
// ciphertext malleable, which is the classic way to get this wrong.
|
|
let mut tagged = nonce.to_vec();
|
|
tagged.extend_from_slice(&body);
|
|
let tag = hmac(&key, &tagged);
|
|
tagged.extend_from_slice(&tag);
|
|
Some(benilla_ui::script::encoding::base64_encode(&tagged))
|
|
}
|
|
|
|
/// The inverse. `None` for a malformed blob, a blob from another machine, or a tampered one —
|
|
/// all three are "this is not a credential I made", which is the only thing a caller can act on.
|
|
pub(crate) fn decrypt(blob: &str) -> Option<String> {
|
|
let key = key_bytes()?;
|
|
let raw = benilla_ui::script::encoding::base64_decode(blob)?;
|
|
if raw.len() < NONCE + MAC {
|
|
return None;
|
|
}
|
|
let (head, tag) = raw.split_at(raw.len() - MAC);
|
|
// Checked before a single byte is decrypted, so a tampered blob never produces a plaintext at
|
|
// all — not even one that is then discarded.
|
|
if hmac(&key, head) != tag {
|
|
return None;
|
|
}
|
|
let (nonce, body) = head.split_at(NONCE);
|
|
let mut body = body.to_vec();
|
|
apply_keystream(&key, nonce, &mut body);
|
|
String::from_utf8(body).ok()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A scoped config home, so the key file is this test's own.
|
|
fn home(
|
|
tag: &str,
|
|
) -> (
|
|
std::path::PathBuf,
|
|
local_state::test_env::EnvGuard,
|
|
local_state::test_env::EnvGuard,
|
|
) {
|
|
let tmp = std::env::temp_dir().join(format!("benilla-cred-{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
std::fs::create_dir_all(&tmp).expect("temp dir");
|
|
let g = local_state::test_env::EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap());
|
|
let c = local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
|
|
(tmp, g, c)
|
|
}
|
|
|
|
/// HMAC-SHA1 against RFC 2202's own vectors, because a hand-written HMAC is exactly the thing
|
|
/// that is plausibly wrong and silently so: a broken one still round-trips with itself.
|
|
#[test]
|
|
fn hmac_sha1_matches_the_rfc_vectors() {
|
|
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
|
|
// Case 1.
|
|
assert_eq!(
|
|
hex(&hmac(&[0x0b; 20], b"Hi There")),
|
|
"b617318655057264e28bc0b6fb378c8ef146be00"
|
|
);
|
|
// Case 2, a short ASCII key.
|
|
assert_eq!(
|
|
hex(&hmac(b"Jefe", b"what do ya want for nothing?")),
|
|
"effcdf6ae5eb2fa2d27416d5f184df9c259a7c79"
|
|
);
|
|
// Case 3, a key and message of 0xaa/0xdd bytes.
|
|
assert_eq!(
|
|
hex(&hmac(&[0xaa; 20], &[0xdd; 50])),
|
|
"125d7342b9ac11cd91a39af48aa17b4f63f175d3"
|
|
);
|
|
// Case 6: a key LONGER than the block, which is the branch that hashes it first.
|
|
assert_eq!(
|
|
hex(&hmac(
|
|
&[0xaa; 80],
|
|
b"Test Using Larger Than Block-Size Key - Hash Key First"
|
|
)),
|
|
"aa4ae5e15272d00e95705637ce8a3b55ed402112"
|
|
);
|
|
}
|
|
|
|
/// The round trip, and the three things that must not decrypt.
|
|
#[test]
|
|
fn a_password_round_trips_and_a_tampered_blob_does_not() {
|
|
let _lock = local_state::test_env::ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
let (tmp, _g, _c) = home("rt");
|
|
|
|
for secret in [
|
|
"hunter2",
|
|
"",
|
|
"a password with spaces and ünïcödé",
|
|
&"x".repeat(200),
|
|
] {
|
|
let blob = encrypt(secret).expect("a config home");
|
|
assert_ne!(blob, secret, "the blob is not the password");
|
|
assert!(!blob.contains("hunter2"), "and does not contain it: {blob}");
|
|
assert_eq!(decrypt(&blob).as_deref(), Some(secret));
|
|
}
|
|
|
|
// Two encryptions of one password differ: the nonce is fresh, so a blob does not leak
|
|
// "this is the same password as that one".
|
|
let (a, b) = (encrypt("same").unwrap(), encrypt("same").unwrap());
|
|
assert_ne!(a, b);
|
|
assert_eq!(decrypt(&a), decrypt(&b));
|
|
|
|
// Tampering: a flipped ciphertext byte, a truncated blob, and something that is not
|
|
// base64 at all. Each is None — never a wrong plaintext.
|
|
let good = encrypt("hunter2").unwrap();
|
|
let mut raw = benilla_ui::script::encoding::base64_decode(&good).unwrap();
|
|
raw[NONCE] ^= 1;
|
|
assert_eq!(
|
|
decrypt(&benilla_ui::script::encoding::base64_encode(&raw)),
|
|
None,
|
|
"a flipped ciphertext byte fails the tag"
|
|
);
|
|
assert_eq!(decrypt(&good[..good.len() - 8]), None, "truncated");
|
|
assert_eq!(decrypt("not base64 at all!"), None);
|
|
assert_eq!(decrypt(""), None);
|
|
|
|
// The key file exists, holds hex of the right length, and is 0600.
|
|
let key_path = tmp.join("credential.key");
|
|
let text = std::fs::read_to_string(&key_path).expect("the key file");
|
|
assert_eq!(text.trim().len(), KEY * 2);
|
|
assert!(text.trim().bytes().all(|b| b.is_ascii_hexdigit()));
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
let mode = std::fs::metadata(&key_path).unwrap().permissions().mode();
|
|
assert_eq!(
|
|
mode & 0o777,
|
|
0o600,
|
|
"the key must not be readable by others"
|
|
);
|
|
}
|
|
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
}
|
|
|
|
/// A blob made under one key does not decrypt under another — which is both the per-machine
|
|
/// property and the reason a nampower blob from a real install answers nil here.
|
|
#[test]
|
|
fn a_blob_does_not_decrypt_under_a_different_key() {
|
|
let _lock = local_state::test_env::ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
let blob = {
|
|
let (tmp, _g, _c) = home("k1");
|
|
let b = encrypt("hunter2").unwrap();
|
|
assert_eq!(decrypt(&b).as_deref(), Some("hunter2"));
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
b
|
|
};
|
|
let (tmp, _g, _c) = home("k2");
|
|
assert_eq!(
|
|
decrypt(&blob),
|
|
None,
|
|
"a fresh key file means a fresh key, and the tag says so"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
}
|
|
|
|
/// A key file of the wrong length is replaced, not used: a truncated one would still
|
|
/// round-trip and would quietly weaken every blob made with it.
|
|
#[test]
|
|
fn a_short_key_file_is_replaced() {
|
|
let _lock = local_state::test_env::ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
let (tmp, _g, _c) = home("short");
|
|
let key_path = tmp.join("credential.key");
|
|
std::fs::write(&key_path, "abcd").unwrap();
|
|
let blob = encrypt("hunter2").expect("a replaced key still encrypts");
|
|
assert_eq!(decrypt(&blob).as_deref(), Some("hunter2"));
|
|
assert_eq!(
|
|
std::fs::read_to_string(&key_path).unwrap().trim().len(),
|
|
KEY * 2
|
|
);
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod store_tests {
|
|
use super::*;
|
|
use crate::autologin::AutoLogin;
|
|
|
|
/// `EncryptedServerLogin`'s end of the pair: the blob goes on disk, the plaintext does not,
|
|
/// and the login screen's refill reads the password back.
|
|
///
|
|
/// This is the property the whole pair exists for — `autologin.toml` holding a blob instead
|
|
/// of a password — so it is asserted against the file's own bytes rather than against the
|
|
/// struct.
|
|
#[test]
|
|
fn storing_a_blob_keeps_the_password_out_of_the_config_file() {
|
|
let _lock = local_state::test_env::ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
let tmp = std::env::temp_dir().join(format!("benilla-credstore-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
std::fs::create_dir_all(&tmp).expect("temp dir");
|
|
let _g = local_state::test_env::EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap());
|
|
let _c = local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
|
|
|
|
let blob = encrypt("hunter2").expect("a config home");
|
|
let mut auto = AutoLogin::default();
|
|
auto.store_encrypted("MYACCOUNT", &blob);
|
|
|
|
// The refill reads the password back through the one reader the screen uses.
|
|
assert_eq!(auto.effective_password(), "hunter2");
|
|
assert!(auto.password.is_empty(), "the plaintext field is cleared");
|
|
assert!(auto.enabled, "and the credential is armed");
|
|
|
|
// The file on disk holds the blob and not the password. This is the point.
|
|
let text = std::fs::read_to_string(tmp.join("autologin.toml")).expect("the store");
|
|
assert!(
|
|
!text.contains("hunter2"),
|
|
"autologin.toml must not contain the password:\n{text}"
|
|
);
|
|
assert!(text.contains(&blob), "it holds the blob instead");
|
|
|
|
// A blob that does not decrypt refills **empty**, not the stale plaintext: the box being
|
|
// empty is the signal that something needs retyping.
|
|
let mut stale = AutoLogin {
|
|
enabled: true,
|
|
password: "old-plaintext".into(),
|
|
password_encrypted: Some("bm90IGEgYmxvYg==".into()),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(
|
|
stale.effective_password(),
|
|
"",
|
|
"a bad blob does not fall back to a password the player has since replaced"
|
|
);
|
|
// With no blob at all the plaintext field is still what refills — the existing path,
|
|
// unchanged, which is the whole point of this being additive.
|
|
stale.password_encrypted = None;
|
|
assert_eq!(stale.effective_password(), "old-plaintext");
|
|
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
}
|
|
}
|