mirror of https://github.com/Chocobozzz/PeerTube
Encrypt OTP secret
parent
a0da6f90d1
commit
a3e5f804ad
|
@ -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
|
||||
|
|
|
@ -5,6 +5,9 @@ listen:
|
|||
webserver:
|
||||
https: false
|
||||
|
||||
secrets:
|
||||
peertube: 'my super dev secret'
|
||||
|
||||
database:
|
||||
hostname: 'localhost'
|
||||
port: 5432
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -5,6 +5,9 @@ listen:
|
|||
webserver:
|
||||
https: false
|
||||
|
||||
secrets:
|
||||
peertube: 'my super secret'
|
||||
|
||||
rates_limit:
|
||||
signup:
|
||||
window: 10 minutes
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -1,6 +1,7 @@
|
|||
import './image'
|
||||
import './crypto'
|
||||
import './core-utils'
|
||||
import './dns'
|
||||
import './dns'
|
||||
import './comment-model'
|
||||
import './markdown'
|
||||
import './request'
|
||||
|
|
Loading…
Reference in New Issue