Rotatable secrets (#9982)

* Rotatable secrets

* Set new api encryption key var

* Lint

* Use fallback keys instead of array

* Point api encryption key to dedicated value

* Add API_ENCRYPTION_KEY to cli

* Lint + add api encryption key to env files
This commit is contained in:
Rory Powell 2023-03-13 15:02:59 +00:00 committed by GitHub
parent e74f80742e
commit e116941750
25 changed files with 132 additions and 84 deletions

View File

@ -62,16 +62,31 @@ spec:
{{ end }}
- name: ENABLE_ANALYTICS
value: {{ .Values.globals.enableAnalytics | quote }}
- name: API_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: budibase-budibase {{ template "budibase.fullname" . }}
key: apiEncryptionKey
- name: INTERNAL_API_KEY
valueFrom:
secretKeyRef:
name: {{ template "budibase.fullname" . }}
key: internalApiKey
- name: INTERNAL_API_KEY_FALLBACK
valueFrom:
secretKeyRef:
name: {{ template "budibase.fullname" . }}
key: internalApiKeyFallback
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ template "budibase.fullname" . }}
key: jwtSecret
- name: JWT_SECRET_FALLBACK
valueFrom:
secretKeyRef:
name: {{ template "budibase.fullname" . }}
key: jwtSecretFallback
{{ if .Values.services.objectStore.region }}
- name: AWS_REGION
value: {{ .Values.services.objectStore.region }}

View File

@ -10,8 +10,14 @@ metadata:
heritage: "{{ .Release.Service }}"
type: Opaque
data:
{{/* For new installations this can be any value. For existing installations this must match the first used jwtSecret */}}
apiEncryptionKey: {{ .Values.globals.apiEncryptionKey }}
internalApiKey: {{ template "budibase.defaultsecret" .Values.globals.internalApiKey }}
{{/* Fallback value auto generated */}}
internalApiKeyFallback: {{ .Values.globals.internalApiKeyFallback }}
jwtSecret: {{ template "budibase.defaultsecret" .Values.globals.jwtSecret }}
{{/* Falback value never auto generated */}}
jwtSecretFallback: {{ .Values.globals.jwtSecretFallback }}
objectStoreAccess: {{ template "budibase.defaultsecret" .Values.services.objectStore.accessKey }}
objectStoreSecret: {{ template "budibase.defaultsecret" .Values.services.objectStore.secretKey }}
{{- end -}}

View File

@ -62,16 +62,31 @@ spec:
{{ else }}
value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }}
{{ end }}
- name: API_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: budibase-budibase {{ template "budibase.fullname" . }}
key: apiEncryptionKey
- name: INTERNAL_API_KEY
valueFrom:
secretKeyRef:
name: {{ template "budibase.fullname" . }}
key: internalApiKey
- name: INTERNAL_API_KEY_FALLBACK
valueFrom:
secretKeyRef:
name: {{ template "budibase.fullname" . }}
key: internalApiKeyFallback
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ template "budibase.fullname" . }}
key: jwtSecret
- name: JWT_SECRET_FALLBACK
valueFrom:
secretKeyRef:
name: {{ template "budibase.fullname" . }}
key: jwtSecretFallback
{{ if .Values.services.objectStore.region }}
- name: AWS_REGION
value: {{ .Values.services.objectStore.region }}

View File

@ -96,9 +96,13 @@ globals:
createSecrets: true # creates an internal API key, JWT secrets and redis password for you
# if createSecrets is set to false, you can hard-code your secrets here
apiEncryptionKey: ""
internalApiKey: ""
jwtSecret: ""
cdnUrl: ""
# fallback values used during live rotation
internalApiKeyFallback: ""
jwtSecretFallback: ""
smtp:
enabled: false

View File

@ -3,6 +3,7 @@ MAIN_PORT=10000
# This section contains all secrets pertaining to the system
# These should be updated
API_ENCRYPTION_KEY=testsecret
JWT_SECRET=testsecret
MINIO_ACCESS_KEY=budibase
MINIO_SECRET_KEY=budibase

View File

@ -17,6 +17,7 @@ services:
INTERNAL_API_KEY: ${INTERNAL_API_KEY}
BUDIBASE_ENVIRONMENT: ${BUDIBASE_ENVIRONMENT}
PORT: 4002
API_ENCRYPTION_KEY: ${API_ENCRYPTION_KEY}
JWT_SECRET: ${JWT_SECRET}
LOG_LEVEL: info
SENTRY_DSN: https://a34ae347621946bf8acded18e5b7d4b8@o420233.ingest.sentry.io/5338131
@ -40,6 +41,7 @@ services:
SELF_HOSTED: 1
PORT: 4003
CLUSTER_PORT: ${MAIN_PORT}
API_ENCRYPTION_KEY: ${API_ENCRYPTION_KEY}
JWT_SECRET: ${JWT_SECRET}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}

View File

@ -3,6 +3,7 @@ MAIN_PORT=10000
# This section contains all secrets pertaining to the system
# These should be updated
API_ENCRYPTION_KEY=testsecret
JWT_SECRET=testsecret
MINIO_ACCESS_KEY=budibase
MINIO_SECRET_KEY=budibase

View File

@ -1,6 +1,5 @@
const _passport = require("koa-passport")
const LocalStrategy = require("passport-local").Strategy
const JwtStrategy = require("passport-jwt").Strategy
import { getGlobalDB } from "../context"
import { Cookie } from "../constants"
import { getSessionsForUser, invalidateSessions } from "../security/sessions"
@ -8,7 +7,6 @@ import {
authenticated,
csrf,
google,
jwt as jwtPassport,
local,
oidc,
tenancy,
@ -21,14 +19,11 @@ import {
OIDCInnerConfig,
PlatformLogoutOpts,
SSOProviderType,
User,
} from "@budibase/types"
import { logAlert } from "../logging"
import * as events from "../events"
import * as configs from "../configs"
import { clearCookie, getCookie } from "../utils"
import { ssoSaveUserNoOp } from "../middleware/passport/sso/sso"
import env from "../environment"
const refresh = require("passport-oauth2-refresh")
export {
@ -51,25 +46,6 @@ export const jwt = require("jsonwebtoken")
// Strategies
_passport.use(new LocalStrategy(local.options, local.authenticate))
if (jwtPassport.options.secretOrKey) {
_passport.use(new JwtStrategy(jwtPassport.options, jwtPassport.authenticate))
} else if (!env.DISABLE_JWT_WARNING) {
logAlert("No JWT Secret supplied, cannot configure JWT strategy")
}
_passport.serializeUser((user: User, done: any) => done(null, user))
_passport.deserializeUser(async (user: User, done: any) => {
const db = getGlobalDB()
try {
const dbUser = await db.get(user._id)
return done(null, dbUser)
} catch (err) {
console.error(`User not found`, err)
return done(null, false, { message: "User not found" })
}
})
async function refreshOIDCAccessToken(
chosenConfig: OIDCInnerConfig,

View File

@ -30,6 +30,12 @@ const DefaultBucketName = {
const selfHosted = !!parseInt(process.env.SELF_HOSTED || "")
function getAPIEncryptionKey() {
return process.env.API_ENCRYPTION_KEY
? process.env.API_ENCRYPTION_KEY
: process.env.JWT_SECRET // fallback to the JWT_SECRET used historically
}
const environment = {
isTest,
isJest,
@ -39,7 +45,9 @@ const environment = {
},
JS_BCRYPT: process.env.JS_BCRYPT,
JWT_SECRET: process.env.JWT_SECRET,
JWT_SECRET_FALLBACK: process.env.JWT_SECRET_FALLBACK,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
API_ENCRYPTION_KEY: getAPIEncryptionKey(),
COUCH_DB_URL: process.env.COUCH_DB_URL || "http://localhost:4005",
COUCH_DB_USERNAME: process.env.COUCH_DB_USER,
COUCH_DB_PASSWORD: process.env.COUCH_DB_PASSWORD,
@ -55,6 +63,7 @@ const environment = {
MINIO_URL: process.env.MINIO_URL,
MINIO_ENABLED: process.env.MINIO_ENABLED || 1,
INTERNAL_API_KEY: process.env.INTERNAL_API_KEY,
INTERNAL_API_KEY_FALLBACK: process.env.INTERNAL_API_KEY_FALLBACK,
MULTI_TENANCY: process.env.MULTI_TENANCY,
ACCOUNT_PORTAL_URL:
process.env.ACCOUNT_PORTAL_URL || "https://account.budibase.app",

View File

@ -1,5 +1,10 @@
import { Cookie, Header } from "../constants"
import { getCookie, clearCookie, openJwt } from "../utils"
import {
getCookie,
clearCookie,
openJwt,
isValidInternalAPIKey,
} from "../utils"
import { getUser } from "../cache/user"
import { getSession, updateSessionTTL } from "../security/sessions"
import { buildMatcherRegex, matches } from "./matchers"
@ -35,7 +40,9 @@ function finalise(ctx: any, opts: FinaliseOpts = {}) {
}
async function checkApiKey(apiKey: string, populateUser?: Function) {
if (apiKey === env.INTERNAL_API_KEY) {
// check both the primary and the fallback internal api keys
// this allows for rotation
if (isValidInternalAPIKey(apiKey)) {
return { valid: true }
}
const decrypted = decrypt(apiKey)

View File

@ -1,4 +1,3 @@
export * as jwt from "./passport/jwt"
export * as local from "./passport/local"
export * as google from "./passport/sso/google"
export * as oidc from "./passport/sso/oidc"

View File

@ -1,13 +1,21 @@
import env from "../environment"
import { Header } from "../constants"
import { BBContext } from "@budibase/types"
import { isValidInternalAPIKey } from "../utils"
/**
* API Key only endpoint.
*/
export default async (ctx: BBContext, next: any) => {
const apiKey = ctx.request.headers[Header.API_KEY]
if (apiKey !== env.INTERNAL_API_KEY) {
if (!apiKey) {
ctx.throw(403, "Unauthorized")
}
if (Array.isArray(apiKey)) {
ctx.throw(403, "Unauthorized")
}
if (!isValidInternalAPIKey(apiKey)) {
ctx.throw(403, "Unauthorized")
}

View File

@ -1,19 +0,0 @@
import { Cookie } from "../../constants"
import env from "../../environment"
import { authError } from "./utils"
import { BBContext } from "@budibase/types"
export const options = {
secretOrKey: env.JWT_SECRET,
jwtFromRequest: function (ctx: BBContext) {
return ctx.cookies.get(Cookie.Auth)
},
}
export async function authenticate(jwt: Function, done: Function) {
try {
return done(null, jwt)
} catch (err) {
return authError(done, "JWT invalid", err)
}
}

View File

@ -8,7 +8,7 @@ const RANDOM_BYTES = 16
const STRETCH_LENGTH = 32
export enum SecretOption {
JWT = "jwt",
API = "api",
ENCRYPTION = "encryption",
}
@ -19,10 +19,10 @@ function getSecret(secretOption: SecretOption): string {
secret = env.ENCRYPTION_KEY
secretName = "ENCRYPTION_KEY"
break
case SecretOption.JWT:
case SecretOption.API:
default:
secret = env.JWT_SECRET
secretName = "JWT_SECRET"
secret = env.API_ENCRYPTION_KEY
secretName = "API_ENCRYPTION_KEY"
break
}
if (!secret) {
@ -37,7 +37,7 @@ function stretchString(string: string, salt: Buffer) {
export function encrypt(
input: string,
secretOption: SecretOption = SecretOption.JWT
secretOption: SecretOption = SecretOption.API
) {
const salt = crypto.randomBytes(RANDOM_BYTES)
const stretched = stretchString(getSecret(secretOption), salt)
@ -50,7 +50,7 @@ export function encrypt(
export function decrypt(
input: string,
secretOption: SecretOption = SecretOption.JWT
secretOption: SecretOption = SecretOption.API
) {
const [salt, encrypted] = input.split(SEPARATOR)
const saltBuffer = Buffer.from(salt, "hex")

View File

@ -1,5 +1,4 @@
import { getAllApps, queryGlobalView } from "../db"
import { options } from "../middleware/passport/jwt"
import {
Header,
MAX_VALID_DATE,
@ -133,7 +132,30 @@ export function openJwt(token: string) {
if (!token) {
return token
}
return jwt.verify(token, options.secretOrKey)
try {
return jwt.verify(token, env.JWT_SECRET)
} catch (e) {
if (env.JWT_SECRET_FALLBACK) {
// fallback to enable rotation
return jwt.verify(token, env.JWT_SECRET_FALLBACK)
} else {
throw e
}
}
}
export function isValidInternalAPIKey(apiKey: string) {
if (env.INTERNAL_API_KEY && env.INTERNAL_API_KEY === apiKey) {
return true
}
// fallback to enable rotation
if (
env.INTERNAL_API_KEY_FALLBACK &&
env.INTERNAL_API_KEY_FALLBACK === apiKey
) {
return true
}
return false
}
/**
@ -165,7 +187,7 @@ export function setCookie(
opts = { sign: true }
) {
if (value && opts && opts.sign) {
value = jwt.sign(value, options.secretOrKey)
value = jwt.sign(value, env.JWT_SECRET)
}
const config: SetOption = {

View File

@ -13,6 +13,7 @@ export const ENV_PATH = path.resolve("./.env")
function getSecrets(opts = { single: false }) {
const secrets = [
"API_ENCRYPTION_KEY",
"JWT_SECRET",
"MINIO_ACCESS_KEY",
"MINIO_SECRET_KEY",

View File

@ -29,13 +29,6 @@ router
br: false,
})
)
.use(async (ctx, next) => {
ctx.config = {
jwtSecret: env.JWT_SECRET,
useAppRootPath: true,
}
await next()
})
// re-direct before any middlewares occur
.redirect("/", "/builder")
.use(

View File

@ -39,7 +39,6 @@ let inThread = false
const environment = {
// important - prefer app port to generic port
PORT: process.env.APP_PORT || process.env.PORT,
JWT_SECRET: process.env.JWT_SECRET,
COUCH_DB_URL: process.env.COUCH_DB_URL,
MINIO_URL: process.env.MINIO_URL,
WORKER_URL: process.env.WORKER_URL,
@ -48,7 +47,6 @@ const environment = {
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
INTERNAL_API_KEY: process.env.INTERNAL_API_KEY,
HTTP_MIGRATIONS: process.env.HTTP_MIGRATIONS,
API_REQ_LIMIT_PER_SEC: process.env.API_REQ_LIMIT_PER_SEC,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,

View File

@ -205,7 +205,6 @@ class TestConfiguration {
request.appId = appId
// fake cookies, we don't need them
request.cookies = { set: () => {}, get: () => {} }
request.config = { jwtSecret: env.JWT_SECRET }
request.user = { appId, tenantId: this.getTenantId() }
request.query = {}
request.request = {
@ -332,8 +331,8 @@ class TestConfiguration {
roleId: roleId,
appId,
}
const authToken = auth.jwt.sign(authObj, env.JWT_SECRET)
const appToken = auth.jwt.sign(app, env.JWT_SECRET)
const authToken = auth.jwt.sign(authObj, coreEnv.JWT_SECRET)
const appToken = auth.jwt.sign(app, coreEnv.JWT_SECRET)
// returning necessary request headers
await cache.user.invalidateUser(userId)
@ -361,8 +360,8 @@ class TestConfiguration {
roleId: roles.BUILTIN_ROLE_IDS.ADMIN,
appId: this.appId,
}
const authToken = auth.jwt.sign(authObj, env.JWT_SECRET)
const appToken = auth.jwt.sign(app, env.JWT_SECRET)
const authToken = auth.jwt.sign(authObj, coreEnv.JWT_SECRET)
const appToken = auth.jwt.sign(app, coreEnv.JWT_SECRET)
const headers: any = {
Accept: "application/json",
Cookie: [

View File

@ -6,6 +6,7 @@ import {
constants,
tenancy,
logging,
env as coreEnv,
} from "@budibase/backend-core"
import { updateAppRole } from "./global"
import { BBContext, User } from "@budibase/types"
@ -15,7 +16,7 @@ export function request(ctx?: BBContext, request?: any) {
request.headers = {}
}
if (!ctx) {
request.headers[constants.Header.API_KEY] = env.INTERNAL_API_KEY
request.headers[constants.Header.API_KEY] = coreEnv.INTERNAL_API_KEY
if (tenancy.isTenantIdSet()) {
request.headers[constants.Header.TENANT_ID] = tenancy.getTenantId()
}

View File

@ -30,10 +30,8 @@ const environment = {
// auth
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
JWT_SECRET: process.env.JWT_SECRET,
SALT_ROUNDS: process.env.SALT_ROUNDS,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
INTERNAL_API_KEY: process.env.INTERNAL_API_KEY,
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,
// urls
MINIO_URL: process.env.MINIO_URL,

View File

@ -1,5 +1,5 @@
import env from "../environment"
import { constants } from "@budibase/backend-core"
import { constants, utils } from "@budibase/backend-core"
import { BBContext } from "@budibase/types"
/**
@ -9,7 +9,15 @@ import { BBContext } from "@budibase/types"
export default async (ctx: BBContext, next: any) => {
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const apiKey = ctx.request.headers[constants.Header.API_KEY]
if (apiKey !== env.INTERNAL_API_KEY) {
if (!apiKey) {
ctx.throw(403, "Unauthorized")
}
if (Array.isArray(apiKey)) {
ctx.throw(403, "Unauthorized")
}
if (!utils.isValidInternalAPIKey(apiKey)) {
ctx.throw(403, "Unauthorized")
}
}

View File

@ -5,10 +5,10 @@ import {
sessions,
events,
HTTPError,
env as coreEnv,
} from "@budibase/backend-core"
import { PlatformLogoutOpts, User } from "@budibase/types"
import jwt from "jsonwebtoken"
import env from "../../environment"
import * as userSdk from "../users"
import * as emails from "../../utilities/email"
import * as redis from "../../utilities/redis"
@ -26,7 +26,7 @@ export async function loginUser(user: User) {
sessionId,
tenantId,
},
env.JWT_SECRET!
coreEnv.JWT_SECRET!
)
return token
}

View File

@ -74,7 +74,6 @@ class TestConfiguration {
const request: any = {}
// fake cookies, we don't need them
request.cookies = { set: () => {}, get: () => {} }
request.config = { jwtSecret: env.JWT_SECRET }
request.user = { tenantId: this.getTenantId() }
request.query = {}
request.request = {
@ -180,7 +179,7 @@ class TestConfiguration {
sessionId: "sessionid",
tenantId: user.tenantId,
}
const authCookie = auth.jwt.sign(authToken, env.JWT_SECRET)
const authCookie = auth.jwt.sign(authToken, coreEnv.JWT_SECRET)
return {
Accept: "application/json",
...this.cookieHeader([`${constants.Cookie.Auth}=${authCookie}`]),
@ -197,7 +196,7 @@ class TestConfiguration {
}
internalAPIHeaders() {
return { [constants.Header.API_KEY]: env.INTERNAL_API_KEY }
return { [constants.Header.API_KEY]: coreEnv.INTERNAL_API_KEY }
}
adminOnlyResponse = () => {
@ -277,7 +276,7 @@ class TestConfiguration {
// CONFIGS - OIDC
getOIDConfigCookie(configId: string) {
const token = auth.jwt.sign(configId, env.JWT_SECRET)
const token = auth.jwt.sign(configId, coreEnv.JWT_SECRET)
return this.cookieHeader([[`${constants.Cookie.OIDC_CONFIG}=${token}`]])
}

View File

@ -1,5 +1,10 @@
import fetch from "node-fetch"
import { constants, tenancy, logging } from "@budibase/backend-core"
import {
constants,
tenancy,
logging,
env as coreEnv,
} from "@budibase/backend-core"
import { checkSlashesInUrl } from "../utilities"
import env from "../environment"
import { SyncUserRequest, User } from "@budibase/types"
@ -9,7 +14,7 @@ async function makeAppRequest(url: string, method: string, body: any) {
return
}
const request: any = { headers: {} }
request.headers[constants.Header.API_KEY] = env.INTERNAL_API_KEY
request.headers[constants.Header.API_KEY] = coreEnv.INTERNAL_API_KEY
if (tenancy.isTenantIdSet()) {
request.headers[constants.Header.TENANT_ID] = tenancy.getTenantId()
}