The beacon this replaces was a separate service wildcard-bound to 0.0.0.0:443 - it silently occupied port 443 on the reserved measurement addresses, voiding the IPv4 interception proof for as long as it ran, and it accepted a port report from anyone who could reach it. So this lives where the repo's own post-mortem said it belongs: POST on the control plane authenticated by the device credential, GET on the admin UI behind the existing apiAdmin helper. No new listener, no new port, no wildcard. Entries expire after 24h (ECHOLOT_ADB_ENDPOINT_RETENTION_H) on both write and read - a LAN address is a breadcrumb for driving a test device, not measurement data worth keeping. Also records the BLE peer-comparison design: the case for it is that BLE is out-of-band, which is what makes client isolation measurable at all - silence over IP cannot distinguish an isolating AP from an absent peer, and a peer confirming out-of-band that it was listening turns that silence into proof. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
270 lines
7.8 KiB
Go
270 lines
7.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
|
|
// How long relayed adb endpoints live. Configuration rather than state, so it is set by the
|
|
// caller after Open and never read back from the file (see SetADBEndpointRetention).
|
|
adbRetention time.Duration
|
|
}
|
|
|
|
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"`
|
|
// Dev-relay breadcrumbs (adbendpoint.go). Not measurement data and not part of the protocol;
|
|
// they ride along here because this is already where small server state lives.
|
|
ADBEndpoints []ADBEndpoint `json:"adb_endpoints,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
|
|
}
|