You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Make nonce reuse impossible through supported APIs.
Make sender handles cloneable and concurrency-safe without cloning counter state.
Support multiple media streams from one MLS member without sharing an unsafe KID/counter domain.
Handle counter exhaustion without corrupting a frame, resetting a counter, or invalidating the whole MLS session.
Provide an explicit, typed recovery model when automatic rotation is not possible.
Preserve the same guarantees in Rust, C ABI, C#, Python, JavaScript, and WASM.
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.
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.
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:
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:
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;
Context
This is the architectural follow-up to:
Upstream #96 correctly proposes making counter exhaustion fallible. That prevents silent
u64wraparound in one counter instance, but it does not by itself prevent:(key, KID);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:
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
Proposed model
1. Session-owned sender registry
SFrameSessionowns sender states. A public sender handle references shared internal state; it never owns an independent counter.Conceptually:
The registry key must identify a logical sender domain, for example:
Repeated acquisition of the same sender domain returns another handle to the same state. It must not create a fresh 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:
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:
Requirements:
wrapping_addfor security counters;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.
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:
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:
Preferred strategy:
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:
8. C ABI and language bindings
The native core owns sender state. Bindings expose opaque references to that state.
Required behavior:
Public API direction
Prefer:
Avoid APIs that manufacture an independent encryptor every time:
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:The library must never encrypt, then discover a conflict, and retry the same message with another nonce invisibly.
Required ADR
Add an ADR defining:
Required tests
Core invariants
open_sender()returns shared state;(KID, CTR);Rollover
0;Restart/persistence
Bindings
Run equivalent lifecycle, concurrency, rollover, and recreation tests through:
Completion criteria
TobTheRock/sframe-rs#96is consumed or equivalent fail-closed behavior is implemented locally until release.