@@ -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 .
5959type 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
0 commit comments