Skip to content

Commit 8fe02d4

Browse files
Adam Fiskclaude
authored 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 be86ee9 commit 8fe02d4

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
@@ -92,8 +93,38 @@ type Client struct {
9293
forwarder portForwarder
9394
box boxService
9495
routeID string
96+
// externalPort / internalPort persist the port mapping picked at
97+
// Start so the cred-rotation loop can re-register against the same
98+
// (address, port) tuple without re-probing UPnP / re-mapping. The
99+
// router-side mapping itself stays put across rotations; only the
100+
// samizdat creds and route_id rotate.
101+
externalPort uint16
102+
internalPort uint16
103+
// boxOptions is the fresh options string passed to BuildBoxService,
104+
// kept for diagnostics and so the rotation path doesn't need to
105+
// re-derive it from the (also-stored) box reference.
106+
boxOptions string
107+
// runCtx is captured here for the cred-rotation goroutine to bind
108+
// the new libbox lifetime to the same context as the original Start.
109+
// Stop's cancelRun() teardown still applies to the rebuilt box.
110+
runCtx context.Context
95111
}
96112

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

292+
rotation := c.cfg.CredRotationInterval
293+
if rotation == 0 {
294+
rotation = peerCredRotationInterval
295+
}
296+
257297
fwd.StartRenewal(runCtx)
258298
go c.heartbeatLoop(runCtx, heartbeat, runDone)
299+
go c.credRotationLoop(runCtx, rotation)
259300

260301
slog.Info("peer client started",
261302
"external_ip", externalIP,
@@ -306,6 +347,10 @@ func (c *Client) Stop(ctx context.Context) error {
306347
c.forwarder = nil
307348
c.box = nil
308349
c.routeID = ""
350+
c.externalPort = 0
351+
c.internalPort = 0
352+
c.boxOptions = ""
353+
c.runCtx = nil
309354
c.status = Status{}
310355
c.mu.Unlock()
311356

@@ -397,6 +442,141 @@ func isNotRegistered(err error) bool {
397442
return errors.As(err, &apiErr) && apiErr.Status == 404
398443
}
399444

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

588+
// TestClient_RotatesCredentialsAtInterval pins the C2 fix from
589+
// engineering#3437: the peer client must re-register and rebuild its
590+
// libbox inbound on a schedule so a leaked credential's blast radius is
591+
// bounded by CredRotationInterval rather than peer process lifetime.
592+
//
593+
// Drives a short rotation interval (50ms) and asserts:
594+
// 1. Multiple registers happen (start + ≥2 rotations within 250ms).
595+
// 2. Each rotation deregisters the prior route_id.
596+
// 3. The peer's exposed RouteID changes — clients freshly assigned
597+
// after a rotation see the new ID; the bandit catalog stops
598+
// handing out the old one once Deregister lands.
599+
// 4. Multiple distinct boxes were built (the rotation actually
600+
// rebuilt libbox; not just a no-op).
601+
// 5. The first box was closed (the old listener released its port).
602+
func TestClient_RotatesCredentialsAtInterval(t *testing.T) {
603+
fwd := &fakeForwarder{externalIP: "203.0.113.42"}
604+
srv := newStubServer(t)
605+
606+
// Each rotation needs a register response with a distinct
607+
// route_id so we can verify the swap actually changed identifiers
608+
// rather than re-registering the same id.
609+
var registerSeq atomic.Int64
610+
srv.registerRespFn = func() RegisterResponse {
611+
n := registerSeq.Add(1)
612+
return RegisterResponse{
613+
RouteID: fmt.Sprintf("00000000-0000-0000-0000-00000000000%d", n),
614+
ServerConfig: `{"inbounds": [{"type":"samizdat","tag":"samizdat-in"}]}`,
615+
HeartbeatIntervalSeconds: 60,
616+
}
617+
}
618+
619+
// Each BuildBoxService call gets a fresh fakeBoxService so we can
620+
// see how many boxes were built and which ones got closed.
621+
var (
622+
boxesMu sync.Mutex
623+
boxes []*fakeBoxService
624+
)
625+
c := newTestClient(t, fwd, &fakeBoxService{}, srv, func(cfg *Config) {
626+
cfg.CredRotationInterval = 50 * time.Millisecond
627+
// Long heartbeat so heartbeat ticks don't compete with the
628+
// register/deregister counters that we're asserting on.
629+
cfg.HeartbeatInterval = time.Hour
630+
cfg.BuildBoxService = func(_ context.Context, options string) (boxService, error) {
631+
b := &fakeBoxService{gotConfig: options}
632+
boxesMu.Lock()
633+
boxes = append(boxes, b)
634+
boxesMu.Unlock()
635+
return b, nil
636+
}
637+
})
638+
639+
require.NoError(t, c.Start(context.Background()))
640+
t.Cleanup(func() { _ = c.Stop(context.Background()) })
641+
642+
// Wait for at least 2 rotations on top of the initial register.
643+
require.Eventually(t, func() bool {
644+
return srv.registerCount.Load() >= 3
645+
}, 1*time.Second, 25*time.Millisecond,
646+
"expected ≥3 registers (initial + 2 rotations) within 1s; got %d",
647+
srv.registerCount.Load())
648+
649+
// Each rotation deregisters the prior route — N rotations =>
650+
// N deregisters (initial register is not preceded by one).
651+
rotations := srv.registerCount.Load() - 1
652+
assert.GreaterOrEqual(t, srv.deregisterCount.Load(), rotations-1,
653+
"each rotation should deregister the prior route_id (got %d deregs vs %d rotations)",
654+
srv.deregisterCount.Load(), rotations)
655+
656+
// RouteID exposed via Status should reflect the latest rotation.
657+
c.mu.Lock()
658+
currentRouteID := c.routeID
659+
c.mu.Unlock()
660+
assert.NotEqual(t, "00000000-0000-0000-0000-000000000001", currentRouteID,
661+
"current route_id should have advanced past the initial register")
662+
663+
// Multiple boxes built; first one closed.
664+
boxesMu.Lock()
665+
defer boxesMu.Unlock()
666+
require.GreaterOrEqual(t, len(boxes), 2,
667+
"expected ≥2 libbox builds (initial + ≥1 rotation)")
668+
assert.True(t, boxes[0].closed.Load(),
669+
"first box should be closed by the first rotation")
670+
}
671+
579672
// Subscribers (the IPC SSE handler in production) need both edges so the UI
580673
// can render fresh state without polling.
581674
func TestClient_StatusEventEmittedOnStartAndStop(t *testing.T) {

0 commit comments

Comments
 (0)