Skip to content

Failover: the trust ratchet does not survive a restart — a superseded local route leads again #479

Description

@KesleyDavid

The trust ratchet holds inside one CandidateRotation value, but not across a restart. A hand-typed local route that has already been upgraded away from leads the rotation again on the next launch, and the long-lived bearer goes with it.

The sequence

Every step is reachable through normal use.

  1. Someone types a LAN address in Settings. Session.updateAddress (ios/App/Session.swift:535-541) builds it with priority: 0 and puts it at the head:
updated.promote(endpoint)
updated.endpoints = [endpoint] + existingRoutes.filter { $0.url != endpoint.url }
  1. That route fails. CandidateRotation.advance upgrades to a hosted or tailnet route and drops the local one from the rotation — the ratchet working as intended.

  2. The protected route says hello, and rememberWorkingRoute() (Session.swift:478-484) calls promote(winner). But promote (Failover.swift:307-319) only assigns activeEndpoint and appends the winner if it was missing:

public mutating func promote(_ winner: CompanionEndpoint) {
    activeEndpoint = winner
    host = winner.host
    port = winner.port
    if let existing = endpoints,
       !existing.contains(where: { $0.url == winner.url }) {
        endpoints = existing + [winner]
    }
    ...
}

It never reorders and never reprioritises, so the priority: 0 local route is still first in endpoints.

  1. orderedEndpoints (Failover.swift:196-214) sorts by priority and original offset only, and appends activeEndpoint only when it is missing — it is never pulled to the front:
candidates = candidates.enumerated().sorted {
    $0.element.priority == $1.element.priority
        ? $0.offset < $1.offset
        : $0.element.priority < $1.element.priority
}.map(\.element)
  1. If the best-effort endpoint refresh 404s, fails, or is cancelled — all deliberately tolerated — that order is what persists.

  2. Next launch, CandidateRotation(endpoints: saved.orderedEndpoints) (Session.swift:155) starts from the cleartext route again, treats it as the explicit local choice, and hands it the stored bearer.

Why it matters

The ratchet exists so that a connection which has reached a protected route does not walk back onto cleartext carrying a long-lived token. That guarantee currently lives in the lifetime of a CandidateRotation value. A process restart — or anything that rebuilds the rotation from the store — resets it.

Nothing is corrupted, and it needs a hand-typed local address to have been used at least once. But after that, the reset is automatic and silent.

Shape of a fix

The version that worked when I hit the same thing while porting: make the ordering itself carry the upgrade, so a route already in use that protects credentials leads the stored order regardless of a lower priority number. Reaching a protected route is one-way; the superseded route can stay in endpoints for display and for a future manual choice without being what the rotation reads as the preferred candidate.

That keeps the escape hatch intact — typing the local address again makes it active and the priority order applies once more, which matters when the hosted route is down and someone needs the LAN.

How it was found

Porting the companion to Android. A reviewer traced the same sequence in the Kotlin port, reproduced it with a temporary test, and then checked whether the Swift it was ported from had the same shape. It does. Verified against main at 3557e748; read from source rather than run on a device, since I have no Apple hardware here.

The Android port is deliberately diverging on this point rather than mirroring it, since it is a credential path.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions