From a3e5f804ad821f6979e8735b0569b1209986fedc Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 10 Oct 2022 11:12:23 +0200 Subject: [PATCH] Encrypt OTP secret --- config/default.yaml | 4 ++ config/dev.yaml | 3 ++ config/production.yaml.example | 4 ++ config/test.yaml | 3 ++ server.ts | 7 +++- server/controllers/api/users/two-factor.ts | 14 ++++--- server/helpers/core-utils.ts | 14 ++++++- server/helpers/otp.ts | 10 +++-- server/helpers/peertube-crypto.ts | 47 ++++++++++++++++++++-- server/initializers/checker-after-init.ts | 7 ++++ server/initializers/checker-before-init.ts | 1 + server/initializers/config.ts | 3 ++ server/initializers/constants.ts | 10 ++++- server/lib/auth/oauth.ts | 4 +- server/tests/helpers/crypto.ts | 33 +++++++++++++++ server/tests/helpers/index.ts | 3 +- 16 files changed, 149 insertions(+), 18 deletions(-) create mode 100644 server/tests/helpers/crypto.ts diff --git a/config/default.yaml b/config/default.yaml index 2d8aaf1ea..890d7acf9 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -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 diff --git a/config/dev.yaml b/config/dev.yaml index ca93874d2..ef93afc19 100644 --- a/config/dev.yaml +++ b/config/dev.yaml @@ -5,6 +5,9 @@ listen: webserver: https: false +secrets: + peertube: 'my super dev secret' + database: hostname: 'localhost' port: 5432 diff --git a/config/production.yaml.example b/config/production.yaml.example index 46d574e42..399ac94f2 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example @@ -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 diff --git a/config/test.yaml b/config/test.yaml index a87642bd8..48cf0c0f6 100644 --- a/config/test.yaml +++ b/config/test.yaml @@ -5,6 +5,9 @@ listen: webserver: https: false +secrets: + peertube: 'my super secret' + rates_limit: signup: window: 10 minutes diff --git a/server.ts b/server.ts index 2085c67d9..417387a4f 100644 --- a/server.ts +++ b/server.ts @@ -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) diff --git a/server/controllers/api/users/two-factor.ts b/server/controllers/api/users/two-factor.ts index 79f63a62d..e6ae9e4dd 100644 --- a/server/controllers/api/users/two-factor.ts +++ b/server/controllers/api/users/two-factor.ts @@ -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) diff --git a/server/helpers/core-utils.ts b/server/helpers/core-utils.ts index c762f6a29..73bd994c1 100644 --- a/server/helpers/core-utils.ts +++ b/server/helpers/core-utils.ts @@ -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 (func: (arg1: T, arg2: U, cb: (err: any, result: A) } } +// eslint-disable-next-line max-len +function promisify3 (func: (arg1: T, arg2: U, arg3: V, cb: (err: any, result: A) => void) => void): (arg1: T, arg2: U, arg3: V) => Promise { + return function promisified (arg1: T, arg2: U, arg3: V): Promise { + return new Promise((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(randomBytes) +const scryptPromise = promisify3(scrypt) const execPromise2 = promisify2(exec) const execPromise = promisify1(exec) const pipelinePromise = promisify(pipeline) @@ -339,6 +349,8 @@ export { promisify1, promisify2, + scryptPromise, + randomBytesPromise, generateRSAKeyPairPromise, diff --git a/server/helpers/otp.ts b/server/helpers/otp.ts index a13edc5e2..a32cc9621 100644 --- a/server/helpers/otp.ts +++ b/server/helpers/otp.ts @@ -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(), diff --git a/server/helpers/peertube-crypto.ts b/server/helpers/peertube-crypto.ts index dcf47ce76..ae7d11800 100644 --- a/server/helpers/peertube-crypto.ts +++ b/server/helpers/peertube-crypto.ts @@ -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 { if (signedDocument.signature.type === 'RsaSignature2017') { @@ -114,12 +120,42 @@ async function signJsonLDObject (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 } // --------------------------------------------------------------------------- diff --git a/server/initializers/checker-after-init.ts b/server/initializers/checker-after-init.ts index 42839d1c9..c83fef425 100644 --- a/server/initializers/checker-after-init.ts +++ b/server/initializers/checker-after-init.ts @@ -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) { diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts index 1fd4ba248..c9268b156 100644 --- a/server/initializers/checker-before-init.ts +++ b/server/initializers/checker-before-init.ts @@ -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', diff --git a/server/initializers/config.ts b/server/initializers/config.ts index 287bf6f6d..a5a0d4e46 100644 --- a/server/initializers/config.ts +++ b/server/initializers/config.ts @@ -20,6 +20,9 @@ const CONFIG = { PORT: config.get('listen.port'), HOSTNAME: config.get('listen.hostname') }, + SECRETS: { + PEERTUBE: config.get('secrets.peertube') + }, DATABASE: { DBNAME: config.has('database.name') ? config.get('database.name') : 'peertube' + config.get('database.suffix'), HOSTNAME: config.get('database.hostname'), diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 9d6087867..cab61948a 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -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, diff --git a/server/lib/auth/oauth.ts b/server/lib/auth/oauth.ts index b541142a5..35b05ec5a 100644 --- a/server/lib/auth/oauth.ts +++ b/server/lib/auth/oauth.ts @@ -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') } } diff --git a/server/tests/helpers/crypto.ts b/server/tests/helpers/crypto.ts new file mode 100644 index 000000000..b508c715b --- /dev/null +++ b/server/tests/helpers/crypto.ts @@ -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 + }) +}) diff --git a/server/tests/helpers/index.ts b/server/tests/helpers/index.ts index 951208842..42d644c40 100644 --- a/server/tests/helpers/index.ts +++ b/server/tests/helpers/index.ts @@ -1,6 +1,7 @@ -import './image' +import './crypto' import './core-utils' import './dns' +import './dns' import './comment-model' import './markdown' import './request'