Skip to content

[Architecture][Security] Make SFrame sender state and counter rollover safe by construction #63

Description

@F000NKKK

Context

This is the architectural follow-up to:

Upstream #96 correctly proposes making counter exhaustion fallible. That prevents silent u64 wraparound in one counter instance, but it does not by itself prevent:

  • multiple independent counters for the same (key, KID);
  • counter reset after an encryptor is recreated;
  • counter reset after process restart;
  • one-counter-per-thread or one-counter-per-stream misuse;
  • application code attempting an unsafe reset after CounterExhausted.

The repository must provide a complete, safe sender-state abstraction so users never have to implement nonce/counter coordination themselves.

Architectural invariant

For every SFrame encryption operation, the tuple below must be globally unique within the lifetime of the relevant key material:

(base key generation, KID, CTR)

No supported Rust API, C ABI call, language binding, restart path, concurrency pattern, or error-recovery path may produce the same tuple twice.

This must be guaranteed by the library rather than documented as a caller responsibility.

Goals

  1. Make nonce reuse impossible through supported APIs.
  2. Make sender handles cloneable and concurrency-safe without cloning counter state.
  3. Support multiple media streams from one MLS member without sharing an unsafe KID/counter domain.
  4. Handle counter exhaustion without corrupting a frame, resetting a counter, or invalidating the whole MLS session.
  5. Provide an explicit, typed recovery model when automatic rotation is not possible.
  6. Preserve the same guarantees in Rust, C ABI, C#, Python, JavaScript, and WASM.
  7. Make restart behavior explicit and safe by construction.

Proposed model

1. Session-owned sender registry

SFrameSession owns sender states. A public sender handle references shared internal state; it never owns an independent counter.

Conceptually:

pub struct SFrameSession {
    // MLS/base-key context
    senders: SenderRegistry,
}

pub struct SFrameSender {
    inner: Arc<SenderState>,
}

The registry key must identify a logical sender domain, for example:

(group_id, MLS epoch, leaf_index, stream_id)

Repeated acquisition of the same sender domain returns another handle to the same state. It must not create a fresh counter.

let audio = session.sender(leaf_index, StreamId::new("audio"))?;
let cloned = audio.clone(); // shared state, not a copied counter

2. Distinct KID per logical stream/key generation

Different concurrent streams from the same leaf must not accidentally share one (KID, CTR) namespace.

Use the RFC 9605 MLS KID Context ID component as a library-managed sender/key-generation identifier. The KID should encode:

Context ID = library-managed sender generation / stream domain
Member Index = MLS leaf index
Epoch = MLS epoch LSBs

Changing the Context ID changes the full KID and therefore derives distinct SFrame key material. The receiver receives the KID on the wire and can derive the correct key from the full KID.

The application must not assign raw Context IDs or reset generations directly.

3. Atomic, non-wrapping counter allocation

Counter allocation must be fallible and atomic:

trait FrameCounter {
    fn try_next(&self) -> Result<Counter, CounterExhausted>;
}

Requirements:

  • no wrapping_add for security counters;
  • no reset operation under the same key/KID;
  • concurrent calls allocate distinct values;
  • a counter is consumed at most once;
  • exhaustion is detected before AEAD encryption starts;
  • no ciphertext or partially valid frame is returned on allocation failure.

The implementation may use an atomic allocator, but correctness must not depend on callers serializing access.

4. Transparent key-generation rollover

Counter exhaustion must not silently break the MLS session or force callers to invent a reset procedure.

Before the active generation is exhausted, the sender state should atomically rotate to a new library-managed Context ID/KID, derive a new SFrame key, reset the counter only for that new key generation, and encrypt the pending message under the new generation.

old: KID generation N, CTR = MAX
new: KID generation N+1, CTR = 0

This is safe because the full KID and derived key change before the counter restarts.

Rotation must be an atomic state transition. Concurrent callers must observe either the old generation with a unique remaining CTR or the new generation with a unique CTR; they must never initialize two copies of the same generation.

5. Typed conflict and rollover results

The library should resolve normal acquisition conflicts internally by sharing existing state. Genuine conflicts must be typed and fail closed:

pub enum SFrameError {
    SenderConfigurationConflict { sender: SenderId },
    CounterExhausted { kid: KeyId },
    GenerationExhausted { sender: SenderId },
    RotationRequired { sender: SenderId },
    PersistentStateUnavailable { sender: SenderId },
    ResumeRequiresNewEpoch { sender: SenderId },
}

A failed transition must leave the sender in a well-defined state and emit no ciphertext. Errors must indicate whether retrying the same operation is safe.

6. Crash-safe and restart-safe state

An in-memory counter cannot safely restart at zero under the same key/KID.

Provide a library-defined persistence abstraction rather than requiring applications to persist raw counters themselves:

pub trait SenderStateStore {
    fn allocate_generation(&self, sender: &SenderId) -> Result<GenerationLease, StoreError>;
}

Preferred strategy:

  • persist/lease a monotonically increasing sender generation, not every frame counter;
  • allocate a new generation atomically on process start or sender activation;
  • encode that generation into the KID Context ID;
  • start CTR at zero only after a new generation/KID has been durably allocated;
  • skipped/unused generations are acceptable; reused generations are not.

If no durable store is configured, the API must explicitly operate in ephemeral mode. Resuming encryption after process restart under the same MLS epoch must be rejected unless the library can guarantee a fresh generation or the MLS epoch/base key is rotated.

The repository should provide at least one supported state-store implementation or a sealed host callback contract for FFI consumers. Users must not be told to serialize raw counters manually.

7. Receiver generation lifecycle

Receiver state remains keyed by the full KID, including Context ID. It must:

  • derive keys from the full KID;
  • maintain replay state per full KID;
  • accept a legitimate new sender generation without out-of-band counter reset;
  • retain a bounded number of previous generations for reordering;
  • evict old generations safely;
  • cap unauthenticated generation/KID allocations to prevent memory DoS;
  • commit replay state only after successful authentication, consistent with Update sframe after upstream replay-window poisoning fix #61.

8. C ABI and language bindings

The native core owns sender state. Bindings expose opaque references to that state.

Required behavior:

  • creating/acquiring the same logical sender twice returns shared state or a typed configuration conflict;
  • cloning/disposal of a wrapper never resets the native counter;
  • one sender handle is safe for concurrent use, or the binding serializes access internally;
  • documentation must not recommend “one encryptor per thread”;
  • C#, Python, JavaScript, and WASM users never provide a raw CTR, nonce, generation, or reset command;
  • error mappings preserve exhaustion, rotation, persistence, and retryability semantics.

Public API direction

Prefer:

let sender = session.open_sender(SenderOptions {
    leaf_index,
    stream: StreamId::new("audio"),
    persistence: SenderPersistence::Required,
})?;

let frame = sender.encrypt(payload, aad)?;

Avoid APIs that manufacture an independent encryptor every time:

session.encryptor(leaf_index) // unsafe lifecycle semantics

If compatibility requires keeping the old method temporarily, it must delegate to the registry-owned state and be deprecated.

Message-level behavior during rollover

Counter exhaustion or generation rotation must not produce a malformed or ambiguously encrypted message.

For each encrypt() call, exactly one of the following occurs:

  1. the message is encrypted once under a unique old-generation counter;
  2. the sender atomically rotates and encrypts once under a unique new-generation counter;
  3. the method returns a typed error before encryption and emits no frame.

The library must never encrypt, then discover a conflict, and retry the same message with another nonce invisibly.

Required ADR

Add an ADR defining:

  • sender identity and stream domains;
  • ownership of counters and generations;
  • Context ID allocation;
  • concurrency semantics;
  • rollover transition;
  • restart and persistence guarantees;
  • receiver generation retention;
  • FFI lifecycle and retry semantics;
  • security invariants that future implementations must preserve.

Required tests

Core invariants

  • repeated open_sender() returns shared state;
  • cloned handles produce one monotonic counter sequence;
  • concurrent encryption never duplicates (KID, CTR);
  • different streams receive different KIDs/key generations;
  • dropping and reacquiring a handle does not reset state;
  • old APIs cannot create independent counters.

Rollover

  • test with an artificially small counter limit;
  • final old-generation counter encrypts correctly;
  • next message rotates and encrypts under a new KID with CTR 0;
  • no message is duplicated, lost internally, or partially emitted;
  • concurrent rollover creates exactly one new generation;
  • failed rotation emits no ciphertext and returns a retry-safe typed error.

Restart/persistence

  • a restarted process obtains a fresh generation before encryption;
  • a simulated crash cannot reuse the previous generation;
  • unavailable persistence fails closed;
  • ephemeral mode requires a new MLS epoch or fresh guaranteed generation before resume.

Bindings

Run equivalent lifecycle, concurrency, rollover, and recreation tests through:

  • Rust API;
  • C ABI;
  • C#;
  • Python;
  • JavaScript;
  • WASM.

Completion criteria

  • [Critical][Security] Prevent SFrame nonce reuse when encryptors are recreated #62 is no longer reproducible.
  • The upstream fix for TobTheRock/sframe-rs#96 is consumed or equivalent fail-closed behavior is implemented locally until release.
  • No public API exposes unsafe counter reset or independent counter construction for an existing sender domain.
  • Counter exhaustion transitions safely to a new KID/key generation or returns a typed pre-encryption error.
  • Restart behavior is guaranteed and tested.
  • All bindings preserve the same lifecycle contract.
  • An ADR and user-facing migration documentation are published.

Metadata

Metadata

Assignees

Labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions