Files
MHSanaei 3fc3992a46 fix(nodetoken): stop refusing every node-token key file on Windows
FileKeySource rejected any key file whose mode had group or other bits,
but Windows has no such bits: Stat reports every writable file as 0666.
On the Windows builds release.yml ships, the key file therefore never
loaded, not even one written 0600, and only XUI_NODE_TOKEN_KEY could
supply a key. The mode check now applies off Windows only, the stance
the DB permission tests already take; there the file's NTFS ACL guards
it, and env-vars.mdx says so in all four locales.

The load test is split so the half that must hold everywhere, an
owner-only file loading, also runs on Windows, and the rejection half
asserts the exact error instead of any error.
2026-09-27 15:55:42 +02:00

118 lines
3.3 KiB
Go

package nodetoken
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"runtime"
"strings"
)
// KeySource loads a startup keyring from a protected file or environment.
// Keys are never accepted on the command line.
type KeySource interface {
Load() (*Keyring, error)
}
// keyFile identifies the active key and all base64-encoded rotation keys.
type keyFile struct {
Active string `json:"active"`
Keys map[string]string `json:"keys"`
}
func parseKeyring(active string, b64keys map[string]string) (*Keyring, error) {
if err := validateKeyID(active); err != nil {
return nil, fmt.Errorf("nodetoken: active key id: %w", err)
}
if active == "" {
return nil, errors.New("nodetoken: key source has no active key id")
}
if len(b64keys) == 0 {
return nil, errors.New("nodetoken: key source has no keys")
}
kr := &Keyring{ActiveID: active, Keys: make(map[string][keyLen]byte, len(b64keys))}
for id, b64 := range b64keys {
if err := validateKeyID(id); err != nil {
return nil, fmt.Errorf("nodetoken: key id %q: %w", id, err)
}
raw, err := decodeKey(b64)
if err != nil {
return nil, fmt.Errorf("nodetoken: key %q: %w", id, err)
}
kr.Keys[id] = raw
}
if _, ok := kr.Keys[active]; !ok {
return nil, fmt.Errorf("nodetoken: active key %q absent from keys", active)
}
return kr, nil
}
func validateKeyID(id string) error {
if id == "" {
return errors.New("must not be empty")
}
if strings.Contains(id, ":") {
return errors.New("must not contain ':'")
}
return nil
}
func decodeKey(b64 string) ([keyLen]byte, error) {
var out [keyLen]byte
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64))
if err != nil {
// tolerate url-safe / unpadded encodings too
if raw2, err2 := base64.RawStdEncoding.DecodeString(strings.TrimSpace(b64)); err2 == nil {
raw = raw2
} else {
return out, fmt.Errorf("base64 decode: %w", err)
}
}
if len(raw) != keyLen {
return out, fmt.Errorf("key must be %d bytes, got %d", keyLen, len(raw))
}
copy(out[:], raw)
return out, nil
}
// FileKeySource accepts only key files that are mode 0600 or stricter. Windows has
// no such bits (Stat reports 0666), so there the file's NTFS ACL is what guards it.
type FileKeySource struct {
Path string
}
func (f FileKeySource) Load() (*Keyring, error) {
info, err := os.Stat(f.Path)
if err != nil {
return nil, fmt.Errorf("nodetoken: stat key file %s: %w", f.Path, err)
}
if perm := info.Mode().Perm(); runtime.GOOS != "windows" && perm&0o077 != 0 {
return nil, fmt.Errorf("nodetoken: key file %s has insecure mode %#o (want 0600)", f.Path, perm)
}
data, err := os.ReadFile(f.Path)
if err != nil {
return nil, fmt.Errorf("nodetoken: read key file %s: %w", f.Path, err)
}
var kf keyFile
if err := json.Unmarshal(data, &kf); err != nil {
return nil, fmt.Errorf("nodetoken: parse key file %s: %w", f.Path, err)
}
return parseKeyring(kf.Active, kf.Keys)
}
// EnvKeySource reads a single base64 32-byte key from an environment variable.
// The key id is fixed ("env"); for multi-key rotation prefer a key file.
type EnvKeySource struct {
Var string
}
func (e EnvKeySource) Load() (*Keyring, error) {
v := strings.TrimSpace(os.Getenv(e.Var))
if v == "" {
return nil, fmt.Errorf("nodetoken: env %s is empty", e.Var)
}
return parseKeyring("env", map[string]string{"env": v})
}