Filename: 368-cdt-rethink.md
Title: Rethinking circuit dirty timeouts
Author: Nick Mathewson
Created: 24 September 2025
Status: Open

Introduction

Based on earlier discussion, this proposal revisits the operation of our timeout for expiring circuits (MaxCircuitDirtiness in the C Tor implementation, and circuit_timing.max_dirtiness in Arti). Hereinafter we'll call this timer the "Circuit Dirty Timeout" or CDT.

Originally, this timeout would cause any circuit that had been used for traffic (that is, a "dirty" circuit) to become unusable for new streams after the timeout had expired. When the circuit had no streams at all, it would be closed.

So the timeline for circuits that are used is:

  • X: Circuit is created
  • Y: Circuit is used for a stream
  • Y + CircuitDirtyTimeout: New streams cannot be attached to circuit
  • Z: last stream closed on circuit
  • MIN(Z, Y+CircuitDirtyTimeout): Circuit is closed.

We had originally set CircuitDirtyTimeout to a low value in order to lower the amount of traffic that we needlessly made linkable. For example, if we used the same circuit forever, we were at risk of multiple websites and chat apps on the same circuit, which would needlessly link user accounts and behavior.

But with proposal 171, we began taking a different approach to splitting user traffic onto different circuits: we gave applications a way to signal (via SOCKS passwords and other means) which traffic could safely share a circuit with other traffic. Tor Browser uses this feature extensively, and some other tools do as well.

The current CircuitDirtyTimeout behavior is not unproblematic: with applications like Tor Browser, it can be exploited by websites that generate requests (such as by reloading themselves or other content) to cause the user to generate a new circuit as frequently as the timeout expires, which makes traffic analysis needlessly easy.

We later added a flag to C Tor called "KeepAliveIsolateSOCKSAuth", indicating that any stream using SOCKS isolation should effectively keep its circuit "alive" indefinitely. (and usable for new streams with the same SOCKS isolation). This flag effectively reset the "dirty" timestamp on a circuit whenever a new stream with SOCKS isolation was opened on it. It can still lead to unfortunate behavior, however: when the last stream on a circuit closes, if it opened a long time ago, the circuit closes immediately, which is usually not what we want.

In this proposal we document a newer, safer approach to timing out old circuits.

Proposed behavior

In summary we propose the following:

  • We should no longer use the same CircuitDirtyTimeout timer both for deciding when streams can no longer be attached, and for deciding when to close unused circuits. (We'll call the first timer the "Circuit Dirty Timeout" and call the second timer the "Unused Circuit Timeout".)
  • KeepAliveIsolateSOCKSAuth should be the default.
  • KeepAliveIsolateSOCKSAuth should effectively set the "Circuit Dirty Timeout" for the relevant circuit to infinity, or to a very high value.
  • The "unused circuit timeout" should begin counting when the last stream on a circuit is closed.

This is the behavior we will implement in Arti. For C Tor, we describe a simpler solution below.

Part 1: Isolation makes dirty timeouts needless

We define two Circuit Dirty Timeouts: one general CDT, and a new IsoCDT for sufficiently isolated circuits. The IsoCDT defaults to a high value, possibly infinity.

A circuit is sufficiently isolated if it has any isolation based on using an application-generated identifier, such as SOCKS authorization, HTTP CONNECT authorization, or Arti RPC identifiers.

The Circuit Dirty Timeout is only used for deciding whether streams can be attached to a circuit. It is not used for closing unused circuits.

Part 2: Closing unused dirty circuits

We no longer close a circuit for being dirty, past its CDT, and without streams. Instead, we close a circuit when it is dirty, and it has been unused for a randomized "Unused Circuit Timeout". (A circuit is defined as being unused when it has no streams.)

Implementation notes

In Arti

Currently, Arti circuits close as soon as they are unreferenced: that is, as soon as nobody is holding a strong reference to the relevant ClientCirc handle. Streams on a circuit hold such a handle, as does the circuit manager for all not-too-dirty circuits.

Instead of having circuits close immediately when they are unreferenced, we should instead have a randomized timeout for closing them, to add a bit of traffic analysis resistance.

In C Tor

Currently in C Tor, KeepAliveIsolateSOCKSAuth is implemented, not by having a separate timeout value, but by updating the dirty time (when the circuit was supposedly first used) every time a stream is attached to the circuit. This can lead to surprising circuit closes, however, if the stream lasts for a long time and the circuit is unused only for a short while.

Instead, we should either implement the behavior defined in this proposal, or update the dirty time whenever a stream is opened or closed on a circuit.

Future work

This proposal does not handle the attack where an adversarial webpage waits for all the circuits for its isolation to time out, and then opens a new stream, causing the Tor client to create a new path. To solve that, we'll need a separate proposal, to make paths persistent even when circuits are closed.

Acknowledgments

This proposal is due to a discussion with Mike Perry on ticket torspec#321.