Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ ALPHONE_TEST_S3_SECRET_ACCESS_KEY=0123456789abcdef0123456789abcdef0123456789abcd
# Comma-separated CIDR ranges of reverse proxies allowed for login rate limiting.
# ALPHONE_TRUSTED_PROXIES=10.0.0.0/8

# The key that encrypts the secrets plugins keep, 64 hex characters from openssl rand -hex 32, none when unset.
# ALPHONE_ENCRYPTION_KEY=
# The keys that still open the secrets saved before the current key, a comma list, none when unset.
# ALPHONE_ENCRYPTION_PREVIOUS_KEYS=

# Internal addresses webhooks may still reach, none when unset. A comma list of CIDR ranges
# such as 127.0.0.1/32 and host names with a port such as n8n:5678. Link-local and cloud
# metadata addresses stay refused whatever this lists. The local n8n of make n8n listens on
Expand Down
7 changes: 7 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ linters:
- github.com/gopherium/framework/goncierge
- github.com/gopherium/framework/gonsole
- github.com/gopherium/framework/pluginkit
encryption-purity:
list-mode: strict
files:
- "**/internal/encryption/*.go"
allow:
- $gostd
- github.com/gopherium/alphone/internal/encryption
plugins-never-import-internal:
list-mode: lax
files:
Expand Down
4 changes: 4 additions & 0 deletions cmd/alphone/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ type composed struct {
roles *goncierge.Registry
files *storage.Files
sweeper *storage.Sweeper
settings *postgres.SettingStore
registered []sdk.Plugin
failed error
}
Expand All @@ -77,6 +78,7 @@ func compose(ctx context.Context, cfg composeConfig, plugins func(sdk.Deps) ([]s
roles: cfg.roles,
files: storage.NewFiles(cfg.storage, pool),
sweeper: storage.NewSweeper(cfg.storage, pool, cfg.logger),
settings: postgres.NewSettingStore(pool, cfg.encryption),
}
built.events = nudgingPublisher{
dispatcher: webhook.NewDispatcher(webhooks, cfg.logger), worker: built.worker, hub: built.hub,
Expand Down Expand Up @@ -115,6 +117,8 @@ func compose(ctx context.Context, cfg composeConfig, plugins func(sdk.Deps) ([]s
wireRoleChecker(built.registered, roleCheckerBridge{registry: cfg.roles})
wireMailSenderFrom(built.registered, sender)
wireFileStores(built.registered, built.files)
wireSettingStores(built.registered, built.settings)
wireEncrypters(built.registered, cfg.encryption)
return built, nil
}

Expand Down
6 changes: 5 additions & 1 deletion cmd/alphone/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (

"github.com/gopherium/alphone/graph"
"github.com/gopherium/alphone/internal/contact"
"github.com/gopherium/alphone/internal/encryption"
"github.com/gopherium/alphone/internal/graphres"
"github.com/gopherium/alphone/internal/graphroot"
"github.com/gopherium/alphone/internal/postgres"
Expand Down Expand Up @@ -291,6 +292,7 @@ type composeSettings struct {
webhookHosts webhook.AllowList
paging graphres.Paging
storage storage.Settings
encryption *encryption.Keys
}

// servingDefaults are the HTTP timeouts and shutdown graces the server runs under when the environment names none.
Expand Down Expand Up @@ -497,10 +499,12 @@ func loadComposeSettings(env gonsole.Env) (composeSettings, error) {
if err != nil {
return composeSettings{}, err
}
keys, err := loadEncryptionKeys(env)
return composeSettings{
machineGrace: machineGrace, tenants: tenants, contactMatchMax: contactMatchMax,
publicURL: publicURL, mail: mail, webhookHosts: webhookHosts, paging: paging, storage: files,
}, nil
encryption: keys,
}, err
}

// loadRunConfig reads the server settings from the environment.
Expand Down
58 changes: 58 additions & 0 deletions cmd/alphone/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ func coreSettings() map[string]string {
"ALPHONE_STORAGE_S3_SECRET_ACCESS_KEY": "example-secret",
"ALPHONE_STORAGE_S3_PATH_STYLE": "true",
"ALPHONE_STORAGE_S3_CHECKSUM": "when_required",
"ALPHONE_ENCRYPTION_KEY": currentEncryptionKey,
"ALPHONE_ENCRYPTION_PREVIOUS_KEYS": olderEncryptionKey,
}
}

Expand Down Expand Up @@ -616,6 +618,62 @@ func TestTheExampleFileStatesTheCommandFallbacks(t *testing.T) {
}
}

func TestABadEncryptionKeyIsNamedWithoutShowingIt(t *testing.T) {
t.Parallel()

tests := map[string]struct {
env map[string]string
want string
}{
"a key that is not hex": {
map[string]string{"ALPHONE_ENCRYPTION_KEY": strings.Repeat("zz", 32)},
"ALPHONE_ENCRYPTION_KEY: must be written in hex, as openssl rand -hex 32 prints it",
},
"a key too short": {
map[string]string{"ALPHONE_ENCRYPTION_KEY": currentEncryptionKey[:32]},
"ALPHONE_ENCRYPTION_KEY: must hold 32 bytes, got 16",
},
"a bad entry in the previous list": {
map[string]string{
"ALPHONE_ENCRYPTION_KEY": currentEncryptionKey,
"ALPHONE_ENCRYPTION_PREVIOUS_KEYS": olderEncryptionKey + ",0a0b",
},
"ALPHONE_ENCRYPTION_PREVIOUS_KEYS: entry 2: must hold 32 bytes, got 2",
},
"previous keys without a current key": {
map[string]string{"ALPHONE_ENCRYPTION_PREVIOUS_KEYS": olderEncryptionKey},
"ALPHONE_ENCRYPTION_PREVIOUS_KEYS is set but ALPHONE_ENCRYPTION_KEY is not",
},
}
for testName, tt := range tests {
t.Run(testName, func(t *testing.T) {
t.Parallel()

env := coreSettings()
delete(env, "ALPHONE_ENCRYPTION_KEY")
delete(env, "ALPHONE_ENCRYPTION_PREVIOUS_KEYS")
maps.Copy(env, tt.env)

_, err := loadRunConfig(testkit.Getenv(env))

if err == nil || err.Error() != tt.want {
t.Fatalf("loadRunConfig() error = %v, want %q", err, tt.want)
}
wantNoValueShown(t, err, tt.env)
})
}
}

// wantNoValueShown fails the test when err shows any value env holds.
func wantNoValueShown(t *testing.T, err error, env map[string]string) {
t.Helper()
for key, value := range env {
if strings.Contains(err.Error(), value) {
t.Errorf("error = %q, want it to leave the value of %s out", err, key)
}
}
}

func TestSettingRefusalsNameTheSettingAndTheReason(t *testing.T) {
t.Parallel()

Expand Down
119 changes: 119 additions & 0 deletions cmd/alphone/settingstore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: Elastic-2.0

package main

import (
"context"
"encoding/hex"
"errors"
"fmt"
"strings"

"github.com/gopherium/framework/gonsole"

"github.com/gopherium/alphone/internal/encryption"
"github.com/gopherium/alphone/internal/postgres"
"github.com/gopherium/alphone/sdk"
)

// encryptionKeySetting names the setting holding the key new secrets are encrypted with, past the program prefix.
const encryptionKeySetting = "ENCRYPTION_KEY"

// previousEncryptionKeysSetting names the setting listing the keys older secrets still open with.
const previousEncryptionKeysSetting = "ENCRYPTION_PREVIOUS_KEYS"

// pluginPurpose leads the associated data every value a plugin encrypts is bound to.
const pluginPurpose = "plugin"

// loadEncryptionKeys reads the key new secrets are encrypted with and the keys older ones still open with.
func loadEncryptionKeys(env gonsole.Env) (*encryption.Keys, error) {
current, err := gonsole.Parse(env, encryptionKeySetting, nil, parseEncryptionKey)
if err != nil {
return nil, err
}
previous, err := gonsole.Parse(env, previousEncryptionKeysSetting, nil, parseEncryptionKeys)
if err != nil {
return nil, err
}
if current == nil && previous != nil {
return nil, fmt.Errorf("%s is set but %s is not",
env.Key(previousEncryptionKeysSetting), env.Key(encryptionKeySetting))
}
return encryption.New(current, previous)
}

// parseEncryptionKey reads one key written as 64 hex characters, leaving the value out of every error.
func parseEncryptionKey(value string) ([]byte, error) {
key, err := hex.DecodeString(value)
if err != nil {
return nil, errors.New("must be written in hex, as openssl rand -hex 32 prints it")
}
if len(key) != encryption.KeySize {
return nil, fmt.Errorf("must hold %d bytes, got %d", encryption.KeySize, len(key))
}
return key, nil
}

// parseEncryptionKeys reads a comma list of keys, each written as 64 hex characters.
func parseEncryptionKeys(value string) ([][]byte, error) {
var keys [][]byte
for at, entry := range strings.Split(value, ",") {
key, err := parseEncryptionKey(strings.TrimSpace(entry))
if err != nil {
return nil, fmt.Errorf("entry %d: %w", at+1, err)
}
keys = append(keys, key)
}
return keys, nil
}

// wireSettingStores hands every plugin taking a settings store a store of its own over settings.
func wireSettingStores(registered []sdk.Plugin, settings *postgres.SettingStore) {
for _, plugin := range registered {
if consumer, ok := plugin.(sdk.SettingStoreConsumer); ok {
consumer.UseSettingStore(settings.ForPlugin(plugin.ID()))
}
}
}

// wireEncrypters hands every plugin taking an encrypter one bound to the plugin over keys.
func wireEncrypters(registered []sdk.Plugin, keys *encryption.Keys) {
for _, plugin := range registered {
if consumer, ok := plugin.(sdk.EncrypterConsumer); ok {
consumer.UseEncrypter(pluginEncrypter{keys: keys, plugin: plugin.ID()})
}
}
}

// pluginEncrypter encrypts one plugin's own values for the workspace each call serves.
type pluginEncrypter struct {
keys *encryption.Keys
plugin string
}

// Encrypt answers plaintext encrypted for the calling workspace, this plugin and purpose.
func (e pluginEncrypter) Encrypt(ctx context.Context, purpose string, plaintext []byte) ([]byte, error) {
ciphertext, err := e.keys.Encrypt(plaintext, e.binding(ctx, purpose)...)
if err != nil {
return nil, sdk.ErrNoEncryptionKey
}
return ciphertext, nil
}

// Decrypt answers what Encrypt took, ErrCannotDecrypt for another workspace, plugin or purpose.
func (e pluginEncrypter) Decrypt(ctx context.Context, purpose string, ciphertext []byte) ([]byte, error) {
plaintext, err := e.keys.Decrypt(ciphertext, e.binding(ctx, purpose)...)
if errors.Is(err, encryption.ErrNoKey) {
return nil, sdk.ErrNoEncryptionKey
}
if err != nil {
return nil, fmt.Errorf("%w: %w", sdk.ErrCannotDecrypt, err)
}
return plaintext, nil
}

// binding answers the associated data a value of the calling workspace, this plugin and purpose is bound to.
func (e pluginEncrypter) binding(ctx context.Context, purpose string) [][]byte {
tenant := sdk.TenantOrDefault(ctx)
return [][]byte{[]byte(pluginPurpose), tenant[:], []byte(e.plugin), []byte(purpose)}
}
Loading
Loading