package secure import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "errors" "fmt" "os" "path/filepath" "strings" "unicode/utf8" "golang.org/x/crypto/argon2" "golang.org/x/crypto/chacha20poly1305" ) const ( argonMemory = 64 * 1024 argonTime = 3 argonThreads = 2 argonKeyLen = 32 PasswordMinLength = 15 PasswordMaxLength = 128 // PasswordTimingHash is a non-secret, precomputed Argon2id hash used only // to equalize failed-login work when a username does not exist. Keeping it // static avoids allocating the full Argon2 workspace during every startup. PasswordTimingHash = "argon2id$v=19$m=65536,t=3,p=2$qrFe78U1tJk+AQzTg0+BHw$n4NjN4mcmJmVrJa7xB3M2AYMDX3PBs+5Y7iYXb/dR5Q" ) type Cipher struct { key []byte } func LoadCipher(path string) (*Cipher, error) { info, err := os.Lstat(path) if err != nil { return nil, fmt.Errorf("inspect master key: %w", err) } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return nil, errors.New("master key must be a regular file") } if err := os.Chmod(path, 0o600); err != nil { return nil, fmt.Errorf("secure master key: %w", err) } raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read master key: %w", err) } return cipherFromKey(raw) } // LoadOrCreateCipher loads an existing master key or creates one on first start. // The key must live on a persistent volume separate from the application data. func LoadOrCreateCipher(path string) (*Cipher, error) { directory := filepath.Dir(path) if err := os.MkdirAll(directory, 0o700); err != nil { return nil, fmt.Errorf("create master key directory: %w", err) } info, err := os.Lstat(directory) if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return nil, errors.New("master key directory must be a regular directory") } if err := os.Chmod(directory, 0o700); err != nil { return nil, fmt.Errorf("secure master key directory: %w", err) } cipher, err := LoadCipher(path) if err == nil { return cipher, nil } if !errors.Is(err, os.ErrNotExist) { return nil, err } key := make([]byte, chacha20poly1305.KeySize) if _, err := rand.Read(key); err != nil { return nil, fmt.Errorf("create master key: %w", err) } encoded := []byte(base64.RawStdEncoding.EncodeToString(key)) file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if errors.Is(err, os.ErrExist) { return LoadCipher(path) } if err != nil { return nil, fmt.Errorf("create master key file: %w", err) } if _, err := file.Write(encoded); err != nil { _ = file.Close() _ = os.Remove(path) return nil, fmt.Errorf("write master key: %w", err) } if err := file.Close(); err != nil { _ = os.Remove(path) return nil, fmt.Errorf("close master key file: %w", err) } return &Cipher{key: key}, nil } func cipherFromKey(raw []byte) (*Cipher, error) { raw = []byte(strings.TrimSpace(string(raw))) key, err := base64.RawStdEncoding.DecodeString(string(raw)) if err != nil { key, err = base64.StdEncoding.DecodeString(string(raw)) } if err != nil || len(key) != chacha20poly1305.KeySize { return nil, errors.New("master key must be a base64-encoded 32-byte value") } return &Cipher{key: key}, nil } func (c *Cipher) Encrypt(plaintext string) (ciphertext, nonce []byte, err error) { aead, err := chacha20poly1305.NewX(c.key) if err != nil { return nil, nil, err } nonce = make([]byte, chacha20poly1305.NonceSizeX) if _, err = rand.Read(nonce); err != nil { return nil, nil, fmt.Errorf("create encryption nonce: %w", err) } return aead.Seal(nil, nonce, []byte(plaintext), nil), nonce, nil } func (c *Cipher) Decrypt(ciphertext, nonce []byte) (string, error) { aead, err := chacha20poly1305.NewX(c.key) if err != nil { return "", err } plaintext, err := aead.Open(nil, nonce, ciphertext, nil) if err != nil { return "", errors.New("could not decrypt stored credential") } return string(plaintext), nil } func (c *Cipher) Fingerprint(value string) string { mac := hmac.New(sha256.New, c.key) _, _ = mac.Write([]byte(value)) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } func RandomToken(bytes int) (string, error) { buf := make([]byte, bytes) if _, err := rand.Read(buf); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(buf), nil } func HashToken(token string) string { sum := sha256.Sum256([]byte(token)) return base64.RawURLEncoding.EncodeToString(sum[:]) } func HashPassword(password string) (string, error) { return hashPassword(password, PasswordMinLength) } // HashProvisionedPassword hashes the initial password chosen by an // administrator when creating another account. Product policy deliberately // leaves its length to that administrator while still rejecting empty, // overlong, or invalid UTF-8 input. func HashProvisionedPassword(password string) (string, error) { return hashPassword(password, 1) } func hashPassword(password string, minimumLength int) (string, error) { if !utf8.ValidString(password) { return "", errors.New("Passwort muss gültiger UTF-8-Text sein") } length := utf8.RuneCountInString(password) if length < minimumLength { if minimumLength == 1 { return "", errors.New("Passwort darf nicht leer sein") } return "", fmt.Errorf("Passwort muss mindestens %d Zeichen enthalten", minimumLength) } if length > PasswordMaxLength { return "", fmt.Errorf("Passwort darf höchstens %d Zeichen enthalten", PasswordMaxLength) } salt := make([]byte, 16) if _, err := rand.Read(salt); err != nil { return "", err } hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) return fmt.Sprintf("argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash)), nil } func VerifyPassword(encoded, password string) bool { if !utf8.ValidString(password) || utf8.RuneCountInString(password) > PasswordMaxLength { return false } parts := strings.Split(encoded, "$") if len(parts) != 5 || parts[0] != "argon2id" || parts[1] != "v=19" { return false } var memory uint32 var iterations uint32 var threads uint8 if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil { return false } if parts[2] != fmt.Sprintf("m=%d,t=%d,p=%d", memory, iterations, threads) || memory != argonMemory || iterations != argonTime || threads != argonThreads { return false } if len(parts[3]) != base64.RawStdEncoding.EncodedLen(16) || len(parts[4]) != base64.RawStdEncoding.EncodedLen(argonKeyLen) { return false } salt, err := base64.RawStdEncoding.DecodeString(parts[3]) if err != nil || len(salt) != 16 { return false } want, err := base64.RawStdEncoding.DecodeString(parts[4]) if err != nil || len(want) != argonKeyLen { return false } got := argon2.IDKey([]byte(password), salt, iterations, memory, threads, uint32(len(want))) return subtle.ConstantTimeCompare(got, want) == 1 }