diff --git a/common/password_crypto.go b/common/password_crypto.go new file mode 100644 index 0000000000..efbb97acbd --- /dev/null +++ b/common/password_crypto.go @@ -0,0 +1,115 @@ +package common + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "strings" + "sync" +) + +const passwordEncryptionKeyBits = 2048 + +var ErrPasswordEncryptionInvalid = errors.New("password encryption payload is invalid") + +var passwordEncryptionState struct { + sync.RWMutex + privateKey *rsa.PrivateKey + publicKey string + keyID string +} + +// GeneratePasswordEncryptionPrivateKey creates the server key used to decrypt +// browser login passwords. The caller is responsible for persisting the PEM. +func GeneratePasswordEncryptionPrivateKey() (string, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, passwordEncryptionKeyBits) + if err != nil { + return "", fmt.Errorf("generate password encryption key: %w", err) + } + privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return "", fmt.Errorf("marshal password encryption key: %w", err) + } + return string(pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: privateKeyDER, + })), nil +} + +// LoadPasswordEncryptionPrivateKey validates a persisted key before replacing +// the active in-memory key used by request handlers. +func LoadPasswordEncryptionPrivateKey(privateKeyPEM string) error { + block, rest := pem.Decode([]byte(privateKeyPEM)) + if block == nil || block.Type != "PRIVATE KEY" || strings.TrimSpace(string(rest)) != "" { + return errors.New("password encryption key is not valid PKCS#8 PEM") + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return fmt.Errorf("parse password encryption key: %w", err) + } + privateKey, ok := parsed.(*rsa.PrivateKey) + if !ok { + return errors.New("password encryption key is not RSA") + } + if privateKey.N == nil || privateKey.N.BitLen() < passwordEncryptionKeyBits { + return fmt.Errorf("password encryption key must be at least %d bits", passwordEncryptionKeyBits) + } + if err := privateKey.Validate(); err != nil { + return fmt.Errorf("validate password encryption key: %w", err) + } + privateKey.Precompute() + + publicKeyDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + if err != nil { + return fmt.Errorf("marshal password encryption public key: %w", err) + } + publicKeyPEM := string(pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKeyDER, + })) + keyDigest := sha256.Sum256(publicKeyDER) + keyID := hex.EncodeToString(keyDigest[:16]) + + passwordEncryptionState.Lock() + defer passwordEncryptionState.Unlock() + passwordEncryptionState.privateKey = privateKey + passwordEncryptionState.publicKey = publicKeyPEM + passwordEncryptionState.keyID = keyID + return nil +} + +// PasswordEncryptionPublicKey returns the active key identifier and SPKI PEM +// public key exposed to browser clients. +func PasswordEncryptionPublicKey() (keyID string, publicKeyPEM string) { + passwordEncryptionState.RLock() + defer passwordEncryptionState.RUnlock() + return passwordEncryptionState.keyID, passwordEncryptionState.publicKey +} + +// DecryptPassword decrypts a base64 RSA-OAEP/SHA-256 password submitted by a +// browser. All malformed inputs share one error so callers do not expose +// cryptographic details to unauthenticated clients. +func DecryptPassword(ciphertextBase64 string, keyID string) (string, error) { + passwordEncryptionState.RLock() + privateKey := passwordEncryptionState.privateKey + activeKeyID := passwordEncryptionState.keyID + passwordEncryptionState.RUnlock() + if privateKey == nil || keyID == "" || keyID != activeKeyID { + return "", ErrPasswordEncryptionInvalid + } + ciphertext, err := base64.StdEncoding.DecodeString(ciphertextBase64) + if err != nil || len(ciphertext) != privateKey.Size() { + return "", ErrPasswordEncryptionInvalid + } + plaintext, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, ciphertext, nil) + if err != nil || len(plaintext) == 0 { + return "", ErrPasswordEncryptionInvalid + } + return string(plaintext), nil +} diff --git a/controller/user.go b/controller/user.go index 7020f1c6a3..1f3b3b35b0 100644 --- a/controller/user.go +++ b/controller/user.go @@ -28,8 +28,10 @@ import ( ) type LoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` + Username string `json:"username"` + Password string `json:"password"` + PasswordEncrypted string `json:"password_encrypted"` + EncryptionKeyID string `json:"encryption_key_id"` } var ( @@ -37,6 +39,18 @@ var ( errOriginalPasswordFail = errors.New("original password is incorrect") ) +func GetPasswordEncryptionKey(c *gin.Context) { + keyID, publicKey := common.PasswordEncryptionPublicKey() + if keyID == "" || publicKey == "" { + common.ApiErrorI18n(c, i18n.MsgDatabaseError) + return + } + common.ApiSuccess(c, gin.H{ + "kid": keyID, + "public_key": publicKey, + }) +} + func Login(c *gin.Context) { if !common.PasswordLoginEnabled { common.ApiErrorI18n(c, i18n.MsgUserPasswordLoginDisabled) @@ -50,6 +64,13 @@ func Login(c *gin.Context) { } username := loginRequest.Username password := loginRequest.Password + if loginRequest.PasswordEncrypted != "" { + password, err = common.DecryptPassword(loginRequest.PasswordEncrypted, loginRequest.EncryptionKeyID) + if err != nil { + common.ApiErrorI18n(c, i18n.MsgUserUsernameOrPasswordError) + return + } + } if username == "" || password == "" { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return diff --git a/main.go b/main.go index ac4e6afc3a..d82918988d 100644 --- a/main.go +++ b/main.go @@ -318,6 +318,10 @@ func InitResources() error { common.FatalLog("failed to initialize authorization: " + err.Error()) return err } + if err = model.InitPasswordEncryption(); err != nil { + common.FatalLog("failed to initialize password encryption: " + err.Error()) + return err + } model.CheckSetup() diff --git a/model/main.go b/model/main.go index 4b72871fec..877b2019d9 100644 --- a/model/main.go +++ b/model/main.go @@ -316,6 +316,7 @@ func migrateDB() error { &ExternalIdentityClaim{}, &PasskeyCredential{}, &Option{}, + &LoginEncryptionKey{}, &Redemption{}, &Ability{}, &Log{}, @@ -380,6 +381,7 @@ func migrateDBFast() error { {&ExternalIdentityClaim{}, "ExternalIdentityClaim"}, {&PasskeyCredential{}, "PasskeyCredential"}, {&Option{}, "Option"}, + {&LoginEncryptionKey{}, "LoginEncryptionKey"}, {&Redemption{}, "Redemption"}, {&Ability{}, "Ability"}, {&Log{}, "Log"}, diff --git a/model/password_crypto.go b/model/password_crypto.go new file mode 100644 index 0000000000..9177471810 --- /dev/null +++ b/model/password_crypto.go @@ -0,0 +1,60 @@ +package model + +import ( + "errors" + "fmt" + + "github.com/QuantumNous/new-api/common" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const activeLoginEncryptionKeySlot = "active" + +// LoginEncryptionKey stores internal key material used by the browser login +// protocol. It is deliberately separate from administrator-facing options. +type LoginEncryptionKey struct { + ID uint `json:"-" gorm:"primaryKey"` + Slot string `json:"-" gorm:"type:varchar(32);not null;uniqueIndex"` + PrivateKeyPEM string `json:"-" gorm:"type:text;not null"` +} + +// InitPasswordEncryption loads the shared login-encryption key from its +// dedicated store. Concurrent replicas converge through the unique slot. +func InitPasswordEncryption() error { + var stored LoginEncryptionKey + queryErr := DB.Where("slot = ?", activeLoginEncryptionKeySlot).First(&stored).Error + if queryErr == nil { + if err := common.LoadPasswordEncryptionPrivateKey(stored.PrivateKeyPEM); err != nil { + return fmt.Errorf("load persisted password encryption key: %w", err) + } + return nil + } + if !errors.Is(queryErr, gorm.ErrRecordNotFound) { + return fmt.Errorf("read password encryption key: %w", queryErr) + } + + privateKeyPEM, err := common.GeneratePasswordEncryptionPrivateKey() + if err != nil { + return err + } + candidate := LoginEncryptionKey{ + Slot: activeLoginEncryptionKeySlot, + PrivateKeyPEM: privateKeyPEM, + } + if err := DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "slot"}}, + DoNothing: true, + }).Create(&candidate).Error; err != nil { + return fmt.Errorf("persist password encryption key: %w", err) + } + + if err := DB.Where("slot = ?", activeLoginEncryptionKeySlot).First(&stored).Error; err != nil { + return fmt.Errorf("reload password encryption key: %w", err) + } + if err := common.LoadPasswordEncryptionPrivateKey(stored.PrivateKeyPEM); err != nil { + return fmt.Errorf("load persisted password encryption key: %w", err) + } + return nil +} diff --git a/router/api-router.go b/router/api-router.go index 092600aa06..18074a9325 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -71,6 +71,7 @@ func SetApiRouter(router *gin.Engine) { userRoute.POST("/auth/refresh", middleware.SessionCookieOriginGuard(), middleware.CriticalRateLimit(), middleware.DisableCache(), controller.RefreshAuth) userRoute.POST("/auth/logout", middleware.SessionCookieOriginGuard(), middleware.CriticalRateLimit(), middleware.DisableCache(), controller.AuthLogout) userRoute.POST("/register", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, middleware.TurnstileCheck(), controller.Register) + userRoute.GET("/login/encryption-key", middleware.DisableCache(), controller.GetPasswordEncryptionKey) userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, middleware.TurnstileCheck(), controller.Login) userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.Verify2FALogin) userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.PasskeyLoginBegin) diff --git a/web/bun.lock b/web/bun.lock index 763317b26d..686e4fd8d4 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -41,6 +41,7 @@ "motion": "^12.42.2", "nanoid": "^5.1.16", "next-themes": "^0.4.6", + "node-forge": "^1.4.0", "qrcode.react": "^4.2.0", "react": "^19.2.7", "react-day-picker": "^10.0.1", @@ -76,6 +77,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^26.1.0", + "@types/node-forge": "^1.3.14", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@typescript/native-preview": "^7.0.0-dev.20260702.3", @@ -992,6 +994,8 @@ "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + "@types/node-forge": ["@types/node-forge@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw=="], + "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], @@ -1992,6 +1996,8 @@ "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], + "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], diff --git a/web/package.json b/web/package.json index d2d858c51d..6f181e8773 100644 --- a/web/package.json +++ b/web/package.json @@ -60,6 +60,7 @@ "motion": "^12.42.2", "nanoid": "^5.1.16", "next-themes": "^0.4.6", + "node-forge": "^1.4.0", "qrcode.react": "^4.2.0", "react": "^19.2.7", "react-day-picker": "^10.0.1", @@ -95,6 +96,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^26.1.0", + "@types/node-forge": "^1.3.14", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@typescript/native-preview": "^7.0.0-dev.20260702.3", diff --git a/web/src/features/auth/api.ts b/web/src/features/auth/api.ts index 7a3ec38b56..bf329d76fc 100644 --- a/web/src/features/auth/api.ts +++ b/web/src/features/auth/api.ts @@ -21,6 +21,10 @@ import axios from 'axios' import { api, refreshAuthentication, type RefreshOutcome } from '@/lib/api' import { useAuthStore } from '@/stores/auth-store' +import { + clearPasswordEncryptionCache, + encryptPassword, +} from './lib/password-encryption' import { getAffiliateCode } from './lib/storage' import type { TelegramAuthorization } from './lib/telegram-login' import type { @@ -41,17 +45,27 @@ import type { // ---------------------------------------------------------------------------- // User login with username and password -export async function login(payload: LoginPayload) { +export async function login(payload: LoginPayload): Promise { const turnstile = payload.turnstile ?? '' - const res = await api.post( - `/api/user/login?turnstile=${turnstile}`, - { - username: payload.username, - password: payload.password, - }, - { skipAuthRefresh: true } - ) - return res.data + try { + const encryptedPassword = await encryptPassword(payload.password) + const res = await api.post( + `/api/user/login?turnstile=${turnstile}`, + { + username: payload.username, + password_encrypted: encryptedPassword.password_encrypted, + encryption_key_id: encryptedPassword.encryption_key_id, + }, + { skipAuthRefresh: true } + ) + if (!res.data?.success) { + clearPasswordEncryptionCache() + } + return res.data + } catch (error: unknown) { + clearPasswordEncryptionCache() + throw error + } } // Two-factor authentication login diff --git a/web/src/features/auth/lib/password-encryption.ts b/web/src/features/auth/lib/password-encryption.ts new file mode 100644 index 0000000000..854c79563b --- /dev/null +++ b/web/src/features/auth/lib/password-encryption.ts @@ -0,0 +1,135 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { t } from 'i18next' + +import { api } from '@/lib/api' + +interface PasswordEncryptionKey { + kid: string + public_key: string +} + +export interface EncryptedPassword { + password_encrypted: string + encryption_key_id: string +} + +const KEY_CACHE_TTL_MS = 5 * 60_000 + +let cachedKey: PasswordEncryptionKey | null = null +let cachedAt = 0 + +export function clearPasswordEncryptionCache(): void { + cachedKey = null + cachedAt = 0 +} + +export async function encryptPassword( + password: string +): Promise { + try { + const key = await getPasswordEncryptionKey() + const ciphertext = await rsaOaepEncrypt(password, key.public_key) + return { + password_encrypted: ciphertext, + encryption_key_id: key.kid, + } + } catch (error: unknown) { + clearPasswordEncryptionCache() + throw new Error(t('Login failed'), { cause: error }) + } +} + +async function getPasswordEncryptionKey(): Promise { + const now = Date.now() + if (cachedKey && now - cachedAt < KEY_CACHE_TTL_MS) { + return cachedKey + } + + const response = await api.get<{ + success: boolean + data?: PasswordEncryptionKey + }>('/api/user/login/encryption-key') + const key = response.data?.data + if (!response.data?.success || !key?.kid || !key.public_key) { + throw new Error('Password encryption key is unavailable') + } + cachedKey = key + cachedAt = now + return key +} + +async function rsaOaepEncrypt( + password: string, + publicKeyPEM: string +): Promise { + if (typeof globalThis.crypto?.subtle !== 'undefined') { + try { + const publicKey = await globalThis.crypto.subtle.importKey( + 'spki', + pemToDER(publicKeyPEM), + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['encrypt'] + ) + const ciphertext = await globalThis.crypto.subtle.encrypt( + { name: 'RSA-OAEP' }, + publicKey, + new TextEncoder().encode(password) + ) + return arrayBufferToBase64(ciphertext) + } catch { + // Older implementations may expose SubtleCrypto without supporting the + // required RSA-OAEP parameters; the HTTP-compatible fallback handles it. + } + } + + // Web Crypto is restricted to secure contexts in browsers. Lazy-loading + // forge keeps the normal HTTPS bundle small while supporting HTTP intranets. + const forge = await import('node-forge') + const publicKey = forge.pki.publicKeyFromPem(publicKeyPEM) + const ciphertext = publicKey.encrypt( + forge.util.encodeUtf8(password), + 'RSA-OAEP', + { md: forge.md.sha256.create() } + ) + return forge.util.encode64(ciphertext) +} + +function pemToDER(pem: string): ArrayBuffer { + const body = pem + .replace('-----BEGIN PUBLIC KEY-----', '') + .replace('-----END PUBLIC KEY-----', '') + .replaceAll(/\s+/g, '') + const binary = atob(body) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index) + } + return bytes.buffer +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary) +}