Skip to content
Open
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
104 changes: 88 additions & 16 deletions cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"strings"
"unicode/utf8"

"golang.org/x/crypto/bcrypt"

"github.com/device-management-toolkit/go-wsman-messages/v2/pkg/security"

"github.com/device-management-toolkit/console/config"
Expand Down Expand Up @@ -414,33 +416,103 @@ func shufflePassword(password []byte) error {
}

// handleAdminPassword ensures cfg.AdminPassword is set, generating one and
// persisting it to config.yml on first run if nothing was provided via config
// or environment.
// persisting it as a bcrypt hash to config.yml on first run if nothing was
// provided via config or environment.
func handleAdminPassword(cfg *config.Config) {
if cfg.AdminPassword != "" {
warnOnWeakAdminPassword(cfg.AdminPassword)
if cfg.AdminPassword == "" {
password, err := generateRandomPassword(adminPasswordLength)
if err != nil {
log.Fatalf("Failed to generate admin password: %v", err)
}

hashedPassword, err := hashAdminPassword(password)
if err != nil {
log.Fatalf("Failed to hash generated admin password: %v", err)
}

cfg.AdminPassword = hashedPassword

if err := config.SaveAdminPassword(cfg.AdminPassword); err != nil {
log.Fatalf(
"Generated admin password but failed to persist it to config (%v).\n"+
"Refusing to start with an unsaved credential that would vanish on restart.\n"+
"Set AUTH_ADMIN_PASSWORD in the environment (or auth.adminPassword in config) "+
"to provide the admin password directly.",
err,
)
}

log.Printf(
"Generated a new admin password. It is shown here once and cannot be "+
"recovered later, because only its bcrypt hash is written to "+
"auth.adminPassword in config.yml:\n\n %s\n\n"+
"Store it now, or set AUTH_ADMIN_PASSWORD to supply your own.",
password,
Comment thread
nbmaiti marked this conversation as resolved.
Dismissed
)

return
}

password, err := generateRandomPassword(adminPasswordLength)
originalPassword := cfg.AdminPassword

hashedPassword, converted, err := normalizeAdminPasswordHash(cfg.AdminPassword)
if err != nil {
log.Fatalf("Failed to generate admin password: %v", err)
log.Fatalf("Failed to normalize admin password: %v", err)
}

cfg.AdminPassword = password
cfg.AdminPassword = hashedPassword

if err := config.SaveAdminPassword(cfg.AdminPassword); err != nil {
log.Fatalf(
"Generated admin password but failed to persist it to config (%v).\n"+
"Refusing to start with an unsaved credential that would vanish on restart.\n"+
"Set AUTH_ADMIN_PASSWORD in the environment (or auth.adminPassword in config) "+
"to provide the admin password directly.",
err,
)
if converted {
// The config file may be read-only (password supplied via env or a mounted
// secret); the in-memory hash still authenticates this run.
if err := config.SaveAdminPassword(cfg.AdminPassword); err != nil {
log.Printf(
"WARNING: could not persist the hashed admin password to config (%v). "+
"Console is starting with the in-memory hash; the plaintext password "+
"will be re-hashed on every restart.",
err,
)
}
}

if !isBcryptHash(originalPassword) {
warnOnWeakAdminPassword(originalPassword)
}
}

func hashAdminPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}

return string(hash), nil
}

func normalizeAdminPasswordHash(password string) (normalized string, converted bool, err error) {
if password == "" {
return "", false, nil
}

if isBcryptHash(password) {
return password, false, nil
}

normalized, err = hashAdminPassword(password)
if err != nil {
return "", false, err
}

return normalized, true, nil
}

// isBcryptHash reports whether s is already a bcrypt hash. Parsing the cost is
// stricter than a prefix check, so a plaintext password that happens to start
// with "$2a$" is not mistaken for a hash and left unhashed.
func isBcryptHash(s string) bool {
_, err := bcrypt.Cost([]byte(s))

log.Printf("Generated new admin password and persisted to config; see auth.adminPassword in config.yml.")
return err == nil
}

// warnOnWeakAdminPassword warns but does not stop startup: migrated MPS/RPS
Expand Down
140 changes: 138 additions & 2 deletions cmd/app/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import (
"bytes"
"crypto/rsa"
"crypto/x509"
"flag"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"

"github.com/device-management-toolkit/go-wsman-messages/v2/pkg/security"

Expand Down Expand Up @@ -171,7 +175,8 @@ func TestHandleAdminPassword_AlreadyConfigured(t *testing.T) {

handleAdminPassword(cfg)

assert.Equal(t, "already-set", cfg.AdminPassword)
assert.True(t, isBcryptHash(cfg.AdminPassword))
assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(cfg.AdminPassword), []byte("already-set")))
}

func TestIsStrongAdminPassword(t *testing.T) {
Expand Down Expand Up @@ -270,6 +275,137 @@ func TestHandleAdminPassword_WeakConfiguredPasswordStillStarts(t *testing.T) { /

handleAdminPassword(cfg)

assert.Equal(t, "weak", cfg.AdminPassword)
assert.True(t, isBcryptHash(cfg.AdminPassword))
assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(cfg.AdminPassword), []byte("weak")))
assert.Contains(t, buf.String(), "Console is starting anyway")
}

func TestHandleAdminPassword_GeneratesAndPersistsWhenUnset(t *testing.T) { //nolint:paralleltest // rebinds the global log output and config flag
var buf bytes.Buffer

orig := log.Writer()

log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(orig) })

configPath := filepath.Join(t.TempDir(), "config.yml")

if flag.Lookup("config") == nil {
flag.String("config", "", "path to config file")
}

prev := flag.Lookup("config").Value.String()

require.NoError(t, flag.Set("config", configPath))
t.Cleanup(func() { _ = flag.Set("config", prev) })

cfg := &config.Config{}

handleAdminPassword(cfg)

assert.True(t, isBcryptHash(cfg.AdminPassword), "generated password must be stored as a hash")

// The operator can only ever learn the generated password from this output,
// so it must be printed and must match the stored hash.
shown := regexp.MustCompile(`\n\n {4}(\S+)\n\n`).FindStringSubmatch(buf.String())
require.Len(t, shown, 2, "generated password must be shown once: %s", buf.String())
assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(cfg.AdminPassword), []byte(shown[1])))

saved, err := os.ReadFile(configPath)
require.NoError(t, err)
assert.Contains(t, string(saved), cfg.AdminPassword, "hash must be persisted so it survives restart")
assert.NotContains(t, string(saved), shown[1], "plaintext must never be written to config")
}

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

hash, err := bcrypt.GenerateFromPassword([]byte("P@ssw0rdd"), bcrypt.DefaultCost)
require.NoError(t, err)

cfg := &config.Config{Auth: config.Auth{AdminPassword: string(hash)}}

handleAdminPassword(cfg)

assert.Equal(t, string(hash), cfg.AdminPassword, "an already-hashed password must not be re-hashed")
}

func TestHandleAdminPassword_StartsWhenConfigIsNotWritable(t *testing.T) { //nolint:paralleltest // rebinds the global log output and config flag
var buf bytes.Buffer

orig := log.Writer()

log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(orig) })

// A path under a regular file cannot be written, standing in for a read-only
// config or a password supplied entirely via the environment.
blocker := filepath.Join(t.TempDir(), "not-a-dir")
require.NoError(t, os.WriteFile(blocker, []byte("x"), 0o600))

if flag.Lookup("config") == nil {
flag.String("config", "", "path to config file")
}

prev := flag.Lookup("config").Value.String()

require.NoError(t, flag.Set("config", filepath.Join(blocker, "config.yml")))
t.Cleanup(func() { _ = flag.Set("config", prev) })

cfg := &config.Config{Auth: config.Auth{AdminPassword: "P@ssw0rdd"}}

handleAdminPassword(cfg)

assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(cfg.AdminPassword), []byte("P@ssw0rdd")),
"startup must continue with the in-memory hash")
assert.Contains(t, buf.String(), "could not persist the hashed admin password")
}

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

t.Run("empty stays empty", func(t *testing.T) {
t.Parallel()

got, converted, err := normalizeAdminPasswordHash("")

require.NoError(t, err)
assert.False(t, converted)
assert.Empty(t, got)
})

t.Run("plaintext beginning with a bcrypt prefix is still hashed", func(t *testing.T) {
t.Parallel()

plaintext := "$2a$notarealhash"

got, converted, err := normalizeAdminPasswordHash(plaintext)

require.NoError(t, err)
assert.True(t, converted, "a prefix alone must not be treated as a hash")
assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(got), []byte(plaintext)))
})

t.Run("plaintext is hashed", func(t *testing.T) {
t.Parallel()

got, converted, err := normalizeAdminPasswordHash("P@ssw0rdd")

require.NoError(t, err)
assert.True(t, converted)
assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(got), []byte("P@ssw0rdd")))
})

t.Run("existing hash is returned as is", func(t *testing.T) {
t.Parallel()

hash, err := bcrypt.GenerateFromPassword([]byte("P@ssw0rdd"), bcrypt.DefaultCost)
require.NoError(t, err)

got, converted, err := normalizeAdminPasswordHash(string(hash))

require.NoError(t, err)
assert.False(t, converted)
assert.Equal(t, string(hash), got)
})
}
19 changes: 12 additions & 7 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,14 +392,19 @@ func SaveAdminPassword(adminPassword string) error {
return err
}

data, err := os.ReadFile(configPath)
if err != nil {
return err
}

fileCfg := defaultConfig()
if err := yaml.Unmarshal(data, fileCfg); err != nil {
return err

if _, statErr := os.Stat(configPath); statErr == nil {
data, readErr := os.ReadFile(configPath)
if readErr != nil {
return readErr
}

if unmarshalErr := yaml.Unmarshal(data, fileCfg); unmarshalErr != nil {
return unmarshalErr
}
} else if !errors.Is(statErr, os.ErrNotExist) {
return statErr
}

fileCfg.AdminPassword = adminPassword
Expand Down
Loading
Loading