The grant is the keystone that makes server->client sends safe: created only by an authenticated control-plane action, bound at creation to the session's OBSERVED data-plane source (so it can never be aimed at a third party), and bounded by bytes, average rate and expiry. Sends stop the moment the budget runs out, so a buggy action cannot become a flood. Two granted actions on top of it: - downtrain: N packets at a given size/interval toward the client, with seq + send-timestamp in the payload — downstream loss/reorder/jitter, which an upstream-only train cannot measure. - big_send: one datagram per requested size, echoing the intended size in the payload — downstream MTU / black-hole evidence the client cannot produce for itself (only the far end can emit a large packet toward it). Tests cover the security properties: no grant without a verified destination, client requests clamped to server limits, byte budget stops sending exactly, expiry refuses, and the rate ceiling throttles a burst. Capabilities gain downtrain + big-send. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
5.5 KiB
Go
201 lines
5.5 KiB
Go
// 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
|
|
|
|
// Active asymmetric grants (spec §3.4/§5) — the only licence to send more than we receive.
|
|
grants []*Grant
|
|
|
|
// Observations (spec §6): per-packet UDP view + connect-back results.
|
|
packetsSeen uint64
|
|
udpObs []UDPObservation // ring, newest last, cap obsCap
|
|
connectBack []ConnectBackResult
|
|
}
|
|
|
|
const obsCap = 4096
|
|
|
|
// UDPObservation is the server's witnessed view of one data-plane packet.
|
|
type UDPObservation struct {
|
|
Seq uint32 `json:"seq"`
|
|
TRxNs int64 `json:"t_rx_ns"`
|
|
TTxNs int64 `json:"t_tx_ns"`
|
|
Src string `json:"src"`
|
|
Size int `json:"size"`
|
|
Type uint8 `json:"type"`
|
|
}
|
|
|
|
// ConnectBackResult records one connect-back action outcome.
|
|
type ConnectBackResult struct {
|
|
ActionID string `json:"action_id"`
|
|
Result string `json:"result"` // connected | refused | timeout
|
|
RttMs float64 `json:"rtt_ms"`
|
|
}
|
|
|
|
// RecordUDP appends a packet observation (ring-capped).
|
|
func (s *Session) RecordUDP(o UDPObservation) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.packetsSeen++
|
|
if len(s.udpObs) >= obsCap {
|
|
s.udpObs = s.udpObs[1:]
|
|
}
|
|
s.udpObs = append(s.udpObs, o)
|
|
}
|
|
|
|
// RecordConnectBack appends a connect-back outcome.
|
|
func (s *Session) RecordConnectBack(r ConnectBackResult) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.connectBack = append(s.connectBack, r)
|
|
}
|
|
|
|
// Observations returns a copy of everything witnessed so far.
|
|
func (s *Session) Observations() (packetsSeen uint64, udp []UDPObservation, cb []ConnectBackResult) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.packetsSeen, append([]UDPObservation(nil), s.udpObs...),
|
|
append([]ConnectBackResult(nil), s.connectBack...)
|
|
}
|
|
|
|
// DataSource returns the last verified data-plane source (invalid when the
|
|
// session has not sent data-plane traffic yet).
|
|
func (s *Session) DataSource() netip.AddrPort {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.dataSource
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ByID resolves a full session id (sessions are keyed by their wire prefix).
|
|
func (m *Manager) ByID(id string) *Session {
|
|
if len(id) < 16 {
|
|
return nil
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
s := m.byPrefix[id[:16]]
|
|
if s == nil || s.ID != id || 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()
|
|
}
|