server-test / test (push) Successful in 44s
If the IdP is misconfigured, unreachable, or the admin group is a typo, the
operator is locked out of their own server with no way back short of editing
JSON on disk. A fallback that only matters when everything else is broken is
exactly the thing you cannot add later - by then you cannot get in to add it.
Stored as PBKDF2-HMAC-SHA256 from the standard library (Go 1.24+ has it, so no
dependency), 600k iterations, per-credential salt. A password rather than a
bearer token on purpose: a break-glass credential is the one most likely to end
up in a backup or a config-management repo, and a hash survives that where a
token does not. There is no email reset flow and should not be -
--set-admin-password on the host is the reset, and whoever can run it already
has the machine.
The password is read from stdin, never a flag, so it stays out of shell history
and the process list; piping still works for automation.
Details the tests pin, each for a reason:
- the username is compared in constant time too, or a fast rejection is a
timing oracle for which usernames exist;
- the *stored* iteration count is used, so raising the constant later does not
lock out existing passwords;
- the throttle grows with consecutive failures but stays bounded and forgives
after a quiet minute - a break-glass credential an attacker can lock out is
a denial of service against the one person who needs it;
- sessions are MAC-checked before anything in them is read, and rotating the
secret invalidates every one at once, which is how they are revoked.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
244 lines
6.8 KiB
Go
244 lines
6.8 KiB
Go
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
// Package store persists enrollment tokens and device credentials as a single
|
|
// JSON file in the state dir. Deliberately boring: the expected scale is a
|
|
// handful of devices per homelab server; a database would be ceremony.
|
|
package store
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
|
|
"echo-lot.app/server/internal/adminauth"
|
|
"time"
|
|
)
|
|
|
|
type EnrollToken struct {
|
|
// SHA-256 of the token, hex. Plaintext exists only in the admin's hands.
|
|
Hash string `json:"hash"`
|
|
Expires time.Time `json:"expires"`
|
|
Used bool `json:"used"`
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
type Device struct {
|
|
ID string `json:"id"`
|
|
// SHA-256 of the bearer credential, hex. The credential itself is also
|
|
// the HKDF ikm for session keys (spec §2.4), so the server needs it in
|
|
// cleartext at session time — kept alongside, file mode 0600.
|
|
// TODO(hardening): move cleartext creds to a separate keyring file.
|
|
CredentialHash string `json:"credential_hash"`
|
|
Credential string `json:"credential"`
|
|
Enrolled time.Time `json:"enrolled"`
|
|
Name string `json:"name,omitempty"`
|
|
|
|
// The account this device belongs to, as issuer#subject — empty when nobody has signed in
|
|
// on it. Enrollment and sign-in are deliberately separate: a device is admitted by an
|
|
// operator's token, and only later (if ever) associated with a person. Servers that accept
|
|
// anonymous uploads never need the second step.
|
|
AccountID string `json:"account_id,omitempty"`
|
|
AccountName string `json:"account_name,omitempty"`
|
|
LinkedAt time.Time `json:"linked_at,omitempty"`
|
|
}
|
|
|
|
// LinkedToAccount reports whether a person has signed in on this device.
|
|
func (d Device) LinkedToAccount() bool { return d.AccountID != "" }
|
|
|
|
type Store struct {
|
|
mu sync.Mutex
|
|
path string
|
|
data fileData
|
|
}
|
|
|
|
type fileData struct {
|
|
Tokens []EnrollToken `json:"tokens"`
|
|
Devices []Device `json:"devices"`
|
|
// The break-glass admin. Absent until an operator sets one.
|
|
LocalAdmin *adminauth.Credential `json:"local_admin,omitempty"`
|
|
// Signing secret for admin session cookies. Persisted so sessions survive a restart;
|
|
// deleting it from the state file invalidates every session at once, which is how an
|
|
// operator revokes them.
|
|
SessionSecret string `json:"session_secret,omitempty"`
|
|
}
|
|
|
|
func Open(stateDir string) (*Store, error) {
|
|
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
s := &Store{path: filepath.Join(stateDir, "devices.json")}
|
|
b, err := os.ReadFile(s.path)
|
|
switch {
|
|
case errors.Is(err, os.ErrNotExist):
|
|
return s, nil
|
|
case err != nil:
|
|
return nil, err
|
|
}
|
|
return s, json.Unmarshal(b, &s.data)
|
|
}
|
|
|
|
func (s *Store) save() error {
|
|
b, err := json.MarshalIndent(s.data, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := s.path + ".tmp"
|
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, s.path)
|
|
}
|
|
|
|
func hashOf(tok string) string {
|
|
h := sha256.Sum256([]byte(tok))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
func randomHex(n int) string {
|
|
b := make([]byte, n)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// NewEnrollToken mints a single-use token (returned in cleartext once).
|
|
func (s *Store) NewEnrollToken(ttl time.Duration, note string) (string, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
tok := randomHex(24)
|
|
s.data.Tokens = append(s.data.Tokens, EnrollToken{
|
|
Hash: hashOf(tok), Expires: time.Now().Add(ttl), Note: note,
|
|
})
|
|
return tok, s.save()
|
|
}
|
|
|
|
// Redeem consumes a valid enrollment token and mints a device credential.
|
|
func (s *Store) Redeem(token, name string) (*Device, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
h := hashOf(token)
|
|
for i := range s.data.Tokens {
|
|
t := &s.data.Tokens[i]
|
|
if subtle.ConstantTimeCompare([]byte(t.Hash), []byte(h)) == 1 {
|
|
if t.Used || time.Now().After(t.Expires) {
|
|
return nil, errors.New("token used or expired")
|
|
}
|
|
t.Used = true
|
|
d := Device{
|
|
ID: randomHex(8),
|
|
Credential: randomHex(32),
|
|
Enrolled: time.Now().UTC(),
|
|
Name: name,
|
|
}
|
|
d.CredentialHash = hashOf(d.Credential)
|
|
s.data.Devices = append(s.data.Devices, d)
|
|
return &d, s.save()
|
|
}
|
|
}
|
|
return nil, errors.New("unknown token")
|
|
}
|
|
|
|
// DeviceByCredential authenticates a bearer credential.
|
|
// LinkAccount ties a device to a signed-in identity, or clears it when accountID is empty.
|
|
func (s *Store) LinkAccount(deviceID, accountID, displayName string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.data.Devices {
|
|
if s.data.Devices[i].ID != deviceID {
|
|
continue
|
|
}
|
|
s.data.Devices[i].AccountID = accountID
|
|
s.data.Devices[i].AccountName = displayName
|
|
if accountID == "" {
|
|
s.data.Devices[i].LinkedAt = time.Time{}
|
|
} else {
|
|
s.data.Devices[i].LinkedAt = time.Now().UTC()
|
|
}
|
|
return s.save()
|
|
}
|
|
return errors.New("no such device")
|
|
}
|
|
|
|
// Devices returns a copy of the device list, for the admin UI.
|
|
func (s *Store) Devices() []Device {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return append([]Device(nil), s.data.Devices...)
|
|
}
|
|
|
|
// DeleteDevice revokes a device: its credential stops working immediately.
|
|
func (s *Store) DeleteDevice(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.data.Devices {
|
|
if s.data.Devices[i].ID == id {
|
|
s.data.Devices = append(s.data.Devices[:i], s.data.Devices[i+1:]...)
|
|
return s.save()
|
|
}
|
|
}
|
|
return errors.New("no such device")
|
|
}
|
|
|
|
// SetLocalAdmin stores (or replaces) the break-glass admin password.
|
|
func (s *Store) SetLocalAdmin(c adminauth.Credential) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.data.LocalAdmin = &c
|
|
return s.save()
|
|
}
|
|
|
|
// LocalAdmin returns the configured break-glass admin, or nil.
|
|
func (s *Store) LocalAdmin() *adminauth.Credential {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.data.LocalAdmin == nil {
|
|
return nil
|
|
}
|
|
c := *s.data.LocalAdmin
|
|
return &c
|
|
}
|
|
|
|
// ClearLocalAdmin removes the break-glass admin.
|
|
func (s *Store) ClearLocalAdmin() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.data.LocalAdmin = nil
|
|
return s.save()
|
|
}
|
|
|
|
// SessionSecret returns the admin session signing secret, creating one on first use.
|
|
func (s *Store) SessionSecret() ([]byte, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.data.SessionSecret != "" {
|
|
if b, err := hex.DecodeString(s.data.SessionSecret); err == nil && len(b) >= 32 {
|
|
return b, nil
|
|
}
|
|
}
|
|
b, err := adminauth.NewSecret()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.data.SessionSecret = hex.EncodeToString(b)
|
|
return b, s.save()
|
|
}
|
|
|
|
func (s *Store) DeviceByCredential(cred string) *Device {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
h := hashOf(cred)
|
|
for i := range s.data.Devices {
|
|
if subtle.ConstantTimeCompare([]byte(s.data.Devices[i].CredentialHash), []byte(h)) == 1 {
|
|
d := s.data.Devices[i]
|
|
return &d
|
|
}
|
|
}
|
|
return nil
|
|
}
|