Skip to content

feat: re-enable private rooms with delegate-driven rotation #228

Description

@sanity

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:

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:

  1. Contract notification on membership change (existing primitive: SubscribeContractRequest + ContractNotification).
  2. Scheduled wakeup for weekly rotation (new primitive, tracked at feat: scheduled wakeup primitive for delegates (ScheduleWakeup / WakeupFired) freenet-core#3972).

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:

secret_v_n = HKDF(
    ikm:  owner_signing_key_seed,    // 32-byte ed25519 seed
    salt: room_owner_vk.as_bytes(),  // domain separation per room
    info: b"river-rotate-v1" || n_le_bytes,
).expand(32)

Concurrent rotations on multiple devices produce byte-identical secret_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

Tracked at freenet/freenet-core#3972. Adds:

  • OutboundDelegateMsg::ScheduleWakeup { at: SystemTime, tag: Vec<u8> }
  • InboundDelegateMsg::WakeupFired { tag: Vec<u8> }
  • 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

  1. 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.

  2. 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.

  3. 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.

[AI-assisted - Claude]

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions