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,125 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package session implements spec §2.4 sessions and the §2.4 key schedule:
|
||||
// HKDF-SHA256(ikm=device_credential, salt=key_salt, info="echolot-v1/"+session_id).
|
||||
package session
|
||||
|
||||
import (
|
||||
"crypto/hkdf"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
ID string // opaque hex; first 8 bytes (16 hex chars) are the wire prefix
|
||||
Key [32]byte // derived, never crosses the wire
|
||||
Epoch time.Time // server wall clock at creation (spec: RFC3339 in the response)
|
||||
Expires time.Time
|
||||
Device string // device ID
|
||||
// Observed source of the session-creating request — the ONLY address
|
||||
// reflected/generated traffic may target (spec §2.5).
|
||||
ControlSource netip.Addr
|
||||
// Last data-plane source seen with a valid HMAC (NAT rebinding evidence).
|
||||
mu sync.Mutex
|
||||
dataSource netip.AddrPort
|
||||
// Replay window (spec §3.1: 1024-wide seq window). Highest seq seen plus
|
||||
// a bitmask of the 1024 preceding.
|
||||
maxSeq uint32
|
||||
window [16]uint64
|
||||
}
|
||||
|
||||
// KeySalt returns nothing — the salt is not retained after derivation; it is
|
||||
// generated in New and returned once for the response body.
|
||||
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
byPrefix map[string]*Session // key: first 16 hex chars of ID
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewManager(ttl time.Duration) *Manager {
|
||||
return &Manager{byPrefix: map[string]*Session{}, ttl: ttl}
|
||||
}
|
||||
|
||||
// New creates a session for a device credential per the spec key schedule.
|
||||
// Returns the session and the one-time key_salt for the response.
|
||||
func (m *Manager) New(deviceID, credential string, controlSource netip.Addr) (*Session, []byte, error) {
|
||||
idBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(idBytes); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
id := hex.EncodeToString(idBytes)
|
||||
key, err := hkdf.Key(sha256.New, []byte(credential), salt, "echolot-v1/"+id, 32)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
s := &Session{
|
||||
ID: id,
|
||||
Epoch: time.Now().UTC(),
|
||||
Expires: time.Now().Add(m.ttl),
|
||||
Device: deviceID,
|
||||
ControlSource: controlSource,
|
||||
}
|
||||
copy(s.Key[:], key)
|
||||
m.mu.Lock()
|
||||
m.byPrefix[id[:16]] = s
|
||||
m.mu.Unlock()
|
||||
return s, salt, nil
|
||||
}
|
||||
|
||||
// ByWirePrefix resolves the 8-byte on-the-wire prefix (as raw bytes).
|
||||
func (m *Manager) ByWirePrefix(prefix [8]byte) *Session {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
s := m.byPrefix[hex.EncodeToString(prefix[:])]
|
||||
if s == nil || time.Now().After(s.Expires) {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (m *Manager) Delete(id string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.byPrefix, id[:16])
|
||||
}
|
||||
|
||||
// CheckSeq enforces the 1024-wide anti-replay window. Returns false for
|
||||
// replays and for packets older than the window.
|
||||
func (s *Session) CheckSeq(seq uint32) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
switch {
|
||||
case seq > s.maxSeq:
|
||||
shift := seq - s.maxSeq
|
||||
for i := uint32(0); i < shift && i < 1024; i++ {
|
||||
idx := (s.maxSeq + 1 + i) % 1024
|
||||
s.window[idx/64] &^= 1 << (idx % 64)
|
||||
}
|
||||
s.maxSeq = seq
|
||||
case s.maxSeq-seq >= 1024:
|
||||
return false
|
||||
}
|
||||
idx := seq % 1024
|
||||
if s.window[idx/64]&(1<<(idx%64)) != 0 {
|
||||
return false
|
||||
}
|
||||
s.window[idx/64] |= 1 << (idx % 64)
|
||||
return true
|
||||
}
|
||||
|
||||
// NoteDataSource records the latest verified data-plane source.
|
||||
func (s *Session) NoteDataSource(ap netip.AddrPort) {
|
||||
s.mu.Lock()
|
||||
s.dataSource = ap
|
||||
s.mu.Unlock()
|
||||
}
|
||||
Reference in New Issue
Block a user