Skip to content

Commit 92a0c96

Browse files
sanityclaude
andauthored
feat(cli): --signing-key-file flag overrides signing identity per command (#282)
* feat(cli): --signing-key-file flag overrides signing identity per command 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> * fix(cli): address PR #282 review findings 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> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent fe4728b commit 92a0c96

4 files changed

Lines changed: 259 additions & 5 deletions

File tree

cli/src/api.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@ impl ApiClient {
6464
}
6565

6666
pub async fn new(node_url: &str, config: Config, config_dir: Option<&str>) -> Result<Self> {
67+
Self::new_with_signing_key_override(node_url, config, config_dir, None).await
68+
}
69+
70+
/// Construct an [`ApiClient`] with an optional in-memory signing-key
71+
/// override. The override is propagated to [`Storage`] so every
72+
/// `get_room` resolves the signing key from the override rather than
73+
/// the per-room `signing_key_bytes`. See [`Storage::signing_key_override`]
74+
/// for the motivating scenario.
75+
pub async fn new_with_signing_key_override(
76+
node_url: &str,
77+
config: Config,
78+
config_dir: Option<&str>,
79+
signing_key_override: Option<SigningKey>,
80+
) -> Result<Self> {
6781
// Use the URL as provided - it should already be in the correct format
6882
info!("Connecting to Freenet node at: {}", node_url);
6983

@@ -77,7 +91,7 @@ impl ApiClient {
7791
// Create WebApi instance
7892
let web_api = WebApi::start(ws_stream);
7993

80-
let storage = Storage::new(config_dir)?;
94+
let storage = Storage::new_with_override(config_dir, signing_key_override)?;
8195

8296
Ok(Self {
8397
web_api: Arc::new(Mutex::new(web_api)),
@@ -866,7 +880,9 @@ impl ApiClient {
866880
}
867881
};
868882

869-
let signing_key = SigningKey::from_bytes(&room_info.signing_key_bytes);
883+
let signing_key = self
884+
.storage
885+
.resolve_signing_key(&room_info.signing_key_bytes);
870886
let room_state = room_info.state.clone();
871887
let previous_contract_key_str = room_info.previous_contract_key.clone();
872888

cli/src/commands/identity.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,29 @@ async fn export_identity(
133133
Err(_) => (None, None), // Network unavailable; export without extras
134134
};
135135

136+
// Wire-format safety check. The export's signing_key MUST match the
137+
// authorized_member.member.member_vk; otherwise importing the token
138+
// produces an identity whose secret key signs nothing the room
139+
// contract accepts.
140+
//
141+
// This catches the case where `--signing-key-file` overrides the
142+
// signing identity but `self_authorized_member` is read from
143+
// `rooms.json` (which still has the previous identity's
144+
// `AuthorizedMember`). The two pieces would otherwise be packaged
145+
// together with mismatched verifying keys, silently breaking the
146+
// imported identity on first use.
147+
if signing_key.verifying_key() != authorized_member.member.member_vk {
148+
return Err(anyhow!(
149+
"Refusing to export an identity with mismatched signing key and \
150+
authorized member. The signing key's verifying key does not match \
151+
the cached AuthorizedMember.member_vk for this room. This usually \
152+
happens when `--signing-key-file` / `RIVER_SIGNING_KEY_FILE` overrides \
153+
the signing identity but `rooms.json` still holds another identity's \
154+
cached membership state. Re-run without the override (or with the \
155+
override pointing at THIS identity) to produce a coherent token."
156+
));
157+
}
158+
136159
let export = IdentityExport {
137160
room_owner: room_owner_key,
138161
signing_key,

cli/src/main.rs

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
use anyhow::{Context, Result};
1+
use anyhow::{anyhow, Context, Result};
22
use clap::{Parser, Subcommand};
3+
use ed25519_dalek::SigningKey;
34
use std::path::{Path, PathBuf};
45
use tracing::info;
56
use tracing_appender::non_blocking::WorkerGuard;
@@ -43,6 +44,33 @@ struct Cli {
4344
/// Optional path to write log output (stdout remains reserved for command output/JSON)
4445
#[arg(long, global = true, value_name = "PATH", env = "RIVERCTL_LOG_FILE")]
4546
log_file: Option<PathBuf>,
47+
48+
/// Override the signing identity for this command only. Reads a raw
49+
/// 32-byte Ed25519 secret key from the given file path.
50+
///
51+
/// The override is in-memory: it does NOT modify `rooms.json`. Use
52+
/// this when you have multiple identities in the same room (e.g.,
53+
/// room owner + invite bot + alt accounts) and want to pick which
54+
/// one signs at command time, without the UI's chat-delegate sync
55+
/// silently rewriting `rooms.json[room].signing_key_bytes` away
56+
/// from your intended identity. The override key must be a valid
57+
/// member of the target room or the command will be rejected by
58+
/// the contract.
59+
///
60+
/// Falls back to the `RIVER_SIGNING_KEY_FILE` env var if the flag
61+
/// is not passed.
62+
///
63+
/// Distinct from `message send --signing-key`, which takes a
64+
/// base64-encoded key inline as a single-command override — the
65+
/// global `--signing-key-file` flag is preferred for non-test use
66+
/// because the key doesn't appear in shell history.
67+
#[arg(
68+
long,
69+
global = true,
70+
value_name = "PATH",
71+
env = "RIVER_SIGNING_KEY_FILE"
72+
)]
73+
signing_key_file: Option<PathBuf>,
4674
}
4775

4876
#[derive(Subcommand)]
@@ -96,8 +124,21 @@ async fn main() -> Result<()> {
96124
// Load configuration
97125
let config = config::Config::load()?;
98126

127+
// Resolve optional --signing-key-file override (or RIVER_SIGNING_KEY_FILE env var).
128+
let signing_key_override = cli
129+
.signing_key_file
130+
.as_deref()
131+
.map(load_signing_key_from_file)
132+
.transpose()?;
133+
99134
// Create API client
100-
let api_client = api::ApiClient::new(&cli.node_url, config, cli.config_dir.as_deref()).await?;
135+
let api_client = api::ApiClient::new_with_signing_key_override(
136+
&cli.node_url,
137+
config,
138+
cli.config_dir.as_deref(),
139+
signing_key_override,
140+
)
141+
.await?;
101142

102143
// Execute command
103144
match cli.command {
@@ -115,6 +156,75 @@ async fn main() -> Result<()> {
115156
Ok(())
116157
}
117158

159+
/// Load a raw 32-byte Ed25519 secret key from the given file path.
160+
/// Used by the `--signing-key-file` flag / `RIVER_SIGNING_KEY_FILE` env var.
161+
/// Errors are surfaced with a clear message identifying the bad path
162+
/// and the actual length seen, so the user can tell "I pointed at the
163+
/// wrong file" from "I pointed at a base64-encoded file".
164+
fn load_signing_key_from_file(path: &Path) -> Result<SigningKey> {
165+
let bytes = std::fs::read(path)
166+
.with_context(|| format!("failed to read signing key file: {}", path.display()))?;
167+
parse_signing_key_bytes(&bytes)
168+
.map_err(|reason| anyhow!("{} — file: {}", reason, path.display()))
169+
}
170+
171+
/// Pure helper for testing the wrong-length / right-length validation
172+
/// without touching the filesystem.
173+
fn parse_signing_key_bytes(bytes: &[u8]) -> std::result::Result<SigningKey, String> {
174+
if bytes.len() != 32 {
175+
return Err(format!(
176+
"signing key must be exactly 32 raw bytes, got {} bytes \
177+
(was this file base64- or hex-encoded? the override expects raw \
178+
bytes — the same format as the room-key backups under \
179+
~/.config/freenet-river-official/*.bin; NOT the armored output of \
180+
`riverctl identity export`, which is a larger multi-field token)",
181+
bytes.len()
182+
));
183+
}
184+
let mut buf = [0u8; 32];
185+
buf.copy_from_slice(bytes);
186+
Ok(SigningKey::from_bytes(&buf))
187+
}
188+
189+
#[cfg(test)]
190+
mod cli_tests {
191+
use super::*;
192+
193+
#[test]
194+
fn parse_signing_key_bytes_accepts_32_byte_input() {
195+
let raw = [7u8; 32];
196+
let sk = parse_signing_key_bytes(&raw).expect("32 raw bytes is valid");
197+
assert_eq!(sk.to_bytes(), raw);
198+
}
199+
200+
#[test]
201+
fn parse_signing_key_bytes_rejects_short_input() {
202+
let raw = [7u8; 16];
203+
let err = parse_signing_key_bytes(&raw).expect_err("must reject short input");
204+
assert!(err.contains("32 raw bytes"), "msg: {}", err);
205+
assert!(err.contains("16 bytes"), "msg: {}", err);
206+
}
207+
208+
#[test]
209+
fn parse_signing_key_bytes_rejects_long_input() {
210+
// 44-byte base64-encoded 32-byte key (the most common user mistake)
211+
let raw = [b'a'; 44];
212+
let err = parse_signing_key_bytes(&raw).expect_err("must reject long input");
213+
assert!(
214+
err.contains("base64"),
215+
"must hint at base64 mistake: {}",
216+
err
217+
);
218+
}
219+
220+
#[test]
221+
fn parse_signing_key_bytes_rejects_empty() {
222+
let raw: [u8; 0] = [];
223+
let err = parse_signing_key_bytes(&raw).expect_err("must reject empty input");
224+
assert!(err.contains("0 bytes"), "msg: {}", err);
225+
}
226+
}
227+
118228
fn init_logging(debug: bool, log_path: Option<&Path>) -> Result<Option<WorkerGuard>> {
119229
use std::fs::OpenOptions;
120230
use tracing_subscriber::{fmt, layer::SubscriberExt, Registry};

cli/src/storage.rs

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,33 @@ pub struct Storage {
4343
/// Side file so the larger `rooms.json` blob stays untouched on
4444
/// each DM send. JSON-serialized [`OutboundDmStore`].
4545
outbound_dms_path: PathBuf,
46+
/// In-memory signing-key override (from `--signing-key-file` flag or
47+
/// `RIVER_SIGNING_KEY_FILE` env var). When set, every call to
48+
/// [`Storage::get_room`] returns this key in place of the room's
49+
/// stored `signing_key_bytes`. Never written back to disk — the
50+
/// override is a per-command-invocation thing.
51+
///
52+
/// Motivates: this machine often has multiple identities for the
53+
/// same room (room owner, invite bot, test alts), but `rooms.json`
54+
/// only stores ONE `signing_key_bytes` per room. The UI's chat-
55+
/// delegate sync can silently rewrite that field — leaving owner
56+
/// ops broken without a manual swap. The override lets owner ops
57+
/// nominate the right identity at command time without touching
58+
/// `rooms.json`. See discussion on river#281.
59+
signing_key_override: Option<SigningKey>,
4660
}
4761

4862
impl Storage {
4963
pub fn new(config_dir: Option<&str>) -> Result<Self> {
64+
Self::new_with_override(config_dir, None)
65+
}
66+
67+
/// Construct a [`Storage`] with an optional in-memory signing-key
68+
/// override. See the field doc on [`Storage::signing_key_override`].
69+
pub fn new_with_override(
70+
config_dir: Option<&str>,
71+
signing_key_override: Option<SigningKey>,
72+
) -> Result<Self> {
5073
// Use provided config_dir, then check environment variable, then use default
5174
let data_dir = if let Some(dir) = config_dir {
5275
PathBuf::from(dir)
@@ -67,9 +90,23 @@ impl Storage {
6790
Ok(Self {
6891
storage_path,
6992
outbound_dms_path,
93+
signing_key_override,
7094
})
7195
}
7296

97+
/// Resolve the signing key to use for the current command: prefer
98+
/// the in-memory override if set, otherwise reconstruct from the
99+
/// per-room `signing_key_bytes`. Used by both [`Storage::get_room`]
100+
/// and [`crate::api::ApiClient::ensure_room_migrated`] (which has
101+
/// its own load_rooms snapshot for migration purposes).
102+
pub fn resolve_signing_key(&self, stored_bytes: &[u8; 32]) -> SigningKey {
103+
if let Some(override_key) = &self.signing_key_override {
104+
override_key.clone()
105+
} else {
106+
SigningKey::from_bytes(stored_bytes)
107+
}
108+
}
109+
73110
pub fn load_rooms(&self) -> Result<RoomStorage> {
74111
if !self.storage_path.exists() {
75112
return Ok(RoomStorage::default());
@@ -183,7 +220,7 @@ impl Storage {
183220
let owner_key_str = bs58::encode(owner_vk.as_bytes()).into_string();
184221

185222
if let Some(room_info) = storage.rooms.get(&owner_key_str) {
186-
let signing_key = SigningKey::from_bytes(&room_info.signing_key_bytes);
223+
let signing_key = self.resolve_signing_key(&room_info.signing_key_bytes);
187224
Ok(Some((
188225
signing_key,
189226
room_info.state.clone(),
@@ -470,4 +507,72 @@ mod tests {
470507
"previous_contract_key should be None when WASM hasn't changed"
471508
);
472509
}
510+
511+
/// `--signing-key-file` / `RIVER_SIGNING_KEY_FILE` override: `Storage::get_room`
512+
/// must return the override key in place of the room's stored
513+
/// `signing_key_bytes`, AND the override must NOT be written back to
514+
/// `rooms.json`. This pins the "in-memory only" contract documented
515+
/// on `Storage::signing_key_override` — the persistent on-disk
516+
/// identity must be untouched, so subsequent invocations without
517+
/// the override revert to the stored identity.
518+
#[test]
519+
fn signing_key_override_is_returned_and_not_persisted() {
520+
let temp_dir = TempDir::new().unwrap();
521+
let stored_sk = create_test_signing_key();
522+
let override_sk = create_test_signing_key();
523+
assert_ne!(
524+
stored_sk.to_bytes(),
525+
override_sk.to_bytes(),
526+
"test invariant: stored and override keys must differ"
527+
);
528+
let owner_vk = stored_sk.verifying_key();
529+
530+
// Set up storage with the stored identity (no override).
531+
let storage_no_override = Storage::new(Some(temp_dir.path().to_str().unwrap())).unwrap();
532+
let state = create_test_state(&stored_sk);
533+
let initial_key = expected_contract_key(&owner_vk);
534+
storage_no_override
535+
.add_room(&owner_vk, &stored_sk, state, &initial_key)
536+
.unwrap();
537+
538+
// Sanity: without override, get_room returns the stored key.
539+
let (sk_no_override, _, _) = storage_no_override
540+
.get_room(&owner_vk)
541+
.unwrap()
542+
.expect("room present");
543+
assert_eq!(
544+
sk_no_override.to_bytes(),
545+
stored_sk.to_bytes(),
546+
"no override → stored key"
547+
);
548+
549+
// With override, get_room returns the override.
550+
let storage_with_override = Storage::new_with_override(
551+
Some(temp_dir.path().to_str().unwrap()),
552+
Some(override_sk.clone()),
553+
)
554+
.unwrap();
555+
let (sk_with_override, _, _) = storage_with_override
556+
.get_room(&owner_vk)
557+
.unwrap()
558+
.expect("room present");
559+
assert_eq!(
560+
sk_with_override.to_bytes(),
561+
override_sk.to_bytes(),
562+
"override → override key returned"
563+
);
564+
565+
// Critical: rooms.json on disk is untouched. A fresh Storage
566+
// without the override must see the ORIGINAL stored bytes.
567+
let storage_fresh = Storage::new(Some(temp_dir.path().to_str().unwrap())).unwrap();
568+
let (sk_fresh, _, _) = storage_fresh
569+
.get_room(&owner_vk)
570+
.unwrap()
571+
.expect("room present");
572+
assert_eq!(
573+
sk_fresh.to_bytes(),
574+
stored_sk.to_bytes(),
575+
"override must NOT be written back to rooms.json"
576+
);
577+
}
473578
}

0 commit comments

Comments
 (0)