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
Private rooms shipped on main last October (commit 15596c6, 2025-10-21) but were disabled in February by commit 58ab2e7 ("feat: message-based member lifecycle with automatic pruning"). The disable lives in ui/src/components/room_list/create_room_modal.rs:26:
let private = false; // Private rooms temporarily disabled
The 58ab2e7 commit message states the reason: "Disable private room creation (non-functional without owner online)".
The collision: today, secret rotation runs in the room owner's UI sync loop (ui/src/components/app/sync_info.rs, gated by RoomData::needs_secret_rotation() in ui/src/room_data.rs:155). Auto-pruning produces continuous, asynchronous membership changes, including when the owner's UI is offline. A pruned-but-not-banned member who held the previous secret can still decrypt new messages until the owner next opens the app. Forward secrecy is broken.
Proposal
Move all rotation logic out of the UI and into the chat delegate, triggered by:
Contract notification on membership change (existing primitive: SubscribeContractRequest + ContractNotification).
Once rotation is in the delegate it runs whenever the node runs, regardless of UI state.
Multi-device coordination via deterministic key derivation
A user running River on multiple devices replicates the chat delegate to all of them. The delegate key is deterministic (BLAKE3(BLAKE3(wasm) || params)) so all replicas share the same identity and the same room signing key. They are two replicas of one logical delegate, not two competing delegates.
Rather than adding leader-election or locking primitives, derive each rotation secret deterministically from the room's signing key seed and the version number:
Concurrent rotations on multiple devices produce byte-identicalsecret_v_n values. There is no conflict to resolve.
The ECIES wrappings (encrypted-for-each-member) remain non-deterministic - ECIES requires a fresh ephemeral keypair per encryption for CCA security, so reusing one on the same (owner, member, version) would weaken it. Two concurrent rotations produce the same inner secret but different wrappings. State design accepts any valid wrapping for a given (member, version) since they all decrypt to the same plaintext; last-write-wins on the wrappings is fine.
Why direct derivation from the signing key
An earlier draft proposed a separate rotation_seed. The intended benefit was compromise recovery - if the seed leaked, post a RotationSeedRecord to switch to a new seed without rotating the room identity. But this only helps in the unlikely scenario "rotation seed leaked but signing key didn't". If the signing key is compromised, the attacker controls the room identity entirely and can issue their own seed updates; if only some downstream secret leaked, recovering it from the signing key is straightforward. The separate seed adds state and complexity without realistic payoff.
HKDF with proper domain separation (salt = owner_vk, info = "river-rotate-v1" || version) is cryptographically sound when applied to the ed25519 seed. HKDF is one-way; this is not key reuse in the dangerous sense.
Trade-off
Compromise of the room signing key now leaks all past secrets (not just retained ones). Today's design lets the owner in principle forget old secrets; deterministic derivation removes that option. Acceptable for River's threat model (group chat, casual-surveillance threat) and gives "owner can replay room history on a new device" for free.
Implementation plan
Phase 1: move rotation into delegate (current primitives only)
Add deterministic-derivation helpers in common/: derive_room_secret(signing_key_seed, owner_vk, version) -> [u8; 32]. Pure function, fully unit-testable.
Move rotation out of sync_info.rs and room_data.rs::needs_secret_rotation.
Chat delegate (delegates/chat-delegate/):
On startup: subscribe to room contracts where we hold the owner signing key.
On ContractNotification: if member set changed, derive secret_v_n+1, ECIES-wrap for each current member, emit UpdateContractRequest (V1 message path, propagates).
No framework changes, no stdlib bump.
Validates the architecture for ban-driven and prune-driven rotation. Weekly rotation deferred to Phase 3.
Phase 2: ScheduleWakeup / WakeupFired in freenet-stdlib + freenet-core
Host-side scheduler persisted to ReDb (survives restart).
Cancel-by-tag semantics.
Phase 3: wire weekly rotation in River
Stdlib bump + delegate migration entry per river-publish workflow.
Delegate schedules weekly wakeup on startup; on fire, rotates if due and reschedules.
Phase 4: state design + UI re-enable
Adapt SecretVersionRecordV1 to allow multiple wrappings for the same (member, version) (last-write-wins on wrappings; both decrypt to the same plaintext).
Add tests in common/tests/private_room_test.rs:
derive_room_secret is deterministic across calls and identical for replicas with the same signing key.
Different versions produce different secrets; same version with different signing key produces different secret.
Two replicas rotate concurrently; both produce identical secret_v_n; member receives equivalent wrappings either way.
Member with secret_v_n cannot decrypt v_n+1 messages after rotation (forward secrecy at the version boundary).
Pruned member triggers rotation via delegate path with no UI involvement.
Re-enable the create-private-room UI path (revert the disable in create_room_modal.rs:26 and the modal label at lines 130-140).
Update README "Privacy Model" and AGENTS.md "Private Room Support" to describe delegate-driven rotation and deterministic derivation.
Open questions
Where does the delegate persist last_secret_rotation? Today rotation timing lives in UI RoomData. The signing key already lives in the delegate's secret store (and is the only thing needed to derive any past or future secret), so no new persistent state for derivation is required. Timing should probably live in contract state since both replicas need it; the version number itself is already there.
Wakeup tag semantics: cancel-by-tag (re-scheduling with same tag replaces) is proposed in freenet-core#3972. Simpler than a separate CancelWakeup and covers "next weekly rotation for room X". Flag if a future delegate needs both semantics.
Stdlib bump cost: Phase 3 will trigger a delegate migration entry plus coordinated riverctl rebuild per the existing pre-release smoke-test checklist.
Related
Companion feature (separate issue): per-user inbox contracts for private DMs and invites between room members. Shares the same delegate plumbing and exercises the related-contracts mechanism (freenet-core PR #3650) which is currently unused in production.
Problem
Private rooms shipped on main last October (commit 15596c6, 2025-10-21) but were disabled in February by commit 58ab2e7 ("feat: message-based member lifecycle with automatic pruning"). The disable lives in
ui/src/components/room_list/create_room_modal.rs:26:The 58ab2e7 commit message states the reason: "Disable private room creation (non-functional without owner online)".
The collision: today, secret rotation runs in the room owner's UI sync loop (
ui/src/components/app/sync_info.rs, gated byRoomData::needs_secret_rotation()inui/src/room_data.rs:155). Auto-pruning produces continuous, asynchronous membership changes, including when the owner's UI is offline. A pruned-but-not-banned member who held the previous secret can still decrypt new messages until the owner next opens the app. Forward secrecy is broken.Proposal
Move all rotation logic out of the UI and into the chat delegate, triggered by:
SubscribeContractRequest+ContractNotification).Once rotation is in the delegate it runs whenever the node runs, regardless of UI state.
Multi-device coordination via deterministic key derivation
A user running River on multiple devices replicates the chat delegate to all of them. The delegate key is deterministic (
BLAKE3(BLAKE3(wasm) || params)) so all replicas share the same identity and the same room signing key. They are two replicas of one logical delegate, not two competing delegates.Rather than adding leader-election or locking primitives, derive each rotation secret deterministically from the room's signing key seed and the version number:
Concurrent rotations on multiple devices produce byte-identical
secret_v_nvalues. There is no conflict to resolve.The ECIES wrappings (encrypted-for-each-member) remain non-deterministic - ECIES requires a fresh ephemeral keypair per encryption for CCA security, so reusing one on the same
(owner, member, version)would weaken it. Two concurrent rotations produce the same inner secret but different wrappings. State design accepts any valid wrapping for a given(member, version)since they all decrypt to the same plaintext; last-write-wins on the wrappings is fine.Why direct derivation from the signing key
An earlier draft proposed a separate
rotation_seed. The intended benefit was compromise recovery - if the seed leaked, post aRotationSeedRecordto switch to a new seed without rotating the room identity. But this only helps in the unlikely scenario "rotation seed leaked but signing key didn't". If the signing key is compromised, the attacker controls the room identity entirely and can issue their own seed updates; if only some downstream secret leaked, recovering it from the signing key is straightforward. The separate seed adds state and complexity without realistic payoff.HKDF with proper domain separation (
salt = owner_vk,info = "river-rotate-v1" || version) is cryptographically sound when applied to the ed25519 seed. HKDF is one-way; this is not key reuse in the dangerous sense.Trade-off
Compromise of the room signing key now leaks all past secrets (not just retained ones). Today's design lets the owner in principle forget old secrets; deterministic derivation removes that option. Acceptable for River's threat model (group chat, casual-surveillance threat) and gives "owner can replay room history on a new device" for free.
Implementation plan
Phase 1: move rotation into delegate (current primitives only)
common/:derive_room_secret(signing_key_seed, owner_vk, version) -> [u8; 32]. Pure function, fully unit-testable.sync_info.rsandroom_data.rs::needs_secret_rotation.delegates/chat-delegate/):ContractNotification: if member set changed, derivesecret_v_n+1, ECIES-wrap for each current member, emitUpdateContractRequest(V1 message path, propagates).Phase 2: ScheduleWakeup / WakeupFired in freenet-stdlib + freenet-core
Tracked at freenet/freenet-core#3972. Adds:
OutboundDelegateMsg::ScheduleWakeup { at: SystemTime, tag: Vec<u8> }InboundDelegateMsg::WakeupFired { tag: Vec<u8> }Phase 3: wire weekly rotation in River
river-publishworkflow.Phase 4: state design + UI re-enable
SecretVersionRecordV1to allow multiple wrappings for the same(member, version)(last-write-wins on wrappings; both decrypt to the same plaintext).common/tests/private_room_test.rs:derive_room_secretis deterministic across calls and identical for replicas with the same signing key.secret_v_n; member receives equivalent wrappings either way.secret_v_ncannot decryptv_n+1messages after rotation (forward secrecy at the version boundary).create_room_modal.rs:26and the modal label at lines 130-140).Open questions
Where does the delegate persist
last_secret_rotation? Today rotation timing lives in UIRoomData. The signing key already lives in the delegate's secret store (and is the only thing needed to derive any past or future secret), so no new persistent state for derivation is required. Timing should probably live in contract state since both replicas need it; the version number itself is already there.Wakeup tag semantics: cancel-by-tag (re-scheduling with same tag replaces) is proposed in freenet-core#3972. Simpler than a separate
CancelWakeupand covers "next weekly rotation for room X". Flag if a future delegate needs both semantics.Stdlib bump cost: Phase 3 will trigger a delegate migration entry plus coordinated
riverctlrebuild per the existing pre-release smoke-test checklist.Related
[AI-assisted - Claude]