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
147 lines
5.4 KiB
Rust
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");
|
|
}
|
|
}
|
|
}
|