Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
bdd338b
markdown_ui: fix multiple tables clashing by adding id_salt
jb55 Feb 16, 2026
6a5ada2
auto_accept: add `beads list` to auto-approved commands
jb55 Feb 16, 2026
0e209c2
wip: AI conversation nostr notes (shelved for redesign)
jb55 Feb 16, 2026
bd94649
session_events: lossless round-trip with seq, split, tool-id, cwd tags
jb55 Feb 16, 2026
9698db9
session_events: split source-data to kind 1989, wire archive into res…
jb55 Feb 16, 2026
c417c78
session_events: fix timestamp inheritance, subscription-based archive…
jb55 Feb 16, 2026
cb39bcb
session_events: generate kind-1988 events in real-time during live co…
jb55 Feb 16, 2026
172743f
session_events: seed live threading from archive events on resume
jb55 Feb 16, 2026
00e1437
session_events: skip redundant archive conversion, fix threading root…
jb55 Feb 16, 2026
bb68dd1
enostr: add NIP-PNS (Private Note Storage) crypto module
jb55 Feb 16, 2026
0c87356
session_events: permission events + relay publishing + remote respons…
jb55 Feb 16, 2026
fc4b673
disable relay publishing until PNS wrapping is implemented
jb55 Feb 16, 2026
be140bc
auto-focus Done sessions in the focus queue
jb55 Feb 16, 2026
4467bf0
suppress auto-steal focus while user is typing
jb55 Feb 16, 2026
c0d7529
add syntax highlighting to code blocks
jb55 Feb 16, 2026
972bb35
persist active sessions via kind-31988 replaceable nostr notes
jb55 Feb 17, 2026
40beb32
wire PNS into dave startup, bump nostrdb to nip-pns branch
jb55 Feb 17, 2026
0cc918a
enable PNS-wrapped relay publishing for AI conversation events
jb55 Feb 17, 2026
1fcf756
subscribe to PNS events on relays for remote session discovery
jb55 Feb 17, 2026
4ddff4b
add SessionSource (Local/Remote) for lite client remote mode
jb55 Feb 17, 2026
eda663d
add live conversation polling and dedup for remote sessions
jb55 Feb 17, 2026
a537ce0
use dedicated PNS relay (ws://relay.jb55.com) instead of broadcasting
jb55 Feb 17, 2026
24bfcda
ingest events via PNS wrapping so 1080 events exist in ndb
jb55 Feb 17, 2026
23a7651
deduplicate replaceable events using ndb.fold in session loading
jb55 Feb 17, 2026
a3c3016
fix PNS ingest: wrap as ["EVENT", {...}] for process_client_event
jb55 Feb 17, 2026
0be6a89
fix PNS key derivation: use HKDF-Extract only, not Extract+Expand
jb55 Feb 17, 2026
950b430
persist permission responses as nostr events for all sessions
jb55 Feb 17, 2026
513c18f
publish deleted state event when session is removed
jb55 Feb 17, 2026
2e8ebb5
use tags instead of JSON content for session state events
jb55 Feb 17, 2026
dcf5423
refactor: extract shared helpers to reduce duplication in dave crate
jb55 Feb 17, 2026
e8a3fb4
skip old JSON-content session state events during restore
jb55 Feb 17, 2026
ff67e87
add remote-only backend for lite client mode
jb55 Feb 17, 2026
fd57c23
fix PNS relay URL trailing slash mismatch
jb55 Feb 17, 2026
22c4e29
fix missing relay events and duplicate sessions in dave app
jb55 Feb 17, 2026
f4e662a
re-subscribe to PNS relay on reconnect and fix duplicate sessions
jb55 Feb 17, 2026
801452d
hide CWD and status bar for remote sessions in session list
jb55 Feb 17, 2026
296512b
fix diff view overflow and move permission buttons to bottom left
jb55 Feb 17, 2026
5e5dc3b
hide git status and interrupt hint for remote sessions
jb55 Feb 17, 2026
8a9243b
move permission buttons to left-to-right layout
jb55 Feb 17, 2026
8d0b9e0
fix duplicate messages on phone and enable phone-to-desktop messaging
jb55 Feb 17, 2026
c4b335e
use horizontal-only scroll for diff view
jb55 Feb 17, 2026
efcc081
move all permission buttons to their own line below tool info
jb55 Feb 17, 2026
4a43719
fix text clipping in chat by overriding StripBuilder truncate mode
jb55 Feb 17, 2026
23c27ff
Revert "hide CWD and status bar for remote sessions in session list"
jb55 Feb 17, 2026
9dbf828
add source hostname to session state events and session list UI
jb55 Feb 17, 2026
660f898
reduce excessive chat margins for tablet and smaller screens
jb55 Feb 17, 2026
ceedaad
shrink sidebar on medium screens for better chat area on tablets
jb55 Feb 18, 2026
afc4767
fix remote messages clobbering in-flight streams
jb55 Feb 18, 2026
4c05d07
fix Q&A answer submit not working for remote sessions
jb55 Feb 18, 2026
44233ec
refactor: unify permission publish data into PermissionPublish struct
jb55 Feb 18, 2026
e6c9c54
add tool-name tag to tool_call/tool_result nostr events
jb55 Feb 18, 2026
56bc190
refactor: extract queue_built_event to deduplicate event publish pattern
jb55 Feb 18, 2026
94a7f9c
refactor: consolidate permission state into PermissionTracker struct
jb55 Feb 18, 2026
7490143
fix remote session status using stale replaceable event revisions
jb55 Feb 18, 2026
56987d9
fix zombie deleted sessions from out-of-order replaceable event batches
jb55 Feb 18, 2026
03df939
fix query_replaceable_filtered not handling arbitrary fold order
jb55 Feb 18, 2026
5dce904
Revert "fix zombie deleted sessions from out-of-order replaceable eve…
jb55 Feb 18, 2026
ae2b22e
refactor: unify permission resolution into PermissionTracker::resolve()
jb55 Feb 18, 2026
d3bf127
fix remote messages not received until local message sent
jb55 Feb 18, 2026
808629c
add clickable toggle for permission feedback on mobile
jb55 Feb 18, 2026
16bd4d8
make PLAN/AUTO badges clickable and move to status bar
jb55 Feb 18, 2026
f0906f4
fix clippy lints: too_many_arguments and question_mark
jb55 Feb 18, 2026
7688fd8
fix PLAN/AUTO badges not showing on remote sessions
jb55 Feb 18, 2026
2964a2d
fix tentative permission buttons: show Send when composing message
jb55 Feb 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ md5 = "0.7.0"
nostr = { version = "0.37.0", default-features = false, features = ["std", "nip44", "nip49"] }
nwc = "0.39.0"
mio = { version = "1.0.3", features = ["os-poll", "net"] }
nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "34738d2894d841ac44b1c46e0334a7cf2ca09b34" }
nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "9aeecd3c4576be0b34df87c26e334e87a39d57e5" }
#nostrdb = "0.6.1"
notedeck = { path = "crates/notedeck" }
notedeck_chrome = { path = "crates/notedeck_chrome" }
Expand Down Expand Up @@ -97,6 +97,7 @@ url = "2.5.2"
urlencoding = "2.1.3"
uuid = { version = "1.10.0", features = ["v4"] }
sha2 = "0.10.8"
hkdf = "0.12.4"
bincode = "1.3.3"
mime_guess = "2.0.5"
pretty_assertions = "1.4.1"
Expand Down
5 changes: 4 additions & 1 deletion crates/enostr/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,7 @@ url = { workspace = true }
mio = { workspace = true }
tokio = { workspace = true }
tokenator = { workspace = true }
hashbrown = { workspace = true }
hashbrown = { workspace = true }
hkdf = { workspace = true }
sha2 = { workspace = true }
base64 = { workspace = true }
1 change: 1 addition & 0 deletions crates/enostr/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod error;
mod filter;
mod keypair;
mod note;
pub mod pns;
mod profile;
mod pubkey;
mod relay;
Expand Down
229 changes: 229 additions & 0 deletions crates/enostr/src/pns.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
//! NIP-PNS: Private Note Storage
//!
//! Deterministic key derivation and encryption for storing private nostr
//! events on relays. Only the owner of the device key can publish and
//! decrypt PNS events (kind 1080).
//!
//! Key derivation:
//! pns_key = hkdf_extract(ikm=device_key, salt="nip-pns")
//! pns_keypair = derive_secp256k1_keypair(pns_key)
//! pns_nip44_key = hkdf_extract(ikm=pns_key, salt="nip44-v2")

use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use hkdf::Hkdf;
use nostr::nips::nip44::v2::{self, ConversationKey};
use sha2::Sha256;

use crate::{FullKeypair, Pubkey};

/// Kind number for PNS events.
pub const PNS_KIND: u32 = 1080;

/// Salt used for deriving pns_key from the device key.
const PNS_SALT: &[u8] = b"nip-pns";

/// Salt used for deriving the NIP-44 symmetric key from pns_key.
const NIP44_SALT: &[u8] = b"nip44-v2";

/// Derived PNS keys — everything needed to create and decrypt PNS events.
pub struct PnsKeys {
/// Keypair for signing kind-1080 events (derived from pns_key).
pub keypair: FullKeypair,
/// NIP-44 conversation key for encrypting/decrypting content.
pub conversation_key: ConversationKey,
}

/// Derive all PNS keys from a device secret key.
///
/// This is deterministic: the same device key always produces the same
/// PNS keypair and encryption key.
pub fn derive_pns_keys(device_key: &[u8; 32]) -> PnsKeys {
let pns_key = hkdf_extract(device_key, PNS_SALT);
let keypair = keypair_from_bytes(&pns_key);
let nip44_key = hkdf_extract(&pns_key, NIP44_SALT);
let conversation_key = ConversationKey::new(nip44_key);

PnsKeys {
keypair,
conversation_key,
}
}

/// Encrypt an inner event JSON string for PNS storage.
///
/// Returns base64-encoded NIP-44 v2 ciphertext suitable for the
/// `content` field of a kind-1080 event.
pub fn encrypt(conversation_key: &ConversationKey, inner_json: &str) -> Result<String, PnsError> {
let payload = v2::encrypt_to_bytes(conversation_key, inner_json).map_err(PnsError::Encrypt)?;
Ok(BASE64.encode(payload))
}

/// Decrypt a PNS event's content field back to the inner event JSON.
///
/// Takes base64-encoded NIP-44 v2 ciphertext from a kind-1080 event.
pub fn decrypt(conversation_key: &ConversationKey, content: &str) -> Result<String, PnsError> {
let payload = BASE64.decode(content).map_err(PnsError::Base64)?;
let plaintext = v2::decrypt_to_bytes(conversation_key, &payload).map_err(PnsError::Decrypt)?;
String::from_utf8(plaintext).map_err(PnsError::Utf8)
}

/// HMAC-SHA256(key=salt, msg=ikm) → 32-byte key.
///
/// This matches the nostrdb C implementation which uses raw HMAC-SHA256
/// (i.e. HKDF-Extract only, without HKDF-Expand).
fn hkdf_extract(ikm: &[u8; 32], salt: &[u8]) -> [u8; 32] {
let (prk, _) = Hkdf::<Sha256>::extract(Some(salt), ikm);
let mut out = [0u8; 32];
out.copy_from_slice(&prk);
out
}

/// Derive a secp256k1 keypair from 32 bytes of key material.
fn keypair_from_bytes(key: &[u8; 32]) -> FullKeypair {
let secret_key =
nostr::SecretKey::from_slice(key).expect("32 bytes of HKDF output is a valid secret key");
let (xopk, _) = secret_key.x_only_public_key(&nostr::SECP256K1);
FullKeypair {
pubkey: Pubkey::new(xopk.serialize()),
secret_key,
}
}

#[derive(Debug)]
pub enum PnsError {
Encrypt(nostr::nips::nip44::Error),
Decrypt(nostr::nips::nip44::Error),
Base64(base64::DecodeError),
Utf8(std::string::FromUtf8Error),
}

impl std::fmt::Display for PnsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PnsError::Encrypt(e) => write!(f, "PNS encrypt failed: {e}"),
PnsError::Decrypt(e) => write!(f, "PNS decrypt failed: {e}"),
PnsError::Base64(e) => write!(f, "PNS base64 decode failed: {e}"),
PnsError::Utf8(e) => write!(f, "PNS decrypted content is not UTF-8: {e}"),
}
}
}

impl std::error::Error for PnsError {}

#[cfg(test)]
mod tests {
use super::*;

fn test_device_key() -> [u8; 32] {
// Deterministic test key
let mut key = [0u8; 32];
key[0] = 0x01;
key[31] = 0xff;
key
}

#[test]
fn test_derive_pns_keys_deterministic() {
let dk = test_device_key();
let keys1 = derive_pns_keys(&dk);
let keys2 = derive_pns_keys(&dk);

assert_eq!(keys1.keypair.pubkey, keys2.keypair.pubkey);
assert_eq!(
keys1.conversation_key.as_bytes(),
keys2.conversation_key.as_bytes()
);
}

#[test]
fn test_pns_pubkey_differs_from_device_pubkey() {
let dk = test_device_key();
let pns = derive_pns_keys(&dk);

// Device pubkey
let device_sk = nostr::SecretKey::from_slice(&dk).unwrap();
let (device_xopk, _) = device_sk.x_only_public_key(&nostr::SECP256K1);
let device_pubkey = Pubkey::new(device_xopk.serialize());

// PNS pubkey should be different (derived via HKDF)
assert_ne!(pns.keypair.pubkey, device_pubkey);
}

#[test]
fn test_encrypt_decrypt_roundtrip() {
let dk = test_device_key();
let keys = derive_pns_keys(&dk);

let inner = r#"{"kind":1,"pubkey":"abc","content":"hello","tags":[],"created_at":0}"#;
let encrypted = encrypt(&keys.conversation_key, inner).unwrap();

// Should be base64
assert!(BASE64.decode(&encrypted).is_ok());

let decrypted = decrypt(&keys.conversation_key, &encrypted).unwrap();
assert_eq!(decrypted, inner);
}

#[test]
fn test_different_keys_cannot_decrypt() {
let dk1 = test_device_key();
let mut dk2 = test_device_key();
dk2[0] = 0x02;

let keys1 = derive_pns_keys(&dk1);
let keys2 = derive_pns_keys(&dk2);

let inner = r#"{"content":"secret"}"#;
let encrypted = encrypt(&keys1.conversation_key, inner).unwrap();

// Different key should fail to decrypt
assert!(decrypt(&keys2.conversation_key, &encrypted).is_err());
}

#[test]
fn test_matches_nostrdb_c_test_vector() {
// Same device key as nostrdb's test_pns_unwrap in test.c:
// unsigned char device_sec[32] = {0,...,0,2};
let mut device_key = [0u8; 32];
device_key[31] = 0x02;

let keys = derive_pns_keys(&device_key);

// The C test expects PNS pubkey:
// "fa22d53e9d38ca7af1e66dcf88f5fb2444368df6bd16580b5827c8cfbc622d4e"
let expected_pns_pubkey =
"fa22d53e9d38ca7af1e66dcf88f5fb2444368df6bd16580b5827c8cfbc622d4e";
let actual_pns_pubkey = hex::encode(keys.keypair.pubkey.bytes());
assert_eq!(
actual_pns_pubkey, expected_pns_pubkey,
"PNS pubkey must match nostrdb C implementation"
);

// Also verify device pubkey matches (sanity check):
// c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5
let device_sk = nostr::SecretKey::from_slice(&device_key).unwrap();
let (device_xopk, _) = device_sk.x_only_public_key(&nostr::SECP256K1);
let expected_device_pubkey =
"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5";
assert_eq!(hex::encode(device_xopk.serialize()), expected_device_pubkey);
}

#[test]
fn test_encrypt_produces_different_ciphertext() {
// NIP-44 uses random nonce, so encrypting same plaintext twice
// should produce different ciphertext
let dk = test_device_key();
let keys = derive_pns_keys(&dk);

let inner = r#"{"content":"hello"}"#;
let enc1 = encrypt(&keys.conversation_key, inner).unwrap();
let enc2 = encrypt(&keys.conversation_key, inner).unwrap();

assert_ne!(enc1, enc2);

// But both should decrypt to the same thing
assert_eq!(decrypt(&keys.conversation_key, &enc1).unwrap(), inner);
assert_eq!(decrypt(&keys.conversation_key, &enc2).unwrap(), inner);
}
}
8 changes: 7 additions & 1 deletion crates/notedeck/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,13 @@ impl Notedeck {

let settings = SettingsHandler::new(&path).load();

let config = Config::new().set_ingester_threads(2).set_mapsize(map_size);
let config = Config::new()
.set_ingester_threads(2)
.set_mapsize(map_size)
.set_sub_callback({
let ctx = ctx.clone();
move |_| ctx.request_repaint()
});

let keystore = if parsed_args.options.contains(NotedeckOptions::UseKeystore) {
let keys_path = path.path(DataPathType::Keys);
Expand Down
2 changes: 2 additions & 0 deletions crates/notedeck_dave/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ egui_extras = { workspace = true }
md-stream = { workspace = true }
similar = "2"
dirs = "5"
gethostname = "1"

[target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))'.dependencies]
rfd = { workspace = true }
Expand All @@ -40,6 +41,7 @@ objc2-app-kit = { version = "0.3.1", features = ["NSApplication", "NSResponder",

[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] }
tempfile = { workspace = true }

[[bin]]
name = "notedeck-spawn"
Expand Down
Loading
Loading