restored the lazy-loading of tssrp6a; removed change_my_srp, use change_srp
Massimo Melina committed
Mar 7, 2026 at 15:54 UTC
257c0917e254bfd8f486bd84190bfa8c0c0e7aca
12 files changed
+81
-62
admin/src/AccountForm.ts
+1
-9
@@ -5,10 +5,9 @@ import { BoolField, Form, MultiSelectField, NumberField, SelectField } from '@hf
5
import { Alert, Box } from '@mui/material'
6
import { apiCall } from './api'
7
import { alertDialog, useDialogBarColors } from './dialog'
8
-import { formatTimestamp, isEqualLax, prefix, reactJoin, useIsMobile, wantArray } from './misc'
8
+import { apiNewPassword, formatTimestamp, isEqualLax, prefix, reactJoin, useIsMobile, wantArray } from './misc'
9
import { Btn, Flex, IconBtn, NetmaskField, propsForModifiedValues } from './mui'
10
import { Account } from './AccountsPage'
11
-import { createVerifierAndSalt, SRPParameters, SRPRoutines } from 'tssrp6a'
11
import { AutoDelete, Delete } from '@mui/icons-material'
12
import { state, useSnapState } from './state'
13
import VfsPathField from './VfsPathField'
@@ -146,10 +145,3 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
145
export function isModifiedConfig(a: any, b: any) {
146
return !isEqualLax(a, b, (a,b) => !a && !b || undefined)
147
}
149
-
150
-// you can set password directly in add/set_account, but using this api instead will add extra security because it is not sent as clear-text, so it's especially good if you are not in localhost and not using https
151
-export async function apiNewPassword(username: string, password: string) {
152
- const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
153
- const res = await createVerifierAndSalt(srp6aNimbusRoutines, username, password)
154
- return apiCall('change_srp', { username, salt: String(res.s), verifier: String(res.v) })
155
-}
admin/src/LoginRequired.ts
+3
-3
@@ -2,10 +2,10 @@
2
3
import { state, useSnapState } from './state'
4
import { createElement as h, Fragment, useEffect, useRef, useState } from 'react'
5
-import { ALLOW_SESSION_IP_CHANGE, HTTP_FORBIDDEN, HTTP_UNAUTHORIZED, makeSessionRefresher } from './misc'
5
+import { ALLOW_SESSION_IP_CHANGE, HTTP_FORBIDDEN, HTTP_UNAUTHORIZED, makeSessionRefresher, withSrpLib } from './misc'
6
import { BoolField, Form } from '@hfs/mui-grid-form'
7
import { apiCall } from './api'
8
-import { srpClientSequence } from '@hfs/shared'
8
+import { srpClientSequence } from '../../src/srp'
9
import { Alert, Box } from '@mui/material'
10
import { Center } from './mui'
11
@@ -63,7 +63,7 @@ function LoginForm() {
63
}
64
65
async function login(username: string, password: string, extra?: object) {
66
- const res = await srpClientSequence(username, password, apiCall, extra).catch(err => {
66
+ const res = await withSrpLib(srpClientSequence)(username, password, apiCall, extra).catch(err => {
67
throw err?.code === HTTP_UNAUTHORIZED ? err.message || "Wrong username or password"
68
: err === 'trust' ? "Login aborted: server identity cannot be trusted"
69
: err?.name === 'AbortError' ? "Server didn't respond"
admin/src/importAccountsCsv.ts
+1
-2
@@ -3,8 +3,7 @@ import { createElement as h, Fragment, useEffect, useState } from 'react'
3
import { Group, Upload } from '@mui/icons-material'
4
import { Box } from '@mui/material'
5
import { apiCall } from './api'
6
-import { apiNewPassword } from './AccountForm'
7
-import { HTTP_CONFLICT, prefix, readFile, selectFiles } from './misc'
6
+import { apiNewPassword, HTTP_CONFLICT, prefix, readFile, selectFiles } from './misc'
7
import { IconProgress } from './mui'
8
import { NumberField, BoolField } from '@hfs/mui-grid-form'
9
import Parser from '@gregoranders/csv';
frontend/src/UserPanel.ts
+6
-11
@@ -6,7 +6,7 @@ import { alertDialog, newDialog, promptDialog } from './dialog'
6
import { apiCall } from '@hfs/shared/api'
7
import { logout } from './login'
8
import { Btn, CustomCode } from './components'
9
-import { formatTimestamp, hIcon, fallbackToBasicAuth, working } from './misc'
9
+import { formatTimestamp, hIcon, fallbackToBasicAuth, working, apiNewPassword } from './misc'
10
import i18n from './i18n'
11
const { t } = i18n
12
@@ -53,14 +53,9 @@ export async function changePassword(required=false) {
53
if (!check) return
54
if (check !== pwd)
55
return alertDialog(t('pass2_mismatch', "The second password you entered did not match the first. Procedure aborted."), 'warning')
56
- const { createVerifierAndSalt, SRPParameters, SRPRoutines } = await import('tssrp6a')
57
- const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
58
- const res = await createVerifierAndSalt(srp6aNimbusRoutines, state.username, pwd)
59
- try {
60
- await apiCall('change_my_srp', { salt: String(res.s), verifier: String(res.v) }, { modal: working })
61
- return alertDialog(t('password_changed', "Password changed"))
62
- }
63
- catch(e) {
64
- return alertDialog(e as Error)
65
- }
56
+
57
+ const modal = working()
58
+ await apiNewPassword(state.username, pwd)
59
+ .then(() => alertDialog(t('password_changed', "Password changed")), alertDialog)
60
+ .finally(modal)
61
}
\ No newline at end of file
frontend/src/login.ts
+3
-2
@@ -4,20 +4,21 @@ import { apiCall } from '@hfs/shared/api'
4
import { state, useSnapState } from './state'
5
import { alertDialog, newDialog, toast } from './dialog'
6
import {
7
- getHFS, hIcon, makeSessionRefresher, srpClientSequence, working, fallbackToBasicAuth, hfsEvent,
7
+ getHFS, hIcon, makeSessionRefresher, working, fallbackToBasicAuth, hfsEvent, withSrpLib,
8
HTTP_CONFLICT, HTTP_UNAUTHORIZED, HTTP_METHOD_NOT_ALLOWED, ALLOW_SESSION_IP_CHANGE,
9
} from './misc'
10
import { createElement as h, Fragment, useEffect, useRef, useState } from 'react'
11
import { reloadList } from './useFetchList'
12
import { Checkbox, CustomCode } from './components'
13
import { changePassword } from './UserPanel'
14
+import { srpClientSequence } from '../../src/srp'
15
import _ from 'lodash'
16
import i18n from './i18n'
17
const { t, useI18N } = i18n
18
19
async function login(username:string, password:string, extra?: object) {
20
const stopWorking = working()
20
- return srpClientSequence(username, password, apiCall, extra).catch(err => {
21
+ return withSrpLib(srpClientSequence)(username, password, apiCall, extra).catch(err => {
22
if (err.code == HTTP_METHOD_NOT_ALLOWED || !password) // allow alternative authentications without a password
23
return apiCall('login', { username, password, ...extra })
24
throw err
shared/index.ts
+12
-1
@@ -6,7 +6,6 @@ import { DAY, Dict, formatBytes, HOUR, MINUTE, objFromKeys, objSameKeys, typedEn
6
export * from './react'
7
export * from './dialogs'
8
export * from './md'
9
-export * from '../src/srp'
9
export * from '../src/cross'
10
// code in this file is shared among frontends, but not backend
11
@@ -248,6 +247,18 @@ export function downloadFileWithContent(name: string, content: Blob | string) {
247
setTimeout(() => a.remove(), 100) // Chrome needs this timeout
248
}
249
250
+export function withSrpLib<Args extends unknown[], Res>(cb: (srp: typeof import('tssrp6a'), ...args: Args) => Res) {
251
+ return (...args: Args) => import('tssrp6a').then(srp => cb(srp, ...args))
252
+}
253
+
254
+// you can set password directly in add/set_account, but using this api instead will add extra security because it is not sent as clear-text, so it's especially good if you are not in localhost and not using https
255
+export async function apiNewPassword(username: string, password: string) {
256
+ const { createVerifierAndSalt, SRPParameters, SRPRoutines } = await import('tssrp6a')
257
+ const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
258
+ const res = await createVerifierAndSalt(srp6aNimbusRoutines, username, password)
259
+ return apiCall('change_srp', { username, salt: String(res.s), verifier: String(res.v) })
260
+}
261
+
262
Element.prototype.replaceChildren ||= function(this:Element, addNodes) { // polyfill
263
while (this.lastChild) this.removeChild(this.lastChild)
264
if (addNodes !== undefined) this.append(addNodes);
src/api.accounts.ts
+1
-8
@@ -3,7 +3,7 @@
3
import { ApiError, ApiHandlers } from './apiMiddleware'
4
import {
5
Account, accountCanLoginAdmin, accountHasPassword, accounts, addAccount, delAccount, getAccount,
6
- changeSrpHelper, updateAccount, accountCanLogin, accountCanChangePassword, normalizeUsername
6
+ updateAccount, accountCanLogin, accountCanChangePassword, normalizeUsername
7
} from './perm'
8
import _ from 'lodash'
9
import { HTTP_BAD_REQUEST, HTTP_CONFLICT, HTTP_NOT_FOUND } from './const'
@@ -96,11 +96,4 @@ export default {
96
return {}
97
},
98
99
- async change_srp({ username, salt, verifier }) {
100
- apiAssertTypes({ string: { username, salt, verifier } })
101
- const a = getAccount(username)
102
- return a ? changeSrpHelper(a, salt, verifier)
103
- : new ApiError(HTTP_NOT_FOUND)
104
- }
105
-
99
} satisfies ApiHandlers
\ No newline at end of file
src/api.auth.ts
+13
-7
@@ -1,7 +1,7 @@
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, changeSrpHelper, expandUsername, getAccount
4
+ accountCanLogin, accountIsDisabled, accountCanChangePassword, expandUsername, getAccount, updateAccount, saveSrpInfo
5
} from './perm'
6
import { ApiError, ApiHandler, ApiHandlers } from './apiMiddleware'
7
import { SRPServerSessionStep1 } from 'tssrp6a'
@@ -126,11 +126,17 @@ export const authApis = {
126
127
refresh_session,
128
129
- async change_my_srp({ salt, verifier }, ctx) {
130
- const a = ctx.state.account
131
- return !a || !accountCanChangePassword(a) ? new ApiError(HTTP_UNAUTHORIZED)
132
- : changeSrpHelper(a, salt, verifier).then(() => {
133
- delete a.require_password_change
134
- })
129
+ async change_srp({ username, salt, verifier }, ctx) {
130
+ const a = username && getAccount(username)
131
+ const can = a && (ctxAdminAccess(ctx) || username === getCurrentUsername(ctx) && accountCanChangePassword(a))
132
+ if (!can)
133
+ return new ApiError(HTTP_UNAUTHORIZED)
134
+ if (!salt || !verifier)
135
+ return new ApiError(HTTP_BAD_REQUEST, 'missing parameters')
136
+ await updateAccount(a, a =>
137
+ saveSrpInfo(a, salt, verifier) )
138
+ delete a.require_password_change
139
+ return {}
140
}
141
+
142
} as const satisfies ApiHandlers
src/auth.ts
+4
-4
@@ -1,6 +1,6 @@
1
import { Account, getAccount, normalizeUsername, updateAccount } from './perm'
2
import { ALLOW_SESSION_IP_CHANGE, HTTP_NOT_ACCEPTABLE, HTTP_SERVER_ERROR } from './cross-const'
3
-import { SRPParameters, SRPRoutines, SRPServerSession } from 'tssrp6a'
3
+import * as srp from 'tssrp6a'
4
import { Context } from 'koa'
5
import { srpClientPart } from './srp'
6
import { DAY } from './cross'
@@ -8,7 +8,7 @@ import { expiringCache } from './expiringCache'
8
import { createHash } from 'node:crypto'
9
import events from './events'
10
11
-const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
11
+const srp6aNimbusRoutines = new srp.SRPRoutines(new srp.SRPParameters())
12
13
export async function srpServerStep1(account: Account) {
14
if (!account.srp)
@@ -16,7 +16,7 @@ export async function srpServerStep1(account: Account) {
16
const [salt, verifier] = account.srp.split('|')
17
if (!salt || !verifier)
18
throw Error("malformed account")
19
- const srpSession = new SRPServerSession(srp6aNimbusRoutines)
19
+ const srpSession = new srp.SRPServerSession(srp6aNimbusRoutines)
20
const srpServer = await srpSession.step1(account.username, BigInt(salt), BigInt(verifier))
21
return { srpServer, salt, pubKey: String(srpServer.B) } // cast to string cause bigint can't be jsonized
22
}
@@ -28,7 +28,7 @@ export async function srpCheck(username: string, password: string) {
28
const k = createHash('sha256').update(username + password + account.srp).digest("hex")
29
const good = await cache.try(k, async () => {
30
const { srpServer, salt, pubKey } = await srpServerStep1(account)
31
- const client = await srpClientPart(username, password, salt, pubKey)
31
+ const client = await srpClientPart(srp, username, password, salt, pubKey)
32
return srpServer.step2(client.A, client.M1).then(() => true, () => false)
33
})
34
return good ? account : undefined
src/perm.ts
-8
@@ -241,14 +241,6 @@ export function accountCanChangePassword(account: Account | undefined) {
241
return account && !getFromAccount(account, a => a.disable_password_change)
242
}
243
244
-export async function changeSrpHelper(account: Account, salt: string, verifier: string) {
245
- if (!salt || !verifier)
246
- return new ApiError(HTTP_BAD_REQUEST, 'missing parameters')
247
- await updateAccount(account, account =>
248
- saveSrpInfo(account, salt, verifier) )
249
- return {}
250
-}
251
-
244
declare module "koa" {
245
interface DefaultState {
246
usernames?: Set<string>
src/srp.ts
+7
-6
@@ -1,19 +1,20 @@
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 { SRPClientSession, SRPParameters, SRPRoutines } from 'tssrp6a'
3
+export type ApiCall = (cmd:string, params:any) => any
4
+type Srp = typeof import('tssrp6a')
5
5
-export async function srpClientSequence(username:string, password:string, apiCall: (cmd:string, params:any) => any, extra?: object) {
6
+export async function srpClientSequence(srp: Srp, username:string, password:string, apiCall: ApiCall, extra?: object) {
7
const { pubKey, salt } = await apiCall('loginSrp1', { username })
8
if (!salt) throw Error('salt')
8
- const client = await srpClientPart(username, password, salt, pubKey)
9
+ const client = await srpClientPart(srp, username, password, salt, pubKey)
10
const res = await apiCall('loginSrp2', { pubKey: String(client.A), proof: String(client.M1), ...extra }) // bigint-s must be cast to string to be json-ed
11
await client.step3(BigInt(res.proof)).catch(() => Promise.reject('trust'))
12
return res
13
}
14
14
-export async function srpClientPart(username: string, password: string, salt: string, pubKey: string) {
15
- const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
16
- const srpClient = new SRPClientSession(srp6aNimbusRoutines);
15
+export async function srpClientPart(srp: Srp, username: string, password: string, salt: string, pubKey: string) {
16
+ const srp6aNimbusRoutines = new srp.SRPRoutines(new srp.SRPParameters())
17
+ const srpClient = new srp.SRPClientSession(srp6aNimbusRoutines)
18
const res = await srpClient.step1(username, password)
19
return await res.step2(BigInt(salt), BigInt(pubKey))
20
}
tests/test.ts
+30
-1
@@ -1,6 +1,7 @@
1
import test, { describe, before, after } from 'node:test';
2
import { promisify } from 'util'
3
import { srpClientSequence } from '../src/srp'
4
+import * as srp from 'tssrp6a'
5
import { createReadStream, existsSync, readFileSync, statfsSync, statSync } from 'fs'
6
import { basename, dirname, resolve } from 'path'
7
import { exec } from 'child_process'
@@ -51,6 +52,7 @@ const WEBDAV_PROPPATCH_BODY = `<?xml version="1.0" encoding="utf-8"?>
52
let defaultBaseUrl = BASE_URL
53
54
const execP = (cmd: string) => promisify(exec)(cmd).then(x => x.stdout)
55
+const srp6aNimbusRoutines = new srp.SRPRoutines(new srp.SRPParameters())
56
57
describe('basics', () => {
58
test('parseHttpUrl.path escapes invalid chars and keeps unresolved segments', () => {
@@ -386,6 +388,33 @@ describe('sessions', () => {
388
await reqApi('del_account', { username: user }, 200, adminReq)().catch(() => {})
389
}
390
})
391
+ test('change_srp enforces self/admin permissions', async () => {
392
+ const selfUser = `change-srp-self-${randomId(6)}`.toLowerCase()
393
+ const otherUser = `change-srp-other-${randomId(6)}`.toLowerCase()
394
+ const selfPwd = `pw-${randomId(8)}`
395
+ const adminReq = { auth, jar: {} }
396
+ try {
397
+ await reqApi('add_account', { username: selfUser, overwrite: true, password: selfPwd }, res => res?.username === selfUser, adminReq)()
398
+ await reqApi('add_account', { username: otherUser, overwrite: true, password: randomId(8) }, res => res?.username === otherUser, adminReq)()
399
+
400
+ const selfChange = await makeSrpChange(selfUser)
401
+ const jar = {}
402
+ await reqApi('change_srp', selfChange, 401, { jar })() // no account
403
+ await reqApi('refresh_session', {}, res => res?.username === selfUser, { jar, auth: `${selfUser}:${selfPwd}` })()
404
+ await reqApi('change_srp', selfChange, 200, { jar })() // my account
405
+ const otherChange = await makeSrpChange(otherUser)
406
+ await reqApi('change_srp', otherChange, 401, { jar })() // another account but no admin
407
+ await reqApi('change_srp', otherChange, 200, adminReq)() // another account and i'm admin
408
+ }
409
+ finally {
410
+ await reqApi('del_account', { username: [selfUser, otherUser] }, 200, adminReq)().catch(() => {})
411
+ }
412
+
413
+ async function makeSrpChange(username: string, password=`next-${randomId(8)}`) {
414
+ const res = await srp.createVerifierAndSalt(srp6aNimbusRoutines, username, password)
415
+ return { salt: String(res.s), verifier: String(res.v), username }
416
+ }
417
+ })
418
})
419
420
describe('accounts', () => {
@@ -785,7 +814,7 @@ describe('admin', () => {
814
})
815
816
function login(usr: string, pwd=password) {
788
- return srpClientSequence(usr, pwd, (cmd: string, params: any) =>
817
+ return srpClientSequence(srp, usr, pwd, (cmd: string, params: any) =>
818
reqApi(cmd, params, (x,res)=> res.statusCode < 400)())
819
}
820