Skip to content
Open
Show file tree
Hide file tree
Changes from all 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.4 [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.3 [2026-05-27]

### 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
49 changes: 49 additions & 0 deletions cmd/chronoctl/disable_secrets_encryption.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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)
}

if err := validateExistingBoltPath(c.BoltPath); 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 a non-existent file to write to"))
} 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
}
129 changes: 129 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,116 @@ 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))
}
}

func TestValidateExistingBoltPath(t *testing.T) {
t.Run("missing path", func(t *testing.T) {
err := validateExistingBoltPath(filepath.Join(t.TempDir(), "missing.db"))
if err == nil {
t.Fatalf("expected error for missing path")
}
})

t.Run("existing path", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "chronograf-v1.db")
if err := os.WriteFile(path, []byte{}, 0600); err != nil {
t.Fatal(err)
}
if err := validateExistingBoltPath(path); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
Loading
Loading