// Package config holds Basking's settings file: where to listen, where notes live, the password // hash and the API tokens. The notes themselves are not in here. package config import ( "crypto/pbkdf2" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "regexp" "strconv" "strings" "sync" "time" ) type Token struct { Name string `json:"name"` Hash string `json:"hash"` Created time.Time `json:"created"` } type Config struct { Listen string `json:"listen"` DataDir string `json:"data_dir"` // PasswordHash is the one password that opens the dashboard. PasswordHash string `json:"password_hash,omitempty"` // Secret signs session cookies, so a restart does not sign you out. It is made on first run. Secret string `json:"secret,omitempty"` Tokens []Token `json:"tokens,omitempty"` // CookieSecure and TrustProxy are for running behind an HTTPS reverse proxy. CookieSecure bool `json:"cookie_secure,omitempty"` TrustProxy bool `json:"trust_proxy,omitempty"` } var tokenName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 ._-]{0,39}$`) // Defaults fills in what is missing. func (c *Config) Defaults() { if c.Listen == "" { c.Listen = "127.0.0.1:8490" } if c.DataDir == "" { c.DataDir = "data" } if c.Secret == "" { b := make([]byte, 32) rand.Read(b) c.Secret = base64.RawURLEncoding.EncodeToString(b) } } func (c *Config) Validate() error { seen := map[string]bool{} for _, t := range c.Tokens { if !tokenName.MatchString(t.Name) { return fmt.Errorf("token name %q must be 1 to 40 letters, numbers, spaces, dots, dashes or underscores", t.Name) } if seen[strings.ToLower(t.Name)] { return fmt.Errorf("there is already a token called %q", t.Name) } seen[strings.ToLower(t.Name)] = true } return nil } // Holder keeps the live configuration and writes every change to disk. type Holder struct { path string mu sync.RWMutex cfg Config } // Load reads the file. A missing file gives the defaults (and is created by the first Update). func Load(path string) (*Holder, error) { h := &Holder{path: path} b, err := os.ReadFile(path) switch { case errors.Is(err, os.ErrNotExist): case err != nil: return nil, err default: if err := json.Unmarshal(b, &h.cfg); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } } h.cfg.Defaults() if err := h.cfg.Validate(); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } return h, nil } func (h *Holder) Path() string { return h.path } // Exists says whether the file is on disk yet. func (h *Holder) Exists() bool { _, err := os.Stat(h.path); return err == nil } // Get returns a private copy; change it freely. func (h *Holder) Get() Config { h.mu.RLock() defer h.mu.RUnlock() return clone(h.cfg) } func clone(c Config) Config { b, _ := json.Marshal(c) var out Config json.Unmarshal(b, &out) return out } // Update applies fn to a copy, validates it, saves it and only then makes it current. func (h *Holder) Update(fn func(*Config) error) error { h.mu.Lock() defer h.mu.Unlock() c := clone(h.cfg) if err := fn(&c); err != nil { return err } c.Defaults() if err := c.Validate(); err != nil { return err } if err := save(h.path, c); err != nil { return err } h.cfg = c return nil } func save(path string, c Config) error { b, err := json.MarshalIndent(c, "", " ") if err != nil { return err } if dir := filepath.Dir(path); dir != "." { os.MkdirAll(dir, 0o755) } tmp := path + ".tmp" if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { return err } return os.Rename(tmp, path) } // ---- passwords and tokens ---- const pbkdf2Rounds = 600_000 // HashPassword returns "pbkdf2$rounds$salt$key". func HashPassword(password string) (string, error) { salt := make([]byte, 16) if _, err := rand.Read(salt); err != nil { return "", err } key, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Rounds, 32) if err != nil { return "", err } return fmt.Sprintf("pbkdf2$%d$%s$%s", pbkdf2Rounds, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil } // CheckPassword compares in constant time. An unparseable hash never matches. func CheckPassword(hash, password string) bool { parts := strings.Split(hash, "$") if len(parts) != 4 || parts[0] != "pbkdf2" { return false } rounds, err := strconv.Atoi(parts[1]) if err != nil || rounds < 1 || rounds > 10_000_000 { return false } salt, err1 := base64.RawStdEncoding.DecodeString(parts[2]) want, err2 := base64.RawStdEncoding.DecodeString(parts[3]) if err1 != nil || err2 != nil { return false } got, err := pbkdf2.Key(sha256.New, password, salt, rounds, len(want)) return err == nil && subtle.ConstantTimeCompare(got, want) == 1 } // NewToken makes an API token and the hash to store for it. The token itself is shown once. func NewToken() (token, hash string, err error) { b := make([]byte, 32) if _, err = rand.Read(b); err != nil { return } token = "bsk_" + base64.RawURLEncoding.EncodeToString(b) return token, HashToken(token), nil } func HashToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } // RandomPassword makes a password for a first run. func RandomPassword() string { b := make([]byte, 12) rand.Read(b) return base64.RawURLEncoding.EncodeToString(b) }