Conversation
`riverctl message send --signing-key <owner-key>` always failed for the
room's own owner with "Signing key is not a current member of this room",
telling them to go accept an invitation to a room they own.
The guard in `send_message_with_key` tested membership by scanning
`room_state.members.members`, but the contract REFUSES a members list
containing the owner (`MembersV1::verify`: "Owner should not be included
in the members list") — owner membership rides on
`ChatRoomParametersV1::owner` instead. So `is_member` was structurally
always false for the owner, and `build_rejoin_delta` early-returns `None`
for them ("Owner doesn't need to re-add"), satisfying both halves of the
guard.
Extract the decision into `room_has_member_key` / `authorize_send` so it
is unit-testable without a live node (the pattern `rejoin_preferred_nickname`
already uses in this file), and count the owner as a member.
The owner exemption compares the full 32-byte `VerifyingKey` against the
room's own owner key — the key the contract key commits to via
`owner_vk_to_contract_key` — NOT the `MemberId`, which is a
non-cryptographic `fast_hash`. It is therefore satisfiable only by a
caller holding the owner's private key, and is unreachable from
attacker-controlled room state.
Closes #441
[AI-assisted - Claude]
Review (testing lens) found the source-scrape pin banned two specific expressions, which a guard written with a differently-named variable would walk straight past — the exact failure mode the pin exists to stop. Pin the COUNT of raw `members.members` scans in production api.rs (2: the owner-aware `room_has_member_key`, and `build_rejoin_delta`'s already-a-member check) and assert `build_rejoin_delta`'s owner early-return still precedes its scan, which is what makes that second site owner-safe. Verified by mutation: injecting a third scan under a fresh variable name, deleting the owner early-return, and making `authorize_send` allow everything are each caught. [AI-assisted - Claude]
The parameter was renamed `key` -> `candidate` during review; the doc comment still referred to the old name. [AI-assisted - Claude]
Multi-lens reviewThree distinct adversarial lenses, run serially, each reading the checked-out code rather than the description. Per the project's review rule this change is Full tier — it touches an authorization path. Claude lenses only; no external models (opt-in per Lens 1 — code-first (read the implementation before the issue text)
Lens 2 — security / authorization (the lens for this change)Each question answered against the code, not by assertion.
Conclusion: no authorization hole. The change strictly widens a client-side pre-flight by one condition that requires the owner's private key, on a path where the contract independently enforces authorization. Lens 3 — testing (coverage gaps, vacuous tests)
Finish-the-fix auditEvery other site that could carry the implicit-owner blind spot was checked; Findings statusAll findings are fixed (1.5, 1.7, 3.2) or dismissed with a specific reason (1.3, 1.4, 1.6, 2.9). Nothing outstanding. [AI-assisted - Claude] |
…#546) * test(cli): close three holes in the #441 membership-guard source pins Independent review of #541 found the pins did not hold. All three were confirmed by executing the mutation against the full riverctl lib suite, which stayed green (294 passed) in every case. 1. The call-site needle stopped at a comma, before `)?;`. Two mutations survived it: `let _ = authorize_send(...)` discards the authorization decision entirely (and silences `unused_must_use`, with no `-D warnings` in CI to catch it), and `authorize_send(..., None)` compiles via inference while silently breaking the inactivity-rejoin path so a pruned member with valid stored credentials can no longer send. The needle now runs through `members_delta.as_ref()` and the `?;`, accepting both trailing-comma spellings so a future rustfmt cannot false-fail it. 2. The scan count was method-sensitive, not just name-sensitive: it matched only `member_vk ==` and missed `.any(|m| m.member.id() == ...)`, which is equally owner-blind, is the more common idiom in this codebase (dm.rs, debug.rs), and has `sender_member_id` already in scope at the call site. Both idioms are now counted. The previous commit message framed this class as closed; it was not. Also, `production` was everything before the test module, which included 15 mid-file `#[cfg(test)]` modules, so an unrelated future test could trip the count. `production_source` now strips test modules line-wise (column-0 markers, no lexer — brace matching would have to handle 43 raw strings correctly). 3. The `authorize_send` docstring claimed `MessagesV1::verify` backs the guard contract-side. It does not: `update_state` runs `apply_delta`, never `verify`. What screens a message on the update path is `apply_delta`'s MemberId-based author retain, with no signature check; `verify` runs from `validate_state`. Corrected. The owner branch is unaffected — it is self-enforcing. The pins are now pure functions over source text, so they are themselves tested: six `pin_catches_*` meta-tests feed mutated source through `membership_guard_violations` and assert it objects. A pin nobody has watched fail is not a pin, which is exactly how these three got shipped. Verified end-to-end: all six mutations (the three above plus call-site re-inlining, argument swap, and reverting the owner clause) now turn the suite red. Refs #441 [AI-assisted - Claude] * test(cli): make the #441 pins detect a disarmed guard, not just a changed one Round-3 review found the round-2 pins were not robust. All three findings reproduced by execution before fixing. F1 — a guard COMMENTED OUT IN PLACE satisfied the needle: the commented text still contains the call verbatim. The suite did go red, but only because the meta-tests' `str::replace` target had gone stale, so the mutation no-opped and they failed on an empty violation list. That is protection by accident — any later edit to `REAL_CALL` removes it, and the pin proper stayed green throughout. Comment lines are now stripped before the search. F2 — the needle searched the whole file, so lifting the guard out of `send_message_with_key` into an unrelated sibling left the pin green while the send path ran unguarded. The search is now scoped to the send path's own method body. Q2 — `production_source` assumed every `#[cfg(test)]` guards a braced item. A `#[cfg(test)] use ...;` made it skip to the next column-0 `}`, eating the production code between. A whole owner-blind helper planted in that window went undetected with the full suite green: a pin silently ceasing to cover production. The stripper is now a string-aware brace matcher rather than a line scan. Two corrections behind that: the review disproved the stated reason for avoiding brace matching (a raw-string-aware matcher produces byte-identical output on this file), and then the line scan was disproved outright — a meta-test added here embeds Rust source containing a column-0 `}` inside a string literal, which the line scan read as the module's closing brace, spilling test code into the production scan. Line scanning was never safer; it had a different bug. Item kind is now determined (braced item vs terminated statement) instead of assumed. F3 — widened to `.find`/`.position` and swapped-operand spellings, and the comment no longer claims robustness it does not have. Deliberately still excludes `HashSet::contains` and `members_by_member_id()`: those appear here but ask a different question (`build_rejoin_delta`'s invite-chain filter asks which members to ADD, not whether the sender may send), so counting them would trip on unrelated code and train people to bump the number. The pins are now documented as tripwires for known-bad shapes, not proofs of absence. Also fixed a meta-test bluntness the review flagged: the scan-planting tests anchored on a comment shared verbatim by all seven send/edit/ delete/react/unreact/reply paths, so `str::replace` planted seven copies while reading as though it planted one. They now anchor on the guard call, pinned unique by `guard_call_anchor_is_unique_in_production`. All nine mutations now turn the suite red, six of them via the pin proper rather than a meta-test side effect: `let _ =`, `None`, commented-out, moved-to-sibling, hidden-behind-cfg-test, MemberId idiom, reverted owner clause, swapped args, deleted early-return. Refs #441 [AI-assisted - Claude]
Problem
riverctl message send <room> <msg> --signing-key <owner-key>(orRIVER_SIGNING_KEYset to the owner's key) always failed for the room's own owner:The message also actively misdirects — it tells a room's owner to go accept an invitation to their own room.
Root cause (verified against the contract, not inferred)
The guard in
send_message_with_keytested membership by scanningroom_state.members.members. The owner is structurally absent from that list:MembersV1::verify(common/src/room_state/member.rs:59-66) rejects a members list containing the owner —— because owner membership rides on
ChatRoomParametersV1::ownerinstead. Sois_memberwas alwaysfalsefor the owner. Andbuild_rejoin_deltaearly-returns(None, None)for the owner ("Owner doesn't need to re-add"), somembers_deltawasNonetoo. Both halves ofif !is_member && members_delta.is_none()were satisfied on every owner send.This is specific to the explicit-signing-key path; the storage path (
send_message) never ran this guard.Approach
Count the owner as a member, and make the decision testable.
The one-line fix the issue suggested is correct but would have landed untested: the guard sits inside an async method that needs a live WebSocket node. So the decision is extracted into two pure helpers —
room_has_member_keyandauthorize_send— following the pattern already used in this file forrejoin_preferred_nickname("lives inrejoin_preferred_nicknameso it is unit-testable without a node connection").CLI-only. No contract, WASM, signed-struct, or
verify/apply_deltachange — so no delegate-migration ritual is involved.git diff --stattouchescli/src/api.rsalone.Why this opens no authorization hole
sender_vk == room_owner_key, comparing the full 32-byteVerifyingKey(ed25519_dalek'sPartialEqcomparesas_bytes()).sender_vkis derived from the caller's own private key, so the clause is satisfiable only by someone holding the owner's private key. It is not a client-supplied claim.MemberIdcomparison.MemberIdisfast_hash(hash = hash*31 + byteoveri64), which is not collision-resistant; using it here would have been the weaker primitive.room_owner_keyis not a free-floating assertion of role — it selects the room.owner_vk_to_contract_keyderives the contract key fromChatRoomParametersV1 { owner }+ code hash, so passing a different owner key addresses a different contract. There is no cross-room escalation.MessagesV1::verifyvalidates an owner-authored message's signature againstparameters.owner, andapply_deltaretains a message only when its author is a listed member or the owner.is_ban_authorizedrefusestarget == owner_idoutright.build_message_bodyruns before the guard and still errors when no room secret is available, so the owner cannot send plaintext into a private room.Sibling explicit-key paths (finish-the-fix audit)
I audited every other site that could carry the same implicit-owner blind spot.
send_message_with_keywas the only one. Reporting this as an audit result rather than inventing changes:edit_message,delete_message,add_reaction,remove_reaction,send_reply,set_nicknamebuild_rejoin_deltaand use its result, so the owner passes through unharmed. Not instances of this bug.dm.rssend +room_has_membermember_id == owner_id || ...).identity.rsexportis_owner.private_room.rs::build_member_info_healdeputies.rsmembers.members".build_rejoin_deltaTesting
Nine tests in
cli/src/api.rs::authorize_send_tests. Every claim below was verified by actually applying the mutation and re-running, not by inspection:room_has_member_key(i.e. revert the fix)owner_may_send_although_members_list_never_lists_them,room_has_member_key_counts_the_owner_and_listed_members_onlysend_paths_delegate_the_membership_guard_to_authorize_sendapply_delta's author retainowners_message_survives_apply_delta_with_no_membership_deltaauthorize_sendallow everything (authorization removed entirely)stranger_without_rejoin_credentials_is_rejected,owner_exemption_is_not_reachable_from_attacker_controlled_statesend_paths_delegate_the_membership_guard_to_authorize_sendbuild_rejoin_delta's owner early-returnsend_paths_delegate_the_membership_guard_to_authorize_sendNotable coverage choices:
owner_may_send_although_members_list_never_lists_themfirst asserts the owner is absent from the fixture's members list, so fixture drift cannot silently make it vacuous.owners_message_survives_apply_delta_with_no_membership_deltabuilds the delta exactly as the owner path does (members: None,member_info: None), applies it, and asserts the message actually lands inrecent_messages— passing the guard but having the contract drop the message would have shipped a send that reports success and delivers nothing.owner_exemption_is_not_reachable_from_attacker_controlled_statestuffs it with a members entry carrying the owner's key and a forged ownermember_info, then asserts the attacker and 34 other keys are all still refused.members.membersscans (2, both owner-safe) and assertsbuild_rejoin_delta's owner early-return still precedes its scan. The mutation table above confirms the rename case is now caught.#[cfg(test)]would have cut thesend_message_with_keycall site out of the scraped text and left the pin permanently green; the test asserts the split landed correctly in both directions.Honest coverage gaps
riverctl message sendcommand itself — it needs a live node and there is no harness for it here. Coverage is the guard decision (unit) + call-site delegation (source pin) + contract acceptance (unit), which spans the whole causal chain except the WebSocket plumbing the bug never involved.contract_rejects_an_owner_entry_in_memberspins ariver-coreinvariant this fix depends on, not CLI code. It is deliberate: if the contract ever permitted owner entries, this special case would deserve a rethink.cargo fmtclean;cargo test -p riverctl -p river-coregreen (284 riverctl lib tests + the river-core suites); no new clippy warnings (the six inriverctlare all pre-existing and outside this diff).Note for a follow-up, not fixed here
MemberIdisfast_hash— a Java-stylehash*31 + byterolling hash overi64, which is trivially collidable algebraically, despite the comment atcommon/src/room_state/member.rs:692-694claiming a collision would take "3 * 10^59 years". Nothing in this PR depends on it (the owner check deliberately compares full verifying keys), and the contract's signature checks mean a collision yields confusion rather than forgery — but the comment is wrong and the primitive is weaker than it looks. Changing it is wire-format/contract territory needing its own review and sign-off, so it is deliberately out of scope here.Closes #441
[AI-assisted - Claude]