Skip to content

Commit dea5216

Browse files
Adam Fiskclaude
andcommitted
peer: rotate samizdat credentials hourly (closes engineering#3437)
Currently peer.Client builds the libbox inbound exactly once per Start and holds the same X25519 keypair / shortID / masquerade for the entire peer process lifetime — a leaked credential (logs, telemetry, support bundles, the route_id leakage in engineering#3440) remains usable for hours or days. This adds a credRotationLoop goroutine on a 1h tick. On each tick: 1. Re-register with lantern-cloud against the same (address, port) tuple — same router-side mapping, fresh server-side row, fresh samizdat creds. 2. Patch the new options for VPN bypass. 3. Build a new libbox service. 4. Close the old box (releases the listening port). 5. Start the new box (re-binds the same port with new creds). 6. Atomic swap of c.box, c.routeID. 7. Best-effort deregister of the prior route_id so the bandit stops handing the old (now-invalid) creds to clients within ~immediately rather than waiting up-to-TTL for the row to expire. Steps 4-5 leave a brief (~hundreds of ms) window where the port is unbound; samizdat clients see TCP RST and reconnect via the bandit. That's the trade-off vs. the security cost of holding the same cred for the peer process lifetime — caps blast radius from cred leakage to ~1h regardless of how long the peer has been running. Rotation is best-effort: a single failure logs and waits for the next tick. The current box and creds remain serving in the failure case so a transient register error doesn't kill the session. Config gains CredRotationInterval (defaults to peerCredRotationInterval = 1h) so tests drive the loop without a 1h sleep — see TestClient_RotatesCredentialsAtInterval. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3b19ec0 commit dea5216

2 files changed

Lines changed: 281 additions & 8 deletions

File tree

peer/peer.go

Lines changed: 187 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,15 @@ type Status struct {
5454
}
5555

5656
// Config plumbs in dependencies. Zero-valued fields fall back to production
57-
// defaults; HeartbeatInterval and HeartbeatTimeout exist so tests can drive
58-
// the loop without sleeping a full minute.
57+
// defaults; HeartbeatInterval, HeartbeatTimeout, and CredRotationInterval
58+
// exist so tests can drive the loops without sleeping a full minute / hour.
5959
type Config struct {
60-
API *API
61-
NewForwarder func(ctx context.Context) (portForwarder, error)
62-
BuildBoxService boxFactory
63-
HeartbeatInterval time.Duration
64-
HeartbeatTimeout time.Duration
60+
API *API
61+
NewForwarder func(ctx context.Context) (portForwarder, error)
62+
BuildBoxService boxFactory
63+
HeartbeatInterval time.Duration
64+
HeartbeatTimeout time.Duration
65+
CredRotationInterval time.Duration
6566
}
6667

6768
// Client orchestrates one peer-proxy session: open UPnP port → register with
@@ -87,8 +88,38 @@ type Client struct {
8788
forwarder portForwarder
8889
box boxService
8990
routeID string
91+
// externalPort / internalPort persist the port mapping picked at
92+
// Start so the cred-rotation loop can re-register against the same
93+
// (address, port) tuple without re-probing UPnP / re-mapping. The
94+
// router-side mapping itself stays put across rotations; only the
95+
// samizdat creds and route_id rotate.
96+
externalPort uint16
97+
internalPort uint16
98+
// boxOptions is the fresh options string passed to BuildBoxService,
99+
// kept for diagnostics and so the rotation path doesn't need to
100+
// re-derive it from the (also-stored) box reference.
101+
boxOptions string
102+
// runCtx is captured here for the cred-rotation goroutine to bind
103+
// the new libbox lifetime to the same context as the original Start.
104+
// Stop's cancelRun() teardown still applies to the rebuilt box.
105+
runCtx context.Context
90106
}
91107

108+
// peerCredRotationInterval bounds how long a leaked samizdat
109+
// credential remains usable. At each tick the peer re-registers with
110+
// lantern-cloud (new route_id, new keypair, new shortID), rebuilds the
111+
// libbox service against the new options, and deregisters the prior
112+
// route. Caps blast radius from credential leakage (logs, telemetry,
113+
// memory dumps, the H2 leakage path in engineering#3440) to ~1h
114+
// regardless of peer process lifetime.
115+
//
116+
// Cost per rotation: one API.Register + Deregister round trip, one
117+
// libbox build + start + close cycle. Brief (~hundreds-of-ms) port-
118+
// rebind window during the swap; samizdat clients see TCP RST and
119+
// reconnect via the bandit. Acceptable trade-off vs. holding the same
120+
// cred for the full peer process lifetime.
121+
const peerCredRotationInterval = 1 * time.Hour
122+
92123
// peerCleanupTimeout caps how long Start's rollback path waits for
93124
// Deregister / UnmapPort. Cleanup uses a fresh Background context (not the
94125
// caller's ctx) so an already-canceled or expired Start ctx doesn't skip
@@ -233,6 +264,10 @@ func (c *Client) Start(ctx context.Context) error {
233264
c.forwarder = fwd
234265
c.box = box
235266
c.routeID = regResp.RouteID
267+
c.externalPort = mapping.ExternalPort
268+
c.internalPort = mapping.InternalPort
269+
c.boxOptions = options
270+
c.runCtx = runCtx
236271
c.cancelRun = cancelRun
237272
c.runDone = runDone
238273
c.status = Status{
@@ -245,8 +280,14 @@ func (c *Client) Start(ctx context.Context) error {
245280
statusSnapshot := c.status
246281
c.mu.Unlock()
247282

283+
rotation := c.cfg.CredRotationInterval
284+
if rotation == 0 {
285+
rotation = peerCredRotationInterval
286+
}
287+
248288
fwd.StartRenewal(runCtx)
249289
go c.heartbeatLoop(runCtx, heartbeat, runDone)
290+
go c.credRotationLoop(runCtx, rotation)
250291

251292
slog.Info("peer client started",
252293
"external_ip", externalIP,
@@ -280,6 +321,10 @@ func (c *Client) Stop(ctx context.Context) error {
280321
c.forwarder = nil
281322
c.box = nil
282323
c.routeID = ""
324+
c.externalPort = 0
325+
c.internalPort = 0
326+
c.boxOptions = ""
327+
c.runCtx = nil
283328
c.status = Status{}
284329
c.mu.Unlock()
285330

@@ -371,6 +416,141 @@ func isNotRegistered(err error) bool {
371416
return errors.As(err, &apiErr) && apiErr.Status == 404
372417
}
373418

419+
// credRotationLoop periodically rotates the peer's samizdat credentials
420+
// (X25519 keypair, shortID, masquerade) by re-registering with
421+
// lantern-cloud, rebuilding the libbox inbound, and deregistering the
422+
// prior route. Caps blast radius from credential leakage to ~interval
423+
// regardless of peer process lifetime — see peerCredRotationInterval.
424+
//
425+
// Closes done is the responsibility of heartbeatLoop; this loop just
426+
// exits when ctx is cancelled. We deliberately don't add another close
427+
// channel: heartbeatLoop's done already gates Stop, and rotation
428+
// failures are non-fatal (log + retry next tick), so there's nothing
429+
// the Stop path needs to wait on from this goroutine.
430+
func (c *Client) credRotationLoop(ctx context.Context, interval time.Duration) {
431+
t := time.NewTicker(interval)
432+
defer t.Stop()
433+
for {
434+
select {
435+
case <-ctx.Done():
436+
return
437+
case <-t.C:
438+
if err := c.rotateCreds(ctx); err != nil {
439+
// Don't kill the loop on a single failure — current
440+
// box / route is still serving. Try again next tick.
441+
slog.Warn("peer cred rotation failed; current creds remain in use", "err", err)
442+
}
443+
}
444+
}
445+
}
446+
447+
// rotateCreds atomically swaps the peer's samizdat credentials. On
448+
// success: a fresh route_id and keypair are in use, the libbox inbound
449+
// has been rebuilt against the new options, the prior route is
450+
// deregistered server-side, and the FlutterEvent stream sees no gap.
451+
// On failure: the prior creds and box continue serving — rotation is
452+
// best-effort. The router-side port mapping is preserved across the
453+
// rotation; only the in-process samizdat state changes.
454+
//
455+
// Sequence:
456+
// 1. Re-register with the same (externalIP, externalPort) as Start.
457+
// 2. Patch the new server-supplied options for VPN bypass.
458+
// 3. Build a new libbox service against the new options.
459+
// 4. Close the old box (releases the listening port).
460+
// 5. Start the new box (re-binds the same port, now with new creds).
461+
// 6. Atomic swap: c.box, c.routeID, c.boxOptions point at the new box.
462+
// 7. Best-effort deregister of the prior route_id so the bandit
463+
// catalog stops handing the old (now-invalid) creds to clients.
464+
//
465+
// Steps 4-5 leave a brief (~hundreds of ms) window where the port
466+
// isn't bound; samizdat clients see TCP RST and reconnect. Acceptable
467+
// trade-off vs. the security cost of holding the same cred for the
468+
// peer process lifetime.
469+
func (c *Client) rotateCreds(ctx context.Context) error {
470+
c.mu.Lock()
471+
if !c.active {
472+
c.mu.Unlock()
473+
return errors.New("not active")
474+
}
475+
fwd := c.forwarder
476+
extPort := c.externalPort
477+
intPort := c.internalPort
478+
oldRouteID := c.routeID
479+
oldBox := c.box
480+
c.mu.Unlock()
481+
482+
if fwd == nil || oldBox == nil {
483+
return errors.New("rotateCreds: client state inconsistent")
484+
}
485+
486+
externalIP, err := fwd.ExternalIP(ctx)
487+
if err != nil {
488+
return fmt.Errorf("get external ip: %w", err)
489+
}
490+
regResp, err := c.cfg.API.Register(ctx, RegisterRequest{
491+
ExternalIP: externalIP,
492+
ExternalPort: extPort,
493+
InternalPort: intPort,
494+
})
495+
if err != nil {
496+
return fmt.Errorf("re-register: %w", err)
497+
}
498+
options, err := ensurePeerOutboundsBypassVPN(regResp.ServerConfig)
499+
if err != nil {
500+
return fmt.Errorf("patch sing-box options: %w", err)
501+
}
502+
503+
c.mu.Lock()
504+
runCtx := c.runCtx
505+
c.mu.Unlock()
506+
if runCtx == nil {
507+
// Stop happened between the unlock above and here. Skip the
508+
// build to avoid spinning up a libbox tied to a torn-down ctx.
509+
// The new register row is harmless — server-side reaper will
510+
// deprecate it after TTL since no heartbeat will arrive.
511+
return errors.New("client stopped during rotation")
512+
}
513+
newBox, err := c.cfg.BuildBoxService(runCtx, options)
514+
if err != nil {
515+
return fmt.Errorf("build new sing-box: %w", err)
516+
}
517+
518+
// Close old, start new. Order matters — both want the same port.
519+
// If newBox.Start fails after oldBox.Close, we lost the listener
520+
// and the next heartbeat / rotation tick is the recovery point.
521+
if closeErr := oldBox.Close(); closeErr != nil {
522+
slog.Warn("close old box during rotation", "err", closeErr)
523+
}
524+
if err := newBox.Start(); err != nil {
525+
// Catastrophic: port is now unbound. Leave c.box pointing at
526+
// oldBox so a future Stop tries to close it (idempotent on
527+
// already-closed); the next rotation tick will try again.
528+
return fmt.Errorf("start new sing-box: %w", err)
529+
}
530+
531+
c.mu.Lock()
532+
c.box = newBox
533+
c.routeID = regResp.RouteID
534+
c.boxOptions = options
535+
c.status.RouteID = regResp.RouteID
536+
c.mu.Unlock()
537+
538+
// Deregister the prior route so the bandit stops handing the old
539+
// (now-invalid) creds to clients. Best-effort: the prior row will
540+
// expire from its TTL anyway, but explicit deregister cuts the
541+
// stale-creds window from up-to-TTL down to ~immediately.
542+
if err := c.cfg.API.Deregister(ctx, oldRouteID); err != nil {
543+
slog.Warn("deregister prior route after rotation",
544+
"err", err, "old_route_id", oldRouteID)
545+
}
546+
547+
slog.Info("peer cred rotation succeeded",
548+
"new_route_id", regResp.RouteID,
549+
"old_route_id", oldRouteID,
550+
)
551+
return nil
552+
}
553+
374554
// ensurePeerOutboundsBypassVPN guarantees the peer sing-box's outbound dials
375555
// bind to the physical interface rather than whatever the OS routing table
376556
// picks. Without this, when the user's own Lantern VPN is up its TUN holds

peer/peer_test.go

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"errors"
7+
"fmt"
78
"net/http"
89
"net/http/httptest"
910
"sync"
@@ -139,6 +140,10 @@ type stubServer struct {
139140
server *httptest.Server
140141
registerStatus int
141142
registerResp RegisterResponse
143+
// registerRespFn lets a test return a different response per
144+
// register call (e.g. cred-rotation tests need a fresh route_id
145+
// each time). When non-nil, takes precedence over registerResp.
146+
registerRespFn func() RegisterResponse
142147
heartbeatStatus int
143148
deregisterStatus int
144149
registerCount atomic.Int64
@@ -174,7 +179,11 @@ func newStubServer(t *testing.T) *stubServer {
174179
http.Error(w, "register failed", s.registerStatus)
175180
return
176181
}
177-
_ = json.NewEncoder(w).Encode(s.registerResp)
182+
resp := s.registerResp
183+
if s.registerRespFn != nil {
184+
resp = s.registerRespFn()
185+
}
186+
_ = json.NewEncoder(w).Encode(resp)
178187
})
179188
mux.HandleFunc("/v1/peer/heartbeat", func(w http.ResponseWriter, r *http.Request) {
180189
s.heartbeatCount.Add(1)
@@ -509,6 +518,90 @@ func TestAPIError_StringFormat(t *testing.T) {
509518
assert.Contains(t, e.Error(), "could not connect")
510519
}
511520

521+
// TestClient_RotatesCredentialsAtInterval pins the C2 fix from
522+
// engineering#3437: the peer client must re-register and rebuild its
523+
// libbox inbound on a schedule so a leaked credential's blast radius is
524+
// bounded by CredRotationInterval rather than peer process lifetime.
525+
//
526+
// Drives a short rotation interval (50ms) and asserts:
527+
// 1. Multiple registers happen (start + ≥2 rotations within 250ms).
528+
// 2. Each rotation deregisters the prior route_id.
529+
// 3. The peer's exposed RouteID changes — clients freshly assigned
530+
// after a rotation see the new ID; the bandit catalog stops
531+
// handing out the old one once Deregister lands.
532+
// 4. Multiple distinct boxes were built (the rotation actually
533+
// rebuilt libbox; not just a no-op).
534+
// 5. The first box was closed (the old listener released its port).
535+
func TestClient_RotatesCredentialsAtInterval(t *testing.T) {
536+
fwd := &fakeForwarder{externalIP: "203.0.113.42"}
537+
srv := newStubServer(t)
538+
539+
// Each rotation needs a register response with a distinct
540+
// route_id so we can verify the swap actually changed identifiers
541+
// rather than re-registering the same id.
542+
var registerSeq atomic.Int64
543+
srv.registerRespFn = func() RegisterResponse {
544+
n := registerSeq.Add(1)
545+
return RegisterResponse{
546+
RouteID: fmt.Sprintf("00000000-0000-0000-0000-00000000000%d", n),
547+
ServerConfig: `{"inbounds": [{"type":"samizdat","tag":"samizdat-in"}]}`,
548+
HeartbeatIntervalSeconds: 60,
549+
}
550+
}
551+
552+
// Each BuildBoxService call gets a fresh fakeBoxService so we can
553+
// see how many boxes were built and which ones got closed.
554+
var (
555+
boxesMu sync.Mutex
556+
boxes []*fakeBoxService
557+
)
558+
c := newTestClient(t, fwd, &fakeBoxService{}, srv, func(cfg *Config) {
559+
cfg.CredRotationInterval = 50 * time.Millisecond
560+
// Long heartbeat so heartbeat ticks don't compete with the
561+
// register/deregister counters that we're asserting on.
562+
cfg.HeartbeatInterval = time.Hour
563+
cfg.BuildBoxService = func(_ context.Context, options string) (boxService, error) {
564+
b := &fakeBoxService{gotConfig: options}
565+
boxesMu.Lock()
566+
boxes = append(boxes, b)
567+
boxesMu.Unlock()
568+
return b, nil
569+
}
570+
})
571+
572+
require.NoError(t, c.Start(context.Background()))
573+
t.Cleanup(func() { _ = c.Stop(context.Background()) })
574+
575+
// Wait for at least 2 rotations on top of the initial register.
576+
require.Eventually(t, func() bool {
577+
return srv.registerCount.Load() >= 3
578+
}, 1*time.Second, 25*time.Millisecond,
579+
"expected ≥3 registers (initial + 2 rotations) within 1s; got %d",
580+
srv.registerCount.Load())
581+
582+
// Each rotation deregisters the prior route — N rotations =>
583+
// N deregisters (initial register is not preceded by one).
584+
rotations := srv.registerCount.Load() - 1
585+
assert.GreaterOrEqual(t, srv.deregisterCount.Load(), rotations-1,
586+
"each rotation should deregister the prior route_id (got %d deregs vs %d rotations)",
587+
srv.deregisterCount.Load(), rotations)
588+
589+
// RouteID exposed via Status should reflect the latest rotation.
590+
c.mu.Lock()
591+
currentRouteID := c.routeID
592+
c.mu.Unlock()
593+
assert.NotEqual(t, "00000000-0000-0000-0000-000000000001", currentRouteID,
594+
"current route_id should have advanced past the initial register")
595+
596+
// Multiple boxes built; first one closed.
597+
boxesMu.Lock()
598+
defer boxesMu.Unlock()
599+
require.GreaterOrEqual(t, len(boxes), 2,
600+
"expected ≥2 libbox builds (initial + ≥1 rotation)")
601+
assert.True(t, boxes[0].closed.Load(),
602+
"first box should be closed by the first rotation")
603+
}
604+
512605
// Subscribers (the IPC SSE handler in production) need both edges so the UI
513606
// can render fresh state without polling.
514607
func TestClient_StatusEventEmittedOnStartAndStop(t *testing.T) {

0 commit comments

Comments
 (0)