add encrypted private section to group lists and channels

This commit is contained in:
2026-10-10 21:07:25 +07:00
parent ea95756637
commit f8a987003d
7 changed files with 451 additions and 248 deletions
+92 -12
View File
@@ -6,7 +6,7 @@ use anyhow::{Error, Result};
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use state::{NostrRegistry, UniversalSigner};
mod discovery;
mod group;
@@ -170,7 +170,7 @@ impl GroupsRegistry {
return;
};
self.upsert_entry(&key, None);
self.upsert_entry(&key, None, false);
self.publish_list(cx);
cx.emit(GroupsEvent::Updated);
@@ -231,6 +231,7 @@ impl GroupsRegistry {
};
let metadata = options.metadata(name.clone());
let list_private = options.list_private;
let builder = key.id().create_group(None);
let published = group.read(cx).publish(builder, cx);
let created = key;
@@ -276,7 +277,7 @@ impl GroupsRegistry {
}
this.update(cx, |this, cx| {
this.upsert_entry(&created, Some(name));
this.upsert_entry(&created, Some(name), list_private);
this.publish_list(cx);
cx.emit(GroupsEvent::Updated);
@@ -463,7 +464,10 @@ impl GroupsRegistry {
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
match client.database().query(filter).await?.into_iter().next() {
Some(event) => GroupList::parse(&event),
Some(event) => {
let list = GroupList::parse(&event)?;
Ok(Self::hydrate_list(list, &signer, &me).await)
}
None => Ok(GroupList::default()),
}
});
@@ -506,6 +510,10 @@ impl GroupsRegistry {
}
fn merge(&mut self, list: GroupList, cx: &mut Context<Self>) {
// Carry the event's private section so a later publish can preserve it.
self.list
.set_content(list.content().map(str::to_owned), list.content_readable());
let entries = list.entries().to_vec();
for entry in entries {
@@ -517,6 +525,58 @@ impl GroupsRegistry {
cx.notify();
}
/// Decrypt a list event's private section into its entries.
async fn hydrate_list(
mut list: GroupList,
signer: &UniversalSigner,
me: &PublicKey,
) -> GroupList {
let Some(content) = list.content().map(str::to_owned) else {
return list;
};
match signer.nip44_decrypt(me, &content).await {
Ok(plaintext) => match parse_private_section(&plaintext) {
Some(entries) => {
list.merge_private(entries);
list.mark_readable();
}
None => log::warn!("ignoring a malformed private group list section"),
},
Err(error) => log::warn!("decrypting the private group list failed: {error}"),
}
list
}
/// Absorb a list event, decrypting its private section off the hot path.
fn absorb_list(&mut self, event: Event, cx: &mut Context<Self>) {
let list = match GroupList::parse(&event) {
Ok(list) => list,
Err(error) => {
log::warn!("ignoring a malformed group list: {error}");
return;
}
};
if list.content().is_none() {
self.merge(list, cx);
return;
}
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
let Some(me) = nostr.read(cx).current_user() else {
self.merge(list, cx);
return;
};
self.tasks.push(cx.spawn(async move |this, cx| {
let list = Self::hydrate_list(list, &signer, &me).await;
this.update(cx, |this, cx| this.merge(list, cx)).ok();
}));
}
fn watch_forks(&mut self, cx: &mut Context<Self>) {
let executor = cx.background_executor().clone();
let tx = self.signal_tx.clone();
@@ -733,13 +793,14 @@ impl GroupsRegistry {
.and_then(|entry| entry.name.clone())
}
fn upsert_entry(&mut self, key: &GroupKey, name: Option<String>) {
fn upsert_entry(&mut self, key: &GroupKey, name: Option<String>, private: bool) {
let name = name.or_else(|| self.entry_name(key));
self.list.upsert(GroupListEntry {
id: key.id().clone(),
relay: key.relay().clone(),
name,
private,
});
}
@@ -761,7 +822,9 @@ impl GroupsRegistry {
return;
}
self.upsert_entry(&key, Some(name));
let private = self.list.entry(&key).is_some_and(|entry| entry.private);
self.upsert_entry(&key, Some(name), private);
self.publish_list(cx);
}
@@ -769,10 +832,30 @@ impl GroupsRegistry {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let builder = self.list.to_builder();
let me = nostr.read(cx).current_user();
let list = self.list.clone();
self.tasks.push(cx.spawn(async move |_this, _cx| {
// Re-encrypt the private section when it carries local entries; a
// section this session could not read is carried verbatim.
let content = match (list.has_private(), list.content_readable()) {
(true, _) => match list.encode_private().zip(me) {
Some((plaintext, me)) => match signer.nip44_encrypt(&me, &plaintext).await {
Ok(content) => Some(content),
Err(error) => {
log::warn!("encrypting the private group list failed: {error}");
list.content().map(str::to_owned)
}
},
None => None,
},
(false, false) => list.content().map(str::to_owned),
(false, true) => None,
};
let builder = EventBuilder::new(Kind::SimpleGroups, content.unwrap_or_default())
.tags(list.public_tags());
let event = match builder.finalize_async(&signer).await {
Ok(event) => event,
Err(error) => {
@@ -929,10 +1012,7 @@ impl GroupsRegistry {
fn handle_event(&mut self, id: SubscriptionId, event: Event, cx: &mut Context<Self>) {
if id == list_subscription_id() {
match GroupList::parse(&event) {
Ok(list) => self.merge(list, cx),
Err(error) => log::warn!("ignoring a malformed group list: {error}"),
}
self.absorb_list(event, cx);
return;
}
+139 -10
View File
@@ -445,6 +445,8 @@ pub struct GroupCreateOptions {
pub closed: bool,
pub restricted: bool,
pub hidden: bool,
/// List the group in the NIP-44 encrypted section of the user's group list.
pub list_private: bool,
pub custom_id: Option<String>,
pub parent: Option<GroupId>,
}
@@ -641,10 +643,19 @@ pub(crate) struct GroupListEntry {
pub id: GroupId,
pub relay: RelayUrl,
pub name: Option<String>,
/// Kept in the encrypted section of the list event, not in public tags.
pub private: bool,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct GroupList(Vec<GroupListEntry>);
pub(crate) struct GroupList {
entries: Vec<GroupListEntry>,
/// The raw private section of the last seen list event.
content: Option<String>,
/// Whether the content was decrypted in this session; an opaque section
/// (another client's encryption, or a refused decrypt) is carried verbatim.
readable: bool,
}
impl GroupList {
pub fn parse(event: &Event) -> Result<Self> {
@@ -672,36 +683,94 @@ impl GroupList {
id,
relay,
name: fields.get(3).cloned(),
private: false,
});
}
Ok(Self(entries))
let content = if event.content.is_empty() {
None
} else {
Some(event.content.clone())
};
Ok(Self {
entries,
readable: content.is_none(),
content,
})
}
pub fn entries(&self) -> &[GroupListEntry] {
&self.0
&self.entries
}
pub fn entry(&self, key: &GroupKey) -> Option<&GroupListEntry> {
self.entries
.iter()
.find(|entry| entry.id == *key.id() && entry.relay == *key.relay())
}
pub fn upsert(&mut self, entry: GroupListEntry) {
match self
.0
.entries
.iter_mut()
.find(|existing| existing.id == entry.id && existing.relay == entry.relay)
{
Some(existing) => *existing = entry,
None => self.0.push(entry),
None => self.entries.push(entry),
}
}
pub fn remove(&mut self, key: &GroupKey) {
self.0
self.entries
.retain(|entry| entry.id != *key.id() || entry.relay != *key.relay());
}
pub fn to_builder(&self) -> EventBuilder {
let mut tags: Vec<Tag> = Vec::with_capacity(self.0.len() * 2);
/// The raw private section of the list event, if any.
pub(crate) fn content(&self) -> Option<&str> {
self.content.as_deref()
}
pub(crate) fn content_readable(&self) -> bool {
self.readable
}
pub(crate) fn set_content(&mut self, content: Option<String>, readable: bool) {
self.content = content;
self.readable = readable;
}
pub(crate) fn has_private(&self) -> bool {
self.entries.iter().any(|entry| entry.private)
}
/// Mark the private section as readable after a successful decrypt.
pub(crate) fn mark_readable(&mut self) {
self.readable = true;
}
/// Add decrypted private entries; the public listing wins on collision.
pub(crate) fn merge_private(&mut self, entries: Vec<GroupListEntry>) {
for entry in entries {
let public = self.entries.iter().any(|existing| {
!existing.private && existing.id == entry.id && existing.relay == entry.relay
});
if !public {
self.upsert(entry);
}
}
}
/// The tags of the public section of the list event.
pub(crate) fn public_tags(&self) -> Vec<Tag> {
let mut tags = Vec::with_capacity(self.entries.len() * 2);
for entry in &self.entries {
if entry.private {
continue;
}
for entry in &self.0 {
let mut values = vec![
entry.id.as_str().to_owned(),
entry.relay.as_str().to_owned(),
@@ -715,8 +784,68 @@ impl GroupList {
tags.push(Tag::custom("r", [entry.relay.as_str()]));
}
EventBuilder::new(Kind::SimpleGroups, "").tags(tags)
tags
}
/// The private entries encoded as a tag array, ready for encryption.
pub(crate) fn encode_private(&self) -> Option<String> {
let tags: Vec<Vec<String>> = self
.entries
.iter()
.filter(|entry| entry.private)
.map(|entry| {
let mut values = vec![
entry.id.as_str().to_owned(),
entry.relay.as_str().to_owned(),
];
if let Some(name) = &entry.name {
values.push(name.clone());
}
values
})
.collect();
if tags.is_empty() {
None
} else {
serde_json::to_string(&tags).ok()
}
}
}
/// Parse a decrypted private section; `None` when it isn't a tag array.
pub(crate) fn parse_private_section(plaintext: &str) -> Option<Vec<GroupListEntry>> {
let tags = serde_json::from_str::<Vec<Vec<String>>>(plaintext).ok()?;
let mut entries = Vec::new();
for tag in tags {
if tag.first().map(String::as_str) != Some("group") {
continue;
}
let (Some(id), Some(relay)) = (tag.get(1), tag.get(2)) else {
continue;
};
let Ok(id) = GroupId::new(id) else {
continue;
};
let Ok(relay) = RelayUrl::parse(relay) else {
continue;
};
entries.push(GroupListEntry {
id,
relay,
name: tag.get(3).cloned(),
private: true,
});
}
Some(entries)
}
#[derive(Debug, Clone, PartialEq, Eq)]