Panel: Yggdrasil auth server (authlib-injector), skins and capes

- Yggdrasil API per the authlib-injector spec: metadata with signing key
  and skin domains, authenticate/refresh/validate/invalidate/signout,
  join/hasJoined, profile lookup, texture upload, and the minecraftservices
  endpoints (chat certificates, publickeys, attributes, blocklist)
- 4096-bit signing key generated once into the data volume; textures and
  chat certificates signed SHA1withRSA (verified with Java's own crypto)
- Skins/capes stored content-addressed after validation and re-encoding;
  cape library with public/group/private visibility; head avatars API
- Launcher login returns a game session; launcher sessions recorded for
  launcher-only servers; authlib-injector download mirror
- Schema v2: player UUIDs (offline UUID backfilled), skins, capes, tokens,
  sessions, chat keys, game server tables
- Remove Microsoft sign-in from the engine and panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
Claude committed 2026-09-28 06:57:07 +00:00
1 parent 1b1bb984bc
commit 99b45141fc
29 files changed
+2436 -400

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
//! Skins and capes: validation, storage (content-addressed PNGs under
//! `data/textures/`) and rendering of player heads for avatars.
use crate::error::{AppError, AppResult};
use image::{imageops, ImageFormat, RgbaImage};
use sha2::{Digest, Sha256};
use std::io::Cursor;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Skin,
Cape,
}
fn decode(bytes: &[u8]) -> AppResult<RgbaImage> {
if bytes.len() > 2 * 1024 * 1024 {
return Err(AppError::bad_request("image too large (2 MB max)"));
}
let img =
image::load_from_memory_with_format(bytes, ImageFormat::Png).map_err(|_| AppError::bad_request("that isn't a valid PNG image"))?;
Ok(img.to_rgba8())
}
fn check_size(kind: Kind, w: u32, h: u32) -> AppResult<()> {
let ok = match kind {
// The vanilla client only accepts these two layouts.
Kind::Skin => (w, h) == (64, 64) || (w, h) == (64, 32),
// 64x32 is standard; HD capes are multiples of it; 22x17 is the old format.
Kind::Cape => (w % 64 == 0 && h * 2 == w && w <= 1024) || (w, h) == (22, 17),
};
if ok {
Ok(())
} else {
Err(AppError::bad_request(match kind {
Kind::Skin => format!("skins must be 64×64 (or legacy 64×32) pixels — this one is {w}×{h}"),
Kind::Cape => format!("capes must be 64×32 pixels — this one is {w}×{h}"),
}))
}
}
/// Validate, re-encode (strips metadata and anything smuggled inside the
/// file) and store a texture. Returns its hash.
pub fn store(dir: &Path, kind: Kind, bytes: &[u8]) -> AppResult<String> {
let img = decode(bytes)?;
check_size(kind, img.width(), img.height())?;
let mut out = Vec::new();
img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
.map_err(|e| AppError::bad_request(format!("couldn't process image: {e}")))?;
let hash = hex::encode(Sha256::digest(&out));
std::fs::create_dir_all(dir)?;
let path = dir.join(format!("{hash}.png"));
if !path.exists() {
std::fs::write(&path, &out)?;
}
Ok(hash)
}
pub fn path(dir: &Path, hash: &str) -> Option<PathBuf> {
// Hashes are hex only — never let a request escape the directory.
(hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| dir.join(format!("{hash}.png")))
}
/// Front view of the head (face + hat layer), scaled with nearest-neighbour
/// so pixels stay crisp.
pub fn render_head(skin_png: &[u8], size: u32) -> AppResult<Vec<u8>> {
let skin = decode(skin_png)?;
let mut face = imageops::crop_imm(&skin, 8, 8, 8, 8).to_image();
if skin.height() >= 16 && skin.width() >= 48 {
let hat = imageops::crop_imm(&skin, 40, 8, 8, 8).to_image();
// Some skins fill the hat layer with an opaque colour; ignore it then.
let opaque_hat = hat.pixels().all(|p| p[3] == 255);
if !opaque_hat {
imageops::overlay(&mut face, &hat, 0, 0);
}
}
let size = size.clamp(8, 512);
let scaled = imageops::resize(&face, size, size, imageops::FilterType::Nearest);
let mut out = Vec::new();
scaled.write_to(&mut Cursor::new(&mut out), ImageFormat::Png).map_err(|e| AppError::bad_request(e.to_string()))?;
Ok(out)
}
#[cfg(test)]
pub mod tests {
use super::*;
pub fn png(w: u32, h: u32, rgba: [u8; 4]) -> Vec<u8> {
let img = RgbaImage::from_pixel(w, h, image::Rgba(rgba));
let mut out = Vec::new();
img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png).unwrap();
out
}
#[test]
fn validates_and_stores() {
let dir = tempfile::tempdir().unwrap();
let h1 = store(dir.path(), Kind::Skin, &png(64, 64, [200, 10, 10, 255])).unwrap();
let h2 = store(dir.path(), Kind::Skin, &png(64, 64, [200, 10, 10, 255])).unwrap();
assert_eq!(h1, h2, "content-addressed");
assert!(path(dir.path(), &h1).unwrap().exists());
assert!(store(dir.path(), Kind::Skin, &png(32, 32, [0, 0, 0, 255])).is_err());
assert!(store(dir.path(), Kind::Cape, &png(64, 32, [0, 0, 0, 255])).is_ok());
assert!(store(dir.path(), Kind::Skin, b"not a png").is_err());
assert!(path(dir.path(), "../../etc/passwd").is_none());
}
#[test]
fn renders_heads() {
let mut skin = RgbaImage::from_pixel(64, 64, image::Rgba([0, 0, 0, 0]));
for x in 8..16 {
for y in 8..16 {
skin.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
}
}
skin.put_pixel(40, 8, image::Rgba([0, 0, 255, 255])); // one hat pixel
let mut bytes = Vec::new();
skin.write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png).unwrap();
let head = image::load_from_memory(&render_head(&bytes, 64).unwrap()).unwrap().to_rgba8();
assert_eq!(head.dimensions(), (64, 64));
assert_eq!(head.get_pixel(0, 0).0, [0, 0, 255, 255], "hat overlays the face");
assert_eq!(head.get_pixel(63, 63).0, [255, 0, 0, 255]);
}
}