diff --git a/.fdignore b/.fdignore new file mode 100644 index 0000000..503e952 --- /dev/null +++ b/.fdignore @@ -0,0 +1,2 @@ +.claude +CLAUDE.md diff --git a/book/04-events.md b/book/04-events.md index 16617a0..7739dc7 100644 --- a/book/04-events.md +++ b/book/04-events.md @@ -1,202 +1,486 @@ # Events -Everything in nostr is an **event**. A short note, a profile update, a reaction, a relay list — -they're all events. An event is a JSON object signed by its author's cryptographic key. Once -signed, it cannot be altered without invalidating the signature, which means any relay or client -can verify that an event is authentic without trusting anyone. +Almost everything else in this book is built out of events. Profiles, follow +lists, direct messages, zap receipts, emoji reactions, long-form articles — +they are all the same data type with different values in a handful of fields. +If you understand events, you understand most of what a nostr library does. -## The event structure +An event is a JSON object with seven fields. What makes it interesting is +that the `id` field is a hash of the other fields and the `sig` field is a +Schnorr signature over that id. Anyone can verify, offline, that a given +event was produced by the holder of a particular secret key and has not been +altered since. There is no database row to update, no server to consult. The +event either hashes to its claimed id and verifies under its claimed pubkey, +or it doesn't — and that property, referential transparency in the functional +sense, is why nostr can work at all. -Here's what a nostr event looks like as JSON: +This chapter builds the Rust types that represent events and the two +primitives that make them go: computing the id and verifying the signature. +Construction is layered across six structs so that each stage of an event's +life is a distinct type; you can't sign something that hasn't been hashed, +and you can't hash something that doesn't know its author. + +## The module + +```rust {file=coracle-lib/src/lib.rs} +pub mod events; +``` + +```rust {file=coracle-lib/src/events.rs} +//! The nostr `Event` type, built up through a layered hierarchy of structs +//! that each add a single field: [`EventContent`], [`EventTemplate`], +//! [`StampedEvent`], [`OwnedEvent`], [`HashedEvent`], and [`Event`]. + +use serde::de::{self, MapAccess, Visitor}; +use serde::ser::SerializeStruct; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use sha2::{Digest, Sha256}; +use std::fmt; + +use crate::keys::PublicKey; +``` + +## Errors + +```rust {file=coracle-lib/src/events.rs} +/// Errors that can occur when parsing or verifying an event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EventError { + /// The recomputed id did not match the event's `id` field. + InvalidId, + /// The Schnorr signature did not verify against the event's pubkey and id. + InvalidSignature, +} + +impl fmt::Display for EventError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + EventError::InvalidId => write!(f, "event id does not match its contents"), + EventError::InvalidSignature => write!(f, "event signature failed to verify"), + } + } +} + +impl std::error::Error for EventError {} +``` + +## The wire format + +A signed event on the wire looks like this: ```json { - "id": "4376c65d2f232afbe9b882a35baa4f6fe8bce184396e157b1a35b3f01b3e285c", + "id": "4376c65d2f232afbe9b882a35baa4f6fe8667c4e684749af565f981833ed6a65", "pubkey": "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", "created_at": 1673347337, "kind": 1, - "tags": [ - ["e", "3da979448d9ba263864c4d6f14984c423a3838364ec255f03c7904b1ae77f206"], - ["p", "bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce"] - ], - "content": "Hello, nostr!", - "sig": "908a15e46fb4d8675bab026fc230a0e3542bfade63da02d542fb78b2a8513fcd..." + "tags": [["t", "nostr"]], + "content": "hello nostr", + "sig": "908a15e46fb4d8675bab026fc230a0e3542bfade63da02d542fb78b2a8513fcd0092619a2c8c1221e581946e0191f2af505dfdf8657a414dbca329186f009262" } ``` -Let's define this in Rust. We'll start with the module declaration: +`id`, `pubkey`, and `sig` are hex strings of fixed length. `created_at` is a +Unix timestamp in seconds. `kind` is a 16-bit integer. `tags` is a list of +lists of strings. `content` is a string whose meaning depends on the kind. -```rust {file=coracle-lib/src/lib.rs} -pub mod event; -``` +## Building up an event -And now the `Event` struct itself. Each field maps directly to the JSON above: +Rather than one `Event` struct that may or may not have its id and signature +filled in, we define six structs, each of which adds exactly one field to the +previous. A value's type tells you which steps it has been through, and a +`.foo()` method takes you from one stage to the next. -```rust {file=coracle-lib/src/event.rs} -use serde::{Deserialize, Serialize}; -use sha2::{Sha256, Digest}; +### `EventContent` -/// A nostr event — the fundamental data type of the protocol. -/// -/// Every interaction on nostr is represented as a signed event. The `id` is a -/// SHA-256 hash of the canonical serialization, and the `sig` is a Schnorr -/// signature over that hash using the author's private key. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Event { - /// The event ID: a 32-byte hex-encoded SHA-256 hash of the canonical serialization. - pub id: String, - /// The author's public key: a 32-byte hex-encoded secp256k1 public key (x-only). - pub pubkey: String, - /// Unix timestamp in seconds when the event was created. - pub created_at: u64, - /// The event kind determines how the event should be interpreted. - /// Kind 0 = metadata, kind 1 = short text note, kind 3 = contacts, etc. - pub kind: u16, - /// A list of tags. Each tag is an array of strings where the first element - /// identifies the tag type. Common tags: ["e", ], ["p", ]. - pub tags: Vec>, - /// The event content. Interpretation depends on the kind. +The starting point is the payload the caller wants to publish: a +human-readable `content` string plus the structured `tags` that reference +other events, pubkeys, topics, and whatever else. Tags and content travel +together because tags are part of what the hash commits to — they are not +metadata in the "could be added later" sense. + +```rust {file=coracle-lib/src/events.rs} +/// The content of an event: the human-readable body plus its tags. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EventContent { pub content: String, - /// A 64-byte hex-encoded Schnorr signature of the event ID. - pub sig: String, + pub tags: Vec>, } -``` -## Computing the event ID - -The event ID is not chosen by the author — it's *derived* from the event's content via a -deterministic process. This is what makes events tamper-proof: change any field and the ID -changes, which invalidates the signature. - -The ID is the SHA-256 hash of a specific JSON serialization called the **canonical form**: - -```json -[0, , , , , ] -``` - -That leading `0` is a version number reserved for future use. The array is serialized as -compact JSON (no whitespace) and UTF-8 encoded before hashing. - -```rust {file=coracle-lib/src/event.rs} -impl Event { - /// Serialize this event into the canonical form used for ID computation. - /// The result is: `[0, pubkey, created_at, kind, tags, content]` - pub fn serialize(&self) -> String { - serde_json::to_string( - &serde_json::json!([0, self.pubkey, self.created_at, self.kind, self.tags, self.content]) - ).expect("event serialization should never fail") - } - - /// Compute the event ID by SHA-256 hashing the canonical serialization. - pub fn compute_id(&self) -> String { - let serialized = self.serialize(); - let hash = Sha256::digest(serialized.as_bytes()); - hex::encode(hash) - } - - /// Check whether the event's `id` field matches the computed hash. - pub fn id_is_valid(&self) -> bool { - self.id == self.compute_id() +impl EventContent { + pub fn new(content: impl Into, tags: Vec>) -> Self { + EventContent { content: content.into(), tags } } } ``` -## Verifying signatures +### `EventTemplate` -Nostr uses **Schnorr signatures** over the secp256k1 curve — the same curve used by Bitcoin. -The signature is computed over the event ID (which is already a hash), and can be verified -using the author's public key. +A template adds the numeric `kind` that decides how the content should be +interpreted. A kind-1 template is a short text note; a kind-6 template is a +repost; a kind-1984 template is a report. This chapter stays agnostic about +which kinds mean what — that's the next layer of the stack. -This means anyone can verify an event's authenticity without contacting the author or any -trusted authority. The math guarantees it. +```rust {file=coracle-lib/src/events.rs} +/// Event content plus its kind. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EventTemplate { + pub content: String, + pub tags: Vec>, + pub kind: u16, +} -```rust {file=coracle-lib/src/event.rs} +impl EventContent { + /// Tag the content with a kind, producing a publishable template. + pub fn kind(self, kind: u16) -> EventTemplate { + EventTemplate { content: self.content, tags: self.tags, kind } + } +} +``` + +### `StampedEvent` + +Stamping adds the `created_at` timestamp. Callers usually pass +`SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()`, but the +library stays out of clock policy — the timestamp is whatever the caller says +it is. + +```rust {file=coracle-lib/src/events.rs} +/// A template with a `created_at` timestamp attached. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StampedEvent { + pub content: String, + pub kind: u16, + pub tags: Vec>, + pub created_at: u64, +} + +impl EventTemplate { + pub fn stamp(self, created_at: u64) -> StampedEvent { + StampedEvent { + content: self.content, + kind: self.kind, + tags: self.tags, + created_at, + } + } +} +``` + +### `OwnedEvent` + +Ownership means attaching an author. This is where the `PublicKey` from +chapter 2 shows up: the event is about to be something that some specific key +claims responsibility for. + +```rust {file=coracle-lib/src/events.rs} +/// A stamped event with its author's public key attached. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OwnedEvent { + pub content: String, + pub kind: u16, + pub tags: Vec>, + pub created_at: u64, + pub pubkey: PublicKey, +} + +impl StampedEvent { + pub fn own(self, pubkey: PublicKey) -> OwnedEvent { + OwnedEvent { + content: self.content, + kind: self.kind, + tags: self.tags, + created_at: self.created_at, + pubkey, + } + } +} +``` + +### `HashedEvent` + +Hashing produces the event id — the 32-byte SHA-256 that NIP-01 defines as +the hash of a specific canonical serialization. The canonical form is a JSON +*array*, not an object: + +```text +[0, pubkey, created_at, kind, tags, content] +``` + +The leading `0` is a version marker reserved by the protocol. The order is +fixed. Every nostr implementation on the network has to produce byte- +identical output from the same fields, or ids wouldn't match. + +```rust {file=coracle-lib/src/events.rs} +fn canonical( + pubkey: &PublicKey, + created_at: u64, + kind: u16, + tags: &[Vec], + content: &str, +) -> String { + serde_json::json!([ + 0, + pubkey.to_hex(), + created_at, + kind, + tags, + content, + ]) + .to_string() +} + +/// An owned event with its computed id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HashedEvent { + pub content: String, + pub kind: u16, + pub tags: Vec>, + pub created_at: u64, + pub pubkey: PublicKey, + pub id: [u8; 32], +} + +impl OwnedEvent { + pub fn hash(self) -> HashedEvent { + let id: [u8; 32] = Sha256::digest( + canonical(&self.pubkey, self.created_at, self.kind, &self.tags, &self.content) + .as_bytes(), + ) + .into(); + HashedEvent { + content: self.content, + kind: self.kind, + tags: self.tags, + created_at: self.created_at, + pubkey: self.pubkey, + id, + } + } +} +``` + +### `Event` + +The final step attaches the signature and yields the type that goes on the +wire. + +```rust {file=coracle-lib/src/events.rs} +/// A fully-formed nostr event: hashed and signed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Event { + pub content: String, + pub kind: u16, + pub tags: Vec>, + pub created_at: u64, + pub pubkey: PublicKey, + pub id: [u8; 32], + pub sig: [u8; 64], +} + +impl HashedEvent { + pub fn sign(self, sig: [u8; 64]) -> Event { + Event { + content: self.content, + kind: self.kind, + tags: self.tags, + created_at: self.created_at, + pubkey: self.pubkey, + id: self.id, + sig, + } + } +} +``` + +The two-step split between `hash` and `sign` is deliberate. `hash` is pure +and knows nothing about keys; `sign` takes an already-computed signature as +input rather than doing the signing itself. That keeps the signing primitive +out of the event module, which is where we need it next. + +## Signing a 32-byte message + +We need exactly one new capability to drive the pipeline above end-to-end: a +way for a `SecretKey` to produce a Schnorr signature over 32 arbitrary bytes. +Not "sign an event" — just "sign these bytes." A future chapter will wrap +this in a `Signer` trait that knows about events; we want only one place in +the library where the cryptographic operation actually happens. + +```rust {file=coracle-lib/src/keys.rs} +impl SecretKey { + /// Schnorr-sign a 32-byte message under this secret key. The digest + /// length is enforced at the type level; callers have to decide what + /// 32 bytes they're signing. Deterministic under BIP-340. + pub fn sign(&self, message: &[u8; 32]) -> [u8; 64] { + let keypair = secp256k1::Keypair::from_secret_key(SECP256K1, &self.0); + let msg = secp256k1::Message::from_digest(*message); + SECP256K1.sign_schnorr_no_aux_rand(&msg, &keypair).serialize() + } +} +``` + +With that in place, the full pipeline from "hello nostr" to a signed event is +one expression: + +```rust +let hashed = EventContent::new("hello nostr", vec![]) + .kind(1) + .stamp(1_700_000_000) + .own(secret.public_key()) + .hash(); +let event = hashed.clone().sign(secret.sign(&hashed.id)); +``` + +The pipeline is illustrative, not tangled — the library does not ship an +`Event::sign_with` or `Signer` convenience yet. The later chapter on signers +wraps these inner two lines in a `Signer::sign_event` default so that remote +signers (NIP-07, NIP-46) plug into the same shape. + +## Verification + +Verification reverses the pipeline. Given an `Event`, recompute the canonical +form from its current fields, compare the resulting hash against the stored +`id`, and check the Schnorr signature. + +```rust {file=coracle-lib/src/events.rs} impl Event { - /// Verify the event's Schnorr signature against its public key and ID. - /// Returns `true` if the signature is valid and the ID matches the content. - pub fn verify(&self) -> bool { - if !self.id_is_valid() { - return false; + /// Recompute the event id and compare it to the stored `id`. + pub fn verify_id(&self) -> bool { + let expected: [u8; 32] = Sha256::digest( + canonical(&self.pubkey, self.created_at, self.kind, &self.tags, &self.content) + .as_bytes(), + ) + .into(); + expected == self.id + } + + /// Verify both the id and the Schnorr signature. + pub fn verify(&self) -> Result<(), EventError> { + if !self.verify_id() { + return Err(EventError::InvalidId); + } + let msg = secp256k1::Message::from_digest(self.id); + let sig = secp256k1::schnorr::Signature::from_slice(&self.sig) + .map_err(|_| EventError::InvalidSignature)?; + let xonly = secp256k1::XOnlyPublicKey::from_slice(&self.pubkey.as_bytes()) + .map_err(|_| EventError::InvalidSignature)?; + sig.verify(&msg, &xonly).map_err(|_| EventError::InvalidSignature) + } +} +``` + +The canonical-form helper is the single source of truth for "what bytes go +into the hash." Both `OwnedEvent::hash` and `Event::verify_id` call it, so +there is no way for the signing and verification paths to disagree about the +serialization. + +## JSON + +On the wire an event is the JSON object from the start of the chapter. The +binary fields — `id`, `pubkey`, and `sig` — are hex strings there, and +fixed-size byte arrays in memory. We hand-write `Serialize` and `Deserialize` +to bridge the two without pulling in `serde_with`. + +```rust {file=coracle-lib/src/events.rs} +impl Serialize for Event { + fn serialize(&self, serializer: S) -> Result { + let mut state = serializer.serialize_struct("Event", 7)?; + state.serialize_field("id", &hex::encode(self.id))?; + state.serialize_field("pubkey", &self.pubkey.to_hex())?; + state.serialize_field("created_at", &self.created_at)?; + state.serialize_field("kind", &self.kind)?; + state.serialize_field("tags", &self.tags)?; + state.serialize_field("content", &self.content)?; + state.serialize_field("sig", &hex::encode(self.sig))?; + state.end() + } +} +``` + +The deserializer walks the fields into `Option`s, validates hex shape at the +end, and silently ignores unknown keys — relays and clients sometimes attach +their own, and rejecting them would make this parser less permissive than +the network. + +```rust {file=coracle-lib/src/events.rs} +impl<'de> Deserialize<'de> for Event { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_map(EventVisitor) + } +} + +struct EventVisitor; + +impl<'de> Visitor<'de> for EventVisitor { + type Value = Event; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a nostr event object") + } + + fn visit_map>(self, mut map: M) -> Result { + let mut id: Option = None; + let mut pubkey: Option = None; + let mut created_at: Option = None; + let mut kind: Option = None; + let mut tags: Option>> = None; + let mut content: Option = None; + let mut sig: Option = None; + + while let Some(key) = map.next_key::()? { + match key.as_str() { + "id" => id = Some(map.next_value()?), + "pubkey" => pubkey = Some(map.next_value()?), + "created_at" => created_at = Some(map.next_value()?), + "kind" => kind = Some(map.next_value()?), + "tags" => tags = Some(map.next_value()?), + "content" => content = Some(map.next_value()?), + "sig" => sig = Some(map.next_value()?), + _ => { + let _: serde::de::IgnoredAny = map.next_value()?; + } + } } - let id_bytes = match hex::decode(&self.id) { - Ok(b) => b, - Err(_) => return false, - }; - let sig_bytes = match hex::decode(&self.sig) { - Ok(b) => b, - Err(_) => return false, - }; - let pubkey_bytes = match hex::decode(&self.pubkey) { - Ok(b) => b, - Err(_) => return false, - }; + let id = decode_32(&id.ok_or_else(|| de::Error::missing_field("id"))?) + .map_err(|_| de::Error::custom("invalid hex in field `id`"))?; + let sig = decode_64(&sig.ok_or_else(|| de::Error::missing_field("sig"))?) + .map_err(|_| de::Error::custom("invalid hex in field `sig`"))?; + let pubkey = PublicKey::from_hex( + &pubkey.ok_or_else(|| de::Error::missing_field("pubkey"))?, + ) + .map_err(|_| de::Error::custom("invalid hex in field `pubkey`"))?; - let msg = match secp256k1::Message::from_digest_slice(&id_bytes) { - Ok(m) => m, - Err(_) => return false, - }; - let sig = match secp256k1::schnorr::Signature::from_slice(&sig_bytes) { - Ok(s) => s, - Err(_) => return false, - }; - let pubkey = match secp256k1::XOnlyPublicKey::from_slice(&pubkey_bytes) { - Ok(k) => k, - Err(_) => return false, - }; - - sig.verify(&msg, &pubkey).is_ok() + Ok(Event { + id, + pubkey, + created_at: created_at.ok_or_else(|| de::Error::missing_field("created_at"))?, + kind: kind.ok_or_else(|| de::Error::missing_field("kind"))?, + tags: tags.ok_or_else(|| de::Error::missing_field("tags"))?, + content: content.ok_or_else(|| de::Error::missing_field("content"))?, + sig, + }) } } + +fn decode_32(s: &str) -> Result<[u8; 32], ()> { + let bytes = hex::decode(s).map_err(|_| ())?; + bytes.try_into().map_err(|_| ()) +} + +fn decode_64(s: &str) -> Result<[u8; 64], ()> { + let bytes = hex::decode(s).map_err(|_| ())?; + let arr: [u8; 64] = bytes.try_into().map_err(|_| ())?; + Ok(arr) +} ``` -## Creating events +Deserialization does not verify the signature. Parsing is cheap; `verify()` +is not, and there are many places where you want to read an event off disk +without paying for a signature check. -To create an event, you provide the content fields and sign with a secret key. The process is: +## What's next -1. Fill in `pubkey`, `created_at`, `kind`, `tags`, and `content` -2. Compute the `id` from the canonical serialization -3. Sign the `id` with the secret key to produce `sig` - -```rust {file=coracle-lib/src/event.rs} -impl Event { - /// Create a new signed event from the given fields and secret key. - /// - /// The `pubkey`, `id`, and `sig` fields are computed automatically. - pub fn new( - kind: u16, - content: &str, - tags: Vec>, - created_at: u64, - secret_key: &secp256k1::SecretKey, - ) -> Self { - let secp = secp256k1::Secp256k1::new(); - let keypair = secp256k1::Keypair::from_secret_key(&secp, secret_key); - let (pubkey, _parity) = keypair.x_only_public_key(); - - let mut event = Event { - id: String::new(), - pubkey: hex::encode(pubkey.serialize()), - created_at, - kind, - tags, - content: content.to_string(), - sig: String::new(), - }; - - event.id = event.compute_id(); - - let id_bytes = hex::decode(&event.id).expect("just computed, must be valid hex"); - let msg = secp256k1::Message::from_digest_slice(&id_bytes) - .expect("SHA-256 output is always 32 bytes"); - let sig = secp.sign_schnorr_no_aux_rand(&msg, &keypair); - event.sig = hex::encode(sig.serialize()); - - event - } -} -``` - -With this, you can create and verify events — the atomic unit of all nostr communication. In -the next chapter, we'll look at how keys work in more detail and introduce signing abstractions -that go beyond a raw secret key. +The next chapter gives `tags` a proper type. Tags are the structured half of +every event — references to other events, pubkeys, topics, relay hints — and +treating them as bare `Vec>` gets tiresome fast. diff --git a/book/SUMMARY.md b/book/SUMMARY.md index a11c7b4..cf2db79 100644 --- a/book/SUMMARY.md +++ b/book/SUMMARY.md @@ -7,59 +7,58 @@ - [Keys](02-keys.md) - [Encryption](03-encryption.md) - [Events](04-events.md) -- [Signing](05-signing.md) -- [Tags](06-tags.md) -- [Kinds](07-kinds.md) -- [Kind Ranges](08-kind-ranges.md) -- [Addresses](09-addresses.md) -- [Proof of Work](10-proof-of-work.md) -- [Filters](11-filters.md) +- [Tags](05-tags.md) +- [Kinds](06-kinds.md) +- [Kind Ranges](07-kind-ranges.md) +- [Addresses](08-addresses.md) +- [Proof of Work](09-proof-of-work.md) +- [Filters](10-filters.md) ## Domain -- [Relay Selections](12-relay-selections.md) -- [Relay Metadata](13-relay-metadata.md) -- [Relay Membership](14-relay-membership.md) -- [Profiles](15-profiles.md) -- [Follows](16-follows.md) -- [Microblogging](17-microblogging.md) -- [Reactions](18-reactions.md) -- [Reports](19-reports.md) -- [Emojis](20-emojis.md) -- [Zaps](21-zaps.md) -- [Rooms](22-rooms.md) +- [Relay Selections](11-relay-selections.md) +- [Relay Metadata](12-relay-metadata.md) +- [Relay Membership](13-relay-membership.md) +- [Profiles](14-profiles.md) +- [Follows](15-follows.md) +- [Microblogging](16-microblogging.md) +- [Reactions](17-reactions.md) +- [Reports](18-reports.md) +- [Emojis](19-emojis.md) +- [Zaps](20-zaps.md) +- [Rooms](21-rooms.md) ## Networking -- [Relay Connections](23-relay-connections.md) -- [Relay Authentication](24-relay-authentication.md) -- [Relay Policies](25-relay-policies.md) -- [Server Authentication](26-server-authentication.md) -- [Relay Management API](27-relay-management-api.md) -- [Blossom Media Storage](28-blossom-media-storage.md) +- [Relay Connections](22-relay-connections.md) +- [Relay Authentication](23-relay-authentication.md) +- [Relay Policies](24-relay-policies.md) +- [Server Authentication](25-server-authentication.md) +- [Relay Management API](26-relay-management-api.md) +- [Blossom Media Storage](27-blossom-media-storage.md) ## Signers -- [Signer Interface](29-signer-interface.md) -- [Secret Signers](30-secret-signers.md) -- [Remote Signers](31-remote-signers.md) -- [Android Signers](32-android-signers.md) -- [Browser Signers](33-browser-signers.md) +- [Signer Interface](28-signer-interface.md) +- [Secret Signers](29-secret-signers.md) +- [Remote Signers](30-remote-signers.md) +- [Android Signers](31-android-signers.md) +- [Browser Signers](32-browser-signers.md) ## Content -- [Entities](34-entities.md) -- [Relays](35-relays.md) -- [Rooms](36-rooms.md) -- [Links](37-links.md) -- [Lightning](38-lightning.md) -- [Cashu](39-cashu.md) -- [Emojis](40-emojis.md) -- [Topics](41-topics.md) -- [Code](42-code.md) +- [Entities](33-entities.md) +- [Relays](34-relays.md) +- [Rooms](35-rooms.md) +- [Links](36-links.md) +- [Lightning](37-lightning.md) +- [Cashu](38-cashu.md) +- [Emojis](39-emojis.md) +- [Topics](40-topics.md) +- [Code](41-code.md) ## Storage -- [Event Repository](43-event-repository.md) -- [In Memory Backend](44-in-memory-backend.md) -- [Sqlite Backend](45-sqlite-backend.md) +- [Event Repository](42-event-repository.md) +- [In Memory Backend](43-in-memory-backend.md) +- [Sqlite Backend](44-sqlite-backend.md) diff --git a/book/plan/events.md b/book/plan/events.md new file mode 100644 index 0000000..2de8012 --- /dev/null +++ b/book/plan/events.md @@ -0,0 +1,234 @@ +# Plan: Events + +## Topic Summary + +Introduce the core nostr `Event` type: the seven wire fields, JSON +(de)serialization, the NIP-01 canonical form used for ID computation, the +unsigned-vs-signed lifecycle distinction, and minimal structural validation. Add +a signing primitive on `SecretKey` that signs an arbitrary byte message (not an +event), and show — in prose, not in tangled code — how to combine `UnsignedEvent::id()` +and `SecretKey::sign()` to produce a complete signed `Event`. A richer +event-signing API and the `Signer` trait are deferred to chapter 05. + +## Chapter Outline + +1. **Opening framing.** Events as the entire nostr substrate: signed, + content-addressable, permissionless facts. Referential transparency. Why a + regular event's id is `sha256` of its canonical form. Connects back to + chapter 02 ("your public key is your name") and forward ("everything in this + book is built out of events"). + +2. **The module.** Register `events` in `lib.rs`. Imports. + +3. **Errors.** `EventError` enum covering JSON failures, id mismatch, signature + failure, and structural problems. + +4. **The wire structure.** Walk through the seven fields with a minimal JSON + example. Show the raw shape the network agrees on. + +5. **The `Event` struct.** Define a plain struct with public fields, derive + `Clone`, `Debug`, `PartialEq`, `Eq`. Field types: + - `id: [u8; 32]` + - `pubkey: PublicKey` (from chapter 02) + - `created_at: u64` + - `kind: u16` + - `tags: Vec>` + - `content: String` + - `sig: [u8; 64]` + Add `serde` impls (custom for hex-encoded binary fields, derived otherwise). + +6. **Canonical form and the id.** Show `[0, pubkey, created_at, kind, tags, content]`. + Produce it with `serde_json::json!`. Hash with `sha2::Sha256`. Return 32 bytes. + Introduce `UnsignedEvent` struct (same fields minus `id` and `sig`) and put + `fn id(&self) -> [u8; 32]` on it. + +7. **Signing primitive on `SecretKey`.** Add `SecretKey::sign(&self, msg: &[u8]) -> [u8; 64]`. + This belongs in `keys.rs` but is introduced *here* because this is where we + first need it. It wraps `secp256k1`'s Schnorr signing over a 32-byte digest, + using the shared `SECP256K1` context. Show a worked example — in prose, NOT + tangled — of `secret.sign(&unsigned.id())` producing a `[u8; 64]` that can be + combined with the `UnsignedEvent` fields to form an `Event`. This keeps + orchestration out of the core so chapter 05 can build a proper `Signer` + abstraction on top. + +8. **Verification.** Two methods on `Event`: + - `fn verify_id(&self) -> bool` — recomputes the id from the other fields and + compares. + - `fn verify(&self) -> Result<(), EventError>` — verifies id and Schnorr + signature. Uses `secp256k1::Message::from_digest` + `verify_schnorr` with + the stored pubkey. + Note: no `PublicKey::verify` method yet — the verification goes directly + through `secp256k1` inside `Event::verify`. A future signer chapter can pull + that out. + +9. **JSON round-tripping.** `Event` implements `Serialize`/`Deserialize`. + Because we store id/sig as bytes, we implement serde by hand via an + intermediate struct with hex-string fields. Highlight: incoming JSON is + validated structurally by serde but the id/sig are NOT cryptographically + validated on deserialization — that's what `verify()` is for. + +10. **A worked example.** Build an unsigned text-note event, compute its id, + sign it, assemble the `Event`, serialize to JSON, parse it back, verify. + This block is illustrative — marked without a `{file=...}` annotation so + it is NOT tangled. It exists only in the narrative to show how the pieces + fit together. + +11. **What's next.** Pointer forward to chapter 05 (Signing), which will wrap + `SecretKey::sign` in a proper `Signer` abstraction that knows how to sign + events directly. + +## API Design + +New in `coracle-lib/src/events.rs`: + +```rust +pub enum EventError { + Json(String), + IdMismatch, + InvalidSignature, + InvalidHexField(&'static str), +} + +pub struct Event { + pub id: [u8; 32], + pub pubkey: PublicKey, + pub created_at: u64, + pub kind: u16, + pub tags: Vec>, + pub content: String, + pub sig: [u8; 64], +} + +impl Event { + pub fn verify_id(&self) -> bool; + pub fn verify(&self) -> Result<(), EventError>; +} + +impl Serialize for Event { ... } +impl<'de> Deserialize<'de> for Event { ... } + +pub struct UnsignedEvent { + pub pubkey: PublicKey, + pub created_at: u64, + pub kind: u16, + pub tags: Vec>, + pub content: String, +} + +impl UnsignedEvent { + pub fn id(&self) -> [u8; 32]; +} +``` + +New in `coracle-lib/src/keys.rs` (addition): + +```rust +impl SecretKey { + pub fn sign(&self, message: &[u8; 32]) -> [u8; 64]; +} +``` + +Taking `&[u8; 32]` rather than `&[u8]` encodes Schnorr-over-a-digest at the +type level and matches `secp256k1::Message::from_digest`. The chapter notes that +this is "sign an arbitrary 32-byte message," not "sign an event." + +## Code Organization + +- `coracle-lib/src/events.rs` — new file, all event-related types. +- `coracle-lib/src/lib.rs` — register `pub mod events;`. +- `coracle-lib/src/keys.rs` — append a small block adding `SecretKey::sign`. +- `coracle-lib/tests/events.rs` — hand-written integration tests (not tangled): + round-trip a known event, verify a pre-signed fixture, reject tampered id/sig. + +## Dependencies + +All already in `coracle-lib/Cargo.toml`: +- `serde`, `serde_json` — JSON and canonical form +- `sha2` — event id hash +- `secp256k1` — Schnorr signing and verification +- `hex` — id/sig hex encoding in serde impls + +No new dependencies. + +## Narrative Notes + +- **Open with referential transparency.** The single most important concept in + this chapter is that the event id is a function of the content. Lead with + that, not with field enumeration. +- **Canonical form deserves its own subsection.** Readers coming from + conventional APIs will expect "just JSON"; it's worth stopping to explain why + nostr uses a positional array, not an object, to compute the hash. +- **Two separate structs, not `Option`.** Explain the choice: making the + lifecycle visible in types is worth the slightly larger API surface in a + teaching library. Reference rust-nostr's similar approach. +- **Why `sign` is on `SecretKey`, not `Event`.** Keep signing as a low-level + primitive over bytes. Chapter 05 will introduce a `Signer` trait that + abstracts over local/remote/browser signers; only that trait will have an + `sign_event` method. Keeping `Event` signer-agnostic now avoids having to + retrofit it later. +- **Show the worked example early enough that readers can see the pieces click.** + Put it right after verification so they see create → hash → sign → verify in + one flow. +- **Structural validation is shallow.** Point out explicitly that `verify()` + does the real work and structural validation is about rejecting garbage, not + about enforcing semantics. Tie back to the "validation philosophy" from + chapter 01's framing and from the building-nostr philosophy. +- **Custom serde for binary fields.** Walk through why we hand-write + `Serialize`/`Deserialize`: the wire format expects id/sig as lowercase hex + strings, but we want to hold them as `[u8; 32]`/`[u8; 64]` internally. An + intermediate struct with hex `String` fields + `From` conversions is the + cleanest approach. + +## Design Decisions + +1. **Plain struct with public fields.** Matches rust-nostr. No getters, no + setters, no builder — the teaching goal is clarity about what an event *is*. + A builder can come later if it earns its place. + +2. **Separate `UnsignedEvent`.** Option 1 from the research. Clearer lifecycle + in types; avoids `Option` juggling. + +3. **`[u8; 32]` / `[u8; 64]` for id/sig, not newtypes.** Keep the type surface + small for this chapter. A later chapter can promote them to `EventId` etc. if + it's worth it. The serde impl hides the raw array at the JSON boundary. + +4. **Custom serde impls via an intermediate hex-string struct.** Mirrors + rust-nostr's `EventIntermediate` approach but simpler: one owned struct for + deserialization, hand-written `Serialize` for output. Avoids pulling in + `serde_with` or writing `serialize_with` attributes. + +5. **`SecretKey::sign` takes `&[u8; 32]`, not `&[u8]`.** Schnorr signs a 32-byte + digest; the fixed-size array makes that contract visible and avoids runtime + errors. Matches `secp256k1::Message::from_digest`. + +6. **No `PublicKey::verify` yet.** One primitive at a time. Verification lives + inside `Event::verify` for now, which hides the secp256k1 details. + Chapter 05 can pull it out when the `Signer` trait emerges. + +7. **`u64` for `created_at`.** Unix seconds fit comfortably. A signed int would + suggest pre-epoch events are meaningful; they aren't in nostr. + +8. **`u16` for `kind`.** nostr kinds fit in 16 bits and the canonical form + serializes them as bare integers. No enum yet — that's chapter 07. + +9. **Tags as `Vec>`.** Matches every reference. Richer tag types + wait for chapter 06. + +10. **Worked example is NOT tangled.** Per user's explicit instruction: show + event signing as a prose example using the `sign` primitive, but do not + generate a `sign_event` or similar helper in the library. + +## Open Questions + +- **Should `Event::verify` validate structural invariants (pubkey length, hex + shape of tags, etc.) in addition to id and signature?** Leaning no — those are + caught at deserialization by serde. `verify()` should be *cryptographic* + verification only. Will decide in writing. + +- **Error granularity for JSON failures.** Keep a single `Json(String)` variant, + or split into `InvalidJson` / `MissingField` / `WrongType`? Leaning on a + single variant to match the style of the keys chapter's `KeyError`. + +- **Should `UnsignedEvent` have a convenience constructor for kind-1 text notes + with `created_at = now()`?** Leaning no — this chapter should show the raw + fields; builders and helpers can come later without breaking anything. diff --git a/book/research/events.md b/book/research/events.md new file mode 100644 index 0000000..dd14218 --- /dev/null +++ b/book/research/events.md @@ -0,0 +1,302 @@ +# Research: Events + +## Topic Summary + +The events chapter covers the core nostr Event data structure: fields (id, pubkey, +created_at, kind, tags, content, sig), JSON serialization, canonical form, event ID +computation (sha256 of canonical JSON), unsigned vs signed event representations, and +structural validation. Signing is addressed by adding a `sign` method to `PrivateKey` +that signs an arbitrary string (not an event). The chapter includes an illustrative +example of event signing using this primitive, but does not tangle a sign method on +events themselves. + +## Philosophy + +From `ref/building-nostr/content/book.md` and `summary.md`: + +**Events as referentially transparent facts.** An event's ID is the hash of its +content (NIP-01), making events content-addressable and immutable. If you have an +event ID, you know the exact content forever. This is the foundation of nostr's +break from server-centric architectures. Regular, non-replaceable events should be +the default because replaceable events break referential transparency. + +**Permissionless validity through signatures.** Events gain legitimacy through +secp256k1 Schnorr signatures. Any event that is signed is valid simply by virtue of +existing — validation is limited to hash and signature verification plus a few +structural checks. The protocol explicitly rejects schema enforcement in favor of +social/reputational filtering. + +**Minimal kernel.** Events are the entire nostr substrate. All application +semantics emerge from event kinds and tagged references. The protocol itself is "a +very humble protocol — it is little more than an empty shell which users can then +fill with content types that solve their use cases." + +**Four-part structure.** +- Cryptographic core: id, pubkey, sig +- Metadata: created_at, kind +- Structured data: tags (array of string arrays) +- Human-readable: content + +Content should hold human-readable payloads. Encoding JSON or encrypted data in +content is considered an antipattern (though encrypted DMs necessarily do this). + +**Weak timestamps.** `created_at` is a second-granularity Unix timestamp supplied +unilaterally by the author. Ordering is delegated to the social layer; nostr refuses +to solve distributed clock problems. + +**Fault-tolerant validation.** "Liberal in what you accept" applied conservatively: +tolerate malformed data but always verify signatures. Never repair data that +contradicts conventions. + +## Reference Implementation Analysis + +### applesauce + +Re-exports core types (`NostrEvent`, `EventTemplate`, `UnsignedEvent`, +`VerifiedEvent`) from `nostr-tools/pure` rather than defining its own. Adds +kind-narrowing wrappers `KnownEvent` etc. for type safety. Delegates +serialization, hashing, and signature verification entirely to nostr-tools. + +The notable contribution is `EventFactory` (`packages/core/src/factories/event.ts`), +a fluent builder extending `Promise` with chainable operations: +`fromKind(1).content("hello").stamp(signer).sign(signer)`. Three lifecycle states: +`EventTemplate` → `UnsignedEvent` (has pubkey) → `NostrEvent` (signed). + +Structural validation (`isEvent`) checks string/number types and 64-char hex lengths +for id/pubkey but does NOT verify signatures. Signature verification is pluggable on +the `AsyncEventStore` via a `verifyEvent` callback, cached after first verification +via a symbol property. Attaches metadata via symbols to avoid polluting the event +object. + +### ndk + +Uses class-based design: `NDKEvent` extends `EventEmitter` with mutable public +fields. Provides inheritance hierarchy for kind-specific subclasses (NDKArticle, +NDKZap, etc.). + +Canonical serialization in `core/src/events/serializer.ts` produces the NIP-01 JSON +array `[0, pubkey, created_at, kind, tags, content]`. Hash computed via +`@noble/hashes` sha256 plus hex encoding. Validation uses regex +`/^[a-f0-9]{64}$/` for pubkey format, and throws detailed errors with event +snapshots on structural failure. + +Signature verification supports two modes: synchronous (blocking, via +`@noble/curves` schnorr) or Web-Worker offloaded for non-blocking verification in +browsers. Results cached in a 1000-entry LRU with 60s TTL. Type-safe signed vs +unsigned via discriminated unions (`NDKSignedEvent`, `NDKUnsignedEvent`) with guard +functions. + +`toNostrEvent()` finalizes by computing id hash before signing; `sign()` delegates +to pluggable `NDKSigner`. Notable: in-place mutation is pervasive (setters on all +fields), trading safety for ergonomics. + +### nostr-gadgets + +Treats events as plain TS objects imported from `@nostr/tools/pure`; does not +define its own Event type. Consumes already-finalized events — ID computation, +signing, and verification are all delegated to nostr-tools. + +Interesting only for its storage strategy: RedEventStore constructs JSON manually +(`'{"pubkey":"${event.pubkey}","id":"${event.id}",...}'`) to control field order +and append custom metadata like `seen_on`. Validation is limited to structural +bounds: `created_at > 0xffffffff || kind > 0xffff`. No signature verification at +the storage layer — that's assumed to happen upstream at the relay/pool boundary. + +Uses Symbol properties (`isLocalSymbol`, `seenOnSymbol`) to attach metadata without +mutating events. No builder pattern — callers use `finalizeEvent` from +nostr-tools directly. + +### nostr-tools + +The de facto reference for TypeScript nostr. Defines the canonical `NostrEvent` +type as a plain object (not a class). Uses a `verifiedSymbol` for type +discrimination of verified events without wrapping them. + +Three type variants via `Pick<>`: +- `EventTemplate`: `kind`, `tags`, `content`, `created_at` +- `UnsignedEvent`: adds `pubkey` +- `Event` / `VerifiedEvent`: complete with `id`, `sig` + +Canonical serialization in `pure.ts`: +```typescript +function serializeEvent(evt: UnsignedEvent): string { + return JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content]) +} +``` + +`getEventHash()` is sha256 over UTF-8 encoded canonical JSON, returned as hex. +`finalizeEvent(template, secretKey)` bridges template → signed event, mutating the +template in-place for efficiency. + +Validation (`validateEvent`) checks field types, regex-validates pubkey format, +and verifies tag shape. `verifyEvent` performs both ID recomputation (catches +tampering) and Schnorr signature verification via `@noble/curves/secp256k1`. Result +cached via `verifiedSymbol`. + +Dual backend: `pure.ts` uses pure-JS `@noble`; `wasm.ts` provides a WASM +alternative with identical API. + +### rust-nostr + +The most relevant reference, being Rust. Defines three event representations: + +**`Event`** (`event/mod.rs`): `#[non_exhaustive]` struct with public immutable +fields. Implements `Clone`, `Eq`, `Ord` (by created_at desc, then id), `Hash`. +Construction only via `Event::new()`, protecting against future additive changes. + +**`UnsignedEvent`** (`event/unsigned.rs`): Same fields minus `sig`, with `id` as +`Option` — lazily computed on first access via interior mutability. + +**`EventBorrow<'a>`** (`event/borrow.rs`): Zero-copy view for serialization and +validation without allocating; holds `&[u8; 32]` for id/pubkey/sig. + +**Supporting types:** `EventId` is a newtype around `[u8; 32]`, serialized as hex; +`Kind` is an enum with named variants plus `Custom(u16)`; `Tag` wraps +`Vec` with lazy standardization parsing via `OnceCell`. + +**Serialization:** Uses an `EventIntermediate<'a>` struct with `Cow<'a, T>` fields +for zero-copy serialize and owned deserialize. Custom hex encoding for signatures +via `serialize_with`. Silently ignores unknown JSON fields (lenient for relay +compat). + +**ID computation:** `EventId::new()` uses `serde_json::json!()` to build the +canonical `[0, pubkey, created_at, kind, tags, content]` array, serializes to +string, hashes with `bitcoin_hashes::sha256::Hash`, returns as 32-byte newtype. + +**Validation:** +- `Event::verify_id()`: recompute and compare +- `Event::verify_signature()`: Schnorr via `secp256k1::verify_schnorr` +- `Event::verify()`: both, returning `Result<(), Error>` + +**EventBuilder** (`event/builder.rs`): fluent builder that produces `UnsignedEvent` +via `.build(pubkey)`. Includes POW mining (optional multi-threaded via +`Arc`), self-tag filtering, tag deduplication, and custom timestamps. +Signing via `NostrSigner` trait (async) or `Keys` directly (sync). + +**no_std support:** Conditional `core::cell::OnceCell` vs `std::sync::OnceLock`. + +**Dependencies:** `secp256k1 0.29`, `serde/serde_json`, `bitcoin_hashes`, +`faster-hex`. Event module is self-contained aside from tight coupling to Tag/Kind +submodules. + +### welshman + +Data-oriented, composition-first design. Defines a layered type hierarchy: + +``` +EventTemplate → StampedEvent → OwnedEvent → HashedEvent → SignedEvent + (content) (+ created_at) (+ pubkey) (+ id) (+ sig) +``` + +Each is a structural type. Construction is a pipeline of pure functions: +`stamp(event, created_at) → own(event, pubkey) → hash(event) → sign(event, secret)`. +No builder class; composition via function calls. + +Delegates crypto to `nostr-tools` and `@noble/curves`. `getHash` is a one-liner +wrapping `getEventHash`. Signature verification upgrades from pure-JS to WASM +asynchronously if available, caches results via the `verifiedSymbol`. + +Adds `TrustedEvent`: an event accepted without verification (e.g., from canonical +relay storage). Represents the distinction between "cryptographically verified" +and "contextually trusted." + +Structural type guards (`isEventTemplate`, `isSignedEvent`) check field types and +formats. + +## Common Patterns + +**Canonical serialization.** All implementations use NIP-01's JSON array form +`[0, pubkey, created_at, kind, tags, content]`. Order matters (deterministic +output). + +**Event ID = sha256(canonical JSON).** Universal. Implementations differ only in +which sha256 library they use. + +**Layered types for lifecycle states.** Everyone distinguishes template / unsigned +/ signed, though the exact type names vary (welshman has the most granular +hierarchy with five stages). + +**Delegation.** Every TypeScript library delegates the hashing and signature +primitives to `@noble/curves` + `@noble/hashes` (via nostr-tools or directly). +Only rust-nostr and nostr-tools actually implement canonical serialization +themselves. + +**Structural validation is cheap and separate from signature verification.** +Libraries uniformly distinguish "is this the right shape?" from "is the signature +valid?" — and often verify signatures lazily, cached, or on-demand. + +**Tags are `string[][]`.** Every implementation represents tags as arrays of +string arrays. None impose a richer schema at the Event level. + +**Plain data over classes.** TypeScript libraries mostly use plain objects +(nostr-tools, nostr-gadgets, welshman, applesauce). NDK is the outlier with a +class hierarchy. rust-nostr uses immutable structs with public fields. + +**Divergence: mutability.** NDK mutates freely; nostr-tools mutates templates in +`finalizeEvent`; welshman and applesauce build new objects. rust-nostr is strictly +immutable after construction. + +**Divergence: builder vs function composition.** rust-nostr and applesauce have +fluent builders. nostr-tools, welshman, and nostr-gadgets expose plain functions. + +## Considerations for Our Implementation + +**Use serde.** `serde` + `serde_json` is the obvious choice for a Rust +implementation. The canonical form `[0, pubkey, created_at, kind, tags, content]` +can be produced via `serde_json::json!` or a custom `Serialize` impl. + +**Event struct.** A plain struct with public fields matches rust-nostr and the +philosophy of "events are data." Fields: `id`, `pubkey`, `created_at`, `kind`, +`tags`, `content`, `sig`. + +**Field types.** +- `id: [u8; 32]` or a `EventId` newtype (rust-nostr uses newtype; keeps the API + clean and enforces length). Start simple — `String` of hex or `[u8; 32]`. +- `pubkey`: reuse the `PublicKey` already defined in the keys chapter. +- `created_at: u64` (Unix timestamp seconds). +- `kind: u16` (nostr kinds fit in 16 bits). +- `tags: Vec>` — simple and correct. +- `content: String`. +- `sig`: a `Signature` type or `[u8; 64]` or hex string. + +**Unsigned vs signed.** Two approaches: +1. Separate `UnsignedEvent` struct (no `id`, no `sig`), with a method to compute + the id and convert into a signed `Event`. Clean but verbose. +2. Single struct with `Option` for id/sig. Mirrors welshman's approach but + loses compile-time guarantees. + +Given the teaching context, option 1 is clearer: it makes the lifecycle visible +in the type system. + +**Canonical serialization via serde_json::json!.** rust-nostr's approach is +compact and readable: +```rust +let canonical = json!([0, pubkey, created_at, kind, tags, content]).to_string(); +let id = sha256(canonical.as_bytes()); +``` + +**ID computation as a method on UnsignedEvent.** `fn id(&self) -> [u8; 32]`. + +**Signing primitive on PrivateKey.** Per user's guidance: add +`PrivateKey::sign(&self, message: &[u8]) -> Signature` (or `&str`), signing an +arbitrary byte string via Schnorr. The chapter shows an example of combining +`unsigned.id()` with `private_key.sign(&id)` to produce a complete `Event`, but +this orchestration is illustrative prose, not tangled source. + +**Verification.** `Event::verify_id()` recomputes and compares; `Event::verify()` +also verifies the Schnorr signature via the existing `PublicKey`-based primitive +(or we add `PublicKey::verify(message, signature)` as a companion to +`PrivateKey::sign`). + +**Dependencies already in tree.** From chapter 02 and 03, `secp256k1` (or +`k256`), `sha2`, and `hex` crates should already be available. Need to confirm +and reuse. Add `serde`, `serde_json` if not yet in `coracle-lib`. + +**Structural validation.** Keep minimal: lengths of id and sig, format of pubkey +(already enforced if `PublicKey` is a typed wrapper), non-negative timestamp +(enforced by `u64`). + +**Kinds chapter is separate.** Don't enumerate kinds; just expose `u16`. A later +chapter adds an enum or constants. + +**Tags chapter is separate.** Don't build tag helpers; just `Vec>`. diff --git a/coracle-lib/tests/events.rs b/coracle-lib/tests/events.rs new file mode 100644 index 0000000..580e090 --- /dev/null +++ b/coracle-lib/tests/events.rs @@ -0,0 +1,117 @@ +use coracle_lib::events::{ + Event, EventContent, EventError, EventTemplate, HashedEvent, OwnedEvent, StampedEvent, +}; +use coracle_lib::keys::SecretKey; + +fn fixed_secret() -> SecretKey { + let bytes: [u8; 32] = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, + 26, 27, 28, 29, 30, 31, 32, + ]; + SecretKey::from_hex(&hex::encode(bytes)).unwrap() +} + +fn build_hashed(content: &str, tags: Vec>) -> HashedEvent { + EventContent::new(content, tags) + .kind(1) + .stamp(1_700_000_000) + .own(fixed_secret().public_key()) + .hash() +} + +fn sign_fixture() -> Event { + let sk = fixed_secret(); + let hashed = build_hashed("hello nostr", vec![vec!["t".into(), "nostr".into()]]); + let sig = sk.sign(&hashed.id); + hashed.sign(sig) +} + +#[test] +fn pipeline_progresses_through_types() { + let content = EventContent::new("hi", vec![]); + let template: EventTemplate = content.kind(1); + let stamped: StampedEvent = template.stamp(1_700_000_000); + let owned: OwnedEvent = stamped.own(fixed_secret().public_key()); + let hashed: HashedEvent = owned.hash(); + let sig = fixed_secret().sign(&hashed.id); + let event: Event = hashed.sign(sig); + assert_eq!(event.content, "hi"); + assert_eq!(event.kind, 1); +} + +#[test] +fn id_is_deterministic() { + let a = build_hashed("hello", vec![]); + let b = build_hashed("hello", vec![]); + assert_eq!(a.id, b.id); +} + +#[test] +fn id_changes_with_content() { + let a = build_hashed("hello", vec![]); + let b = build_hashed("goodbye", vec![]); + assert_ne!(a.id, b.id); +} + +#[test] +fn sign_and_verify_roundtrip() { + let event = sign_fixture(); + assert!(event.verify_id()); + event.verify().expect("signature should verify"); +} + +#[test] +fn tampered_content_fails_id_check() { + let mut event = sign_fixture(); + event.content = "something else".to_string(); + assert!(!event.verify_id()); + assert_eq!(event.verify(), Err(EventError::InvalidId)); +} + +#[test] +fn tampered_sig_fails_verify() { + let mut event = sign_fixture(); + event.sig[0] ^= 0x01; + assert_eq!(event.verify(), Err(EventError::InvalidSignature)); +} + +#[test] +fn json_roundtrip() { + let event = sign_fixture(); + let json = serde_json::to_string(&event).unwrap(); + let parsed: Event = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, event); + parsed.verify().expect("roundtripped event still verifies"); +} + +#[test] +fn json_contains_hex_fields() { + let event = sign_fixture(); + let value: serde_json::Value = serde_json::to_value(&event).unwrap(); + let obj = value.as_object().unwrap(); + assert_eq!(obj["id"].as_str().unwrap().len(), 64); + assert_eq!(obj["pubkey"].as_str().unwrap().len(), 64); + assert_eq!(obj["sig"].as_str().unwrap().len(), 128); + assert_eq!(obj["kind"].as_u64().unwrap(), 1); + assert_eq!(obj["content"].as_str().unwrap(), "hello nostr"); + assert!(obj["tags"].is_array()); +} + +#[test] +fn json_ignores_unknown_fields() { + let event = sign_fixture(); + let mut value: serde_json::Value = serde_json::to_value(&event).unwrap(); + value + .as_object_mut() + .unwrap() + .insert("custom_field".to_string(), serde_json::json!("ignored")); + let reparsed: Event = serde_json::from_value(value).unwrap(); + assert_eq!(reparsed, event); +} + +#[test] +fn secret_key_sign_is_deterministic() { + let sk = fixed_secret(); + let msg = [42u8; 32]; + assert_eq!(sk.sign(&msg), sk.sign(&msg)); +}