Skip to content

Commit

Permalink
HMS-2692 feat: App secrets with HKDF
Browse files Browse the repository at this point in the history
Change internal secrets handling to derive domain secrets from a common
app secrets with HKDF.

`app.domain_reg_key` has been removed. Instead the domain registration
token key is derived from `app.secret` using RFC 5869 HKDF extract and
expand. In the future, other secrets like JWK encryption key will be
derived the same way.

Signed-off-by: Christian Heimes <[email protected]>
  • Loading branch information
tiran authored and frasertweedale committed Oct 5, 2023
1 parent 2446952 commit 3bfd562
Show file tree
Hide file tree
Showing 11 changed files with 127 additions and 41 deletions.
6 changes: 3 additions & 3 deletions configs/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,6 @@ app:
accept_x_rh_fake_identity: true
# Validate API requests and response against the openapi specification
validate_api: true
# HMAC key for domain registration token (raw standard base64).
# "random" generates an ephemeral, random key
domain_reg_key: random
# main secret for various MAC and encryptions like domain registration
# token and encrypted private JWKs. "random" generates an ephemeral secret.
secret: random
2 changes: 1 addition & 1 deletion deployments/backend-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ services:
- DATABASE_NAME=${DATABASE_NAME:-idmsvc-db}
- DATABASE_USER=${DATABASE_USER:-idmsvc-user}
- DATABASE_PASSWORD=${DATABASE_PASSWORD:-idmsvc-secret}
- APP_DOMAIN_REG_KEY=${APP_DOMAIN_REG_KEY:-random}
- APP_SECRET=${APP_SECRET:-random}
- APP_VALIDATE_API=${APP_VALIDATE_API:-false}
- APP_TOKEN_EXPIRATION_SECONDS=${APP_TOKEN_EXPIRATION_SECONDS:-3600}
depends_on:
Expand Down
47 changes: 47 additions & 0 deletions docs/app-secret.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Backend App Secrets

The backend service requires secrets for some cryptographic operations. At
the moment, the backend needs a secret for the

- key for domain registration token. The token is generated with HMAC-SHA256.
- encryption key to store private JWKs in the database. Key data is encrypted
with AES-GCM.

The security of the token and key encryption depends on the strength of the
secrets. The secret must be unpredictable and should be created with a
cryptographically secure random number generator (CSRNG).

To make configuration and deployment simple, the backend borrows a trick from
TLS 1.3. It uses [RFC 5869](https://datatracker.ietf.org/doc/html/rfc5869)
HKDF to derive secrets from an initial main secret. HKDF stands for HMAC-based
key derivation function. The algorithm extracts a pseudo-random key from an
input value, then expands the PRK into domain-specific keys. It is considered
good practice to use distinct secrets instead of reusing the same secret value
for different parts of the application

## Implementation

The config value `app.secret` / `APP_SECRET` is an URL-safe base64 encoded
byte string without padding (`base64.RawURLEncoding`). The byte string must
be at least 16 bytes long and should be created from a CSRNG.

Next, a PRK is extracted from the app secret with `HKDF_Extract`. The extract
step uses `SHA-256` as hashing algorithm and static salt `"idmsvc-backend"`.

Finally, the PRK is expanded into domain-specific secrets.

```
ikm = base64_rawurl_decode(app_secret)
prk = HKDF_Extract(sha256, secret, "idmsvc-backend")
key = HDKF_Expand(sha256, prk, "domain registration key", 32)
```

## Keys

- Domain registration token secret (input for HMAC-SHA256)
HKDF info: "domain registration key"
Length: 32 bytes

- JWK encryption key (input for AES-GCM AEAD)
HKDF info: "JWK encryption key"
Length: 16 bytes
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ require (
github.com/rs/zerolog v1.30.0
github.com/spf13/viper v1.16.0
github.com/stretchr/testify v1.8.4
golang.org/x/crypto v0.13.0
gorm.io/driver/postgres v1.5.2
gorm.io/gorm v1.25.4
k8s.io/utils v0.0.0-20230726121419-3b25d923346b
Expand Down Expand Up @@ -78,7 +79,6 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/net v0.15.0 // indirect
golang.org/x/sys v0.12.0 // indirect
Expand Down
11 changes: 5 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,10 @@ type Application struct {
// ValidateAPI indicate when the middleware to validate the API
// requests and responses is disabled; by default it is enabled.
ValidateAPI bool `mapstructure:"validate_api"`
// Secret HMAC key for domain registration token
DomainRegTokenKey string `mapstructure:"domain_reg_key" validate:"required"`
// secret for various MAC and encryptions like domain registration
// token and encrypted private JWKs. "random" generates an ephemeral secret.
// Secrets are derived with HKDF-SHA256.
MainSecret string `mapstructure:"secret" validate:"required,base64rawurl"`
}

var config *Config = nil
Expand Down Expand Up @@ -210,10 +212,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("app.accept_x_rh_fake_identity", DefaultAcceptXRHFakeIdentity)
v.SetDefault("app.validate_api", DefaultValidateAPI)
v.SetDefault("app.url_path_prefix", DefaultPathPrefix)
// Domain registration token key is a standard base64 encoded string,
// which is used as HMAC key. The string "random" creates an random,
// ephemeral key for testing.
v.SetDefault("app.domain_reg_key", "")
v.SetDefault("app.secret", "")
v.SetDefault("app.debug", false)
}

Expand Down
2 changes: 1 addition & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ func TestValidateConfig(t *testing.T) {
cfg := Config{
Application: Application{
PathPrefix: DefaultPathPrefix,
DomainRegTokenKey: "random",
MainSecret: "random",
TokenExpirationTimeSeconds: 0,
},
}
Expand Down
74 changes: 50 additions & 24 deletions internal/handler/impl/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package impl

import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"hash"
"io"

"github.com/podengo-project/idmsvc-backend/internal/config"
"github.com/podengo-project/idmsvc-backend/internal/handler"
Expand All @@ -15,9 +18,15 @@ import (
usecase_interactor "github.com/podengo-project/idmsvc-backend/internal/usecase/interactor"
usecase_presenter "github.com/podengo-project/idmsvc-backend/internal/usecase/presenter"
usecase_repository "github.com/podengo-project/idmsvc-backend/internal/usecase/repository"
"golang.org/x/crypto/hkdf"
"gorm.io/gorm"
)

const (
Salt = "idmsvc-backend"
DomainRegKeyInfo = "domain registration key"
)

type domainComponent struct {
interactor interactor.DomainInteractor
repository repository.DomainRepository
Expand Down Expand Up @@ -47,8 +56,7 @@ type application struct {

func NewHandler(config *config.Config, db *gorm.DB, m *metrics.Metrics, inventory client.HostInventory) handler.Application {
var (
err error
domainRegKey []byte
err error
)
if config == nil {
panic("config is nil")
Expand All @@ -66,21 +74,15 @@ func NewHandler(config *config.Config, db *gorm.DB, m *metrics.Metrics, inventor
usecase_repository.NewHostRepository(),
usecase_presenter.NewHostPresenter(config),
}
// TODO move unmarshal and verification to Viper?
if domainRegKey, err = getSecretBytes(
"app.domain_reg_key", config.Application.DomainRegTokenKey, 8,
); err != nil {
panic(err)
}

sec := appSecrets{
domainRegKey: domainRegKey,
sec, err := getAppSecret(config)
if err != nil {
panic(err)
}

// Instantiate application
return &application{
config: config,
secrets: sec,
secrets: *sec,
db: db,
metrics: m,
domain: dc,
Expand All @@ -89,21 +91,45 @@ func NewHandler(config *config.Config, db *gorm.DB, m *metrics.Metrics, inventor
}
}

// Convert and check secret (raw standard base64 string)
func getSecretBytes(name string, value string, minLength int) (data []byte, err error) {
// ephemeral random key for testing and development
if value == "random" {
data = make([]byte, minLength)
if _, err = rand.Read(data); err != nil {
// Parse main secret and get sub secrets
func getAppSecret(config *config.Config) (sec *appSecrets, err error) {
const mainSecretLength = 16
// get / create main secret
var secret []byte
if config.Application.MainSecret == "random" {
secret = make([]byte, mainSecretLength)
if _, err = rand.Read(secret); err != nil {
return nil, err
}
return data, nil
} else {
if secret, err = base64.RawURLEncoding.DecodeString(config.Application.MainSecret); err != nil {
return nil, fmt.Errorf("Failed to main secret: %v", err)
}
if len(secret) < mainSecretLength {
return nil, fmt.Errorf("Master secret is too short, expected at least %d bytes.", mainSecretLength)
}
}
if data, err = base64.RawStdEncoding.DecodeString(value); err != nil {
return nil, fmt.Errorf("Failed to decode std base64 secret '%s': %v", name, err)

// extract PRK from main secret
var hash = sha256.New
prk := hkdf.Extract(hash, secret, []byte(Salt))

sec = &appSecrets{}
sec.domainRegKey, err = hkdfExpand(hash, prk, []byte(DomainRegKeyInfo), 32)
if err != nil {
return nil, err
}
if len(data) < minLength {
return nil, fmt.Errorf("Secrets '%s' is too short, expected %d bytes.", name, minLength)

return sec, nil

}

// expand pseudo random key with HKDF
func hkdfExpand(hash func() hash.Hash, prk []byte, info []byte, length int) (secret []byte, err error) {
reader := hkdf.Expand(hash, prk, info)
secret = make([]byte, length)
if _, err := io.ReadFull(reader, secret); err != nil {
return nil, err
}
return data, nil
return secret, err
}
13 changes: 13 additions & 0 deletions internal/handler/impl/application_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,16 @@ func TestNewHandler(t *testing.T) {
NewHandler(cfg, gormDB, &metrics.Metrics{}, inventoryMock)
})
}

func TestAppSecrets(t *testing.T) {
_, gormDB, err := test.NewSqlMock(&gorm.Session{SkipHooks: true})
inventoryMock := client.NewHostInventory(t)
require.NoError(t, err)
cfg := test.GetTestConfig()

handler := NewHandler(cfg, gormDB, &metrics.Metrics{}, inventoryMock)
app := handler.(*application)

assert.NotEmpty(t, app.secrets.domainRegKey)
assert.Equal(t, len(app.secrets.domainRegKey), 32)
}
2 changes: 1 addition & 1 deletion internal/test/config_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ func GetTestConfig() (cfg *config.Config) {
cfg = &config.Config{}
config.Load(cfg)
// override some default settings
cfg.Application.DomainRegTokenKey = "random"
cfg.Application.MainSecret = "random"
cfg.Application.PaginationDefaultLimit = 10
cfg.Application.PaginationMaxLimit = 100
return cfg
Expand Down
2 changes: 1 addition & 1 deletion scripts/mk/compose.mk
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ COMPOSE_VARS_KAFKA=\
KAFKA_TOPICS=$(KAFKA_TOPICS)

COMPOSE_VARS_APP=\
APP_DOMAIN_REG_KEY="$(APP_DOMAIN_REG_KEY)" \
APP_SECRET="$(APP_SECRET)" \
APP_VALIDATE_API=$(APP_VALIDATE_API) \
APP_TOKEN_EXPIRATION_SECONDS=$(APP_TOKEN_EXPIRATION_SECONDS)

Expand Down
7 changes: 4 additions & 3 deletions scripts/mk/variables.mk
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export APP_VALIDATE_API
APP_TOKEN_EXPIRATION_SECONDS ?= 7200
export APP_TOKEN_EXPIRATION_SECONDS

# MAC key for domain registration token (random value)
APP_DOMAIN_REG_KEY ?= CXJH0VsLUTkFR8RRIOhKmw
export APP_DOMAIN_REG_KEY
# main secret for various MAC and encryptions like
# domain registration token and encrypted private JWKs
APP_SECRET ?= sFamo2ER65JN7wxZ48UZb5GbtDc053ahIPJ0Qx47bzA
export APP_SECRET

0 comments on commit 3bfd562

Please sign in to comment.