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
Generated
+2
View File
@@ -1986,11 +1986,13 @@ dependencies = [
"fluxer-svc", "fluxer-svc",
"fluxer_common", "fluxer_common",
"futures", "futures",
"hmac 0.13.0",
"moka", "moka",
"rmp-serde", "rmp-serde",
"scylla", "scylla",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0",
"tokio", "tokio",
"tracing", "tracing",
] ]
+1
View File
@@ -89,6 +89,7 @@ FLUXER_ADMIN_OAUTH_REDIRECT_URI=http://localhost:8088/admin/oauth2_callback
FLUXER_SUDO_MODE_SECRET=dev-sudo-secret FLUXER_SUDO_MODE_SECRET=dev-sudo-secret
FLUXER_CONNECTION_INITIATION_SECRET=dev-connection-initiation-secret FLUXER_CONNECTION_INITIATION_SECRET=dev-connection-initiation-secret
FLUXER_PROFILE_PSEUDONYM_SECRET=fluxer-dev-profile-pseudonym-secret
FLUXER_VAPID_PUBLIC_KEY=BHIbdKs24FdPkOQS7hbeg3adceLS0IqlKsn71ywEe6kbeopeFFiG3lkvJac7BVqkuk7mxwEa555O2FXV3HLt56w FLUXER_VAPID_PUBLIC_KEY=BHIbdKs24FdPkOQS7hbeg3adceLS0IqlKsn71ywEe6kbeopeFFiG3lkvJac7BVqkuk7mxwEa555O2FXV3HLt56w
FLUXER_VAPID_PRIVATE_KEY=cs24JvXSxHiqJQgkJNocJFAdzJpPmpfU9xD-fDpn3tw FLUXER_VAPID_PRIVATE_KEY=cs24JvXSxHiqJQgkJNocJFAdzJpPmpfU9xD-fDpn3tw
FLUXER_VAPID_EMAIL=dev@localhost FLUXER_VAPID_EMAIL=dev@localhost
+1
View File
@@ -184,6 +184,7 @@ FLUXER_S3_SECRET_KEY=CHANGE_ME
FLUXER_SUDO_MODE_SECRET=CHANGE_ME FLUXER_SUDO_MODE_SECRET=CHANGE_ME
FLUXER_CONNECTION_INITIATION_SECRET=CHANGE_ME FLUXER_CONNECTION_INITIATION_SECRET=CHANGE_ME
FLUXER_PROFILE_PSEUDONYM_SECRET=CHANGE_ME
FLUXER_GATEWAY_RPC_AUTH_TOKEN=CHANGE_ME FLUXER_GATEWAY_RPC_AUTH_TOKEN=CHANGE_ME
FLUXER_ERLANG_COOKIE=CHANGE_ME FLUXER_ERLANG_COOKIE=CHANGE_ME
FLUXER_MEDIA_PROXY_SECRET_KEY=CHANGE_ME FLUXER_MEDIA_PROXY_SECRET_KEY=CHANGE_ME
+1
View File
@@ -177,6 +177,7 @@ x-fluxer-env: &fluxer-env
FLUXER_SUDO_MODE_SECRET: ${FLUXER_SUDO_MODE_SECRET:?set FLUXER_SUDO_MODE_SECRET in .env} FLUXER_SUDO_MODE_SECRET: ${FLUXER_SUDO_MODE_SECRET:?set FLUXER_SUDO_MODE_SECRET in .env}
FLUXER_CONNECTION_INITIATION_SECRET: ${FLUXER_CONNECTION_INITIATION_SECRET:?set FLUXER_CONNECTION_INITIATION_SECRET in .env} FLUXER_CONNECTION_INITIATION_SECRET: ${FLUXER_CONNECTION_INITIATION_SECRET:?set FLUXER_CONNECTION_INITIATION_SECRET in .env}
FLUXER_PROFILE_PSEUDONYM_SECRET: ${FLUXER_PROFILE_PSEUDONYM_SECRET:?set FLUXER_PROFILE_PSEUDONYM_SECRET in .env}
FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES: ${FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES:-} FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES: ${FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES:-}
FLUXER_VAPID_PUBLIC_KEY: ${FLUXER_VAPID_PUBLIC_KEY:?set FLUXER_VAPID_PUBLIC_KEY in .env} FLUXER_VAPID_PUBLIC_KEY: ${FLUXER_VAPID_PUBLIC_KEY:?set FLUXER_VAPID_PUBLIC_KEY in .env}
FLUXER_VAPID_PRIVATE_KEY: ${FLUXER_VAPID_PRIVATE_KEY:?set FLUXER_VAPID_PRIVATE_KEY in .env} FLUXER_VAPID_PRIVATE_KEY: ${FLUXER_VAPID_PRIVATE_KEY:?set FLUXER_VAPID_PRIVATE_KEY in .env}
+1 -2
View File
@@ -14265,8 +14265,7 @@
"value": "32", "value": "32",
"description": "Bot requires manual approval for friend requests" "description": "Bot requires manual approval for friend requests"
}, },
{"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"}, {"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"}
{"name": "PROFILE_HIDDEN", "value": "128", "description": "User profile details are hidden"}
] ]
}, },
"MessageEmbedChildResponse": { "MessageEmbedChildResponse": {
+1
View File
@@ -391,6 +391,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
auth: { auth: {
sudoModeSecret: master.auth.sudo_mode_secret, sudoModeSecret: master.auth.sudo_mode_secret,
connectionInitiationSecret: master.auth.connection_initiation_secret, connectionInitiationSecret: master.auth.connection_initiation_secret,
profilePseudonymSecret: master.auth.profile_pseudonym_secret,
ssoAllowPrivateAddresses: master.auth.sso_allow_private_addresses, ssoAllowPrivateAddresses: master.auth.sso_allow_private_addresses,
passkeys: { passkeys: {
rpName: master.auth.passkeys.rp_name, rpName: master.auth.passkeys.rp_name,
+1
View File
@@ -249,6 +249,7 @@ export interface APIConfig {
auth: { auth: {
sudoModeSecret: string; sudoModeSecret: string;
connectionInitiationSecret: string; connectionInitiationSecret: string;
profilePseudonymSecret: string;
ssoAllowPrivateAddresses: boolean; ssoAllowPrivateAddresses: boolean;
passkeys: { passkeys: {
rpName: string; rpName: string;
+1 -2
View File
@@ -31309,8 +31309,7 @@
"value": "32", "value": "32",
"description": "Bot requires manual approval for friend requests" "description": "Bot requires manual approval for friend requests"
}, },
{"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"}, {"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"}
{"name": "PROFILE_HIDDEN", "value": "128", "description": "User profile details are hidden"}
] ]
}, },
"RefreshedAttachmentUrl": { "RefreshedAttachmentUrl": {
+45 -16
View File
@@ -1,14 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {createHmac} from 'node:crypto';
import {Config} from '@app/api/Config';
import type {User} from '@app/api/models/User'; import type {User} from '@app/api/models/User';
import {isTemporarilyBanned} from '@app/api/user/UserHelpers'; import {isTemporarilyBanned} from '@app/api/user/UserHelpers';
import {generateSeededUsername} from '@app/api/utils/UsernameGenerator';
import {DeletionReasons} from '@fluxer/constants/src/Core'; import {DeletionReasons} from '@fluxer/constants/src/Core';
import { import {NON_SELF_HOSTED_RESERVED_DISCRIMINATORS} from '@fluxer/constants/src/DiscriminatorConstants';
HIDDEN_USER_DISCRIMINATOR, import {UserFlags} from '@fluxer/constants/src/UserConstants';
HIDDEN_USER_USERNAME,
PublicUserFlags,
UserFlags,
} from '@fluxer/constants/src/UserConstants';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
@@ -42,20 +41,50 @@ export function isProfileHidden(user: ProfileStanding, now = Date.now()): boolea
return (user.flags & UserFlags.PROFILE_HIDDEN) !== 0n || isUnderEnforcement(user, now); return (user.flags & UserFlags.PROFILE_HIDDEN) !== 0n || isUnderEnforcement(user, now);
} }
export function hiddenUserPartial(partial: UserPartialResponse): UserPartialResponse { const MAX_DISCRIMINATOR = 9999;
interface ProfilePseudonym {
username: string;
discriminator: string;
}
function pseudonymDiscriminator(seed: Buffer): number {
let value = (seed.readUInt32BE(seed.byteLength - 4) % MAX_DISCRIMINATOR) + 1;
while (NON_SELF_HOSTED_RESERVED_DISCRIMINATORS.has(value)) {
value = (value % MAX_DISCRIMINATOR) + 1;
}
return value;
}
export function profilePseudonym(
userId: string | bigint,
secret: string = Config.auth.profilePseudonymSecret,
): ProfilePseudonym {
const seed = createHmac('sha256', secret).update(userId.toString()).digest();
return { return {
...partial, username: generateSeededUsername(seed),
username: HIDDEN_USER_USERNAME, discriminator: pseudonymDiscriminator(seed).toString().padStart(4, '0'),
discriminator: HIDDEN_USER_DISCRIMINATOR.toString().padStart(4, '0'),
global_name: null,
avatar: null,
avatar_color: null,
flags: partial.flags | PublicUserFlags.PROFILE_HIDDEN,
}; };
} }
export function isHiddenPartial(partial: Pick<UserPartialResponse, 'flags'>): boolean { export function hiddenUserPartial(partial: UserPartialResponse): UserPartialResponse {
return (partial.flags & PublicUserFlags.PROFILE_HIDDEN) !== 0; const pseudonym = profilePseudonym(partial.id);
return {
...partial,
username: pseudonym.username,
discriminator: pseudonym.discriminator,
global_name: null,
avatar: null,
avatar_color: null,
};
}
export function isHiddenPartial(
partial: Pick<UserPartialResponse, 'id' | 'username' | 'discriminator' | 'global_name' | 'avatar'>,
): boolean {
if (partial.global_name != null || partial.avatar != null) return false;
const pseudonym = profilePseudonym(partial.id);
return partial.username === pseudonym.username && partial.discriminator === pseudonym.discriminator;
} }
export function hiddenGuildMember(member: GuildMemberResponse): GuildMemberResponse { export function hiddenGuildMember(member: GuildMemberResponse): GuildMemberResponse {
@@ -6,12 +6,15 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
import {NoopGatewayService} from '@app/api/test/NoopGatewayService'; import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
import {HTTP_STATUS} from '@app/api/test/TestConstants'; import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder'; import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {profilePseudonym} from '@app/api/user/ProfileVisibility';
import {fetchUser, fetchUserMe, fetchUserProfile} from '@app/api/user/tests/UserTestUtils'; import {fetchUser, fetchUserMe, fetchUserProfile} from '@app/api/user/tests/UserTestUtils';
import {DeletionReasons} from '@fluxer/constants/src/Core'; import {DeletionReasons} from '@fluxer/constants/src/Core';
import {PublicUserFlags, UserFlags} from '@fluxer/constants/src/UserConstants'; import {UserFlags} from '@fluxer/constants/src/UserConstants';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
const PROFILE_HIDDEN_BIT = Number(UserFlags.PROFILE_HIDDEN);
interface AdminUser { interface AdminUser {
username: string; username: string;
global_name: string | null; global_name: string | null;
@@ -27,6 +30,7 @@ describe('hidden profiles', () => {
let target: TestAccount; let target: TestAccount;
let guildId: string; let guildId: string;
let targetName: string; let targetName: string;
let pseudonym: {username: string; discriminator: string};
beforeEach(async () => { beforeEach(async () => {
harness = await createApiTestHarness(); harness = await createApiTestHarness();
@@ -52,6 +56,7 @@ describe('hidden profiles', () => {
.expect(HTTP_STATUS.OK) .expect(HTTP_STATUS.OK)
.execute(); .execute();
targetName = (await fetchUser(harness, target.userId, viewer.token)).json.username; targetName = (await fetchUser(harness, target.userId, viewer.token)).json.username;
pseudonym = profilePseudonym(target.userId);
}); });
afterEach(async () => { afterEach(async () => {
@@ -62,7 +67,7 @@ describe('hidden profiles', () => {
async function expectShown(): Promise<void> { async function expectShown(): Promise<void> {
const {json} = await fetchUser(harness, target.userId, viewer.token); const {json} = await fetchUser(harness, target.userId, viewer.token);
expect(json).toMatchObject({username: targetName, global_name: 'Shown Name'}); expect(json).toMatchObject({username: targetName, global_name: 'Shown Name'});
expect(json.flags & PublicUserFlags.PROFILE_HIDDEN).toBe(0); expect(json.flags & PROFILE_HIDDEN_BIT).toBe(0);
const profile = await fetchUserProfile(harness, target.userId, viewer.token); const profile = await fetchUserProfile(harness, target.userId, viewer.token);
expect(profile.json.user_profile).toMatchObject({bio: 'shown bio', pronouns: 'they/them'}); expect(profile.json.user_profile).toMatchObject({bio: 'shown bio', pronouns: 'they/them'});
const member = await getMember(harness, viewer.token, guildId, target.userId); const member = await getMember(harness, viewer.token, guildId, target.userId);
@@ -71,13 +76,15 @@ describe('hidden profiles', () => {
async function expectHidden(): Promise<void> { async function expectHidden(): Promise<void> {
const {json} = await fetchUser(harness, target.userId, viewer.token); const {json} = await fetchUser(harness, target.userId, viewer.token);
expect(json).toMatchObject({username: 'HiddenUser', discriminator: '0000', global_name: null, avatar: null}); expect(json).toMatchObject({...pseudonym, global_name: null, avatar: null});
expect(json.flags & PublicUserFlags.PROFILE_HIDDEN).toBe(PublicUserFlags.PROFILE_HIDDEN); expect(json.username).not.toBe(targetName);
expect(json.flags & PROFILE_HIDDEN_BIT).toBe(0);
const profile = await fetchUserProfile(harness, target.userId, viewer.token); const profile = await fetchUserProfile(harness, target.userId, viewer.token);
expect(profile.json.user_profile).toMatchObject({bio: null, pronouns: null, banner: null, accent_color: null}); expect(profile.json.user_profile).toMatchObject({bio: null, pronouns: null, banner: null, accent_color: null});
const member = await getMember(harness, viewer.token, guildId, target.userId); const member = await getMember(harness, viewer.token, guildId, target.userId);
expect(member).toMatchObject({nick: null, avatar: null, banner: null}); expect(member).toMatchObject({nick: null, avatar: null, banner: null});
expect(member.user.username).toBe('HiddenUser'); expect(member.user).toMatchObject(pseudonym);
expect(member.user.flags & PROFILE_HIDDEN_BIT).toBe(0);
} }
async function expectStaffSeeStoredProfile(): Promise<void> { async function expectStaffSeeStoredProfile(): Promise<void> {
@@ -111,8 +118,15 @@ describe('hidden profiles', () => {
.execute(); .execute();
await expectHidden(); await expectHidden();
await expectStaffSeeStoredProfile(); await expectStaffSeeStoredProfile();
expect(memberUpdates().map((member) => [member.user.username, member.nick])).toEqual([['HiddenUser', null]]); expect(memberUpdates().map((member) => [member.user.username, member.user.discriminator, member.nick])).toEqual([
expect(presence.mock.calls.some(([params]) => params.event === 'USER_UPDATE')).toBe(true); [pseudonym.username, pseudonym.discriminator, null],
]);
const ownUpdates = presence.mock.calls
.map(([params]) => params)
.filter((params) => params.event === 'USER_UPDATE' && params.userId.toString() === target.userId)
.map((params) => params.data as {username: string});
expect(ownUpdates.length).toBeGreaterThan(0);
expect(ownUpdates.every((update) => update.username === targetName)).toBe(true);
await createBuilder(harness, admin.token) await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/ban`) .delete(`/admin/users/${target.userId}/ban`)
.body({notify_user: false}) .body({notify_user: false})
@@ -120,7 +134,7 @@ describe('hidden profiles', () => {
.execute(); .execute();
await expectShown(); await expectShown();
expect(memberUpdates().map((member) => [member.user.username, member.nick])).toEqual([ expect(memberUpdates().map((member) => [member.user.username, member.nick])).toEqual([
['HiddenUser', null], [pseudonym.username, null],
[targetName, 'Shown Nick'], [targetName, 'Shown Nick'],
]); ]);
}); });
@@ -168,6 +182,7 @@ describe('hidden profiles', () => {
await expectHidden(); await expectHidden();
const own = await fetchUserMe(harness, target.token); const own = await fetchUserMe(harness, target.token);
expect(own.json).toMatchObject({username: targetName, global_name: 'Shown Name', bio: 'shown bio'}); expect(own.json).toMatchObject({username: targetName, global_name: 'Shown Name', bio: 'shown bio'});
expect(own.json.flags & PROFILE_HIDDEN_BIT).toBe(0);
await createBuilder(harness, admin.token) await createBuilder(harness, admin.token)
.patch(`/admin/users/${target.userId}/flags`) .patch(`/admin/users/${target.userId}/flags`)
.body({remove_flags: [UserFlags.PROFILE_HIDDEN.toString()]}) .body({remove_flags: [UserFlags.PROFILE_HIDDEN.toString()]})
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {readFileSync} from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {Config} from '@app/api/Config';
import {profilePseudonym} from '@app/api/user/ProfileVisibility';
import {NON_SELF_HOSTED_RESERVED_DISCRIMINATORS} from '@fluxer/constants/src/DiscriminatorConstants';
import {describe, expect, it} from 'vitest';
interface PseudonymVector {
secret: string;
user_id: string;
username: string;
discriminator: string;
}
interface PseudonymFixture {
development_secret: string;
reserved_discriminators: Array<number>;
vectors: Array<PseudonymVector>;
}
const VECTORS_PATH = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../../../fluxer_common/src/testdata/profile_pseudonym_vectors.json',
);
const fixture = JSON.parse(readFileSync(VECTORS_PATH, 'utf8')) as PseudonymFixture;
describe('profile pseudonym vectors shared with the users service', () => {
it('has vectors', () => {
expect(fixture.vectors.length).toBeGreaterThan(0);
});
it.each(fixture.vectors)('$user_id under $secret', (vector) => {
expect(profilePseudonym(vector.user_id, vector.secret)).toEqual({
username: vector.username,
discriminator: vector.discriminator,
});
});
it('pins the reserved discriminators and the development secret', () => {
expect([...NON_SELF_HOSTED_RESERVED_DISCRIMINATORS]).toEqual(fixture.reserved_discriminators);
expect(Config.auth.profilePseudonymSecret).toBe(fixture.development_secret);
});
});
@@ -9,7 +9,7 @@ import {LimitConfigService, resetGlobalLimitConfigServiceForTesting} from '@app/
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import {GuildMember} from '@app/api/models/GuildMember'; import {GuildMember} from '@app/api/models/GuildMember';
import {User} from '@app/api/models/User'; import {User} from '@app/api/models/User';
import {isProfileHidden, isUnderEnforcement} from '@app/api/user/ProfileVisibility'; import {isHiddenPartial, isProfileHidden, isUnderEnforcement, profilePseudonym} from '@app/api/user/ProfileVisibility';
import { import {
hasPartialUserFieldsChanged, hasPartialUserFieldsChanged,
mapGuildMemberToProfileResponse, mapGuildMemberToProfileResponse,
@@ -18,17 +18,21 @@ import {
mapUserToProfileResponse, mapUserToProfileResponse,
} from '@app/api/user/UserMappers'; } from '@app/api/user/UserMappers';
import {DeletionReasons} from '@fluxer/constants/src/Core'; import {DeletionReasons} from '@fluxer/constants/src/Core';
import {PublicUserFlags, UserFlags} from '@fluxer/constants/src/UserConstants'; import {NON_SELF_HOSTED_RESERVED_DISCRIMINATORS} from '@fluxer/constants/src/DiscriminatorConstants';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {InMemoryProvider} from '@pkgs/cache/src/providers/InMemoryProvider'; import {InMemoryProvider} from '@pkgs/cache/src/providers/InMemoryProvider';
import {afterAll, beforeAll, describe, expect, it} from 'vitest'; import {afterAll, beforeAll, describe, expect, it} from 'vitest';
const NOW = Date.now(); const NOW = Date.now();
const HOUR = 3_600_000; const HOUR = 3_600_000;
const USER_ID = 1174109840998400001n;
const PROFILE_HIDDEN_BIT = Number(UserFlags.PROFILE_HIDDEN);
const GENERATED_USERNAME = /^[A-Z][a-z0-9_]*[A-Z][a-z0-9_]*$/;
function user(overrides: Partial<UserRow> = {}): User { function user(overrides: Partial<UserRow> = {}): User {
return new User({ return new User({
...EMPTY_USER_ROW, ...EMPTY_USER_ROW,
user_id: createUserID(1174109840998400001n), user_id: createUserID(USER_ID),
username: 'ada', username: 'ada',
discriminator: 7, discriminator: 7,
global_name: 'Ada Lovelace', global_name: 'Ada Lovelace',
@@ -96,16 +100,16 @@ describe('profile visibility', () => {
const subject = user(overrides); const subject = user(overrides);
expect(isProfileHidden(subject, NOW)).toBe(hidden); expect(isProfileHidden(subject, NOW)).toBe(hidden);
const partial = mapUserToPartialResponse(subject); const partial = mapUserToPartialResponse(subject);
expect(partial.flags & PROFILE_HIDDEN_BIT).toBe(0);
if (hidden) { if (hidden) {
expect(partial).toMatchObject({ expect(partial).toMatchObject({
id: subject.id.toString(), id: subject.id.toString(),
username: 'HiddenUser', ...profilePseudonym(USER_ID),
discriminator: '0000',
global_name: null, global_name: null,
avatar: null, avatar: null,
avatar_color: null, avatar_color: null,
}); });
expect(partial.flags & PublicUserFlags.PROFILE_HIDDEN).toBe(PublicUserFlags.PROFILE_HIDDEN); expect(isHiddenPartial(partial)).toBe(true);
expect(mapUserToProfileResponse(subject)).toEqual({ expect(mapUserToProfileResponse(subject)).toEqual({
bio: null, bio: null,
pronouns: null, pronouns: null,
@@ -114,7 +118,7 @@ describe('profile visibility', () => {
accent_color: null, accent_color: null,
}); });
} else { } else {
expect(partial.flags & PublicUserFlags.PROFILE_HIDDEN).toBe(0); expect(isHiddenPartial(partial)).toBe(false);
expect(partial.username).toBe('ada'); expect(partial.username).toBe('ada');
expect(partial.global_name).toBe('Ada Lovelace'); expect(partial.global_name).toBe('Ada Lovelace');
expect(partial.avatar).toBe('a1b2c3'); expect(partial.avatar).toBe('a1b2c3');
@@ -126,12 +130,54 @@ describe('profile visibility', () => {
const own = mapUserToPrivateResponse(user({flags: UserFlags.PROFILE_HIDDEN})); const own = mapUserToPrivateResponse(user({flags: UserFlags.PROFILE_HIDDEN}));
expect(own).toMatchObject({ expect(own).toMatchObject({
username: 'ada', username: 'ada',
discriminator: '0007',
global_name: 'Ada Lovelace', global_name: 'Ada Lovelace',
avatar: 'a1b2c3', avatar: 'a1b2c3',
bio: 'analytical engine enjoyer', bio: 'analytical engine enjoyer',
pronouns: 'she/her', pronouns: 'she/her',
accent_color: 99, accent_color: 99,
}); });
expect(own.flags & PROFILE_HIDDEN_BIT).toBe(0);
});
it('gives each account one stable pseudonym in the generated username format', () => {
const first = profilePseudonym(USER_ID);
expect(profilePseudonym(USER_ID)).toEqual(first);
expect(mapUserToPartialResponse(user({flags: UserFlags.SPAMMER}))).toMatchObject(first);
expect(mapUserToPartialResponse(user({flags: UserFlags.PROFILE_HIDDEN}))).toMatchObject(first);
const seen = new Set<string>();
for (let offset = 0n; offset < 200n; offset++) {
const pseudonym = profilePseudonym(USER_ID + offset);
seen.add(`${pseudonym.username}#${pseudonym.discriminator}`);
expect(pseudonym.username).toMatch(GENERATED_USERNAME);
expect(pseudonym.username.length).toBeLessThanOrEqual(32);
expect(pseudonym.discriminator).toMatch(/^\d{4}$/);
const discriminator = Number(pseudonym.discriminator);
expect(discriminator).toBeGreaterThanOrEqual(1);
expect(discriminator).toBeLessThanOrEqual(9999);
expect(NON_SELF_HOSTED_RESERVED_DISCRIMINATORS.has(discriminator)).toBe(false);
}
expect(seen.size).toBe(200);
expect(profilePseudonym(USER_ID, 'another-secret')).not.toEqual(first);
});
it('restores the stored profile as soon as the mask lifts', () => {
const masked = mapUserToPartialResponse(user({flags: UserFlags.PROFILE_HIDDEN}));
const lifted = mapUserToPartialResponse(user({flags: 0n}));
expect(masked.username).not.toBe('ada');
expect(lifted).toMatchObject({
username: 'ada',
discriminator: '0007',
global_name: 'Ada Lovelace',
avatar: 'a1b2c3',
});
expect(isHiddenPartial(lifted)).toBe(false);
});
it('does not mistake a deleted account for a masked one', () => {
const deleted = mapUserToPartialResponse(user({flags: UserFlags.DELETED | UserFlags.SPAMMER}));
expect(deleted).toMatchObject({username: 'DeletedUser', discriminator: '0000', global_name: 'Deleted User'});
expect(isHiddenPartial(deleted)).toBe(false);
}); });
it('treats hiding and restoring as a partial change so clients are told both ways', () => { it('treats hiding and restoring as a partial change so clients are told both ways', () => {
@@ -150,7 +196,7 @@ describe('profile visibility', () => {
it('hides guild-specific profile details for a hidden member', async () => { it('hides guild-specific profile details for a hidden member', async () => {
const member = new GuildMember({ const member = new GuildMember({
guild_id: createGuildID(5n), guild_id: createGuildID(5n),
user_id: createUserID(1174109840998400001n), user_id: createUserID(USER_ID),
joined_at: new Date(NOW - HOUR), joined_at: new Date(NOW - HOUR),
nick: 'Countess', nick: 'Countess',
avatar_hash: 'm4v', avatar_hash: 'm4v',
@@ -178,7 +224,7 @@ describe('profile visibility', () => {
createRequestCache(), createRequestCache(),
); );
expect(hidden).toMatchObject({nick: null, avatar: null, banner: null, accent_color: null}); expect(hidden).toMatchObject({nick: null, avatar: null, banner: null, accent_color: null});
expect(hidden.user.username).toBe('HiddenUser'); expect(hidden.user).toMatchObject(profilePseudonym(USER_ID));
const shown = await mapGuildMemberToResponse(member, cache(mapUserToPartialResponse(user())), createRequestCache()); const shown = await mapGuildMemberToResponse(member, cache(mapUserToPartialResponse(user())), createRequestCache());
expect(shown).toMatchObject({nick: 'Countess', avatar: 'm4v', banner: 'm8n', accent_color: 12}); expect(shown).toMatchObject({nick: 'Countess', avatar: 'm4v', banner: 'm8n', accent_color: 12});
expect(mapGuildMemberToProfileResponse(member, {hidden: true})).toEqual({ expect(mapGuildMemberToProfileResponse(member, {hidden: true})).toEqual({
+26 -6
View File
@@ -8,19 +8,17 @@ import {UsernameType} from '@fluxer/schema/src/primitives/UserValidators';
const scales = readFileSync(resolveAssetPath('words', 'scales.txt'), 'utf-8').trim().split('\n').filter(Boolean); const scales = readFileSync(resolveAssetPath('words', 'scales.txt'), 'utf-8').trim().split('\n').filter(Boolean);
const tails = readFileSync(resolveAssetPath('words', 'tails.txt'), 'utf-8').trim().split('\n').filter(Boolean); const tails = readFileSync(resolveAssetPath('words', 'tails.txt'), 'utf-8').trim().split('\n').filter(Boolean);
type IndexPicker = (size: number) => number;
function capitalize(word: string): string { function capitalize(word: string): string {
return word.charAt(0).toUpperCase() + word.slice(1); return word.charAt(0).toUpperCase() + word.slice(1);
} }
function pickRandom(words: Array<string>): string { function generateUsername(pick: IndexPicker): string {
return words[randomInt(words.length)];
}
export function generateRandomUsername(): string {
const MAX_LENGTH = 32; const MAX_LENGTH = 32;
const MAX_ATTEMPTS = 100; const MAX_ATTEMPTS = 100;
for (let i = 0; i < MAX_ATTEMPTS; i++) { for (let i = 0; i < MAX_ATTEMPTS; i++) {
const username = capitalize(pickRandom(scales)) + capitalize(pickRandom(tails)); const username = capitalize(scales[pick(scales.length)]) + capitalize(tails[pick(tails.length)]);
if (username.length <= MAX_LENGTH && UsernameType.safeParse(username).success) { if (username.length <= MAX_LENGTH && UsernameType.safeParse(username).success) {
return username; return username;
} }
@@ -33,3 +31,25 @@ export function generateRandomUsername(): string {
} }
return 'BotUser'; return 'BotUser';
} }
function seededPicker(seed: Uint8Array): IndexPicker {
const view = new DataView(seed.buffer, seed.byteOffset, seed.byteLength);
const words = Math.floor(seed.byteLength / 4);
let next = 0;
return (size) => {
const value = view.getUint32((next % words) * 4);
next++;
return value % size;
};
}
export function generateRandomUsername(): string {
return generateUsername((size) => randomInt(size));
}
export function generateSeededUsername(seed: Uint8Array): string {
if (seed.byteLength < 4) {
throw new Error('Username seed must hold at least four bytes');
}
return generateUsername(seededPicker(seed));
}
@@ -0,0 +1,83 @@
{
"version": 1,
"development_secret": "fluxer-dev-profile-pseudonym-secret",
"reserved_discriminators": [
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
],
"vectors": [
{
"secret": "fluxer-profile-pseudonym-test-vector-secret",
"user_id": "1",
"username": "VelociraptorAbyssinian",
"discriminator": "5640"
},
{
"secret": "fluxer-profile-pseudonym-test-vector-secret",
"user_id": "1174109840998400001",
"username": "HarmonicGate",
"discriminator": "1839"
},
{
"secret": "fluxer-profile-pseudonym-test-vector-secret",
"user_id": "1472906353451343872",
"username": "DeltaBombay",
"discriminator": "8696"
},
{
"secret": "fluxer-profile-pseudonym-test-vector-secret",
"user_id": "1544725486800732163",
"username": "AlteredBeaver",
"discriminator": "6114"
},
{
"secret": "fluxer-profile-pseudonym-test-vector-secret",
"user_id": "9223372036854775807",
"username": "SalmonPolydactyl",
"discriminator": "2936"
},
{
"secret": "fluxer-dev-profile-pseudonym-secret",
"user_id": "1",
"username": "SqueakerWeasel",
"discriminator": "2226"
},
{
"secret": "fluxer-dev-profile-pseudonym-secret",
"user_id": "1174109840998400001",
"username": "MambaEgret",
"discriminator": "6542"
},
{
"secret": "fluxer-dev-profile-pseudonym-secret",
"user_id": "1472906353451343872",
"username": "MenkentBrown",
"discriminator": "8304"
},
{
"secret": "fluxer-dev-profile-pseudonym-secret",
"user_id": "1544725486800732163",
"username": "HamalMacaroni",
"discriminator": "1160"
},
{
"secret": "fluxer-dev-profile-pseudonym-secret",
"user_id": "9223372036854775807",
"username": "HerringGrolar",
"discriminator": "2718"
},
{
"secret": "fluxer-profile-pseudonym-test-vector-secret",
"user_id": "1500000000000000461",
"username": "IdeRoyal",
"discriminator": "7655"
},
{
"secret": "fluxer-profile-pseudonym-test-vector-secret",
"user_id": "1500000000000002226",
"username": "OpaleyeMara",
"discriminator": "0010"
}
]
}
+4 -5
View File
@@ -13,8 +13,7 @@ const PUBLIC_USER_FLAGS: i64 = USER_FLAG_STAFF
| USER_FLAG_BUG_HUNTER | USER_FLAG_BUG_HUNTER
| USER_FLAG_FRIENDLY_BOT | USER_FLAG_FRIENDLY_BOT
| USER_FLAG_FRIENDLY_BOT_MANUAL_APPROVAL | USER_FLAG_FRIENDLY_BOT_MANUAL_APPROVAL
| USER_FLAG_SPAMMER | USER_FLAG_SPAMMER;
| USER_FLAG_PROFILE_HIDDEN;
const PUBLIC_USER_FLAGS_WITHOUT_STAFF: i64 = PUBLIC_USER_FLAGS & !USER_FLAG_STAFF; const PUBLIC_USER_FLAGS_WITHOUT_STAFF: i64 = PUBLIC_USER_FLAGS & !USER_FLAG_STAFF;
const NON_ENFORCEMENT_DELETION_REASONS: [i32; 3] = [1, 2, 19]; const NON_ENFORCEMENT_DELETION_REASONS: [i32; 3] = [1, 2, 19];
@@ -95,10 +94,10 @@ mod tests {
} }
#[test] #[test]
fn the_hidden_bit_is_public() { fn the_hidden_bit_is_never_public() {
assert_eq!( assert_eq!(
visible_user_flags(USER_FLAG_PROFILE_HIDDEN | USER_FLAG_DISABLED), visible_user_flags(USER_FLAG_PROFILE_HIDDEN | USER_FLAG_DISABLED | USER_FLAG_PARTNER),
USER_FLAG_PROFILE_HIDDEN as i32 USER_FLAG_PARTNER as i32
); );
} }
} }
@@ -78,6 +78,10 @@ Sudo mode JWTs, as a raw HS256 key. Changing it ends every active sudo mode sess
Connection initiation tokens and harvest download links. Changing it invalidates connection initiation tokens not yet verified and harvest download links already issued. Connection initiation tokens and harvest download links. Changing it invalidates connection initiation tokens not yet verified and harvest download links already issued.
#### `FLUXER_PROFILE_PSEUDONYM_SECRET`
Derives the stable placeholder name and tag that other users see while an account's profile is hidden. Must be byte-identical on `api`, `worker`, and `users-shard`, or the same account shows two different names. Changing it gives every hidden profile a new placeholder. `api` and `worker` refuse to start without it when `FLUXER_ENV` is `production`. `users-shard` refuses to start without it unless `FLUXER_ENV` is `development` or `test`.
#### `FLUXER_GATEWAY_RPC_AUTH_TOKEN` #### `FLUXER_GATEWAY_RPC_AUTH_TOKEN`
Internal RPC between the API and the Gateway. Must be byte-identical on `api`, `worker`, and `gateway`. Internal RPC between the API and the Gateway. Must be byte-identical on `api`, `worker`, and `gateway`.
@@ -75,6 +75,14 @@ To write it by hand, run the generator in a shell the same way:
printf '\nFLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=%s\n' "$(openssl rand -base64 32)" >> .env printf '\nFLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=%s\n' "$(openssl rand -base64 32)" >> .env
``` ```
The refreshed stack requires `FLUXER_PROFILE_PSEUDONYM_SECRET` on `api`, `worker`, and `users-shard`. `--update` replaces a missing or `CHANGE_ME` value with 64 hex characters. Without it, Compose commands fail with `set FLUXER_PROFILE_PSEUDONYM_SECRET in .env`.
To write it by hand, run the generator in a shell the same way:
```bash
printf '\nFLUXER_PROFILE_PSEUDONYM_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env
```
## The script is the reference ## The script is the reference
Read the [Linux and macOS installer](https://fluxer.dev/install.sh) or [Windows installer](https://fluxer.dev/install.ps1) before running it. Read the [Linux and macOS installer](https://fluxer.dev/install.sh) or [Windows installer](https://fluxer.dev/install.ps1) before running it.
+2
View File
@@ -173,6 +173,7 @@ $FluxerBackupVolumes = @(
$FluxerUpgradeSecretKeys = @( $FluxerUpgradeSecretKeys = @(
@{Name = 'FLUXER_ERLANG_COOKIE'; Kind = 'hex'} @{Name = 'FLUXER_ERLANG_COOKIE'; Kind = 'hex'}
@{Name = 'FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64'; Kind = 'base64'} @{Name = 'FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64'; Kind = 'base64'}
@{Name = 'FLUXER_PROFILE_PSEUDONYM_SECRET'; Kind = 'hex'}
) )
$FluxerSecretKeys = @( $FluxerSecretKeys = @(
@@ -181,6 +182,7 @@ $FluxerSecretKeys = @(
@{Name = 'FLUXER_S3_SECRET_KEY'; Kind = 'hex'} @{Name = 'FLUXER_S3_SECRET_KEY'; Kind = 'hex'}
@{Name = 'FLUXER_SUDO_MODE_SECRET'; Kind = 'hex'} @{Name = 'FLUXER_SUDO_MODE_SECRET'; Kind = 'hex'}
@{Name = 'FLUXER_CONNECTION_INITIATION_SECRET'; Kind = 'hex'} @{Name = 'FLUXER_CONNECTION_INITIATION_SECRET'; Kind = 'hex'}
@{Name = 'FLUXER_PROFILE_PSEUDONYM_SECRET'; Kind = 'hex'}
@{Name = 'FLUXER_GATEWAY_RPC_AUTH_TOKEN'; Kind = 'hex'} @{Name = 'FLUXER_GATEWAY_RPC_AUTH_TOKEN'; Kind = 'hex'}
@{Name = 'FLUXER_ERLANG_COOKIE'; Kind = 'hex'} @{Name = 'FLUXER_ERLANG_COOKIE'; Kind = 'hex'}
@{Name = 'FLUXER_MEDIA_PROXY_SECRET_KEY'; Kind = 'hex'} @{Name = 'FLUXER_MEDIA_PROXY_SECRET_KEY'; Kind = 'hex'}
+2
View File
@@ -110,6 +110,7 @@ MEILI_MASTER_KEY hex
FLUXER_S3_SECRET_KEY hex FLUXER_S3_SECRET_KEY hex
FLUXER_SUDO_MODE_SECRET hex FLUXER_SUDO_MODE_SECRET hex
FLUXER_CONNECTION_INITIATION_SECRET hex FLUXER_CONNECTION_INITIATION_SECRET hex
FLUXER_PROFILE_PSEUDONYM_SECRET hex
FLUXER_GATEWAY_RPC_AUTH_TOKEN hex FLUXER_GATEWAY_RPC_AUTH_TOKEN hex
FLUXER_ERLANG_COOKIE hex FLUXER_ERLANG_COOKIE hex
FLUXER_MEDIA_PROXY_SECRET_KEY hex FLUXER_MEDIA_PROXY_SECRET_KEY hex
@@ -1124,6 +1125,7 @@ fluxer_upgrade_secret_keys() {
cat <<'KEYS' cat <<'KEYS'
FLUXER_ERLANG_COOKIE hex FLUXER_ERLANG_COOKIE hex
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 base64 FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 base64
FLUXER_PROFILE_PSEUDONYM_SECRET hex
KEYS KEYS
} }
+2
View File
@@ -11,11 +11,13 @@ chrono = { version = "0.4", default-features = false }
fluxer_common = { path = "../fluxer_common" } fluxer_common = { path = "../fluxer_common" }
fluxer-svc = { path = "../fluxer_svc" } fluxer-svc = { path = "../fluxer_svc" }
futures = "0.3.34" futures = "0.3.34"
hmac = "0.13.0"
moka = { version = "0.12.16", features = ["future"] } moka = { version = "0.12.16", features = ["future"] }
rmp-serde = "1.3" rmp-serde = "1.3"
scylla = { version = "1.9.0", features = ["chrono-04"], optional = true } scylla = { version = "1.9.0", features = ["chrono-04"], optional = true }
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151" serde_json = "1.0.151"
sha2 = "0.11.0"
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread", "signal"] } tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread", "signal"] }
tracing = "0.1.44" tracing = "0.1.44"
+2
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
mod pseudonym;
mod router_impl; mod router_impl;
mod shard_impl; mod shard_impl;
mod types; mod types;
@@ -28,6 +29,7 @@ async fn main() -> anyhow::Result<()> {
match config.mode { match config.mode {
Mode::Router => fluxer_svc::router::run_router(&config, UsersRouter, transport).await, Mode::Router => fluxer_svc::router::run_router(&config, UsersRouter, transport).await,
Mode::Shard => { Mode::Shard => {
pseudonym::configure_from_env()?;
let shard = match config.database_backend { let shard = match config.database_backend {
DatabaseBackend::Postgres => { DatabaseBackend::Postgres => {
let postgres_config = 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) { fn assert_hidden(partial: &UserPartial) {
assert_eq!(partial.username, "HiddenUser"); let expected = crate::pseudonym::pseudonym(partial.user_id);
assert_eq!(partial.discriminator, 0); 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.global_name, None);
assert_eq!(partial.avatar_hash, None); assert_eq!(partial.avatar_hash, None);
assert_eq!(partial.avatar_color, None); assert_eq!(partial.avatar_color, None);
assert_eq!(partial.banner_hash, None); assert_eq!(partial.banner_hash, None);
assert_eq!(partial.banner_color, None); assert_eq!(partial.banner_color, None);
assert_eq!(partial.accent_color, None); assert_eq!(partial.accent_color, None);
assert_ne!(partial.flags.unwrap_or_default() & PROFILE_HIDDEN, 0);
let api = partial.to_api_partial(); 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!( assert_eq!(
(api.username.as_str(), api.discriminator.as_str()), (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] #[test]
@@ -1294,6 +1317,7 @@ mod tests {
assert_eq!(partial.username, "Ada", "{}", user.user_id); assert_eq!(partial.username, "Ada", "{}", user.user_id);
assert_eq!(partial.global_name.as_deref(), Some("Ada Lovelace")); assert_eq!(partial.global_name.as_deref(), Some("Ada Lovelace"));
assert_eq!(partial.banner_hash.as_deref(), Some("banner_hash")); 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); 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 // SPDX-License-Identifier: AGPL-3.0-or-later
use fluxer_common::user_flags::{ use crate::pseudonym::pseudonym;
AccountStanding, USER_FLAG_PROFILE_HIDDEN, USER_FLAG_STAFF, visible_user_flags, use fluxer_common::user_flags::{AccountStanding, USER_FLAG_STAFF, visible_user_flags};
};
#[cfg(test)] #[cfg(test)]
use fluxer_common::user_flags::{USER_FLAG_PARTNER, USER_FLAG_STAFF_HIDDEN}; use fluxer_common::user_flags::{USER_FLAG_PARTNER, USER_FLAG_STAFF_HIDDEN};
use serde::{Deserialize, Deserializer, Serialize}; 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_USERNAME: &str = "Fluxer";
const FLUXER_SYSTEM_DISCRIMINATOR: &str = "0000"; const FLUXER_SYSTEM_DISCRIMINATOR: &str = "0000";
const HIDDEN_USER_USERNAME: &str = "HiddenUser";
pub fn now_ms() -> i64 { pub fn now_ms() -> i64 {
std::time::SystemTime::now() std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .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) { if self.user_id == FLUXER_SYSTEM_USER_ID || !standing.profile_hidden(now_ms) {
return self; return self;
} }
let pseudonym = pseudonym(self.user_id);
UserPartial { UserPartial {
username: HIDDEN_USER_USERNAME.to_owned(), username: pseudonym.username,
discriminator: 0, discriminator: pseudonym.discriminator,
global_name: None, global_name: None,
avatar_hash: None, avatar_hash: None,
flags: Some(self.flags.unwrap_or_default() | USER_FLAG_PROFILE_HIDDEN),
banner_hash: None, banner_hash: None,
banner_color: None, banner_color: None,
accent_color: None, accent_color: None,
+14
View File
@@ -143,6 +143,7 @@ function defaultConfig(): MasterConfig {
auth: { auth: {
sudo_mode_secret: '', sudo_mode_secret: '',
connection_initiation_secret: '', connection_initiation_secret: '',
profile_pseudonym_secret: '',
sso_allow_private_addresses: false, sso_allow_private_addresses: false,
passkeys: { passkeys: {
rp_name: 'Fluxer', rp_name: 'Fluxer',
@@ -302,6 +303,18 @@ function requireString(value: string | undefined, envName: string): void {
} }
} }
const DEVELOPMENT_PROFILE_PSEUDONYM_SECRET = 'fluxer-dev-profile-pseudonym-secret';
function applyProfilePseudonymSecret(config: MasterConfig): void {
if (config.auth.profile_pseudonym_secret.trim().length > 0) {
return;
}
if (config.env === 'production') {
throw new Error('FLUXER_PROFILE_PSEUDONYM_SECRET is required');
}
config.auth.profile_pseudonym_secret = DEVELOPMENT_PROFILE_PSEUDONYM_SECRET;
}
function validateReplyToEmail(value: string): void { function validateReplyToEmail(value: string): void {
if (value !== '' && !/^[^\s@<>,;"]+@[^\s@<>,;"]+$/.test(value)) { if (value !== '' && !/^[^\s@<>,;"]+@[^\s@<>,;"]+$/.test(value)) {
throw new Error('FLUXER_EMAIL_REPLY_TO_EMAIL must be a single email address such as [email protected]'); throw new Error('FLUXER_EMAIL_REPLY_TO_EMAIL must be a single email address such as [email protected]');
@@ -600,6 +613,7 @@ function normalizeConfig(config: MasterConfig): MasterConfig {
} }
requireString(config.auth.sudo_mode_secret, 'FLUXER_SUDO_MODE_SECRET'); requireString(config.auth.sudo_mode_secret, 'FLUXER_SUDO_MODE_SECRET');
requireString(config.auth.connection_initiation_secret, 'FLUXER_CONNECTION_INITIATION_SECRET'); requireString(config.auth.connection_initiation_secret, 'FLUXER_CONNECTION_INITIATION_SECRET');
applyProfilePseudonymSecret(config);
validateVapidConfig(config); validateVapidConfig(config);
requireString(config.s3?.access_key_id, 'FLUXER_S3_ACCESS_KEY_ID'); requireString(config.s3?.access_key_id, 'FLUXER_S3_ACCESS_KEY_ID');
requireString(config.s3?.secret_access_key, 'FLUXER_S3_SECRET_ACCESS_KEY'); requireString(config.s3?.secret_access_key, 'FLUXER_S3_SECRET_ACCESS_KEY');
+1
View File
@@ -147,6 +147,7 @@ export interface MasterConfig {
auth: { auth: {
sudo_mode_secret: string; sudo_mode_secret: string;
connection_initiation_secret: string; connection_initiation_secret: string;
profile_pseudonym_secret: string;
sso_allow_private_addresses: boolean; sso_allow_private_addresses: boolean;
passkeys: { passkeys: {
rp_name: string; rp_name: string;
@@ -25,6 +25,7 @@ const MINIMAL_ENV: Record<string, string> = {
FLUXER_GATEWAY_RPC_AUTH_TOKEN: 'test-gateway-token', FLUXER_GATEWAY_RPC_AUTH_TOKEN: 'test-gateway-token',
FLUXER_SUDO_MODE_SECRET: 'test-sudo-secret', FLUXER_SUDO_MODE_SECRET: 'test-sudo-secret',
FLUXER_CONNECTION_INITIATION_SECRET: 'test-connection-secret', FLUXER_CONNECTION_INITIATION_SECRET: 'test-connection-secret',
FLUXER_PROFILE_PSEUDONYM_SECRET: 'test-profile-pseudonym-secret',
FLUXER_VAPID_PUBLIC_KEY: 'BB76bTFIuoqmxJtTfZX0yGTn1f_qu9H03B_nkj8OyExJFkN7Y-HBZZzShnHZoEhXKc5ZRy3jFu7OkBbnaQG-4aw', FLUXER_VAPID_PUBLIC_KEY: 'BB76bTFIuoqmxJtTfZX0yGTn1f_qu9H03B_nkj8OyExJFkN7Y-HBZZzShnHZoEhXKc5ZRy3jFu7OkBbnaQG-4aw',
FLUXER_VAPID_PRIVATE_KEY: 'Xgi-3P8J-I3Q6U1HlCcXMuc_tKLGAM9nIfznX3Hz68o', FLUXER_VAPID_PRIVATE_KEY: 'Xgi-3P8J-I3Q6U1HlCcXMuc_tKLGAM9nIfznX3Hz68o',
}; };
@@ -475,6 +476,25 @@ describe('ConfigLoader', () => {
await expect(loadConfig()).rejects.toThrow('Invalid FLUXER_KV_MODE: sentinel'); await expect(loadConfig()).rejects.toThrow('Invalid FLUXER_KV_MODE: sentinel');
}); });
test('reads the profile pseudonym secret and requires it in production', async () => {
stubMinimalEnv();
expect((await loadConfig()).auth.profile_pseudonym_secret).toBe('test-profile-pseudonym-secret');
resetConfig();
stubMinimalEnv({FLUXER_PROFILE_PSEUDONYM_SECRET: ''});
expect((await loadConfig()).auth.profile_pseudonym_secret).toBe('fluxer-dev-profile-pseudonym-secret');
resetConfig();
stubMinimalEnv({
FLUXER_ENV: 'production',
FLUXER_POSTGRES_HOST: 'postgres.internal',
FLUXER_POSTGRES_DATABASE: 'fluxer_prod',
FLUXER_POSTGRES_USERNAME: 'fluxer_app',
FLUXER_POSTGRES_PASSWORD: 'prod-postgres-secret',
FLUXER_POSTGRES_SSL: 'true',
FLUXER_PROFILE_PSEUDONYM_SECRET: '',
});
await expect(loadConfig()).rejects.toThrow('FLUXER_PROFILE_PSEUDONYM_SECRET is required');
});
test('rejects unsafe production Postgres defaults', async () => { test('rejects unsafe production Postgres defaults', async () => {
stubMinimalEnv({FLUXER_ENV: 'production'}); stubMinimalEnv({FLUXER_ENV: 'production'});
await expect(loadConfig()).rejects.toThrow('FLUXER_POSTGRES_HOST'); await expect(loadConfig()).rejects.toThrow('FLUXER_POSTGRES_HOST');
@@ -21,6 +21,7 @@ const SECRETS: Record<string, string> = {
FLUXER_ERLANG_COOKIE: 'erlang-cookie', FLUXER_ERLANG_COOKIE: 'erlang-cookie',
FLUXER_SUDO_MODE_SECRET: 'sudo-mode-secret', FLUXER_SUDO_MODE_SECRET: 'sudo-mode-secret',
FLUXER_CONNECTION_INITIATION_SECRET: 'connection-initiation-secret', FLUXER_CONNECTION_INITIATION_SECRET: 'connection-initiation-secret',
FLUXER_PROFILE_PSEUDONYM_SECRET: 'profile-pseudonym-secret',
FLUXER_GATEWAY_RPC_AUTH_TOKEN: 'gateway-rpc-auth-token', FLUXER_GATEWAY_RPC_AUTH_TOKEN: 'gateway-rpc-auth-token',
FLUXER_MEDIA_PROXY_SECRET_KEY: 'media-proxy-secret-key', FLUXER_MEDIA_PROXY_SECRET_KEY: 'media-proxy-secret-key',
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=', FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=',
@@ -128,6 +128,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
FLUXER_GATEWAY_RPC_AUTH_TOKEN: {path: ['services', 'gateway', 'rpc_auth_token']}, FLUXER_GATEWAY_RPC_AUTH_TOKEN: {path: ['services', 'gateway', 'rpc_auth_token']},
FLUXER_SUDO_MODE_SECRET: {path: ['auth', 'sudo_mode_secret']}, FLUXER_SUDO_MODE_SECRET: {path: ['auth', 'sudo_mode_secret']},
FLUXER_CONNECTION_INITIATION_SECRET: {path: ['auth', 'connection_initiation_secret']}, FLUXER_CONNECTION_INITIATION_SECRET: {path: ['auth', 'connection_initiation_secret']},
FLUXER_PROFILE_PSEUDONYM_SECRET: {path: ['auth', 'profile_pseudonym_secret']},
FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES: {path: ['auth', 'sso_allow_private_addresses'], parse: parseBoolean}, FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES: {path: ['auth', 'sso_allow_private_addresses'], parse: parseBoolean},
FLUXER_VAPID_PUBLIC_KEY: {path: ['auth', 'vapid', 'public_key']}, FLUXER_VAPID_PUBLIC_KEY: {path: ['auth', 'vapid', 'public_key']},
FLUXER_VAPID_PRIVATE_KEY: {path: ['auth', 'vapid', 'private_key']}, FLUXER_VAPID_PRIVATE_KEY: {path: ['auth', 'vapid', 'private_key']},
-4
View File
@@ -155,8 +155,6 @@ export const DELETED_USER_USERNAME = 'DeletedUser';
export const DELETED_USER_GLOBAL_NAME = 'Deleted User'; export const DELETED_USER_GLOBAL_NAME = 'Deleted User';
export const DELETED_USER_DISCRIMINATOR = 0; export const DELETED_USER_DISCRIMINATOR = 0;
export const DELETED_USER_ID = 1n; export const DELETED_USER_ID = 1n;
export const HIDDEN_USER_USERNAME = 'HiddenUser';
export const HIDDEN_USER_DISCRIMINATOR = 0;
export const PublicUserFlags = { export const PublicUserFlags = {
STAFF: Number(UserFlags.STAFF), STAFF: Number(UserFlags.STAFF),
PARTNER: Number(UserFlags.PARTNER), PARTNER: Number(UserFlags.PARTNER),
@@ -164,7 +162,6 @@ export const PublicUserFlags = {
FRIENDLY_BOT: Number(UserFlags.FRIENDLY_BOT), FRIENDLY_BOT: Number(UserFlags.FRIENDLY_BOT),
FRIENDLY_BOT_MANUAL_APPROVAL: Number(UserFlags.FRIENDLY_BOT_MANUAL_APPROVAL), FRIENDLY_BOT_MANUAL_APPROVAL: Number(UserFlags.FRIENDLY_BOT_MANUAL_APPROVAL),
SPAMMER: Number(UserFlags.SPAMMER), SPAMMER: Number(UserFlags.SPAMMER),
PROFILE_HIDDEN: Number(UserFlags.PROFILE_HIDDEN),
} as const; } as const;
export const PublicUserFlagsDescriptions: Record<keyof typeof PublicUserFlags, string> = { export const PublicUserFlagsDescriptions: Record<keyof typeof PublicUserFlags, string> = {
STAFF: 'User is a staff member', STAFF: 'User is a staff member',
@@ -173,7 +170,6 @@ export const PublicUserFlagsDescriptions: Record<keyof typeof PublicUserFlags, s
FRIENDLY_BOT: 'Bot accepts friend requests from users', FRIENDLY_BOT: 'Bot accepts friend requests from users',
FRIENDLY_BOT_MANUAL_APPROVAL: 'Bot requires manual approval for friend requests', FRIENDLY_BOT_MANUAL_APPROVAL: 'Bot requires manual approval for friend requests',
SPAMMER: 'User is flagged as a spammer', SPAMMER: 'User is flagged as a spammer',
PROFILE_HIDDEN: 'User profile details are hidden',
}; };
export const ThemeTypes = { export const ThemeTypes = {
DARK: 'dark', DARK: 'dark',