feat(api): derive stable placeholder names for hidden profiles (#3224)

This commit is contained in:
Hampus
2026-10-05 16:57:25 +02:00
committed by GitHub
parent 02c82f0038
commit 3093e7334b
30 changed files with 629 additions and 65 deletions
+2
View File
@@ -11,11 +11,13 @@ chrono = { version = "0.4", default-features = false }
fluxer_common = { path = "../fluxer_common" }
fluxer-svc = { path = "../fluxer_svc" }
futures = "0.3.34"
hmac = "0.13.0"
moka = { version = "0.12.16", features = ["future"] }
rmp-serde = "1.3"
scylla = { version = "1.9.0", features = ["chrono-04"], optional = true }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
sha2 = "0.11.0"
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread", "signal"] }
tracing = "0.1.44"
+2
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
mod pseudonym;
mod router_impl;
mod shard_impl;
mod types;
@@ -28,6 +29,7 @@ async fn main() -> anyhow::Result<()> {
match config.mode {
Mode::Router => fluxer_svc::router::run_router(&config, UsersRouter, transport).await,
Mode::Shard => {
pseudonym::configure_from_env()?;
let shard = match config.database_backend {
DatabaseBackend::Postgres => {
let postgres_config =
+246
View File
@@ -0,0 +1,246 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use hmac::{KeyInit, Mac};
use std::sync::{LazyLock, OnceLock};
type HmacSha256 = hmac::Hmac<sha2::Sha256>;
const SECRET_ENV: &str = "FLUXER_PROFILE_PSEUDONYM_SECRET";
const DEVELOPMENT_SECRET: &str = "fluxer-dev-profile-pseudonym-secret";
const SCALES_TEXT: &str = include_str!("../../fluxer_api/src/api/words/scales.txt");
const TAILS_TEXT: &str = include_str!("../../fluxer_api/src/api/words/tails.txt");
const MAX_USERNAME_LENGTH: usize = 32;
const MAX_ATTEMPTS: usize = 100;
const FALLBACK_USERNAME: &str = "BotUser";
const MAX_DISCRIMINATOR: u32 = 9999;
const RESERVED_DISCRIMINATORS: [u32; 55] = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 67, 69, 404, 420, 666, 911, 1000, 1111, 1234, 1337, 2000, 2025,
2026, 2027, 2222, 2345, 3000, 3333, 3456, 4000, 4321, 4444, 4567, 5000, 5432, 5555, 5678, 6000,
6543, 6666, 6789, 6969, 7000, 7654, 7777, 7890, 8000, 8008, 8055, 8080, 8765, 8888, 9000, 9001,
9876, 9999,
];
static SECRET: OnceLock<Vec<u8>> = OnceLock::new();
static SCALES: LazyLock<Vec<&'static str>> = LazyLock::new(|| word_list(SCALES_TEXT));
static TAILS: LazyLock<Vec<&'static str>> = LazyLock::new(|| word_list(TAILS_TEXT));
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Pseudonym {
pub username: String,
pub discriminator: i32,
}
pub fn configure(secret: Option<String>, environment: Option<&str>) -> anyhow::Result<()> {
let secret = match secret.filter(|value| !value.trim().is_empty()) {
Some(value) => value,
None if matches!(environment, Some("development" | "test")) => {
tracing::warn!("{SECRET_ENV} is not set, using the development secret");
DEVELOPMENT_SECRET.to_owned()
}
None => anyhow::bail!("{SECRET_ENV} is required"),
};
SECRET
.set(secret.into_bytes())
.map_err(|_| anyhow::anyhow!("{SECRET_ENV} was already configured"))
}
pub fn configure_from_env() -> anyhow::Result<()> {
configure(
std::env::var(SECRET_ENV).ok(),
std::env::var("FLUXER_ENV").ok().as_deref(),
)
}
pub fn pseudonym(user_id: i64) -> Pseudonym {
let secret = SECRET
.get()
.map_or(DEVELOPMENT_SECRET.as_bytes(), Vec::as_slice);
pseudonym_with_secret(secret, user_id)
}
pub fn pseudonym_with_secret(secret: &[u8], user_id: i64) -> Pseudonym {
let mut mac = HmacSha256::new_from_slice(secret).expect("hmac accepts any key length");
mac.update(user_id.to_string().as_bytes());
let seed: [u8; 32] = mac.finalize().into_bytes().into();
Pseudonym {
username: seeded_username(&seed),
discriminator: seeded_discriminator(&seed),
}
}
fn word_list(text: &'static str) -> Vec<&'static str> {
text.trim()
.split('\n')
.filter(|word| !word.is_empty())
.collect()
}
fn capitalize(word: &str) -> String {
let mut chars = word.chars();
chars.next().map_or_else(String::new, |first| {
first.to_uppercase().chain(chars).collect()
})
}
fn is_valid_username(value: &str) -> bool {
let trimmed = value.trim();
let lower = trimmed.to_lowercase();
!trimmed.is_empty()
&& trimmed.encode_utf16().count() <= MAX_USERNAME_LENGTH
&& trimmed
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
&& lower != "everyone"
&& lower != "here"
&& !lower.contains("fluxer")
&& !lower.contains("system message")
}
fn seed_word(seed: &[u8; 32], index: usize) -> u32 {
let offset = (index % (seed.len() / 4)) * 4;
u32::from_be_bytes([
seed[offset],
seed[offset + 1],
seed[offset + 2],
seed[offset + 3],
])
}
fn seeded_username(seed: &[u8; 32]) -> String {
let scales = &*SCALES;
let tails = &*TAILS;
let mut picks = 0;
let mut pick = |size: usize| {
let value = seed_word(seed, picks) as usize % size;
picks += 1;
value
};
for _ in 0..MAX_ATTEMPTS {
let scale = scales[pick(scales.len())];
let tail = tails[pick(tails.len())];
let candidate = capitalize(scale) + &capitalize(tail);
if is_valid_username(&candidate) {
return candidate;
}
}
tails
.iter()
.map(|tail| capitalize(tail))
.find(|candidate| is_valid_username(candidate))
.unwrap_or_else(|| FALLBACK_USERNAME.to_owned())
}
fn seeded_discriminator(seed: &[u8; 32]) -> i32 {
let mut value = seed_word(seed, seed.len() / 4 - 1) % MAX_DISCRIMINATOR + 1;
while RESERVED_DISCRIMINATORS.contains(&value) {
value = value % MAX_DISCRIMINATOR + 1;
}
value as i32
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
const VECTORS: &str =
include_str!("../../fluxer_common/src/testdata/profile_pseudonym_vectors.json");
fn fixture() -> Value {
serde_json::from_str(VECTORS).expect("the pseudonym vectors parse as json")
}
#[test]
fn matches_the_shared_vectors() {
let fixture = fixture();
let vectors = fixture["vectors"].as_array().expect("a vector list");
assert!(!vectors.is_empty());
for vector in vectors {
let secret = vector["secret"].as_str().expect("a secret");
let user_id = vector["user_id"]
.as_str()
.expect("a user id")
.parse::<i64>()
.expect("a numeric user id");
let expected = Pseudonym {
username: vector["username"].as_str().expect("a username").to_owned(),
discriminator: vector["discriminator"]
.as_str()
.expect("a discriminator")
.parse()
.expect("a numeric discriminator"),
};
assert_eq!(
pseudonym_with_secret(secret.as_bytes(), user_id),
expected,
"{user_id}"
);
assert_eq!(
format!("{:04}", expected.discriminator),
vector["discriminator"].as_str().unwrap()
);
}
}
#[test]
fn shares_the_reserved_list_and_development_secret() {
let fixture = fixture();
let reserved = fixture["reserved_discriminators"]
.as_array()
.expect("a reserved list")
.iter()
.map(|value| value.as_u64().expect("a number") as u32)
.collect::<Vec<_>>();
assert_eq!(reserved, RESERVED_DISCRIMINATORS.to_vec());
assert_eq!(fixture["development_secret"], DEVELOPMENT_SECRET);
}
#[test]
fn is_stable_per_account_and_differs_across_accounts() {
let secret = b"stable-secret";
let first = pseudonym_with_secret(secret, 1_174_109_840_998_400_001);
assert_eq!(
first,
pseudonym_with_secret(secret, 1_174_109_840_998_400_001)
);
let names = (0..200)
.map(|offset| pseudonym_with_secret(secret, 1_174_109_840_998_400_001 + offset))
.collect::<std::collections::HashSet<_>>();
assert_eq!(names.len(), 200);
assert_ne!(
first,
pseudonym_with_secret(b"other-secret", 1_174_109_840_998_400_001)
);
}
#[test]
fn looks_like_a_generated_account() {
for user_id in 0..500 {
let generated = pseudonym_with_secret(b"format-secret", user_id);
assert!(
is_valid_username(&generated.username),
"{}",
generated.username
);
assert!(
SCALES.iter().any(|scale| {
generated
.username
.strip_prefix(&capitalize(scale))
.is_some_and(|rest| TAILS.iter().any(|tail| rest == capitalize(tail)))
}),
"{}",
generated.username
);
let discriminator = generated.discriminator as u32;
assert!((1..=MAX_DISCRIMINATOR).contains(&discriminator));
assert!(!RESERVED_DISCRIMINATORS.contains(&discriminator));
}
}
#[test]
fn configuration_requires_a_secret_outside_development() {
assert!(configure(None, None).is_err());
assert!(configure(Some(" ".to_owned()), Some("production")).is_err());
}
}
+29 -5
View File
@@ -1244,21 +1244,44 @@ mod tests {
}
fn assert_hidden(partial: &UserPartial) {
assert_eq!(partial.username, "HiddenUser");
assert_eq!(partial.discriminator, 0);
let expected = crate::pseudonym::pseudonym(partial.user_id);
assert_eq!(partial.username, expected.username);
assert_eq!(partial.discriminator, expected.discriminator);
assert_ne!(partial.username, "Ada");
assert_eq!(partial.global_name, None);
assert_eq!(partial.avatar_hash, None);
assert_eq!(partial.avatar_color, None);
assert_eq!(partial.banner_hash, None);
assert_eq!(partial.banner_color, None);
assert_eq!(partial.accent_color, None);
assert_ne!(partial.flags.unwrap_or_default() & PROFILE_HIDDEN, 0);
let api = partial.to_api_partial();
assert_eq!(api.username, expected.username);
assert_eq!(api.discriminator, format!("{:04}", expected.discriminator));
assert_eq!(api.flags & PROFILE_HIDDEN as i32, 0);
}
#[test]
fn a_masked_partial_matches_the_shared_development_vector() {
let api = styled(1_174_109_840_998_400_001, SPAMMER)
.to_partial()
.to_api_partial();
assert_eq!(
(api.username.as_str(), api.discriminator.as_str()),
("HiddenUser", "0000")
("MambaEgret", "6542")
);
assert_eq!(api.flags & PROFILE_HIDDEN as i32, 0);
assert_eq!(api.global_name, None);
assert_eq!(api.avatar, None);
}
#[test]
fn a_masked_partial_is_stable_across_reads() {
let first = styled(53, PROFILE_HIDDEN).to_partial();
let second = styled(53, PROFILE_HIDDEN).to_partial();
assert_eq!(
(first.username, first.discriminator),
(second.username, second.discriminator)
);
assert_ne!(api.flags & PROFILE_HIDDEN as i32, 0);
}
#[test]
@@ -1294,6 +1317,7 @@ mod tests {
assert_eq!(partial.username, "Ada", "{}", user.user_id);
assert_eq!(partial.global_name.as_deref(), Some("Ada Lovelace"));
assert_eq!(partial.banner_hash.as_deref(), Some("banner_hash"));
assert_eq!(partial.discriminator, 7);
assert_eq!(partial.flags.unwrap_or_default() & PROFILE_HIDDEN, 0);
}
}
+5 -8
View File
@@ -1,8 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use fluxer_common::user_flags::{
AccountStanding, USER_FLAG_PROFILE_HIDDEN, USER_FLAG_STAFF, visible_user_flags,
};
use crate::pseudonym::pseudonym;
use fluxer_common::user_flags::{AccountStanding, USER_FLAG_STAFF, visible_user_flags};
#[cfg(test)]
use fluxer_common::user_flags::{USER_FLAG_PARTNER, USER_FLAG_STAFF_HIDDEN};
use serde::{Deserialize, Deserializer, Serialize};
@@ -145,8 +144,6 @@ const FLUXER_SYSTEM_USER_ID: i64 = 0;
const FLUXER_SYSTEM_USERNAME: &str = "Fluxer";
const FLUXER_SYSTEM_DISCRIMINATOR: &str = "0000";
const HIDDEN_USER_USERNAME: &str = "HiddenUser";
pub fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -193,12 +190,12 @@ impl UserPartial {
if self.user_id == FLUXER_SYSTEM_USER_ID || !standing.profile_hidden(now_ms) {
return self;
}
let pseudonym = pseudonym(self.user_id);
UserPartial {
username: HIDDEN_USER_USERNAME.to_owned(),
discriminator: 0,
username: pseudonym.username,
discriminator: pseudonym.discriminator,
global_name: None,
avatar_hash: None,
flags: Some(self.flags.unwrap_or_default() | USER_FLAG_PROFILE_HIDDEN),
banner_hash: None,
banner_color: None,
accent_color: None,