Encrypt OTP secret

pull/5340/head
Chocobozzz 2022-10-10 11:12:23 +02:00
parent a0da6f90d1
commit a3e5f804ad
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
16 changed files with 149 additions and 18 deletions

View File

@ -10,6 +10,10 @@ webserver:
hostname: 'localhost'
port: 9000
# Secrets you need to generate the first time you run PeerTube
secrets:
peertube: ''
rates_limit:
api:
# 50 attempts in 10 seconds

View File

@ -5,6 +5,9 @@ listen:
webserver:
https: false
secrets:
peertube: 'my super dev secret'
database:
hostname: 'localhost'
port: 5432

View File

@ -8,6 +8,10 @@ webserver:
hostname: 'example.com'
port: 443
# Secrets you need to generate the first time you run PeerTube
secret:
peertube: ''
rates_limit:
api:
# 50 attempts in 10 seconds

View File

@ -5,6 +5,9 @@ listen:
webserver:
https: false
secrets:
peertube: 'my super secret'
rates_limit:
signup:
window: 10 minutes

View File

@ -45,7 +45,12 @@ try {
import { checkConfig, checkActivityPubUrls, checkFFmpegVersion } from './server/initializers/checker-after-init'
checkConfig()
try {
checkConfig()
} catch (err) {
logger.error('Config error.', { err })
process.exit(-1)
}
// Trust our proxy (IP forwarding...)
app.set('trust proxy', CONFIG.TRUST_PROXY)

View File

@ -1,5 +1,7 @@
import express from 'express'
import { generateOTPSecret, isOTPValid } from '@server/helpers/otp'
import { encrypt } from '@server/helpers/peertube-crypto'
import { CONFIG } from '@server/initializers/config'
import { Redis } from '@server/lib/redis'
import { asyncMiddleware, authenticate, usersCheckCurrentPasswordFactory } from '@server/middlewares'
import {
@ -44,7 +46,9 @@ async function requestTwoFactor (req: express.Request, res: express.Response) {
const user = res.locals.user
const { secret, uri } = generateOTPSecret(user.email)
const requestToken = await Redis.Instance.setTwoFactorRequest(user.id, secret)
const encryptedSecret = await encrypt(secret, CONFIG.SECRETS.PEERTUBE)
const requestToken = await Redis.Instance.setTwoFactorRequest(user.id, encryptedSecret)
return res.json({
otpRequest: {
@ -60,22 +64,22 @@ async function confirmRequestTwoFactor (req: express.Request, res: express.Respo
const otpToken = req.body.otpToken
const user = res.locals.user
const secret = await Redis.Instance.getTwoFactorRequestToken(user.id, requestToken)
if (!secret) {
const encryptedSecret = await Redis.Instance.getTwoFactorRequestToken(user.id, requestToken)
if (!encryptedSecret) {
return res.fail({
message: 'Invalid request token',
status: HttpStatusCode.FORBIDDEN_403
})
}
if (isOTPValid({ secret, token: otpToken }) !== true) {
if (await isOTPValid({ encryptedSecret, token: otpToken }) !== true) {
return res.fail({
message: 'Invalid OTP token',
status: HttpStatusCode.FORBIDDEN_403
})
}
user.otpSecret = secret
user.otpSecret = encryptedSecret
await user.save()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)

View File

@ -6,7 +6,7 @@
*/
import { exec, ExecOptions } from 'child_process'
import { ED25519KeyPairOptions, generateKeyPair, randomBytes, RSAKeyPairOptions } from 'crypto'
import { ED25519KeyPairOptions, generateKeyPair, randomBytes, RSAKeyPairOptions, scrypt } from 'crypto'
import { truncate } from 'lodash'
import { pipeline } from 'stream'
import { URL } from 'url'
@ -311,7 +311,17 @@ function promisify2<T, U, A> (func: (arg1: T, arg2: U, cb: (err: any, result: A)
}
}
// eslint-disable-next-line max-len
function promisify3<T, U, V, A> (func: (arg1: T, arg2: U, arg3: V, cb: (err: any, result: A) => void) => void): (arg1: T, arg2: U, arg3: V) => Promise<A> {
return function promisified (arg1: T, arg2: U, arg3: V): Promise<A> {
return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
func.apply(null, [ arg1, arg2, arg3, (err: any, res: A) => err ? reject(err) : resolve(res) ])
})
}
}
const randomBytesPromise = promisify1<number, Buffer>(randomBytes)
const scryptPromise = promisify3<string, string, number, Buffer>(scrypt)
const execPromise2 = promisify2<string, any, string>(exec)
const execPromise = promisify1<string, string>(exec)
const pipelinePromise = promisify(pipeline)
@ -339,6 +349,8 @@ export {
promisify1,
promisify2,
scryptPromise,
randomBytesPromise,
generateRSAKeyPairPromise,

View File

@ -1,11 +1,15 @@
import { Secret, TOTP } from 'otpauth'
import { CONFIG } from '@server/initializers/config'
import { WEBSERVER } from '@server/initializers/constants'
import { decrypt } from './peertube-crypto'
function isOTPValid (options: {
secret: string
async function isOTPValid (options: {
encryptedSecret: string
token: string
}) {
const { token, secret } = options
const { token, encryptedSecret } = options
const secret = await decrypt(encryptedSecret, CONFIG.SECRETS.PEERTUBE)
const totp = new TOTP({
...baseOTPOptions(),

View File

@ -1,11 +1,11 @@
import { compare, genSalt, hash } from 'bcrypt'
import { createSign, createVerify } from 'crypto'
import { createCipheriv, createDecipheriv, createSign, createVerify } from 'crypto'
import { Request } from 'express'
import { cloneDeep } from 'lodash'
import { sha256 } from '@shared/extra-utils'
import { BCRYPT_SALT_SIZE, HTTP_SIGNATURE, PRIVATE_RSA_KEY_SIZE } from '../initializers/constants'
import { BCRYPT_SALT_SIZE, ENCRYPTION, HTTP_SIGNATURE, PRIVATE_RSA_KEY_SIZE } from '../initializers/constants'
import { MActor } from '../types/models'
import { generateRSAKeyPairPromise, promisify1, promisify2 } from './core-utils'
import { generateRSAKeyPairPromise, promisify1, promisify2, randomBytesPromise, scryptPromise } from './core-utils'
import { jsonld } from './custom-jsonld-signature'
import { logger } from './logger'
@ -21,7 +21,9 @@ function createPrivateAndPublicKeys () {
return generateRSAKeyPairPromise(PRIVATE_RSA_KEY_SIZE)
}
// ---------------------------------------------------------------------------
// User password checks
// ---------------------------------------------------------------------------
function comparePassword (plainPassword: string, hashPassword: string) {
if (!plainPassword) return Promise.resolve(false)
@ -35,7 +37,9 @@ async function cryptPassword (password: string) {
return bcryptHashPromise(password, salt)
}
// ---------------------------------------------------------------------------
// HTTP Signature
// ---------------------------------------------------------------------------
function isHTTPSignatureDigestValid (rawBody: Buffer, req: Request): boolean {
if (req.headers[HTTP_SIGNATURE.HEADER_NAME] && req.headers['digest']) {
@ -64,7 +68,9 @@ function parseHTTPSignature (req: Request, clockSkew?: number) {
return parsed
}
// ---------------------------------------------------------------------------
// JSONLD
// ---------------------------------------------------------------------------
function isJsonLDSignatureVerified (fromActor: MActor, signedDocument: any): Promise<boolean> {
if (signedDocument.signature.type === 'RsaSignature2017') {
@ -114,12 +120,42 @@ async function signJsonLDObject <T> (byActor: MActor, data: T) {
return Object.assign(data, { signature })
}
// ---------------------------------------------------------------------------
function buildDigest (body: any) {
const rawBody = typeof body === 'string' ? body : JSON.stringify(body)
return 'SHA-256=' + sha256(rawBody, 'base64')
}
// ---------------------------------------------------------------------------
// Encryption
// ---------------------------------------------------------------------------
async function encrypt (str: string, secret: string) {
const iv = await randomBytesPromise(ENCRYPTION.IV)
const key = await scryptPromise(secret, ENCRYPTION.SALT, 32)
const cipher = createCipheriv(ENCRYPTION.ALGORITHM, key, iv)
let encrypted = iv.toString(ENCRYPTION.ENCODING) + ':'
encrypted += cipher.update(str, 'utf8', ENCRYPTION.ENCODING)
encrypted += cipher.final(ENCRYPTION.ENCODING)
return encrypted
}
async function decrypt (encryptedArg: string, secret: string) {
const [ ivStr, encryptedStr ] = encryptedArg.split(':')
const iv = Buffer.from(ivStr, 'hex')
const key = await scryptPromise(secret, ENCRYPTION.SALT, 32)
const decipher = createDecipheriv(ENCRYPTION.ALGORITHM, key, iv)
return decipher.update(encryptedStr, ENCRYPTION.ENCODING, 'utf8') + decipher.final('utf8')
}
// ---------------------------------------------------------------------------
export {
@ -131,7 +167,10 @@ export {
comparePassword,
createPrivateAndPublicKeys,
cryptPassword,
signJsonLDObject
signJsonLDObject,
encrypt,
decrypt
}
// ---------------------------------------------------------------------------

View File

@ -42,6 +42,7 @@ function checkConfig () {
logger.warn('services.csp-logger configuration has been renamed to csp.report_uri. Please update your configuration file.')
}
checkSecretsConfig()
checkEmailConfig()
checkNSFWPolicyConfig()
checkLocalRedundancyConfig()
@ -103,6 +104,12 @@ export {
// ---------------------------------------------------------------------------
function checkSecretsConfig () {
if (!CONFIG.SECRETS.PEERTUBE) {
throw new Error('secrets.peertube is missing in config. Generate one using `openssl rand -hex 32`')
}
}
function checkEmailConfig () {
if (!isEmailEnabled()) {
if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {

View File

@ -11,6 +11,7 @@ const config: IConfig = require('config')
function checkMissedConfig () {
const required = [ 'listen.port', 'listen.hostname',
'webserver.https', 'webserver.hostname', 'webserver.port',
'secrets.peertube',
'trust_proxy',
'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max',
'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address',

View File

@ -20,6 +20,9 @@ const CONFIG = {
PORT: config.get<number>('listen.port'),
HOSTNAME: config.get<string>('listen.hostname')
},
SECRETS: {
PEERTUBE: config.get<string>('secrets.peertube')
},
DATABASE: {
DBNAME: config.has('database.name') ? config.get<string>('database.name') : 'peertube' + config.get<string>('database.suffix'),
HOSTNAME: config.get<string>('database.hostname'),

View File

@ -1,5 +1,5 @@
import { RepeatOptions } from 'bullmq'
import { randomBytes } from 'crypto'
import { Encoding, randomBytes } from 'crypto'
import { invert } from 'lodash'
import { join } from 'path'
import { randomInt, root } from '@shared/core-utils'
@ -637,6 +637,13 @@ let PRIVATE_RSA_KEY_SIZE = 2048
// Password encryption
const BCRYPT_SALT_SIZE = 10
const ENCRYPTION = {
ALGORITHM: 'aes-256-cbc',
IV: 16,
SALT: 'peertube',
ENCODING: 'hex' as Encoding
}
const USER_PASSWORD_RESET_LIFETIME = 60000 * 60 // 60 minutes
const USER_PASSWORD_CREATE_LIFETIME = 60000 * 60 * 24 * 7 // 7 days
@ -959,6 +966,7 @@ const VIDEO_FILTERS = {
export {
WEBSERVER,
API_VERSION,
ENCRYPTION,
VIDEO_LIVE,
PEERTUBE_VERSION,
LAZY_STATIC_PATHS,

View File

@ -9,12 +9,12 @@ import OAuth2Server, {
UnsupportedGrantTypeError
} from '@node-oauth/oauth2-server'
import { randomBytesPromise } from '@server/helpers/core-utils'
import { isOTPValid } from '@server/helpers/otp'
import { MOAuthClient } from '@server/types/models'
import { sha1 } from '@shared/extra-utils'
import { HttpStatusCode } from '@shared/models'
import { OAUTH_LIFETIME, OTP } from '../../initializers/constants'
import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model'
import { isOTPValid } from '@server/helpers/otp'
class MissingTwoFactorError extends Error {
code = HttpStatusCode.UNAUTHORIZED_401
@ -138,7 +138,7 @@ async function handlePasswordGrant (options: {
throw new MissingTwoFactorError('Missing two factor header')
}
if (isOTPValid({ secret: user.otpSecret, token: request.headers[OTP.HEADER_NAME] }) !== true) {
if (await isOTPValid({ encryptedSecret: user.otpSecret, token: request.headers[OTP.HEADER_NAME] }) !== true) {
throw new InvalidTwoFactorError('Invalid two factor header')
}
}

View File

@ -0,0 +1,33 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { decrypt, encrypt } from '@server/helpers/peertube-crypto'
describe('Encrypt/Descrypt', function () {
it('Should encrypt and decrypt the string', async function () {
const secret = 'my_secret'
const str = 'my super string'
const encrypted = await encrypt(str, secret)
const decrypted = await decrypt(encrypted, secret)
expect(str).to.equal(decrypted)
})
it('Should not decrypt without the same secret', async function () {
const str = 'my super string'
const encrypted = await encrypt(str, 'my_secret')
let error = false
try {
await decrypt(encrypted, 'my_sicret')
} catch (err) {
error = true
}
expect(error).to.be.true
})
})

View File

@ -1,6 +1,7 @@
import './image'
import './crypto'
import './core-utils'
import './dns'
import './dns'
import './comment-model'
import './markdown'
import './request'