mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-08-31 02:41:34 +00:00
parent
98d50d5383
commit
b80d633cf5
115
common/password_crypto.go
Normal file
115
common/password_crypto.go
Normal file
@ -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
|
||||
}
|
||||
@ -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
|
||||
|
||||
4
main.go
4
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()
|
||||
|
||||
|
||||
@ -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"},
|
||||
|
||||
60
model/password_crypto.go
Normal file
60
model/password_crypto.go
Normal file
@ -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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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=="],
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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<LoginResponse> {
|
||||
const turnstile = payload.turnstile ?? ''
|
||||
const res = await api.post<LoginResponse>(
|
||||
`/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<LoginResponse>(
|
||||
`/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
|
||||
|
||||
135
web/src/features/auth/lib/password-encryption.ts
Normal file
135
web/src/features/auth/lib/password-encryption.ts
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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<EncryptedPassword> {
|
||||
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<PasswordEncryptionKey> {
|
||||
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<string> {
|
||||
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)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user