Files
echolot/server/internal/store/store.go
T
mrambossekandClaude Fable 5 4e6f2da3fb
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 36s
server-release / release (push) Successful in 38s
runs: scope by account; app: the PKCE half of signing in
Three phones on one account now produce one history, which is the main reason to
have accounts beyond upload permission. GET /v1/runs returns the account's runs
and says how many devices contributed; fetching and deleting resolve a run id
against the caller's own devices, so an id from another account is not found
rather than fetched from wherever it happens to live.

The rule that needed stating: the empty account is never a group. Devices nobody
has signed in on are unrelated devices that share the absence of an owner, and
matching on "" would let any anonymous device read every other one's runs.
Tested, along with sibling-device access working and cross-account access not.

App side: authorization code with PKCE. The app is a public client - anything
compiled into an APK can be read out with unzip and strings - and the redirect
returns through a custom URI scheme that any app on the device may register, so
an intercepted code is a real risk. PKCE makes a stolen code worthless: it can
only be exchanged by presenting a verifier that never left the process.

A callback whose state does not match is refused before the code is spent and
before any network call, since that is exactly how someone gets a victim to
complete the attacker's sign-in.

Nothing from the IdP is retained. The ID token is used once to prove who is
signing in and then discarded; the device credential authenticates everything
afterwards. No access tokens to store, no refresh tokens to rotate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 19:46:32 +02:00

264 lines
7.4 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")
}
// DeviceIDsForAccount returns every device signed in to the same account.
//
// The empty account is never matched: devices that nobody has signed in on are not a group, they
// are unrelated devices that happen to share the absence of an owner. Treating them as an account
// would let any anonymous device read every other anonymous device's runs.
func (s *Store) DeviceIDsForAccount(accountID string) []string {
if accountID == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
var out []string
for _, d := range s.data.Devices {
if d.AccountID == accountID {
out = append(out, d.ID)
}
}
return out
}
// 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
}