Files
benilla-octo/crates/benilla-ui/src/script/encoding.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

147 lines
5.4 KiB
Rust

//! Base64, written here because nothing in the tree had it and two surfaces want it: nampower's
//! `EncryptPassword`, whose argument name is literally `encryptedPasswordBase64String`, and
//! ClassicAPI's `C_EncodingUtil.EncodeBase64`/`DecodeBase64`.
//!
//! Standard alphabet with `=` padding (RFC 4648 §4), which is what both of those mean by base64 —
//! not the URL-safe variant, and not the unpadded form. Decoding is strict about length and
//! alphabet but **tolerant of embedded whitespace**, because a blob that has been through a
//! SavedVariables file, a chat line or a forum post has usually been wrapped.
/// RFC 4648 §4's alphabet, in value order.
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Encode `bytes`, padded to a multiple of four with `=`.
pub fn base64_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
// The three input bytes as one 24-bit group, missing bytes reading zero.
let b = [
u32::from(chunk[0]),
u32::from(chunk.get(1).copied().unwrap_or(0)),
u32::from(chunk.get(2).copied().unwrap_or(0)),
];
let group = b[0] << 16 | b[1] << 8 | b[2];
for i in 0..4 {
// A sextet that exists only because of a zero-filled byte is padding, not data.
if i <= chunk.len() {
out.push(char::from(
ALPHABET[(group >> (18 - 6 * i) & 0x3f) as usize],
));
} else {
out.push('=');
}
}
}
out
}
/// One character's value, or `None` for anything outside the alphabet.
fn value(c: u8) -> Option<u32> {
let v = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' => 62,
b'/' => 63,
_ => return None,
};
Some(u32::from(v))
}
/// Decode `text`, or `None` for anything that is not a well-formed padded base64 string.
///
/// Whitespace anywhere is skipped first (see the module note). After that the body must be a
/// multiple of four with at most two trailing `=`, every other character in the alphabet, and the
/// unused low bits of a padded group zero — a non-zero tail means the string was truncated or
/// re-encoded, and accepting it would silently drop data.
pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
let body: Vec<u8> = text.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
if !body.len().is_multiple_of(4) {
return None;
}
let pad = body.iter().rev().take_while(|&&b| b == b'=').count();
if pad > 2 {
return None;
}
let data = &body[..body.len() - pad];
let mut out = Vec::with_capacity(data.len() / 4 * 3);
for chunk in data.chunks(4) {
let mut group = 0u32;
for (i, &c) in chunk.iter().enumerate() {
group |= value(c)? << (18 - 6 * i);
}
// One sextet alone encodes no whole byte: `AAAA=` style groups of 1 are malformed.
let bytes = match chunk.len() {
4 => 3,
3 => 2,
2 => 1,
_ => return None,
};
for i in 0..bytes {
out.push((group >> (16 - 8 * i)) as u8);
}
// The bits below the last whole byte must be zero in a canonical encoding.
let used = 8 * bytes;
if used < 24 && group & ((1 << (24 - used)) - 1) != 0 {
return None;
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// RFC 4648 §10's own vectors, which is the point of using them: they pin the padding at
/// every remainder, and padding is where a hand-written codec goes wrong.
#[test]
fn the_rfc_vectors_round_trip() {
for (plain, encoded) in [
("", ""),
("f", "Zg=="),
("fo", "Zm8="),
("foo", "Zm9v"),
("foob", "Zm9vYg=="),
("fooba", "Zm9vYmE="),
("foobar", "Zm9vYmFy"),
] {
assert_eq!(base64_encode(plain.as_bytes()), encoded, "encode {plain:?}");
assert_eq!(
base64_decode(encoded).as_deref(),
Some(plain.as_bytes()),
"decode {encoded:?}"
);
}
}
/// Every byte value, so the `+`/`/` end of the alphabet is exercised rather than assumed.
#[test]
fn all_256_bytes_round_trip() {
let all: Vec<u8> = (0..=255u8).collect();
let text = base64_encode(&all);
assert!(text.contains('+') && text.contains('/'), "got {text}");
assert_eq!(base64_decode(&text).as_deref(), Some(all.as_slice()));
}
/// Whitespace is skipped, because a blob that has been through a SavedVariables file or a
/// forum post has usually been wrapped.
#[test]
fn wrapped_text_decodes_and_malformed_text_does_not() {
assert_eq!(
base64_decode("Zm9v\n YmFy").as_deref(),
Some(&b"foobar"[..])
);
for bad in [
"Zm9vYmF", // not a multiple of four
"Zm9v!mFy", // outside the alphabet
"Zg===", // three pads
"Zm9vYg=A", // data after the pad
"Zh==", // a non-zero tail under the padding: truncated or re-encoded
"Zm9vY===", // one data sextet in the group encodes no whole byte
] {
assert!(base64_decode(bad).is_none(), "{bad:?} must not decode");
}
}
}