mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(api): derive stable placeholder names for hidden profiles (#3224)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user