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
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user