Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
efba8ea
feat: add master key config and validation
alespour May 12, 2026
787f11d
feat(kv): add DEK/secret crypto primitives
alespour May 12, 2026
989478e
feat(kv): add secret encoding metadata and DEK-based source/server pe…
alespour May 13, 2026
64d7fef
feat(kv): bootstrap wrapped DEK and load secret DEK on startup
alespour May 13, 2026
10c6c3c
feat(kv): bootstrap wrapped DEK and load secret DEK on startup
alespour May 13, 2026
0d93861
feat(kv): auto-migrate legacy plaintext secrets after DEK init
alespour May 13, 2026
2fe6657
feat(kv): fail startup when encrypted secrets exist without key state
alespour May 13, 2026
127af0b
feat(chronoctl): add gen-secrets-master-key command
alespour May 13, 2026
5321b15
feat(secrets): add DEK rewrap workflow and chronoctl rotation command
alespour May 13, 2026
c40e280
feat(secrets): add DEK rewrap workflow and chronoctl rotation command
alespour May 13, 2026
11c5f3c
feat(secrets): add disable-secrets-encryption workflow and chronoctl …
alespour May 13, 2026
bb9e50c
docs: document secrets key generation, rewrap, and disable workflows
alespour May 13, 2026
941675a
test: add tests
alespour May 14, 2026
521e112
fix(server): redact source database and management tokens from API re…
alespour May 14, 2026
73a0949
Revert "test: add tests"
alespour May 14, 2026
afeaf65
Revert "Revert "test: add tests""
alespour May 14, 2026
2743c05
fix: zero key material
alespour May 14, 2026
2db014a
refactor: remove unnecessary wrapper
alespour May 14, 2026
1fb3586
fix: harden output file checks
alespour May 14, 2026
3bd403c
fix: secrets are redacted now
alespour May 15, 2026
5b0702b
docs: better phrase for encryption rollback command
alespour May 15, 2026
b8afbfe
docs: update CHANGELOG
alespour May 15, 2026
2bb6296
fix: error message
alespour May 15, 2026
b17a357
fix: fail fast when --bolt-path DB is missing for secrets ops
alespour May 27, 2026
80ef3d7
Merge branch 'master' into fix/issue-1046
alespour May 27, 2026
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
## v1.11.3 [unreleased]

### Security Fixes

1. [#6211](https://github.com/influxdata/chronograf/pull/6211): Harden secrets-at-rest protections for persisted source and server credentials using envelope encryption.
* Add startup migration for legacy plaintext secrets when a secrets master key is configured.
* Add `chronoctl` commands for master-key generation, rewrap, and disable workflows.

## v1.11.2 [2026-05-06]

### Other
Expand Down
50 changes: 50 additions & 0 deletions cmd/chronoctl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,60 @@ Chronoctl is a tool to interact with an instance of a chronograf's bolt database
```
Available commands:
add-superadmin Creates a new superadmin user (bolt specific)
gen-secrets-master-key Generates a secrets master key
list-users Lists users (bolt specific)
disable-secrets-encryption Disables secrets encryption and removes wrapped DEK
rewrap-secrets-master-key Rewraps stored DEK with new master key
migrate Migrate db (beta)
```

### Secrets Encryption Commands

Use these commands when Chronograf secret-at-rest encryption is enabled.

##### Generate Secrets Master Key
Generate a base64-encoded 32-byte key:

```sh
$ chronoctl gen-secrets-master-key
```

Write the generated key to a file (0600):

```sh
$ chronoctl gen-secrets-master-key --out ./chronograf-secrets.key
```

##### Rewrap Secrets Master Key
Rotate the secrets master key by rewrapping the stored DEK:

```sh
$ chronoctl rewrap-secrets-master-key \
--bolt-path ./chronograf-v1.db \
--old-secrets-master-key-file ./old.key \
--new-secrets-master-key-file ./new.key
```

After successful rewrap, start Chronograf with the new key.

##### Disable Secrets Encryption
Disable secret encryption by decrypting persisted secrets to plaintext and
removing the wrapped DEK:

```sh
$ chronoctl disable-secrets-encryption \
--bolt-path ./chronograf-v1.db \
--secrets-master-key-file ./current.key
```

After successful disable:
- Chronograf no longer requires `--secrets-master-key` / `--secrets-master-key-file`
- persisted secrets are plaintext again

Important:
- `rewrap-secrets-master-key` changes only master-key wrapping and does not re-encrypt secret records.
- `disable-secrets-encryption` decrypts encrypted secrets and stores them as plaintext.


### Migrate

Expand Down
45 changes: 45 additions & 0 deletions cmd/chronoctl/disable_secrets_encryption.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package main

import (
"context"
"fmt"
)

func init() {
parser.AddCommand("disable-secrets-encryption",
"Disable secrets encryption and rewrite persisted secrets to plaintext.",
"Decrypt stored secrets in BoltDB and remove wrapped DEK. Requires current secrets master key.",
&disableSecretsEncryptionCommand{})
}

type disableSecretsEncryptionCommand struct {
BoltPath string `short:"b" long:"bolt-path" description:"Full path to boltDB file (e.g. './chronograf-v1.db')" env:"BOLT_PATH" default:"chronograf-v1.db"`

SecretsMasterKey string `long:"secrets-master-key" description:"Current base64-encoded 32-byte secrets master key." env:"SECRETS_MASTER_KEY"`
SecretsMasterKeyFile string `long:"secrets-master-key-file" description:"Path to file containing current base64-encoded 32-byte secrets master key." env:"SECRETS_MASTER_KEY_FILE"`
}

func (c *disableSecretsEncryptionCommand) Execute(args []string) error {
key, err := loadCLISecretsMasterKey(c.SecretsMasterKey, c.SecretsMasterKeyFile, "current")
if err != nil {
errExit(err)
}

store, err := NewBoltClient(c.BoltPath)
if err != nil {
return err
}
defer store.Close()

svc, err := NewService(store)
if err != nil {
return err
}

if err := svc.DisableSecretEncryption(context.Background(), key); err != nil {
errExit(err)
}

fmt.Println("Successfully disabled secrets encryption and removed wrapped DEK")
return nil
}
55 changes: 55 additions & 0 deletions cmd/chronoctl/gen_secrets_master_key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package main

import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"os"

flags "github.com/jessevdk/go-flags"
)

func init() {
parser.AddCommand("gen-secrets-master-key",
"Generate secrets master key.",
"Generate base64-encoded 32-byte key for Chronograf --secrets-master-key / --secrets-master-key-file.",
&genSecretsMasterKeyCommand{})
}

type genSecretsMasterKeyCommand struct {
Out flags.Filename `long:"out" description:"File to save the generated key (0600 permissions). If omitted, key is printed to stdout."`
}

func generateSecretsMasterKey() (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(raw), nil
}

func (c *genSecretsMasterKeyCommand) Execute(args []string) error {
key, err := generateSecretsMasterKey()
if err != nil {
errExit(err)
}

if c.Out == "" {
fmt.Println(key)
return nil
}

if _, err := os.Stat(string(c.Out)); err == nil {
errExit(errors.New("Specify non-existant file to write to."))
Comment thread
alespour marked this conversation as resolved.
Outdated
} else if !errors.Is(err, os.ErrNotExist) {
errExit(err)
}

if err := os.WriteFile(string(c.Out), []byte(key+"\n"), 0600); err != nil {
errExit(err)
}

fmt.Printf("Secrets master key generated and saved at %s\n", c.Out)
return nil
}
109 changes: 109 additions & 0 deletions cmd/chronoctl/main_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package main

import (
"bytes"
"encoding/base64"
"os"
"path/filepath"
"testing"

flags "github.com/jessevdk/go-flags"
Expand All @@ -23,6 +27,18 @@ func TestChronoctlCommands(t *testing.T) {
args: []string{"gen-keypair", "-h"},
err: false,
},
{
args: []string{"gen-secrets-master-key", "-h"},
err: false,
},
{
args: []string{"disable-secrets-encryption", "-h"},
err: false,
},
{
args: []string{"rewrap-secrets-master-key", "-h"},
err: false,
},
{
args: []string{"list-users", "-h"},
err: false,
Expand All @@ -48,3 +64,96 @@ func TestChronoctlCommands(t *testing.T) {
}
}
}

func TestLoadCLISecretsMasterKey(t *testing.T) {
validRaw := bytes.Repeat([]byte{0x11}, 32)
validB64 := base64.StdEncoding.EncodeToString(validRaw)

tests := []struct {
name string
value string
fileBody string
useFile bool
wantErr bool
}{
{
name: "value and file both set",
value: validB64,
fileBody: validB64,
useFile: true,
wantErr: true,
},
{
name: "value and file both empty",
wantErr: true,
},
{
name: "invalid base64 in value",
value: "%%%not-base64%%%",
wantErr: true,
},
{
name: "wrong decoded length",
value: base64.StdEncoding.EncodeToString([]byte("short")),
wantErr: true,
},
{
name: "valid value",
value: validB64,
wantErr: false,
},
{
name: "valid file",
fileBody: validB64 + "\n",
useFile: true,
wantErr: false,
},
{
name: "invalid base64 file",
fileBody: "invalid@@@",
useFile: true,
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var filePath string
if tt.useFile {
dir := t.TempDir()
filePath = filepath.Join(dir, "secrets.key")
if err := os.WriteFile(filePath, []byte(tt.fileBody), 0600); err != nil {
t.Fatal(err)
}
}

key, err := loadCLISecretsMasterKey(tt.value, filePath, "test")
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(key) != 32 {
t.Fatalf("unexpected key length: got %d, want 32", len(key))
}
})
}
}

func TestGenerateSecretsMasterKey(t *testing.T) {
key, err := generateSecretsMasterKey()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
raw, err := base64.StdEncoding.DecodeString(key)
if err != nil {
t.Fatalf("generated key is not valid base64: %v", err)
}
if len(raw) != 32 {
t.Fatalf("unexpected decoded key length: got %d, want 32", len(raw))
}
}
87 changes: 87 additions & 0 deletions cmd/chronoctl/rewrap_secrets_master_key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package main

import (
"context"
"encoding/base64"
"errors"
"fmt"
"os"
"strings"
)

func init() {
parser.AddCommand("rewrap-secrets-master-key",
"Rewrap stored DEK with new secrets master key.",
"Rotate Chronograf secrets master key by rewrapping the stored DEK in BoltDB.",
&rewrapSecretsMasterKeyCommand{})
}

type rewrapSecretsMasterKeyCommand struct {
BoltPath string `short:"b" long:"bolt-path" description:"Full path to boltDB file (e.g. './chronograf-v1.db')" env:"BOLT_PATH" default:"chronograf-v1.db"`

OldSecretsMasterKey string `long:"old-secrets-master-key" description:"Current base64-encoded 32-byte secrets master key." env:"OLD_SECRETS_MASTER_KEY"`
OldSecretsMasterKeyFile string `long:"old-secrets-master-key-file" description:"Path to file containing current base64-encoded 32-byte secrets master key." env:"OLD_SECRETS_MASTER_KEY_FILE"`

NewSecretsMasterKey string `long:"new-secrets-master-key" description:"New base64-encoded 32-byte secrets master key." env:"NEW_SECRETS_MASTER_KEY"`
NewSecretsMasterKeyFile string `long:"new-secrets-master-key-file" description:"Path to file containing new base64-encoded 32-byte secrets master key." env:"NEW_SECRETS_MASTER_KEY_FILE"`
}

func (c *rewrapSecretsMasterKeyCommand) Execute(args []string) error {
oldKey, err := loadCLISecretsMasterKey(c.OldSecretsMasterKey, c.OldSecretsMasterKeyFile, "old")
if err != nil {
errExit(err)
}
newKey, err := loadCLISecretsMasterKey(c.NewSecretsMasterKey, c.NewSecretsMasterKeyFile, "new")
if err != nil {
errExit(err)
}

store, err := NewBoltClient(c.BoltPath)
if err != nil {
return err
}
defer store.Close()

svc, err := NewService(store)
if err != nil {
return err
}

if err := svc.RewrapSecretDEK(context.Background(), oldKey, newKey); err != nil {
errExit(err)
}

fmt.Println("Successfully rewrapped DEK with new secrets master key")
return nil
}

func loadCLISecretsMasterKey(value, filePath, label string) ([]byte, error) {
if value != "" && filePath != "" {
return nil, fmt.Errorf("%s secrets master key must be provided by value or file, not both", label)
}
if value == "" && filePath == "" {
return nil, fmt.Errorf("%s secrets master key is required", label)
}

raw := value
if filePath != "" {
b, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("reading %s secrets master key file: %w", label, err)
}
raw = string(b)
}
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("%s secrets master key is empty", label)
}

key, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("decoding %s secrets master key: %w", label, err)
}
if len(key) != 32 {
return nil, errors.New("secrets master key must decode to 32 bytes")
}
return key, nil
}
Loading
Loading