server: §3.4 asymmetric grants + downtrain and big_send actions
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c75a9f5eb7
commit
7e1015c211
@@ -0,0 +1,100 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package session
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Grant is the spec §3.4 "asymmetric grant": the ONLY thing that lets the server send more than
|
||||
// it receives. Without one, every response is capped at the request size, which is what keeps an
|
||||
// unauthenticated observer from using this server as a reflector/amplifier.
|
||||
//
|
||||
// A grant is created by an authenticated control-plane action (§5) for one session, and is bounded
|
||||
// three ways: total bytes, send rate, and wall-clock expiry. It is consumed as the data plane
|
||||
// sends; when the budget runs out the send stops, so a bug in an action cannot turn into an
|
||||
// unbounded flood.
|
||||
type Grant struct {
|
||||
ActionID string
|
||||
// Destination is fixed at creation to the session's observed data-plane source — a grant can
|
||||
// never be pointed somewhere else, so it cannot be used to attack a third party.
|
||||
Dest string
|
||||
MaxBytes int64
|
||||
MaxKbps int
|
||||
ExpiresAt time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
sentBytes int64
|
||||
started time.Time
|
||||
}
|
||||
|
||||
// GrantLimits are the server-side ceilings an action may not exceed, independent of what the
|
||||
// client asks for.
|
||||
type GrantLimits struct {
|
||||
MaxBytes int64
|
||||
MaxKbps int
|
||||
MaxHold time.Duration
|
||||
}
|
||||
|
||||
var DefaultGrantLimits = GrantLimits{
|
||||
MaxBytes: 8 << 20, // 8 MiB per action
|
||||
MaxKbps: 50_000, // matches the profile's advertised max_kbps
|
||||
MaxHold: 30 * time.Second, // an action must finish inside this window
|
||||
}
|
||||
|
||||
// NewGrant clamps the request to the server's limits and binds it to the session's observed
|
||||
// data-plane source. Returns nil when the session has no observed source yet — refusing to send
|
||||
// anywhere we have not verifiably received from is the whole point.
|
||||
func (s *Session) NewGrant(actionID string, wantBytes int64, wantKbps int, lim GrantLimits) *Grant {
|
||||
dest := s.DataSource()
|
||||
if !dest.IsValid() {
|
||||
return nil
|
||||
}
|
||||
if wantBytes <= 0 || wantBytes > lim.MaxBytes {
|
||||
wantBytes = lim.MaxBytes
|
||||
}
|
||||
if wantKbps <= 0 || wantKbps > lim.MaxKbps {
|
||||
wantKbps = lim.MaxKbps
|
||||
}
|
||||
g := &Grant{
|
||||
ActionID: actionID, Dest: dest.String(),
|
||||
MaxBytes: wantBytes, MaxKbps: wantKbps,
|
||||
ExpiresAt: time.Now().Add(lim.MaxHold),
|
||||
started: time.Now(),
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.grants = append(s.grants, g)
|
||||
s.mu.Unlock()
|
||||
return g
|
||||
}
|
||||
|
||||
// Allow reports whether n more bytes may be sent now, consuming the budget when they may. It
|
||||
// enforces the byte ceiling, the expiry, and the average rate (by refusing early sends rather
|
||||
// than sleeping, so callers stay in control of pacing).
|
||||
func (g *Grant) Allow(n int) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if time.Now().After(g.ExpiresAt) {
|
||||
return false
|
||||
}
|
||||
if g.sentBytes+int64(n) > g.MaxBytes {
|
||||
return false
|
||||
}
|
||||
// Average-rate check: bytes allowed so far = kbps/8 * elapsed_seconds.
|
||||
elapsed := time.Since(g.started).Seconds()
|
||||
allowed := float64(g.MaxKbps) * 125 * elapsed // kbps -> bytes/s is kbps*1000/8 = kbps*125
|
||||
if elapsed > 0.05 && float64(g.sentBytes+int64(n)) > allowed {
|
||||
return false
|
||||
}
|
||||
g.sentBytes += int64(n)
|
||||
return true
|
||||
}
|
||||
|
||||
// Sent returns how many bytes this grant has consumed.
|
||||
func (g *Grant) Sent() int64 {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.sentBytes
|
||||
}
|
||||
Reference in New Issue
Block a user