@samitouri / QOSami-HFS / commits / 257c0917

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