diff --git a/Cargo.lock b/Cargo.lock index c4a5996c5..6ab8fcbe9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1986,11 +1986,13 @@ dependencies = [ "fluxer-svc", "fluxer_common", "futures", + "hmac 0.13.0", "moka", "rmp-serde", "scylla", "serde", "serde_json", + "sha2 0.11.0", "tokio", "tracing", ] diff --git a/config/env/development.env b/config/env/development.env index 175931913..578830e23 100644 --- a/config/env/development.env +++ b/config/env/development.env @@ -89,6 +89,7 @@ FLUXER_ADMIN_OAUTH_REDIRECT_URI=http://localhost:8088/admin/oauth2_callback FLUXER_SUDO_MODE_SECRET=dev-sudo-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_PRIVATE_KEY=cs24JvXSxHiqJQgkJNocJFAdzJpPmpfU9xD-fDpn3tw FLUXER_VAPID_EMAIL=dev@localhost diff --git a/deploy/self-hosting/.env.example b/deploy/self-hosting/.env.example index f937ccfea..9ca71daf6 100644 --- a/deploy/self-hosting/.env.example +++ b/deploy/self-hosting/.env.example @@ -184,6 +184,7 @@ FLUXER_S3_SECRET_KEY=CHANGE_ME FLUXER_SUDO_MODE_SECRET=CHANGE_ME FLUXER_CONNECTION_INITIATION_SECRET=CHANGE_ME +FLUXER_PROFILE_PSEUDONYM_SECRET=CHANGE_ME FLUXER_GATEWAY_RPC_AUTH_TOKEN=CHANGE_ME FLUXER_ERLANG_COOKIE=CHANGE_ME FLUXER_MEDIA_PROXY_SECRET_KEY=CHANGE_ME diff --git a/deploy/self-hosting/docker-compose.yml b/deploy/self-hosting/docker-compose.yml index dfed1bd0e..7ad1b3dd6 100644 --- a/deploy/self-hosting/docker-compose.yml +++ b/deploy/self-hosting/docker-compose.yml @@ -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_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_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} diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 9e769ddab..3795e4bb5 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -14265,8 +14265,7 @@ "value": "32", "description": "Bot requires manual approval for friend requests" }, - {"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"}, - {"name": "PROFILE_HIDDEN", "value": "128", "description": "User profile details are hidden"} + {"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"} ] }, "MessageEmbedChildResponse": { diff --git a/fluxer_api/src/api/Config.ts b/fluxer_api/src/api/Config.ts index 0a2983f7c..8a0d906a3 100644 --- a/fluxer_api/src/api/Config.ts +++ b/fluxer_api/src/api/Config.ts @@ -391,6 +391,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig { auth: { sudoModeSecret: master.auth.sudo_mode_secret, connectionInitiationSecret: master.auth.connection_initiation_secret, + profilePseudonymSecret: master.auth.profile_pseudonym_secret, ssoAllowPrivateAddresses: master.auth.sso_allow_private_addresses, passkeys: { rpName: master.auth.passkeys.rp_name, diff --git a/fluxer_api/src/api/config/APIConfig.ts b/fluxer_api/src/api/config/APIConfig.ts index 492f8e7d1..f71332380 100644 --- a/fluxer_api/src/api/config/APIConfig.ts +++ b/fluxer_api/src/api/config/APIConfig.ts @@ -249,6 +249,7 @@ export interface APIConfig { auth: { sudoModeSecret: string; connectionInitiationSecret: string; + profilePseudonymSecret: string; ssoAllowPrivateAddresses: boolean; passkeys: { rpName: string; diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 64d8202ac..d397fdbd0 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -31309,8 +31309,7 @@ "value": "32", "description": "Bot requires manual approval for friend requests" }, - {"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"}, - {"name": "PROFILE_HIDDEN", "value": "128", "description": "User profile details are hidden"} + {"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"} ] }, "RefreshedAttachmentUrl": { diff --git a/fluxer_api/src/api/user/ProfileVisibility.ts b/fluxer_api/src/api/user/ProfileVisibility.ts index ca8c8b2fb..6765cfa87 100644 --- a/fluxer_api/src/api/user/ProfileVisibility.ts +++ b/fluxer_api/src/api/user/ProfileVisibility.ts @@ -1,14 +1,13 @@ // 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 {isTemporarilyBanned} from '@app/api/user/UserHelpers'; +import {generateSeededUsername} from '@app/api/utils/UsernameGenerator'; import {DeletionReasons} from '@fluxer/constants/src/Core'; -import { - HIDDEN_USER_DISCRIMINATOR, - HIDDEN_USER_USERNAME, - 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 type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; 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); } -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 { - ...partial, - username: HIDDEN_USER_USERNAME, - discriminator: HIDDEN_USER_DISCRIMINATOR.toString().padStart(4, '0'), - global_name: null, - avatar: null, - avatar_color: null, - flags: partial.flags | PublicUserFlags.PROFILE_HIDDEN, + username: generateSeededUsername(seed), + discriminator: pseudonymDiscriminator(seed).toString().padStart(4, '0'), }; } -export function isHiddenPartial(partial: Pick): boolean { - return (partial.flags & PublicUserFlags.PROFILE_HIDDEN) !== 0; +export function hiddenUserPartial(partial: UserPartialResponse): UserPartialResponse { + 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, +): 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 { diff --git a/fluxer_api/src/api/user/tests/HiddenProfileRoutes.test.ts b/fluxer_api/src/api/user/tests/HiddenProfileRoutes.test.ts index e083d2dc1..a66affcc4 100644 --- a/fluxer_api/src/api/user/tests/HiddenProfileRoutes.test.ts +++ b/fluxer_api/src/api/user/tests/HiddenProfileRoutes.test.ts @@ -6,12 +6,15 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa import {NoopGatewayService} from '@app/api/test/NoopGatewayService'; import {HTTP_STATUS} from '@app/api/test/TestConstants'; 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 {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 {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'; +const PROFILE_HIDDEN_BIT = Number(UserFlags.PROFILE_HIDDEN); + interface AdminUser { username: string; global_name: string | null; @@ -27,6 +30,7 @@ describe('hidden profiles', () => { let target: TestAccount; let guildId: string; let targetName: string; + let pseudonym: {username: string; discriminator: string}; beforeEach(async () => { harness = await createApiTestHarness(); @@ -52,6 +56,7 @@ describe('hidden profiles', () => { .expect(HTTP_STATUS.OK) .execute(); targetName = (await fetchUser(harness, target.userId, viewer.token)).json.username; + pseudonym = profilePseudonym(target.userId); }); afterEach(async () => { @@ -62,7 +67,7 @@ describe('hidden profiles', () => { async function expectShown(): Promise { const {json} = await fetchUser(harness, target.userId, viewer.token); 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); expect(profile.json.user_profile).toMatchObject({bio: 'shown bio', pronouns: 'they/them'}); const member = await getMember(harness, viewer.token, guildId, target.userId); @@ -71,13 +76,15 @@ describe('hidden profiles', () => { async function expectHidden(): Promise { const {json} = await fetchUser(harness, target.userId, viewer.token); - expect(json).toMatchObject({username: 'HiddenUser', discriminator: '0000', global_name: null, avatar: null}); - expect(json.flags & PublicUserFlags.PROFILE_HIDDEN).toBe(PublicUserFlags.PROFILE_HIDDEN); + expect(json).toMatchObject({...pseudonym, global_name: null, avatar: null}); + expect(json.username).not.toBe(targetName); + expect(json.flags & PROFILE_HIDDEN_BIT).toBe(0); const profile = await fetchUserProfile(harness, target.userId, viewer.token); 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); 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 { @@ -111,8 +118,15 @@ describe('hidden profiles', () => { .execute(); await expectHidden(); await expectStaffSeeStoredProfile(); - expect(memberUpdates().map((member) => [member.user.username, member.nick])).toEqual([['HiddenUser', null]]); - expect(presence.mock.calls.some(([params]) => params.event === 'USER_UPDATE')).toBe(true); + expect(memberUpdates().map((member) => [member.user.username, member.user.discriminator, member.nick])).toEqual([ + [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) .delete(`/admin/users/${target.userId}/ban`) .body({notify_user: false}) @@ -120,7 +134,7 @@ describe('hidden profiles', () => { .execute(); await expectShown(); expect(memberUpdates().map((member) => [member.user.username, member.nick])).toEqual([ - ['HiddenUser', null], + [pseudonym.username, null], [targetName, 'Shown Nick'], ]); }); @@ -168,6 +182,7 @@ describe('hidden profiles', () => { await expectHidden(); const own = await fetchUserMe(harness, target.token); 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) .patch(`/admin/users/${target.userId}/flags`) .body({remove_flags: [UserFlags.PROFILE_HIDDEN.toString()]}) diff --git a/fluxer_api/src/api/user/tests/ProfilePseudonymVectors.test.ts b/fluxer_api/src/api/user/tests/ProfilePseudonymVectors.test.ts new file mode 100644 index 000000000..bdf2fd5b4 --- /dev/null +++ b/fluxer_api/src/api/user/tests/ProfilePseudonymVectors.test.ts @@ -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; + vectors: Array; +} + +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); + }); +}); diff --git a/fluxer_api/src/api/user/tests/ProfileVisibility.test.ts b/fluxer_api/src/api/user/tests/ProfileVisibility.test.ts index 7dcafdc01..262a6e7ef 100644 --- a/fluxer_api/src/api/user/tests/ProfileVisibility.test.ts +++ b/fluxer_api/src/api/user/tests/ProfileVisibility.test.ts @@ -9,7 +9,7 @@ import {LimitConfigService, resetGlobalLimitConfigServiceForTesting} from '@app/ import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import {GuildMember} from '@app/api/models/GuildMember'; 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 { hasPartialUserFieldsChanged, mapGuildMemberToProfileResponse, @@ -18,17 +18,21 @@ import { mapUserToProfileResponse, } from '@app/api/user/UserMappers'; 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 {afterAll, beforeAll, describe, expect, it} from 'vitest'; const NOW = Date.now(); 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 = {}): User { return new User({ ...EMPTY_USER_ROW, - user_id: createUserID(1174109840998400001n), + user_id: createUserID(USER_ID), username: 'ada', discriminator: 7, global_name: 'Ada Lovelace', @@ -96,16 +100,16 @@ describe('profile visibility', () => { const subject = user(overrides); expect(isProfileHidden(subject, NOW)).toBe(hidden); const partial = mapUserToPartialResponse(subject); + expect(partial.flags & PROFILE_HIDDEN_BIT).toBe(0); if (hidden) { expect(partial).toMatchObject({ id: subject.id.toString(), - username: 'HiddenUser', - discriminator: '0000', + ...profilePseudonym(USER_ID), global_name: null, avatar: null, avatar_color: null, }); - expect(partial.flags & PublicUserFlags.PROFILE_HIDDEN).toBe(PublicUserFlags.PROFILE_HIDDEN); + expect(isHiddenPartial(partial)).toBe(true); expect(mapUserToProfileResponse(subject)).toEqual({ bio: null, pronouns: null, @@ -114,7 +118,7 @@ describe('profile visibility', () => { accent_color: null, }); } else { - expect(partial.flags & PublicUserFlags.PROFILE_HIDDEN).toBe(0); + expect(isHiddenPartial(partial)).toBe(false); expect(partial.username).toBe('ada'); expect(partial.global_name).toBe('Ada Lovelace'); expect(partial.avatar).toBe('a1b2c3'); @@ -126,12 +130,54 @@ describe('profile visibility', () => { const own = mapUserToPrivateResponse(user({flags: UserFlags.PROFILE_HIDDEN})); expect(own).toMatchObject({ username: 'ada', + discriminator: '0007', global_name: 'Ada Lovelace', avatar: 'a1b2c3', bio: 'analytical engine enjoyer', pronouns: 'she/her', 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(); + 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', () => { @@ -150,7 +196,7 @@ describe('profile visibility', () => { it('hides guild-specific profile details for a hidden member', async () => { const member = new GuildMember({ guild_id: createGuildID(5n), - user_id: createUserID(1174109840998400001n), + user_id: createUserID(USER_ID), joined_at: new Date(NOW - HOUR), nick: 'Countess', avatar_hash: 'm4v', @@ -178,7 +224,7 @@ describe('profile visibility', () => { createRequestCache(), ); 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()); expect(shown).toMatchObject({nick: 'Countess', avatar: 'm4v', banner: 'm8n', accent_color: 12}); expect(mapGuildMemberToProfileResponse(member, {hidden: true})).toEqual({ diff --git a/fluxer_api/src/api/utils/UsernameGenerator.ts b/fluxer_api/src/api/utils/UsernameGenerator.ts index 4e1a1120d..771721d07 100644 --- a/fluxer_api/src/api/utils/UsernameGenerator.ts +++ b/fluxer_api/src/api/utils/UsernameGenerator.ts @@ -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 tails = readFileSync(resolveAssetPath('words', 'tails.txt'), 'utf-8').trim().split('\n').filter(Boolean); +type IndexPicker = (size: number) => number; + function capitalize(word: string): string { return word.charAt(0).toUpperCase() + word.slice(1); } -function pickRandom(words: Array): string { - return words[randomInt(words.length)]; -} - -export function generateRandomUsername(): string { +function generateUsername(pick: IndexPicker): string { const MAX_LENGTH = 32; const MAX_ATTEMPTS = 100; 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) { return username; } @@ -33,3 +31,25 @@ export function generateRandomUsername(): string { } 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)); +} diff --git a/fluxer_common/src/testdata/profile_pseudonym_vectors.json b/fluxer_common/src/testdata/profile_pseudonym_vectors.json new file mode 100644 index 000000000..cec8d33e3 --- /dev/null +++ b/fluxer_common/src/testdata/profile_pseudonym_vectors.json @@ -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" + } + ] +} diff --git a/fluxer_common/src/user_flags.rs b/fluxer_common/src/user_flags.rs index b03366aea..f3c03f4dd 100644 --- a/fluxer_common/src/user_flags.rs +++ b/fluxer_common/src/user_flags.rs @@ -13,8 +13,7 @@ const PUBLIC_USER_FLAGS: i64 = USER_FLAG_STAFF | USER_FLAG_BUG_HUNTER | USER_FLAG_FRIENDLY_BOT | USER_FLAG_FRIENDLY_BOT_MANUAL_APPROVAL - | USER_FLAG_SPAMMER - | USER_FLAG_PROFILE_HIDDEN; + | USER_FLAG_SPAMMER; const PUBLIC_USER_FLAGS_WITHOUT_STAFF: i64 = PUBLIC_USER_FLAGS & !USER_FLAG_STAFF; const NON_ENFORCEMENT_DELETION_REASONS: [i32; 3] = [1, 2, 19]; @@ -95,10 +94,10 @@ mod tests { } #[test] - fn the_hidden_bit_is_public() { + fn the_hidden_bit_is_never_public() { assert_eq!( - visible_user_flags(USER_FLAG_PROFILE_HIDDEN | USER_FLAG_DISABLED), - USER_FLAG_PROFILE_HIDDEN as i32 + visible_user_flags(USER_FLAG_PROFILE_HIDDEN | USER_FLAG_DISABLED | USER_FLAG_PARTNER), + USER_FLAG_PARTNER as i32 ); } } diff --git a/fluxer_docs/src/content/docs/operator/configuration.mdx b/fluxer_docs/src/content/docs/operator/configuration.mdx index 946107b75..a7208860b 100644 --- a/fluxer_docs/src/content/docs/operator/configuration.mdx +++ b/fluxer_docs/src/content/docs/operator/configuration.mdx @@ -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. +#### `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` Internal RPC between the API and the Gateway. Must be byte-identical on `api`, `worker`, and `gateway`. diff --git a/fluxer_docs/src/content/docs/operator/upgrading.mdx b/fluxer_docs/src/content/docs/operator/upgrading.mdx index 4088fe45d..1984b331f 100644 --- a/fluxer_docs/src/content/docs/operator/upgrading.mdx +++ b/fluxer_docs/src/content/docs/operator/upgrading.mdx @@ -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 ``` +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 Read the [Linux and macOS installer](https://fluxer.dev/install.sh) or [Windows installer](https://fluxer.dev/install.ps1) before running it. diff --git a/fluxer_docs/src/installer/install.ps1 b/fluxer_docs/src/installer/install.ps1 index 8b6845a44..262dd7a70 100644 --- a/fluxer_docs/src/installer/install.ps1 +++ b/fluxer_docs/src/installer/install.ps1 @@ -173,6 +173,7 @@ $FluxerBackupVolumes = @( $FluxerUpgradeSecretKeys = @( @{Name = 'FLUXER_ERLANG_COOKIE'; Kind = 'hex'} @{Name = 'FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64'; Kind = 'base64'} + @{Name = 'FLUXER_PROFILE_PSEUDONYM_SECRET'; Kind = 'hex'} ) $FluxerSecretKeys = @( @@ -181,6 +182,7 @@ $FluxerSecretKeys = @( @{Name = 'FLUXER_S3_SECRET_KEY'; Kind = 'hex'} @{Name = 'FLUXER_SUDO_MODE_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_ERLANG_COOKIE'; Kind = 'hex'} @{Name = 'FLUXER_MEDIA_PROXY_SECRET_KEY'; Kind = 'hex'} diff --git a/fluxer_docs/src/installer/install.sh b/fluxer_docs/src/installer/install.sh index 38dad8f64..74ab1a5b5 100644 --- a/fluxer_docs/src/installer/install.sh +++ b/fluxer_docs/src/installer/install.sh @@ -110,6 +110,7 @@ MEILI_MASTER_KEY hex FLUXER_S3_SECRET_KEY hex FLUXER_SUDO_MODE_SECRET hex FLUXER_CONNECTION_INITIATION_SECRET hex +FLUXER_PROFILE_PSEUDONYM_SECRET hex FLUXER_GATEWAY_RPC_AUTH_TOKEN hex FLUXER_ERLANG_COOKIE hex FLUXER_MEDIA_PROXY_SECRET_KEY hex @@ -1124,6 +1125,7 @@ fluxer_upgrade_secret_keys() { cat <<'KEYS' FLUXER_ERLANG_COOKIE hex FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 base64 +FLUXER_PROFILE_PSEUDONYM_SECRET hex KEYS } diff --git a/fluxer_users/Cargo.toml b/fluxer_users/Cargo.toml index a14ba3f6e..60a15b870 100644 --- a/fluxer_users/Cargo.toml +++ b/fluxer_users/Cargo.toml @@ -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" diff --git a/fluxer_users/src/main.rs b/fluxer_users/src/main.rs index 5ca4df749..1f9bcda69 100644 --- a/fluxer_users/src/main.rs +++ b/fluxer_users/src/main.rs @@ -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 = diff --git a/fluxer_users/src/pseudonym.rs b/fluxer_users/src/pseudonym.rs new file mode 100644 index 000000000..604532f02 --- /dev/null +++ b/fluxer_users/src/pseudonym.rs @@ -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; + +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> = OnceLock::new(); +static SCALES: LazyLock> = LazyLock::new(|| word_list(SCALES_TEXT)); +static TAILS: LazyLock> = 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, 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::() + .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::>(); + 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::>(); + 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()); + } +} diff --git a/fluxer_users/src/shard_impl.rs b/fluxer_users/src/shard_impl.rs index a2da555a4..9fd031b00 100644 --- a/fluxer_users/src/shard_impl.rs +++ b/fluxer_users/src/shard_impl.rs @@ -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); } } diff --git a/fluxer_users/src/types.rs b/fluxer_users/src/types.rs index 60a0e4466..a52fa6297 100644 --- a/fluxer_users/src/types.rs +++ b/fluxer_users/src/types.rs @@ -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, diff --git a/packages/config/src/ConfigLoader.ts b/packages/config/src/ConfigLoader.ts index 41f0236fc..7d618dd89 100644 --- a/packages/config/src/ConfigLoader.ts +++ b/packages/config/src/ConfigLoader.ts @@ -143,6 +143,7 @@ function defaultConfig(): MasterConfig { auth: { sudo_mode_secret: '', connection_initiation_secret: '', + profile_pseudonym_secret: '', sso_allow_private_addresses: false, passkeys: { 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 { if (value !== '' && !/^[^\s@<>,;"]+@[^\s@<>,;"]+$/.test(value)) { throw new Error('FLUXER_EMAIL_REPLY_TO_EMAIL must be a single email address such as support@example.com'); @@ -600,6 +613,7 @@ function normalizeConfig(config: MasterConfig): MasterConfig { } requireString(config.auth.sudo_mode_secret, 'FLUXER_SUDO_MODE_SECRET'); requireString(config.auth.connection_initiation_secret, 'FLUXER_CONNECTION_INITIATION_SECRET'); + applyProfilePseudonymSecret(config); validateVapidConfig(config); requireString(config.s3?.access_key_id, 'FLUXER_S3_ACCESS_KEY_ID'); requireString(config.s3?.secret_access_key, 'FLUXER_S3_SECRET_ACCESS_KEY'); diff --git a/packages/config/src/MasterConfig.ts b/packages/config/src/MasterConfig.ts index 2982302ff..3832d9a81 100644 --- a/packages/config/src/MasterConfig.ts +++ b/packages/config/src/MasterConfig.ts @@ -147,6 +147,7 @@ export interface MasterConfig { auth: { sudo_mode_secret: string; connection_initiation_secret: string; + profile_pseudonym_secret: string; sso_allow_private_addresses: boolean; passkeys: { rp_name: string; diff --git a/packages/config/src/__tests__/ConfigLoader.test.ts b/packages/config/src/__tests__/ConfigLoader.test.ts index c6cea336b..2d4e8a7e6 100644 --- a/packages/config/src/__tests__/ConfigLoader.test.ts +++ b/packages/config/src/__tests__/ConfigLoader.test.ts @@ -25,6 +25,7 @@ const MINIMAL_ENV: Record = { FLUXER_GATEWAY_RPC_AUTH_TOKEN: 'test-gateway-token', FLUXER_SUDO_MODE_SECRET: 'test-sudo-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_PRIVATE_KEY: 'Xgi-3P8J-I3Q6U1HlCcXMuc_tKLGAM9nIfznX3Hz68o', }; @@ -475,6 +476,25 @@ describe('ConfigLoader', () => { 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 () => { stubMinimalEnv({FLUXER_ENV: 'production'}); await expect(loadConfig()).rejects.toThrow('FLUXER_POSTGRES_HOST'); diff --git a/packages/config/src/__tests__/NonDefaultPortCompose.test.ts b/packages/config/src/__tests__/NonDefaultPortCompose.test.ts index 7c5271e26..0e0cc25c3 100644 --- a/packages/config/src/__tests__/NonDefaultPortCompose.test.ts +++ b/packages/config/src/__tests__/NonDefaultPortCompose.test.ts @@ -21,6 +21,7 @@ const SECRETS: Record = { FLUXER_ERLANG_COOKIE: 'erlang-cookie', FLUXER_SUDO_MODE_SECRET: 'sudo-mode-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_MEDIA_PROXY_SECRET_KEY: 'media-proxy-secret-key', FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=', diff --git a/packages/config/src/config_loader/EnvironmentOverrides.ts b/packages/config/src/config_loader/EnvironmentOverrides.ts index def624ee0..f20039f06 100644 --- a/packages/config/src/config_loader/EnvironmentOverrides.ts +++ b/packages/config/src/config_loader/EnvironmentOverrides.ts @@ -128,6 +128,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { FLUXER_GATEWAY_RPC_AUTH_TOKEN: {path: ['services', 'gateway', 'rpc_auth_token']}, FLUXER_SUDO_MODE_SECRET: {path: ['auth', 'sudo_mode_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_VAPID_PUBLIC_KEY: {path: ['auth', 'vapid', 'public_key']}, FLUXER_VAPID_PRIVATE_KEY: {path: ['auth', 'vapid', 'private_key']}, diff --git a/packages/constants/src/UserConstants.ts b/packages/constants/src/UserConstants.ts index 39a7ebaf8..0fdb4c1e7 100644 --- a/packages/constants/src/UserConstants.ts +++ b/packages/constants/src/UserConstants.ts @@ -155,8 +155,6 @@ export const DELETED_USER_USERNAME = 'DeletedUser'; export const DELETED_USER_GLOBAL_NAME = 'Deleted User'; export const DELETED_USER_DISCRIMINATOR = 0; export const DELETED_USER_ID = 1n; -export const HIDDEN_USER_USERNAME = 'HiddenUser'; -export const HIDDEN_USER_DISCRIMINATOR = 0; export const PublicUserFlags = { STAFF: Number(UserFlags.STAFF), PARTNER: Number(UserFlags.PARTNER), @@ -164,7 +162,6 @@ export const PublicUserFlags = { FRIENDLY_BOT: Number(UserFlags.FRIENDLY_BOT), FRIENDLY_BOT_MANUAL_APPROVAL: Number(UserFlags.FRIENDLY_BOT_MANUAL_APPROVAL), SPAMMER: Number(UserFlags.SPAMMER), - PROFILE_HIDDEN: Number(UserFlags.PROFILE_HIDDEN), } as const; export const PublicUserFlagsDescriptions: Record = { STAFF: 'User is a staff member', @@ -173,7 +170,6 @@ export const PublicUserFlagsDescriptions: Record