Skip to content

fix(gbp-sframe): prevent SFrame nonce reuse when encryptors are recreated - #64

Merged
F000NKKK merged 2 commits into
masterfrom
fix/sframe-encryptor-nonce-reuse
Aug 5, 2026
Merged

fix(gbp-sframe): prevent SFrame nonce reuse when encryptors are recreated#64
F000NKKK merged 2 commits into
masterfrom
fix/sframe-encryptor-nonce-reuse

Conversation

@F000NKKK

@F000NKKK F000NKKK commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #62.

SFrameSession::encryptor(leaf_index) returned a fresh SFrameEncryptor with its own MonotonicCounter starting at 0 on every call, while every encryptor for a given leaf derives the same key from the same (base_key, KID). Recreating a handle - one per thread, one per request, after a free/reacquire cycle - could therefore reuse the same (key, KID, CTR) nonce as an earlier handle, breaking AES-GCM confidentiality and authenticity.

Changes

  • gbp-sframe: SFrameEncryptor is now a cheap Clone handle around an Arc<Mutex<..>> holding the derived key and counter. SFrameSession caches one handle per leaf_index, so repeated encryptor() calls reconnect to the same counter instead of resetting it, and concurrent encrypt() calls from cloned handles allocate the counter under a lock - no two calls can ever observe the same value.
  • gbp-stack-ffi: gbp_sframe_encryptor_create re-derives a fresh SFrameSession on every call (it only borrows an MlsContext, not a persisted session), which would silently undo the fix above. Added a cache keyed by (session_handle, leaf_index) so repeated create calls reconnect to the same counter. gbp_sframe_encryptor_free only releases that specific handle; the underlying counter is released for good by gbp_sframe_session_free (call on epoch change).
  • gbp-stack-wasm: same issue in SFrameSession::createEncryptor (re-derives the session from MLS every call) - added an equivalent per-leafIndex cache.

Scope

This addresses the "Required immediate fix" items 1-4 and 6, and the "Temporary mitigation" section of #62 - it is no longer possible, through any of the Rust, FFI, or WASM APIs, to obtain two independent counters for the same (session, leaf). It does not implement the full architecture from #63 (distinct KID per logical stream, generation rollover on counter exhaustion, crash-safe persistence across process restarts, typed conflict/rotation errors, C#/Python bindings) - that's a substantially larger, separately-tracked follow-up.

Counter exhaustion itself (u64::MAX wraparound) is still bounded by the upstream sframe crate's current behavior, tracked separately by #61/#96/#97.

Test plan

  • cargo test -p gbp-sframe -p gbp-stack-ffi -p gbp-stack - all pass
  • cargo test --workspace --exclude gbp-stack-wasm - all pass
  • wasm-pack test --node (gbp-stack-wasm) - 39/39 pass
  • cargo clippy -p gbp-sframe -p gbp-stack-ffi -p gbp-stack-wasm --all-targets - clean
  • cargo fmt --check - clean
  • New regression tests:
    • gbp-sframe: repeated_encryptor_calls_share_one_counter_sequence, cloned_encryptor_handles_never_duplicate_a_counter_value (8 threads racing on encrypt(), decrypts each and asserts all 8 counter values 0..8 were used exactly once)
    • gbp-stack-wasm: sframe_repeated_create_encryptor_shares_counter_state (two createEncryptor calls for the same leaf; both frames must still decrypt - a reset counter would make the second a replay-window duplicate)

…ated

SFrameSession::encryptor(leaf_index) returned a fresh SFrameEncryptor
with its own MonotonicCounter starting at 0 on every call, while every
encryptor for a given leaf derives the same key from the same
(base_key, KID). Recreating a handle - one per thread, one per request,
after a free/reacquire cycle - could therefore reuse the same
(key, KID, CTR) nonce as an earlier handle, breaking AES-GCM
confidentiality and authenticity.

SFrameEncryptor is now a cheap Clone handle around an Arc<Mutex<..>>
holding the derived key and counter. SFrameSession caches one handle
per leaf_index, so repeated encryptor() calls reconnect to the same
counter instead of resetting it, and concurrent encrypt() calls from
cloned handles allocate the counter under a lock (no two calls can ever
observe the same value).

The FFI and WASM bindings had the same bug one layer up: both re-derive
a fresh SFrameSession on every encryptor-creation call (they only hold
a borrowed MLS context, not a persisted session), which would silently
undo the fix above by handing out a brand-new SFrameSession - and
therefore a brand-new counter - each time. Added a matching cache in
each binding, keyed by (session handle, leaf) for FFI and by leaf for
WASM, so repeated creation calls reconnect to the same counter there
too; freeing an FFI encryptor handle releases only that handle; the
counter is released for good by gbp_sframe_session_free (on epoch
change).

Addresses the "Required immediate fix" items 1-4 and 6, and the
"Temporary mitigation" of #62. Full library-owned sender-state
architecture (distinct KID per stream, generation rollover, crash-safe
persistence, typed conflict errors) is left to the follow-up in #63.

Closes #62
Unrelated to this branch's sframe fix, but CI's clippy (running a
newer version than locally available) flags collapsible_match here,
blocking the build. Applies clippy's own suggested rewrite; behavior
is unchanged (a false condition falls through to the existing `_ => {}`
arm either way).
@F000NKKK
F000NKKK merged commit a6b9f50 into master Aug 5, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Critical][Security] Prevent SFrame nonce reuse when encryptors are recreated

1 participant