fix(cli): sign and encrypt sidebar events through Credentials

main moved BuzzClient to Credentials: no `keys()`, an async `sign_event`,
and NIP-44 through `Credentials::nip44_encrypt` and `nip44_decrypt`. The
sidebar sync and its tests now use those, so a signer without a local key
(desktop provider) reads and writes the sidebar documents too.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Blake McAnally <bmcanally@squareup.com>
This commit is contained in:
Blake McAnally
2026-11-05 19:14:39 -06:00
parent dbf5c06b74
commit 7fe3e6f9c3
4 changed files with 34 additions and 21 deletions

View File

@@ -204,6 +204,7 @@ mod tests {
use super::super::sidebar_sync::now; use super::super::sidebar_sync::now;
use super::super::sidebar_sync::test_support::{self, Shared}; use super::super::sidebar_sync::test_support::{self, Shared};
use super::*; use super::*;
use crate::credentials::Credentials;
use nostr::Keys; use nostr::Keys;
async fn setup() -> (BuzzClient, Shared) { async fn setup() -> (BuzzClient, Shared) {
@@ -387,7 +388,8 @@ mod tests {
assert_eq!(list["sections"][0]["name"], json!(winner)); assert_eq!(list["sections"][0]["name"], json!(winner));
// Another author's head is never read as ours. // Another author's head is never read as ours.
let other = BuzzClient::new(String::new(), Keys::generate(), None, None).unwrap(); let other =
BuzzClient::new(String::new(), Credentials::from_keys(Keys::generate())).unwrap();
seed_at(&other, &relay, &doc_with_sections(&["Theirs"]), future + 5); seed_at(&other, &relay, &doc_with_sections(&["Theirs"]), future + 5);
// An uppercase channel id is stored in desktop's canonical form. // An uppercase channel id is stored in desktop's canonical form.

View File

@@ -319,6 +319,7 @@ mod tests {
published, published_count, seed_at, setup, Shared, published, published_count, seed_at, setup, Shared,
}; };
use super::*; use super::*;
use crate::credentials::Credentials;
use nostr::Keys; use nostr::Keys;
const C1: &str = "11111111-1111-4111-8111-111111111111"; const C1: &str = "11111111-1111-4111-8111-111111111111";
@@ -468,7 +469,8 @@ mod tests {
"{refused}" "{refused}"
); );
let (client, relay) = setup().await; let (client, relay) = setup().await;
let other = BuzzClient::new(String::new(), Keys::generate(), None, None).unwrap(); let other =
BuzzClient::new(String::new(), Credentials::from_keys(Keys::generate())).unwrap();
seed(&other, &relay, &MUTES, json!({"version": 1}), 10); seed(&other, &relay, &MUTES, json!({"version": 1}), 10);
relay.lock().unwrap().leaky = true; relay.lock().unwrap().leaky = true;
let refused = run(&MUTES, set(&[C1], true), &client).await.unwrap_err(); let refused = run(&MUTES, set(&[C1], true), &client).await.unwrap_err();
@@ -480,7 +482,8 @@ mod tests {
// Another author's head is never read as ours. // Another author's head is never read as ours.
let (client, relay) = setup().await; let (client, relay) = setup().await;
let other = BuzzClient::new(String::new(), Keys::generate(), None, None).unwrap(); let other =
BuzzClient::new(String::new(), Credentials::from_keys(Keys::generate())).unwrap();
seed( seed(
&other, &other,
&relay, &relay,

View File

@@ -389,7 +389,11 @@ mod tests {
assert_eq!(doc["meta"]["g"]["dms"][0], json!(far_v + 1)); assert_eq!(doc["meta"]["g"]["dms"][0], json!(far_v + 1));
// Another author's head is never read as ours. // Another author's head is never read as ours.
let other = BuzzClient::new(String::new(), nostr::Keys::generate(), None, None).unwrap(); let other = BuzzClient::new(
String::new(),
crate::credentials::Credentials::from_keys(nostr::Keys::generate()),
)
.unwrap();
seed_sort( seed_sort(
&other, &other,
&relay, &relay,

View File

@@ -4,7 +4,6 @@
use std::time::SystemTime; use std::time::SystemTime;
use buzz_core::kind::KIND_READ_STATE; use buzz_core::kind::KIND_READ_STATE;
use nostr::nips::nip44;
use nostr::{EventBuilder, Kind, Tag, Timestamp}; use nostr::{EventBuilder, Kind, Tag, Timestamp};
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -58,7 +57,7 @@ pub(crate) async fn fetch_head<T>(
lane: Lane, lane: Lane,
decode: impl FnOnce(&Value, u64) -> Option<T>, decode: impl FnOnce(&Value, u64) -> Option<T>,
) -> Result<Head<T>, CliError> { ) -> Result<Head<T>, CliError> {
let me = client.keys().public_key(); let me = client.public_key();
let filter = json!({ let filter = json!({
"kinds": [KIND_READ_STATE], "kinds": [KIND_READ_STATE],
"authors": [me.to_hex()], "authors": [me.to_hex()],
@@ -103,8 +102,11 @@ pub(crate) async fn fetch_head<T>(
event.id.to_hex() event.id.to_hex()
)) ))
}; };
let plaintext = nip44::decrypt(client.keys().secret_key(), &me, &event.content) let plaintext = client
.map_err(|e| unreadable(e.to_string()))?; .credentials()
.nip44_decrypt(&me, &event.content)
.await?
.ok_or_else(|| unreadable("not valid ciphertext for this key".into()))?;
let json: Value = serde_json::from_str(&plaintext).map_err(|e| unreadable(e.to_string()))?; let json: Value = serde_json::from_str(&plaintext).map_err(|e| unreadable(e.to_string()))?;
let created_at = event.created_at.as_secs(); let created_at = event.created_at.as_secs();
let doc = decode(&json, created_at).ok_or_else(|| { let doc = decode(&json, created_at).ok_or_else(|| {
@@ -125,7 +127,7 @@ pub(crate) async fn publish(
plaintext: String, plaintext: String,
head_created_at: u64, head_created_at: u64,
) -> Result<Value, CliError> { ) -> Result<Value, CliError> {
let keys = client.keys(); let me = client.public_key();
let too_large = || { let too_large = || {
CliError::Usage(format!( CliError::Usage(format!(
"{} would exceed the relay's event size limit", "{} would exceed the relay's event size limit",
@@ -136,13 +138,11 @@ pub(crate) async fn publish(
if plaintext.len() > u16::MAX as usize { if plaintext.len() > u16::MAX as usize {
return Err(too_large()); return Err(too_large());
} }
let content = nip44::encrypt( let content = client
keys.secret_key(), .credentials()
&keys.public_key(), .nip44_encrypt(&me, &plaintext)
plaintext, .await
nip44::Version::V2, .map_err(|e| CliError::Other(format!("failed to encrypt {}: {e}", lane.label)))?;
)
.map_err(|e| CliError::Other(format!("failed to encrypt {}: {e}", lane.label)))?;
if content.len() > MAX_CIPHERTEXT_BYTES { if content.len() > MAX_CIPHERTEXT_BYTES {
return Err(too_large()); return Err(too_large());
} }
@@ -150,7 +150,9 @@ pub(crate) async fn publish(
let builder = EventBuilder::new(Kind::Custom(KIND_READ_STATE as u16), content) let builder = EventBuilder::new(Kind::Custom(KIND_READ_STATE as u16), content)
.tags([Tag::identifier(lane.d_tag), Tag::hashtag(lane.d_tag)]) .tags([Tag::identifier(lane.d_tag), Tag::hashtag(lane.d_tag)])
.custom_created_at(Timestamp::from(created_at)); .custom_created_at(Timestamp::from(created_at));
let raw = client.submit_event(client.sign_event(builder)?).await?; let raw = client
.submit_event(client.sign_event(builder).await?)
.await?;
let normalized = parse_write_response( let normalized = parse_write_response(
&raw, &raw,
&format!("a newer {} update won; run the command again", lane.label), &format!("a newer {} update won; run the command again", lane.label),
@@ -210,7 +212,9 @@ pub(crate) fn unchanged() -> Value {
#[cfg(test)] #[cfg(test)]
pub(crate) mod test_support { pub(crate) mod test_support {
use super::*; use super::*;
use crate::credentials::Credentials;
use axum::{body::Bytes, extract::State, routing::post, Json, Router}; use axum::{body::Bytes, extract::State, routing::post, Json, Router};
use nostr::nips::nip44;
use nostr::Keys; use nostr::Keys;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tokio::net::TcpListener; use tokio::net::TcpListener;
@@ -279,7 +283,7 @@ pub(crate) mod test_support {
pub async fn setup() -> (BuzzClient, Shared) { pub async fn setup() -> (BuzzClient, Shared) {
let relay = Shared::default(); let relay = Shared::default();
let url = fake_relay(relay.clone()).await; let url = fake_relay(relay.clone()).await;
let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); let client = BuzzClient::new(url, Credentials::from_keys(Keys::generate())).unwrap();
(client, relay) (client, relay)
} }
@@ -290,7 +294,7 @@ pub(crate) mod test_support {
plaintext: &str, plaintext: &str,
created_at: u64, created_at: u64,
) -> Value { ) -> Value {
let keys = client.keys(); let keys = client.credentials().test_keys();
let content = nip44::encrypt( let content = nip44::encrypt(
keys.secret_key(), keys.secret_key(),
&keys.public_key(), &keys.public_key(),
@@ -301,7 +305,7 @@ pub(crate) mod test_support {
let event = EventBuilder::new(Kind::Custom(KIND_READ_STATE as u16), content) let event = EventBuilder::new(Kind::Custom(KIND_READ_STATE as u16), content)
.tags([Tag::identifier(lane.d_tag)]) .tags([Tag::identifier(lane.d_tag)])
.custom_created_at(Timestamp::from(created_at)) .custom_created_at(Timestamp::from(created_at))
.sign_with_keys(keys) .sign_with_keys(&keys)
.unwrap(); .unwrap();
let event = serde_json::to_value(event).unwrap(); let event = serde_json::to_value(event).unwrap();
relay.lock().unwrap().events.push(event.clone()); relay.lock().unwrap().events.push(event.clone());
@@ -312,7 +316,7 @@ pub(crate) mod test_support {
pub fn published(client: &BuzzClient, relay: &Shared) -> (nostr::Event, Value) { pub fn published(client: &BuzzClient, relay: &Shared) -> (nostr::Event, Value) {
let raw = relay.lock().unwrap().events.last().cloned().unwrap(); let raw = relay.lock().unwrap().events.last().cloned().unwrap();
let event: nostr::Event = serde_json::from_value(raw).unwrap(); let event: nostr::Event = serde_json::from_value(raw).unwrap();
let keys = client.keys(); let keys = client.credentials().test_keys();
let plaintext = let plaintext =
nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content).unwrap(); nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content).unwrap();
(event, serde_json::from_str(&plaintext).unwrap()) (event, serde_json::from_str(&plaintext).unwrap())