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
}