add nip4e key rotation

This commit is contained in:
2026-11-04 09:28:57 +07:00
parent 029ab21983
commit 06ff8ce65d
7 changed files with 357 additions and 358 deletions
+105 -221
View File
@@ -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));
}
}
+174
View File
@@ -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))
}