@samitouri / QOSami-HFS / commits / 593376ba

avoid username probing by sending same reply for bad username and bad password

Massimo Melina committed Jul 11, 2026 at 16:00 UTC 593376ba984aa89e47837661ef001470ab76c93b
2 files changed +29 -10
src/api.auth.ts
+22 -7
@@ -1,7 +1,8 @@
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 import {
4 - accountCanLogin, accountIsDisabled, accountCanChangePassword, expandUsername, getAccount, updateAccount, saveSrpInfo
4 + Account, accountCanLogin, accountIsDisabled, accountCanChangePassword, expandUsername, getAccount, normalizeUsername,
5 + updateAccount, saveSrpInfo
6 } from './perm'
7 import { ApiError, ApiHandler, ApiHandlers } from './apiMiddleware'
8 import { SRPServerSessionStep1 } from 'tssrp6a'
@@ -15,10 +16,11 @@ import { clearTextLogin, getCurrentUsername, setLoggedIn, srpServerStep1 } from
16 import { defineConfig } from './config'
17 import events from './events'
18 import { apiAssertTypes } from './misc'
18 -import { randomUUID } from 'node:crypto'
19 +import { createHmac, randomBytes, randomUUID } from 'node:crypto'
20
21 const ongoingLogins:Record<string,SRPServerSessionStep1> = {} // store data that doesn't fit session object
22 const keepSessionAlive = defineConfig('keep_session_alive', true)
23 +const fakeSrpSecret = randomBytes(32)
24
25 const refresh_session: ApiHandler = async ({}, ctx) => {
26 const username = getCurrentUsername(ctx)
@@ -67,15 +69,15 @@ export const authApis = {
69 if (account?.plugin?.auth) // tell client to do clear-text login, before firing attemptingLogin, before triggering anti-brute
70 return new ApiError(HTTP_METHOD_NOT_ALLOWED)
71 if ((await events.emitAsync('attemptingLogin', { ctx, username }))?.isDefaultPrevented()) return
70 - if (!account || !accountCanLogin(account)) { // TODO simulate fake account to prevent knowing valid usernames
72 + if (account && !accountCanLogin(account)) {
73 ctx.logExtra({ u: username })
74 ctx.state.dontLog = false // log even if log_api is false
73 - return unauthorized(account && accountIsDisabled(account) ? 'Account disabled' : undefined)
75 + return unauthorized(accountIsDisabled(account) ? 'Account disabled' : undefined)
76 }
75 - if (failAllowNet(ctx, account))
77 + if (account && failAllowNet(ctx, account))
78 return unauthorized()
77 - try {
78 - const { srpServer, ...rest } = await srpServerStep1(account)
79 + try { // unknown users complete step 1 so only a full failed login can reveal and penalize the attempt
80 + const { srpServer, ...rest } = await srpServerStep1(account || fakeSrpAccount(username))
81 // keep the public handshake identifier independent of predictable application PRNG state
82 const sid = randomUUID()
83 ongoingLogins[sid] = srpServer
@@ -91,6 +93,19 @@ export const authApis = {
93 events.emit('failedLogin', { ctx, username })
94 return new ApiError(HTTP_UNAUTHORIZED, message)
95 }
96 +
97 + function fakeSrpAccount(username: string): Account {
98 + username = normalizeUsername(username)
99 + return {
100 + username,
101 + // process-secret derivation keeps fake credentials stable per username without storing attacker-controlled names
102 + srp: `${derive('salt')}|${derive('verifier')}`,
103 + }
104 +
105 + function derive(field: string) {
106 + return BigInt('0x' + createHmac('sha256', fakeSrpSecret).update(field).update(username).digest('hex')).toString()
107 + }
108 + }
109 },
110
111 async loginSrp2({ pubKey, proof }, ctx) {
tests/test.ts
+7 -3
@@ -1386,9 +1386,12 @@ describe('admin', () => {
1386 await withPluginConfig('antibrute', antibruteCfg, async () => {
1387 const user = `missing-srp-${randomId(6)}`
1388 const first = await reqLoginSrp1(user)
1389 + if (first.status !== 200) throw "unknown srp login was rejected at step 1"
1390 + const repeated = await reqLoginSrp1(user)
1391 + if (first.salt !== repeated.salt) throw "unknown srp salt was not stable"
1392 + await login(user).then(() => { throw "unknown srp login succeeded" }, () => {})
1393 const second = await reqLoginSrp1(user)
1390 - if (first.status !== 401) throw "first unknown srp login was not rejected"
1391 - if (second.status !== 401) throw "second unknown srp login was not rejected"
1394 + if (second.status !== 200) throw "unknown srp login was rejected at step 1"
1395 if (second.delay < 500) throw `missing srp delay escalation: ${second.delay}`
1396 })
1397 })
@@ -1744,11 +1747,12 @@ async function reqLoginSrp1(username: string) {
1747 headers: { 'content-type': 'application/json', 'x-hfs-anti-csrf': '1' },
1748 body: JSON.stringify({ username }),
1749 })
1747 - await stream2string(response).catch(() => '')
1750 + const data = tryJson(await stream2string(response).catch(() => ''))
1751 const rawDelay = response.headers?.['x-anti-brute-force']
1752 const delayValue = Array.isArray(rawDelay) ? rawDelay[0] : rawDelay
1753 return {
1754 status: response.statusCode,
1755 delay: Number(delayValue) || 0,
1756 + salt: data?.salt,
1757 }
1758 }