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