server: Go skeleton — control plane, UDP data plane, Docker + systemd modes
Pure stdlib. Implements the spec's core: enrollment (single-use tokens), profile (SPKI pin, only real capabilities advertised), sessions with the §2.4 HKDF-SHA256 key schedule; UDP data plane with the 32-byte ELT1 header, 4-byte HMAC gate, 1024-wide anti-replay window, ECHO_RESP with observation block, TIMESYNC, and the §3.4 anti-amplification cap. Wire format has tests (roundtrip + silent-drop cases); enroll→profile→session smoke-tested live. Modes: container (autodetect /.dockerenv|/run/.containerenv|cgroup, or --docker/ECHOLOT_DOCKER=1; config via ECHOLOT_* env; distroless image; network_mode host required — Docker NAT would falsify observed sources) and native (--install-systemd/--uninstall-systemd with a hardened unit, opt-in --self-update from Gitea releases; refused in containers). CI: tests on any server/ push; server-v* tags build+push the image to the Gitea registry and attach linux amd64/arm64 binaries + SHA256SUMS to a release — the artifact self-update consumes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ee66648e3c
commit
8a80026d49
@@ -0,0 +1,140 @@
|
||||
// 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"`
|
||||
}
|
||||
|
||||
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.
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user