]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Encrypt OTP secret
authorChocobozzz <me@florianbigard.com>
Mon, 10 Oct 2022 09:12:23 +0000 (11:12 +0200)
committerChocobozzz <me@florianbigard.com>
Mon, 10 Oct 2022 09:12:23 +0000 (11:12 +0200)
16 files changed:
config/default.yaml
config/dev.yaml
config/production.yaml.example
config/test.yaml
server.ts
server/controllers/api/users/two-factor.ts
server/helpers/core-utils.ts
server/helpers/otp.ts
server/helpers/peertube-crypto.ts
server/initializers/checker-after-init.ts
server/initializers/checker-before-init.ts
server/initializers/config.ts
server/initializers/constants.ts
server/lib/auth/oauth.ts
server/tests/helpers/crypto.ts [new file with mode: 0644]
server/tests/helpers/index.ts

index 2d8aaf1eaa61279d4b8699bf3b4a5c049fc11a2b..890d7acf96c3498fe1924dff1c95952d2b7d0e04 100644 (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
index ca93874d23f6c20c97fc079e6a0b0e5d263457f8..ef93afc19322782a6c6e5e76f27e0738781969a4 100644 (file)
@@ -5,6 +5,9 @@ listen:
 webserver:
   https: false
 
+secrets:
+  peertube: 'my super dev secret'
+
 database:
   hostname: 'localhost'
   port: 5432
index 46d574e4237252a182fd020f6acfc87111f20925..399ac94f2b13ac63d7a8b6b997f1e3db708483b2 100644 (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
index a87642bd834493be38b513f472aac8e30b12b7e8..48cf0c0f679cc95d35225f89f3347aee1b365008 100644 (file)
@@ -5,6 +5,9 @@ listen:
 webserver:
   https: false
 
+secrets:
+  peertube: 'my super secret'
+
 rates_limit:
   signup:
     window: 10 minutes
index 2085c67d91ebd6b5ab985c3e0cf70477eb307032..417387a4fc14c440865afb792454c64b5d28af86 100644 (file)
--- 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)
index 79f63a62d4e971680265ffd0f53bd2d4168f1e6e..e6ae9e4dd42b855528ce1feb9b2265ef6f5093e0 100644 (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)
index c762f6a29331523b203354d17b22292e26997627..73bd994c17c7618a69db3eb65de751681f8ff6aa 100644 (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,
index a13edc5e2103a8c3f294fa30ee29da7d0bee5b25..a32cc96210b8cab68ca34216dc7cbf0e582a9a79 100644 (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(),
index dcf47ce761efb5752cb2af92c0fb7283a9a62cf4..ae7d11800b431888b54a1634eaeaa78480fc54ae 100644 (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
 }
 
 // ---------------------------------------------------------------------------
index 42839d1c97434ac445d5694481249c393f37b22c..c83fef425af8fc4ddd46ade9745000e54fff27e3 100644 (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) {
index 1fd4ba248372f6cdb15b94320e6e648bd04e8ff7..c9268b156fbbc79ae119dbccf6d6ab775b34b88e 100644 (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',
index 287bf6f6df69c1efced967133d0d58a920ac1ed0..a5a0d4e46dd385b5af1a6ced51ac2c2c879184f1 100644 (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'),
index 9d6087867ab3ae2b96317c1c18d331b9cb4ce9df..cab61948acfff87efb9394f46d1d4fe7e5ea2b0d 100644 (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,
index b541142a584d46fbc26ee430a6ed1c624418566f..35b05ec5abd25cbe9271e563ebdbe6603f0ce432 100644 (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')
     }
   }
diff --git a/server/tests/helpers/crypto.ts b/server/tests/helpers/crypto.ts
new file mode 100644 (file)
index 0000000..b508c71
--- /dev/null
@@ -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
+  })
+})
index 951208842cba52f3dbaec491084e68f91d84fa6f..42d644c40c657c18d3189ba50a177c026b9bc4ef 100644 (file)
@@ -1,6 +1,7 @@
-import './image'
+import './crypto'
 import './core-utils'
 import './dns'
+import './dns'
 import './comment-model'
 import './markdown'
 import './request'