Echolot delegates identity to whatever IdP the operator already runs and stores no passwords - no hashing, no reset flow, no lockout policy, and no credential database to lose. For a tool people self-host next to other services, that is the difference between one more service and one more thing that can leak someone's password. Verification is stdlib-only, matching the server's no-dependency rule. Longer than jwt.Parse, and auditable in one sitting. The part that matters is the algorithm allow-list: taking `alg` from the token is the classic forgery, so it is fixed in code. Tests cover the real attacks against a genuine signer - a self-contained IdP with real keys, because a mock that returns success proves nothing about a verifier: alg=none, HS256/RS256 confusion, a payload swapped under a valid signature, a token addressed to another client, a token from another issuer, expired and future-dated tokens, and discovery that renames the issuer (which would otherwise have us fetch a stranger's keys believing they were the provider's). With no admin group configured nobody is an admin. An operator who has not said who may administer the server has not thereby said "anyone who can log in". Device and account stay separate concepts: enrollment admits a device (operator's token), signing in attributes it to a person (POST /v1/account/link, device credential plus ID token - both required, neither substitutes). uploads=account now means what it says instead of refusing everyone, and signing in does not override uploads=off. The profile advertises the sign-in configuration so the app can offer the button only when there is something behind it, and drive PKCE without anyone typing an issuer URL. A discovery failure is reported rather than hidden, so "configured but the provider is not answering" is distinguishable from "not configured". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
192 lines
5.3 KiB
Go
192 lines
5.3 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"
|
|
"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"`
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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
|
|
}
|