Skip to content

feat(cli): --signing-key-file flag overrides signing identity per command#282

Merged
sanity merged 2 commits into
mainfrom
feat/cli-signing-key-override
May 18, 2026
Merged

feat(cli): --signing-key-file flag overrides signing identity per command#282
sanity merged 2 commits into
mainfrom
feat/cli-signing-key-override

Conversation

@sanity
Copy link
Copy Markdown
Contributor

@sanity sanity commented May 18, 2026

Problem

`rooms.json` only stores ONE `signing_key_bytes` per room, but this machine often has multiple identities for the same room (room owner, invite bot, alt accounts). The UI's chat-delegate sync periodically overwrites `rooms.json[room].signing_key_bytes` with whatever the delegate has stored — silently leaving owner ops (announcements, bans, room config edits) broken without a manual swap of the on-disk key.

The current mitigation in the `river-official-room` skill is: stash the existing `rooms.json`, copy the room-owner backup key into it, run the command, optionally swap back. Fragile, recurring, and easy to forget.

Approach

Add a top-level `--signing-key-file ` flag (with `RIVER_SIGNING_KEY_FILE` env var fallback) that reads a raw 32-byte Ed25519 secret key and uses it in place of the room's stored `signing_key_bytes` for the current command. The override is in-memory only; `rooms.json` on disk is never modified.

Implementation:

  • `Storage::new_with_override` accepts an optional `SigningKey`. Stored on the `Storage` struct.
  • `Storage::resolve_signing_key(stored_bytes)` returns the override if present, else `SigningKey::from_bytes(stored_bytes)`.
  • `Storage::get_room` uses the resolver, so every command that pulls a signing key from storage gets the override transparently.
  • `ApiClient::ensure_room_migrated` (which has its own `load_rooms` snapshot) uses the same resolver.
  • `ApiClient::new_with_signing_key_override` threads the override from CLI to Storage.
  • `main.rs` parses the flag, reads the file (rejects non-32-byte payloads with a clear error message), constructs the `SigningKey`, and passes it through.

Distinct from `message send --signing-key` which takes a base64-encoded inline key — the global file-based flag is preferred for non-test use because the key doesn't appear in shell history.

Testing

Unit test `signing_key_override_is_returned_and_not_persisted` in `cli/src/storage.rs` pins the contract: with override, `get_room` returns the override; without override (fresh Storage from same data dir), the original stored bytes come back — proving the override is not written back to `rooms.json`.

Manual end-to-end: sent the river#275/#276/#278 release announcement to the official Freenet River room as Room Owner via `--signing-key-file ~/.config/freenet-river-official/room_owner_signing_key.bin`, no `rooms.json` swap. Confirmed via `riverctl message list` that the message landed signed by Room Owner. This is the use case that motivated the PR.

Notes

[AI-assisted - Claude]

sanity and others added 2 commits May 17, 2026 20:00
…mand

Adds a top-level `--signing-key-file <PATH>` flag (with
`RIVER_SIGNING_KEY_FILE` env var fallback) that reads a raw 32-byte
Ed25519 secret key and uses it in place of the room's stored
`signing_key_bytes` for the current command. The override is
in-memory only; `rooms.json` on disk is never modified.

# Why

`rooms.json` only stores ONE `signing_key_bytes` per room, but this
machine often has multiple identities for the same room (room owner,
invite bot, alt accounts). The UI's chat-delegate sync periodically
overwrites `rooms.json[room].signing_key_bytes` with whatever the
delegate has stored — silently leaving owner ops broken without a
manual swap of the on-disk key.

Pattern as documented in `river-official-room` skill: swap key → run
command → optionally swap back. Fragile and recurring. The flag
formalizes the "I have multiple identities, pick at command time"
model: nominate the right identity per command, no rooms.json
mutation, the existing identity stays loaded.

Distinct from `message send --signing-key` which takes a base64-
encoded inline key — the global file-based flag is preferred for
non-test use because the key doesn't appear in shell history.

# Tests

- `signing_key_override_is_returned_and_not_persisted` in
  `cli/src/storage.rs` pins the contract: with override, `get_room`
  returns the override; without override (fresh Storage), the
  ORIGINAL stored bytes come back — proving the override is not
  written back to rooms.json.

# Manual verification

Sent the river#275/#276/#278 release announcement to the official
Freenet River room as Room Owner via `--signing-key-file
~/.config/freenet-river-official/room_owner_signing_key.bin`, no
rooms.json swap. Confirmed via `riverctl message list` that the
message landed signed by Room Owner.

# Follow-up

#281 tracks the "perfect world" decentralized-version-pointer
follow-up (a `freenet-updates` crate that uses a Freenet contract
for tooling version checks). Separate PR.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Skeptical HIGH-1: identity export under `--signing-key-file` could
produce a wire-format-corrupted token. When the override resolves to
a different identity than the cached `self_authorized_member` in
rooms.json, the bundled IdentityExport had a signing_key whose
verifying_key() did NOT match authorized_member.member.member_vk.
Importing such a token would silently fail every subsequent contract
operation.

Fix: validate `signing_key.verifying_key() == authorized_member.member.member_vk`
just before constructing IdentityExport; error out cleanly with a
message pointing at the override as the likely cause if they
disagree. The owner-self-signed path (line 74-81) and the
network-lookup path (line 82-113) already produce coherent pairs;
only the cached-from-disk path could mismatch.

Code-first/skeptical Important: doc-comment drift on
`Storage::signing_key_override` field said `--signing-key` /
`RIVER_SIGNING_KEY` but the actual flag/env are `--signing-key-file`
/ `RIVER_SIGNING_KEY_FILE`. Fixed both the field doc and the test
docstring so a future grep for the right names actually hits this
code.

Code-first Important: the file-loader error message referenced
`riverctl identity export` as if it produced raw 32-byte output. It
doesn't — it produces an armored multi-field token. Clarified the
error to point at the room-key .bin backup format (which IS raw 32
bytes) and explicitly contrast with `identity export`'s armored
shape.

Skeptical Important #5: added unit tests for the file loader's
length validation. Extracted `parse_signing_key_bytes` as a pure
helper so the tests don't need a tempfile. Tests cover: 32-byte
accept, short reject (with byte-count in message), long reject
(with base64 hint in message), empty reject.

Other Important items (skeptical #2 info-log on override, #3
file-permissions warning, #5 env-vs-flag precedence test) are
documented for follow-up but not blocking — the override-mismatch
fix is the load-bearing correctness item from this review round.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@sanity sanity merged commit 92a0c96 into main May 18, 2026
5 checks passed
@sanity sanity deleted the feat/cli-signing-key-override branch May 18, 2026 14:57
sanity added a commit that referenced this pull request May 18, 2026
- #282 (--signing-key-file flag): fixes recurring rooms.json drift
  for owner ops on the same machine.
- #286 (UI polish bundle): #283 auto-scroll regression, #284 sync-
  window placeholder UX, #267 same-second DM revive, #279 processed-
  invite gate, #280 banned-card state.

riverctl 0.1.61 published to crates.io alongside.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.

1 participant