add nip4e key rotation
This commit is contained in:
+105
-221
@@ -17,12 +17,14 @@ use gpui_kit::component::notification::{Notification, NotificationType};
|
||||
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::Directory;
|
||||
use settings::AppSettings;
|
||||
use settings::{AppSettings, NIP4E_ROTATION_MAX_DAYS, NIP4E_ROTATION_MIN_DAYS};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{Announcement, NostrRegistry, UniversalSigner, credentials};
|
||||
use ui::Avatar;
|
||||
|
||||
const IDENTIFIER: &str = "coop:device";
|
||||
mod storage;
|
||||
|
||||
use storage::{SECONDS_PER_DAY, archive_key, get_keys, load_device_keys, set_keys};
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
|
||||
@@ -60,7 +62,7 @@ impl DeviceEvent {
|
||||
#[derive(Debug)]
|
||||
pub struct DeviceRegistry {
|
||||
signer: Entity<Option<UniversalSigner>>,
|
||||
/// Ephemeral client key for the in-flight key request. never persisted
|
||||
/// Ephemeral client key for the in-flight key request; never persisted
|
||||
app_keys: Option<Keys>,
|
||||
/// The key request currently awaiting user approval on this device
|
||||
pending_request: Option<PendingRequest>,
|
||||
@@ -68,6 +70,10 @@ pub struct DeviceRegistry {
|
||||
processed_requests: VecDeque<EventId>,
|
||||
/// Whether an announcement has been made for this device
|
||||
pub(crate) announcement_existed: Arc<AtomicBool>,
|
||||
/// Whether the local key is stale against the current announcement.
|
||||
resync: Arc<AtomicBool>,
|
||||
/// Whether a key rotation is in flight, to coalesce overlapping requests
|
||||
rotating: bool,
|
||||
/// Person directory for requesters shown in notifications
|
||||
directory: Entity<Directory>,
|
||||
/// Relay notification listener
|
||||
@@ -113,6 +119,7 @@ impl DeviceRegistry {
|
||||
if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) {
|
||||
// The old account's state must not leak into the new one
|
||||
this.announcement_existed.store(false, Ordering::Relaxed);
|
||||
this.resync.store(false, Ordering::Relaxed);
|
||||
// The in-flight request and client key belong to the old account
|
||||
this.app_keys = None;
|
||||
|
||||
@@ -145,6 +152,8 @@ impl DeviceRegistry {
|
||||
pending_request: None,
|
||||
processed_requests: VecDeque::new(),
|
||||
announcement_existed: Arc::new(AtomicBool::new(false)),
|
||||
resync: Arc::new(AtomicBool::new(false)),
|
||||
rotating: false,
|
||||
directory: Directory::new(cx),
|
||||
notification_listener: None,
|
||||
tasks: vec![],
|
||||
@@ -306,9 +315,16 @@ impl DeviceRegistry {
|
||||
*this = Some(UniversalSigner::new(new_signer));
|
||||
cx.notify();
|
||||
});
|
||||
// The local key is now consistent with the announcement
|
||||
self.resync.store(false, Ordering::Release);
|
||||
cx.emit(DeviceEvent::Set);
|
||||
}
|
||||
|
||||
/// Whether the local encryption key is stale against the current announcement.
|
||||
pub fn is_resyncing(&self) -> bool {
|
||||
self.resync.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Backup the encryption's secret key, returning the `nsec1...` content
|
||||
pub fn backup_bytes(&self, cx: &App) -> Task<Result<String, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
@@ -411,6 +427,88 @@ impl DeviceRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Replace the encryption key with a new one, archiving the old key.
|
||||
pub fn rotate(&mut self, cx: &mut Context<Self>) {
|
||||
if self.rotating {
|
||||
return;
|
||||
}
|
||||
self.rotating = true;
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result: Result<Keys, Error> = async {
|
||||
// Archive the current key before replacing it
|
||||
if let Ok(old) = get_keys(&client, &signer).await {
|
||||
archive_key(&client, &signer, &old.secret_key().to_secret_hex()).await?;
|
||||
}
|
||||
|
||||
let created = this
|
||||
.update(cx, |this, cx| this.create_encryption(Keys::generate(), cx))?
|
||||
.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(created.clone(), cx);
|
||||
this.wait_for_request(cx);
|
||||
})?;
|
||||
|
||||
Ok(created)
|
||||
}
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.rotating = false;
|
||||
if let Err(e) = result {
|
||||
cx.emit(DeviceEvent::error(e.to_string()));
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Rotate the encryption key when the configured interval has elapsed.
|
||||
///
|
||||
/// Runs after the announcement reconciled, outside the blocking boot path.
|
||||
pub fn rotate_if_due(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(interval_days) = AppSettings::get_nip4e_rotation_days(cx) else {
|
||||
return;
|
||||
};
|
||||
let interval_days = interval_days.clamp(NIP4E_ROTATION_MIN_DAYS, NIP4E_ROTATION_MAX_DAYS);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let Ok(keys) = load_device_keys(&client, &signer).await else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let elapsed = Timestamp::now()
|
||||
.as_secs()
|
||||
.saturating_sub(keys.created_at.as_secs());
|
||||
if elapsed < u64::from(interval_days) * SECONDS_PER_DAY {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
this.update(cx, |this, cx| this.rotate(cx)).ok();
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Load encryption keys retired by a rotation from the local database.
|
||||
pub async fn archived_keys<S>(client: &Client, signer: &S) -> Result<Vec<Keys>, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
storage::archived_keys(client, signer).await
|
||||
}
|
||||
|
||||
/// Create new encryption key and announce it to user's relay list
|
||||
fn create_encryption(&self, keys: Keys, cx: &App) -> Task<Result<Keys, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
@@ -419,6 +517,7 @@ impl DeviceRegistry {
|
||||
|
||||
let secret = keys.secret_key().to_secret_hex();
|
||||
let n = keys.public_key();
|
||||
let created_at = Timestamp::now();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Construct an announcement event
|
||||
@@ -428,7 +527,7 @@ impl DeviceRegistry {
|
||||
.await?;
|
||||
|
||||
// Persist the key locally before announcing it
|
||||
set_keys(&client, &signer, &secret).await?;
|
||||
set_keys(&client, &signer, &secret, created_at).await?;
|
||||
|
||||
// Publish announcement
|
||||
client
|
||||
@@ -471,9 +570,11 @@ impl DeviceRegistry {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
this.wait_for_request(cx);
|
||||
this.rotate_if_due(cx);
|
||||
})?;
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.resync.store(true, Ordering::Release);
|
||||
this.request(Some(device_pubkey), cx);
|
||||
})?;
|
||||
}
|
||||
@@ -1179,220 +1280,3 @@ async fn decrypt_approval(
|
||||
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// Encrypt and store device keys in the local database.
|
||||
async fn set_keys<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
let content = signer.nip44_encrypt_async(&public_key, secret).await?;
|
||||
|
||||
// Construct the application data event
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||
.tag(Tag::identifier(IDENTIFIER))
|
||||
.finalize_async(signer)
|
||||
.await?;
|
||||
|
||||
// Save the event to the database
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get device keys from the local database.
|
||||
async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(IDENTIFIER)
|
||||
.author(public_key);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first() {
|
||||
let content = signer
|
||||
.nip44_decrypt_async(&public_key, &event.content)
|
||||
.await?;
|
||||
|
||||
let secret = SecretKey::parse(&content)?;
|
||||
let keys = Keys::new(secret);
|
||||
|
||||
Ok(keys)
|
||||
} else {
|
||||
Err(anyhow!("Key not found"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Compose a minimal event whose validity doesn't matter for the helpers
|
||||
fn event_with_tags(kind: Kind, tags: Vec<Tag>) -> Event {
|
||||
let keys = Keys::generate();
|
||||
let public_key = keys.public_key();
|
||||
let created_at = Timestamp::now();
|
||||
let tags = Tags::from_list(tags);
|
||||
let id = EventId::compute(&public_key, &created_at, &kind, &tags, "");
|
||||
|
||||
Event::new(
|
||||
id,
|
||||
public_key,
|
||||
created_at,
|
||||
kind,
|
||||
tags,
|
||||
"",
|
||||
Signature::from_slice(&[0u8; 64]).expect("signature should parse"),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_processed_requests_roundtrip() {
|
||||
let ids: Vec<EventId> = (0u8..4)
|
||||
.map(|i| {
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes[0] = i;
|
||||
EventId::from_byte_array(bytes)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let bytes: Vec<u8> = ids.iter().flat_map(|id| id.to_hex().into_bytes()).collect();
|
||||
let parsed = parse_processed_requests(&bytes);
|
||||
|
||||
assert_eq!(parsed.len(), 4);
|
||||
assert!(ids.iter().all(|id| parsed.contains(id)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_processed_requests_ignores_garbage() {
|
||||
assert!(parse_processed_requests(&[]).is_empty());
|
||||
assert!(parse_processed_requests(b"not hex at all").is_empty());
|
||||
assert!(parse_processed_requests(&[b'a'; 63]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_pubkey_from_event_reads_p_tag() {
|
||||
let client_key = Keys::generate().public_key();
|
||||
let event = event_with_tags(
|
||||
Kind::Custom(4454),
|
||||
vec![Tag::custom("P", vec![client_key.to_hex()])],
|
||||
);
|
||||
|
||||
assert_eq!(client_pubkey_from_event(&event), Some(client_key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_pubkey_from_event_rejects_missing_or_invalid() {
|
||||
let event = event_with_tags(Kind::Custom(4454), vec![]);
|
||||
assert_eq!(client_pubkey_from_event(&event), None);
|
||||
|
||||
let event = event_with_tags(Kind::Custom(4454), vec![Tag::custom("P", vec!["nope"])]);
|
||||
assert_eq!(client_pubkey_from_event(&event), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_pubkey_from_event_falls_back_to_alias() {
|
||||
let client_key = Keys::generate().public_key();
|
||||
|
||||
let event = event_with_tags(
|
||||
Kind::Custom(4454),
|
||||
vec![Tag::custom("pubkey", vec![client_key.to_hex()])],
|
||||
);
|
||||
|
||||
assert_eq!(client_pubkey_from_event(&event), Some(client_key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_pubkey_from_event_prefers_p_tag() {
|
||||
let client_key = Keys::generate().public_key();
|
||||
let alias = Keys::generate().public_key();
|
||||
|
||||
let event = event_with_tags(
|
||||
Kind::Custom(4454),
|
||||
vec![
|
||||
Tag::custom("P", vec![client_key.to_hex()]),
|
||||
Tag::custom("pubkey", vec![alias.to_hex()]),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(client_pubkey_from_event(&event), Some(client_key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_addresses_matches_client_key_p_tag() {
|
||||
let client_key = Keys::generate().public_key();
|
||||
let other = Keys::generate().public_key();
|
||||
let identity = Keys::generate().public_key();
|
||||
|
||||
let event = event_with_tags(
|
||||
Kind::Custom(4455),
|
||||
vec![
|
||||
Tag::custom("P", vec![other.to_hex()]),
|
||||
Tag::public_key(client_key),
|
||||
],
|
||||
);
|
||||
|
||||
assert!(transfer_addresses(&event, client_key));
|
||||
assert!(!transfer_addresses(&event, other));
|
||||
assert!(!transfer_addresses(&event, identity));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_addresses_ignores_uppercase_p_tag() {
|
||||
let client_key = Keys::generate().public_key();
|
||||
|
||||
let event = event_with_tags(
|
||||
Kind::Custom(4455),
|
||||
vec![Tag::custom("P", vec![client_key.to_hex()])],
|
||||
);
|
||||
|
||||
assert!(!transfer_addresses(&event, client_key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_relay_hints_parses_and_drops_invalid() {
|
||||
let valid = RelayUrl::parse("wss://relay.example.com").expect("relay should parse");
|
||||
|
||||
let event = event_with_tags(
|
||||
Kind::Custom(4454),
|
||||
vec![
|
||||
Tag::custom("relay", vec!["wss://relay.example.com"]),
|
||||
Tag::custom("relay", vec!["not a relay"]),
|
||||
Tag::custom("relay", vec!["wss://other.example.com"]),
|
||||
],
|
||||
);
|
||||
|
||||
let hints = request_relay_hints(&event);
|
||||
assert_eq!(hints.len(), 2);
|
||||
assert_eq!(hints[0], valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_encryption_key_reads_n_tag() {
|
||||
let encryption_key = Keys::generate().public_key();
|
||||
|
||||
let event = event_with_tags(
|
||||
Kind::Custom(4454),
|
||||
vec![Tag::custom("n", vec![encryption_key.to_hex()])],
|
||||
);
|
||||
assert_eq!(requested_encryption_key(&event), Some(encryption_key));
|
||||
|
||||
let event = event_with_tags(Kind::Custom(4454), vec![]);
|
||||
assert_eq!(requested_encryption_key(&event), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_relays_deduplicates() {
|
||||
let a = RelayUrl::parse("wss://a.example.com").expect("relay should parse");
|
||||
let b = RelayUrl::parse("wss://b.example.com").expect("relay should parse");
|
||||
|
||||
let merged = merge_relays(vec![a.clone(), b.clone()], vec![a.clone()]);
|
||||
|
||||
assert_eq!(merged.len(), 2);
|
||||
assert!(merged.contains(&a));
|
||||
assert!(merged.contains(&b));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
use anyhow::{Error, anyhow};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// The storage identifier for the device encryption key
|
||||
const IDENTIFIER: &str = "coop:device";
|
||||
/// The storage identifier for encryption keys retired by a rotation
|
||||
const ARCHIVE_IDENTIFIER: &str = "coop:device-archive";
|
||||
/// Keep the archived key list bounded
|
||||
const ARCHIVE_LIMIT: usize = 10;
|
||||
/// Seconds in a day, for rotation intervals
|
||||
pub(crate) const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
|
||||
|
||||
/// Device encryption key material with its creation time
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct DeviceKeys {
|
||||
pub(crate) keys: Keys,
|
||||
pub(crate) created_at: Timestamp,
|
||||
}
|
||||
|
||||
/// Encode the stored key content: secret hex and creation time
|
||||
fn encode_keys_content(secret: &str, created_at: &Timestamp) -> String {
|
||||
format!("{secret}\n{}", created_at.as_secs())
|
||||
}
|
||||
|
||||
/// Parse stored key content.
|
||||
fn parse_keys_content(content: &str) -> Result<(SecretKey, Timestamp), Error> {
|
||||
match content.split_once('\n') {
|
||||
Some((secret, created_at)) => {
|
||||
let created_at = created_at
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.unwrap_or_else(|_| Timestamp::now().as_secs());
|
||||
Ok((
|
||||
SecretKey::from_hex(secret)?,
|
||||
Timestamp::from_secs(created_at),
|
||||
))
|
||||
}
|
||||
None => Ok((SecretKey::from_hex(content)?, Timestamp::now())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse archived secrets from stored content, one hex secret per line
|
||||
fn parse_archived_keys(content: &str) -> Vec<Keys> {
|
||||
content
|
||||
.lines()
|
||||
.filter_map(|line| SecretKey::from_hex(line.trim()).ok())
|
||||
.map(Keys::new)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Load the device keys with their creation time from the local database.
|
||||
pub(crate) async fn load_device_keys<S>(client: &Client, signer: &S) -> Result<DeviceKeys, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(IDENTIFIER)
|
||||
.author(public_key);
|
||||
|
||||
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
|
||||
return Err(anyhow!("Key not found"));
|
||||
};
|
||||
|
||||
let content = signer
|
||||
.nip44_decrypt_async(&public_key, &event.content)
|
||||
.await?;
|
||||
let (secret, created_at) = parse_keys_content(&content)?;
|
||||
|
||||
Ok(DeviceKeys {
|
||||
keys: Keys::new(secret),
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encrypt and store device keys in the local database.
|
||||
pub(crate) async fn set_keys<S>(
|
||||
client: &Client,
|
||||
signer: &S,
|
||||
secret: &str,
|
||||
created_at: Timestamp,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
let content = signer
|
||||
.nip44_encrypt_async(&public_key, &encode_keys_content(secret, &created_at))
|
||||
.await?;
|
||||
|
||||
// Construct the application data event
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||
.tag(Tag::identifier(IDENTIFIER))
|
||||
.finalize_async(signer)
|
||||
.await?;
|
||||
|
||||
// Save the event to the database
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append an encryption secret to the bounded archive of retired keys.
|
||||
pub(crate) async fn archive_key<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(ARCHIVE_IDENTIFIER)
|
||||
.author(public_key);
|
||||
|
||||
let mut archived: Vec<String> = match client.database().query(filter).await?.into_iter().next()
|
||||
{
|
||||
Some(event) => {
|
||||
let content = signer
|
||||
.nip44_decrypt_async(&public_key, &event.content)
|
||||
.await?;
|
||||
content.lines().map(str::to_string).collect()
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
archived.insert(0, secret.to_string());
|
||||
archived.truncate(ARCHIVE_LIMIT);
|
||||
|
||||
let content = signer
|
||||
.nip44_encrypt_async(&public_key, &archived.join("\n"))
|
||||
.await?;
|
||||
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||
.tag(Tag::identifier(ARCHIVE_IDENTIFIER))
|
||||
.finalize_async(signer)
|
||||
.await?;
|
||||
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get device keys from the local database.
|
||||
pub(crate) async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
Ok(load_device_keys(client, signer).await?.keys)
|
||||
}
|
||||
|
||||
/// Load encryption keys retired by a rotation from the local database.
|
||||
pub(crate) async fn archived_keys<S>(client: &Client, signer: &S) -> Result<Vec<Keys>, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(ARCHIVE_IDENTIFIER)
|
||||
.author(public_key);
|
||||
|
||||
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
|
||||
let content = signer
|
||||
.nip44_decrypt_async(&public_key, &event.content)
|
||||
.await?;
|
||||
|
||||
Ok(parse_archived_keys(&content))
|
||||
}
|
||||
Reference in New Issue
Block a user