Files
benilla-octo/crates/benilla-app/src/credential.rs
T
gromandClaude Opus 5 e716709bca app+ui: the nine file and credential verbs, fenced and keyed
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
2026-10-24 11:30:49 -06:00

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);
}
}