diff --git a/Cargo.lock b/Cargo.lock index fc9c70f2..6e03af56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1310,8 +1310,12 @@ dependencies = [ "data-encoding", "hkdf", "nostr", + "nostr-memory", "nostr-sdk", + "rand 0.10.2", + "serde_json", "sha2 0.10.9", + "smol", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3eac7717..39b40fd4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,8 @@ aes-gcm = "0.10" sha2 = "0.10" data-encoding = "2" hkdf = "0.12" +# Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it +rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng" ] } # Others anyhow = "1.0.44" diff --git a/PLAN.md b/PLAN.md index fbda8e66..e40af3c2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -46,7 +46,7 @@ Verified against the pinned revision (`nostr` 0.45.4 / `nostr-sdk` 0.45.2, git ` | NIP-44 under a signer | `nip44::{encrypt, decrypt}` (already wrapped by `state::UniversalSigner`) | | Event id recomputation | `EventId::compute(pubkey, created_at, kind, tags, content)`, `UnsignedEvent::compute_id` | | Event (de)serialization | `Event::{from_json, as_json, verify}`, `UnsignedEvent::from_json` | -| Building events | `EventBuilder::new(..).tags(..).custom_created_at(..)`, `FinalizeEvent::finalize(&Keys)` | +| Building events | `EventBuilder::new(..).tags(..).custom_created_at(..)`, `FinalizeEvent::finalize(&Keys)`; `UnsignedEvent::new(..)` for rumors, whose tags are the author's contract and must not be normalized | | Tags | `Tag::{custom, identifier, public_key, expiration}`, `Tags`, `SingleLetterTag` | | Kinds | `Kind::GiftWrap` (1059), `Kind::Custom(21059|20013|20014|3308|…)`, `Kind::is_ephemeral` | | Publish | `Client::send_event(&event).to(relays).ack_policy(AckPolicy::none())` | @@ -59,16 +59,16 @@ Verified against the pinned revision (`nostr` 0.45.4 / `nostr-sdk` 0.45.2, git ` **Not needed.** `secp256k1` (use `nostr::SecretKey::from_slice` + `Keys::new`), `base64` (use `data_encoding::BASE64`, already a workspace dep), `bech32` (NIP-19 is in the SDK), any new storage engine (the client's LMDB database is enough), any new HTTP client. -**Add one dependency now:** `hkdf = "0.12"` (already in `Cargo.lock` transitively). Add it to `[workspace.dependencies]` and to the new crate. `sha2` is already a workspace dep. **Two more at M8:** the Pin List's per-message key disclosure needs `chacha20 = "0.9"` (already in the tree because we enable nostr's `nip44`, which is where `chacha20` comes from) and `hmac = "0.12"` (already in the tree via `hkdf`) — see §14.8. Zero new crates, all three are direct-dependency lines only. +**Dependencies added so far:** `hkdf = "0.12"` at M0 (already in `Cargo.lock` transitively) and `rand = "0.10"` at M1 for the NIP-44 nonce, pinned to the exact instance `nostr` already builds (`default-features = false`, features `std` + `sys_rng`) so `nostr`'s `os-rng` and ours unify on one `rand`/`getrandom`. `sha2` and `data-encoding` were already workspace deps. **Two more at M8:** the Pin List's per-message key disclosure needs `chacha20 = "0.9"` (already in the tree because we enable nostr's `nip44`, which is where `chacha20` comes from) and `hmac = "0.12"` (already in the tree via `hkdf`) — see §14.8. Zero new crates so far, and all of these are direct-dependency lines only. **Why not depend on Vector's crates.** `vector-core` (published, MIT) holds the only other Rust Concord implementation, in `src/community/v2/*`. It is not reusable as a dependency, and the "reuse their crypto" argument does not hold: - **No shared types.** It exact-pins the nostr family (`nostr = "=0.45.1"`, `nostr-sdk = "=0.45.1"`, `nostr-connect = "=0.45.1"`, `nostr-blossom = "=0.45.0"`) with the note that a caret range would let a consumer resolve a mixed set, while we track git master (`b230cec`, 0.45.4 / 0.45.2). A registry 0.45.1 and a git 0.45.4 cannot unify, so a build linking both carries two `nostr` crates whose `Event`/`Keys`/`PublicKey`/`Client` are unrelated types. - **Not wasm-buildable.** `rusqlite` (bundled C SQLite), `libc`, `rustls`, `reqwest`, `image`, `bip39`, and a `tokio` `net` + `rt-multi-thread` requirement; `VectorCore::init` installs a process-global rustls provider and raises the fd limit. Coop's `web` target is wasm32. - **It is an application core, not a Concord library.** 80k+ lines over 111 files, built on process-global singletons (`state::STATE`, `MY_SECRET_KEY`, one app-data dir, one live account, `traits::set_event_emitter`) and its own SQLite schema, relay pool and blocking `listen()` loop. Adopting it means handing it the nsec and letting it own the client, the database and the event loop — replacing `state`, `chat` and `person` rather than reusing a component. Its `login` stores raw secret-key bytes in that global vault, so an account whose key lives in a signer cannot drive it. -- **There is no cryptography to share.** Both implementations call the same audited crates — `hkdf`, `sha2`, nostr's secp256k1 keypair, and nostr's NIP-44 v2. Vector's comment on that same dependency is "audited RustCrypto crate rather than a hand-rolled construction". Our `derive.rs` has no curve arithmetic, AEAD or randomness of its own: it holds the frozen `info` layout and label table, which are the wire format, not a primitive. +- **There is no cryptography to share.** Both implementations call the same audited crates — `hkdf`, `sha2`, nostr's secp256k1 keypair, and nostr's NIP-44 v2. Vector's comment on that same dependency is "audited RustCrypto crate rather than a hand-rolled construction". Confirmed in M1 by reading `community/cipher.rs`: it is a ~20-line wrapper that draws an OS nonce, calls `nostr::nip44::v2::encrypt_to_bytes_with_nonce`, and base64s the result — which is precisely what `stream.rs` does. Their `stream.rs` likewise calls `nostr_sdk::prelude::nip44::v2::{decrypt_to_bytes, ConversationKey}` directly. Our `derive.rs` holds the frozen `info` layout and label table, and `stream.rs` the seal/wrap ordering; both are wire format, not primitives. -So Vector's crates earn their place as an **oracle, not a dependency**: the golden vectors in `derive.rs` are their published data, produced by an independent implementation. +So Vector's crates earn their place as an **oracle, not a dependency**: the golden vectors in `derive.rs` are their published data, and their `community/v2/stream.rs` was diffed against our §7 before the codec was written. It agrees on every wire detail, and contributed the `ms` first-wins rule, the Control Plane's no-`ms` rumor shape and the `rewrap_seal` contract. ## 4. Crate layout @@ -91,7 +91,9 @@ crates/concord/ `Community` and `Channel` GPUI entities live in `src/lib.rs` next to the registry — they are the public surface, not a separate concern. Ten modules, each with real content; no single-fn files. -Dependencies: `common`, `state`, `person`, `device`, `settings`, `gpui`, `nostr` (for `nip44` features), `nostr-sdk`, `hkdf`, `sha2`, `serde`, `serde_json`, `anyhow`, `flume`, `smallvec`, `itertools`, `futures`, `log`, `instant`. Everything under `cfg(not(target_arch = "wasm32"))` follows the `chat`/`state` split so the crate still builds for web. +Dependencies: `common`, `state`, `person`, `device`, `settings`, `gpui`, `nostr` (for `nip44` features), `nostr-sdk`, `hkdf`, `sha2`, `data-encoding`, `rand`, `serde`, `serde_json`, `anyhow`, `flume`, `smallvec`, `itertools`, `futures`, `log`, `instant`. Everything under `cfg(not(target_arch = "wasm32"))` follows the `chat`/`state` split so the crate still builds for web. + +Declare only what a milestone actually uses. As of M1 the crate depends on `nostr`, `nostr-sdk`, `hkdf`, `sha2`, `data-encoding`, `rand`, `anyhow` (plus `nostr-memory`, `serde_json`, `smol` for tests). `rand` is pinned to the `0.10.2` instance `nostr` already builds and shares its `getrandom`, which the `web` crate already enables `wasm_js` on — so no new package and no new wasm obligation. ## 5. Core types @@ -185,7 +187,7 @@ Rules that must be enforced by construction, not by convention: **Golden vectors.** `derive.rs` pins all 18 published vectors (the seed and `pk` for channel, control, control-signer and guestbook; both keyed labels at epoch `0` and at `0x0102030405060708`; both rekey labels at epoch 1; dissolved; all four locators; the invite key; the community id; the epoch-key commitment), cross-checked against an independent Python implementation (RFC 5869 HKDF plus pure-integer secp256k1) before being frozen. One vector is missing upstream — `pins_locator` — so we mint it from our own implementation and pin it, flagged in the test as self-referential. Changing any pinned value means the wire format changed. -## 7. Stream codec (`stream.rs`) +## 7. Stream codec (`stream.rs`) — implemented in M1 ```rust pub const KIND_WRAP: u16 = 1059; @@ -194,29 +196,56 @@ pub const KIND_SEAL_ENCRYPTED: u16 = 20013; pub const KIND_SEAL_PLAINTEXT: u16 = 20014; pub const NIP44_MAX_PLAINTEXT: usize = 65_535; +pub enum SealForm { Encrypted, Plaintext } + +pub struct OpenedStream { + pub rumor_id: EventId, + pub author: PublicKey, + pub seal_form: SealForm, + pub seal: Event, + pub wrapper_id: EventId, + pub at_ms: u64, + pub rumor: UnsignedEvent, +} + +pub fn split_ms(at_ms: u64) -> (u64, u16); +pub fn resolve_ms_strict(rumor: &UnsignedEvent) -> Result; + pub fn seal_content(rumor: &UnsignedEvent, form: SealForm, group: &GroupKey) -> Result; pub fn build_seal(rumor: &UnsignedEvent, form: SealForm, group: &GroupKey, author: &Keys) -> Result; pub fn wrap_seal(seal: &Event, group: &GroupKey, wrap_kind: u16, at: Timestamp, extra: &[Tag]) -> Result<(Event, Keys), StreamError>; +pub fn rewrap_seal(seal: &Event, new_group: &GroupKey, at: Timestamp) -> Result<(Event, Keys), StreamError>; pub fn open_wrap(wrap: &Event, group: &GroupKey) -> Result; -pub fn open_wrap_at(wrap: &Event, address: &PublicKey, conversation: &ConversationKey, verify_wrap_sig: bool) -> Result; +pub fn open_wrap_at(wrap: &Event, address: &PublicKey, conversation: &ConversationKey, verify_wrap_signature: bool) -> Result; + +pub fn build_rumor_ms(kind: u16, author: PublicKey, content: &str, tags: Vec, at_ms: u64) -> UnsignedEvent; +pub fn build_rumor_secs(kind: u16, author: PublicKey, content: &str, tags: Vec, at_secs: u64) -> UnsignedEvent; pub fn channel_binding_tags(channel: &ChannelId, epoch: Epoch) -> Vec; pub fn check_channel_binding(rumor: &UnsignedEvent, channel: &ChannelId, epoch: Epoch) -> Result<(), StreamError>; -pub fn build_rumor(kind: u16, author: PublicKey, content: &str, tags: Vec, at_ms: u64) -> UnsignedEvent; // appends ["ms", n] -pub fn resolve_ms_strict(rumor: &UnsignedEvent) -> Result; ``` +`StreamError` is a typed enum, not `anyhow`: the caller has to tell a drop from a fatal, and M1's acceptance criterion is that rejections happen in the documented order. + +Refinements against the draft this plan opened with, decided after diffing Vector's `crates/vector-core/src/community/v2/stream.rs` (Concord has no crate of its own there, and `envelope.rs` does not exist): + +- `build_rumor` became `build_rumor_ms` plus `build_rumor_secs`. The Control Plane edition carries **no** `ms` tag, because editions fold by version, not by time. +- `rewrap_seal` was added to the codec. Without it the plaintext-seal carry-forward has no expression, and M7's compaction is its only caller. +- NIP-44 is reached through `nostr`'s own `nip44::v2::{encrypt_to_bytes_with_nonce, decrypt_to_bytes, ConversationKey}`, with a fresh OS nonce per message and `data_encoding::BASE64` for carriage. This is exactly what Vector does; there is no cryptography of theirs to reuse. + Design points that are easy to get wrong: - The wrap is signed by the **stream key** with a random ephemeral `p` tag — NIP-59 reversed. `extra` is how the caller mirrors a NIP-40 expiration onto the wrap. - The seal is signed by the **real author** and carries `created_at` equal to the rumor's. It is never published bare. - Control plane **must** use the plaintext seal; chat, guestbook and rekey planes **must** use the encrypted one. Each plane asserts its own form at both ends. - The control plane is a write-restricted stream: the wrap key derives from `control_root` while the content is encrypted under the `community_root`-derived conversation key. `open_wrap_at` takes the two halves separately for this reason. -- Open order: kind → address match → wrap signature (only when `verify_wrap_sig`) → NIP-44 open → seal kind → seal signature → rumor parse → `rumor.pubkey == seal.pubkey` → recompute the rumor id and reject a mismatch → strict `ms` resolve. +- Open order: kind → address match → wrap signature (only when `verify_wrap_signature`) → NIP-44 open → seal kind → seal signature → rumor parse → `rumor.pubkey == seal.pubkey` → recompute the rumor id and reject a mismatch → strict `ms` resolve. - Enforce the 65 535-byte NIP-44 cap at every nesting layer before publishing. - Do not verify the wrap signature for ordinary planes: every reader holds the group key, so the signature proves nothing. It matters only for the restricted control plane and for rekeys. - The ephemeral wrap keypair is returned to the caller so a client may NIP-09-scrub its own wrap later. +- **A duplicate `ms` tag takes the first value; it is not rejected.** Rejecting made Vector and Armada disagree on whether the event *exists*, and because `ms` orders messages that divergence reached membership. `ms` is the publisher's own value, so conceding a second tag grants an attacker no reach a single one did not. A present-but-valueless `ms`, or one that is not a lone canonical decimal in `0..=999`, is `BadMs` and the event is dropped, never clamped — `u64::from_str` alone would accept a leading `+`, a second encoding a strict peer rejects, so the digit check comes first. +- A binding tag that names the same key twice is rejected outright, since first-match would then be the reader's choice rather than the author's; a valueless tag counts as absent, so a true absence reports `MissingTag`. ## 8. Planes, state and folds @@ -373,14 +402,26 @@ pub fn compact(fold, epoch, new_control_root, ...) -> Vec; // re-wrap h Dissolution (CORD-02 §9) also lives here: a chainless, owner-signed `vsk 10` tombstone at `dissolved_group_key(id)`, plaintext-sealed, and a verifier **must** refuse any tombstone whose `eid` is not the community's own id (including the all-zero placeholder — accepting it lets an owner's genuine tombstone for one community be re-wrapped at another of theirs and kill it permanently). On sight the community is sealed read-only: subscriptions halt, nothing new is honored, existing history stays readable, and a member's delete of their own message is still honored. -## 9. Storage (`store.rs`) +## 9. Storage (`store.rs`) — local layer implemented in M1 Three layers, no new storage engine: 1. **Raw wraps** (kind 1059) are persisted automatically by the SDK's relay pool when a subscription or fetch matches a filter. Nothing to write. 2. **Opened rumors** are cached locally as NIP-78 `Kind::ApplicationSpecificData` events signed by a session-local keypair, exactly like `chat::set_rumor`. Tags: `["d", rumor_id]` (replace key), `["c", channel_hex]`, `["p", author]`, `["k", kind]`, `["e", wrap_id]`, `["t", "concord"]`. Contents are the rumor JSON. - The `c`/`t` keys deliberately differ from chat's `r` key so the two message namespaces can never collide in one database. - - The read path dedupes by rumor id and keeps the newest `created_at`, because the local signing key changes per session. + - `created_at` is the **message's own second** (from `at_ms`), not the wall clock. Otherwise `until` and the ordering would page on cache time rather than message time. + - The read path dedupes by rumor id and keeps the newest `created_at`, because the local signing key changes per session and each session leaves its own copy. The query therefore carries no filter `limit` — every copy has to be in hand before they can be collapsed — and the cap is applied to the deduplicated result instead. + - The layer takes `&dyn NostrDatabase`, not `&Client`: it is local-only, which keeps it testable without a relay or a GPUI context. + +```rust +pub async fn cache_rumor(database: &dyn NostrDatabase, channel: &ChannelId, opened: &OpenedStream) -> Result<()>; +pub async fn query_rumors(database: &dyn NostrDatabase, channel: &ChannelId, until: Option, limit: usize) -> Result>; +``` + +`query_rumors` returns `UnsignedEvent`, not `Event`: the cached payload *is* a rumor, which is also what `OpenedStream` carries, so the caller never has to re-parse. + +**Deferred to M4:** the relay-paging `backfill`. It is network history paging whose "step past the same-second wall" policy belongs with the sync engine, and M1 has no subscription to test it against. + 3. **Community state** — one local document per community, `Kind::ApplicationSpecificData` with `["d", "concord/"]`: ```rust @@ -404,19 +445,10 @@ pub struct CommunityState { } ``` -Writes are debounced (a fold head changes on every edition); reads load once at init. +Writes are debounced (a fold head changes on every edition); reads load once at init. This layer lands in M2, once `Community` exists to hold it. **Decision, stated for the record:** this document stores raw community keys unencrypted in a plaintext local database. That matches the existing posture — `chat` already caches decrypted message rumors in the same LMDB. If that posture ever changes, the state document is the one to wrap with NIP-44-to-self, since it is the only local artifact holding keys. -History queries: - -```rust -pub async fn query_messages(&self, channel: &ChannelId, until: Option, limit: usize) -> Result, Error>; -pub async fn backfill(&self, plane_authors: &[PublicKey], relays: &[RelayUrl], until: Option, limit: usize) -> Result, Error>; -``` - -`query_messages` reads the local cache (`Filter::new().kind(ApplicationSpecificData).custom_tag(LOWERCASE_C, channel_hex)`); `backfill` pages relays newest-first with `until`, deduplicating by wrap id and stepping past same-second walls. - ## 10. Sync engine and GPUI conventions `ConcordRegistry` mirrors `ChatRegistry`'s shape exactly: a foreground GPUI entity holding `Entity` handles, a `flume` signal bus, one background notification listener, one foreground consumer, and task slots that are cleared when the signer changes. @@ -537,7 +569,7 @@ pub fn pin(&self, id: EventId, cx: &App) -> Task>; // vsk 11, ## 11. Integration with existing crates -1. **`crates/chat/src/lib.rs` — required fix.** `handle_notifications` currently treats *every* kind 1059 event as a NIP-17 wrap for the current user and pushes an unwrap failure into the trash. Concord wraps are kind 1059 with an ephemeral `p` tag, so they would flood the trash and leak error toasts. Route by `subscription_id` from `RelayMessage::Event` against `sub_id1`/`sub_id2`, and drop the `if rumor.tags.is_empty()` heuristic once the real recipient check is in place. +1. **`crates/chat/src/lib.rs` — required fix, applied in M2.** `handle_notifications` currently treats *every* kind 1059 event as a NIP-59 gift wrap for the current user and pushes an unwrap failure into the trash. Concord wraps are kind 1059 with an ephemeral `p` tag, so they would flood the trash and leak error toasts. Route by `subscription_id` from `RelayMessage::Event` against `sub_id1`/`sub_id2`, and drop the `if rumor.tags.is_empty()` recipient heuristic. M1 did not need it because nothing subscribes yet. 2. **`desktop/src/main.rs` and `web/src/lib.rs`** — add `concord::init(window, cx)` after `chat::init(window, cx)`. 3. **`Cargo.toml`** — add `hkdf = "0.12"` to `[workspace.dependencies]`; add the crate to `desktop` and `web` dependencies. No other workspace changes. 4. **No changes** to `state`, `person`, `device`, `settings`, `common`, or `ui`. @@ -550,7 +582,7 @@ Each of these has burned a real implementation, or is a documented cross-client - Require `rumor.pubkey == seal.pubkey`. - Require the plaintext seal form on Control and the encrypted form on Chat/Guestbook/Rekey — a strict reader must drop a mis-sealed edition rather than fold a chain a later compaction would fork. - Check `channel` **and** `epoch` against the plane whose key opened the wrap; reject duplicates of either tag. -- Reject duplicate `vsk`/`eid`/`ev`/`ep`/`vac` tags; require decimal-with-no-leading-zeros on every numeric tag. +- Reject duplicate `vsk`/`eid`/`ev`/`ep`/`vac` tags; require decimal-with-no-leading-zeros on every numeric tag. The one exception is `ms`, which takes its first value rather than erroring — see §7 for why rejecting it reached membership. - Refuse a tombstone whose `eid` is not this community's id. - Adopt a `control_root` from a Grant only if it derives to the `control_pk` held for that epoch; adopt a rekey blob only if its bound plaintext matches the scope and epoch and its `prevcommit` matches the key currently held. - Never conclude removal from a partial rekey chunk set. @@ -564,11 +596,11 @@ Each of these has burned a real implementation, or is a documented cross-client | # | Deliverable | Done when | | --- | --- | --- | -| M0 | Crate skeleton, `derive.rs`, golden vectors, workspace wiring | `cargo test -p concord` pins every derivation from an independent vector set; all labels match Appendix A.6 | -| M1 | `stream.rs` + `store.rs` | seal/wrap/open round-trips for both seal forms; malformed inputs rejected in the documented order; local cache reads back after a restart | +| M0 | Crate skeleton, `derive.rs`, golden vectors, workspace wiring | ✅ `cargo test -p concord` pins every derivation; all labels match Appendix A.6 | +| M1 | `stream.rs` + `store.rs` | ✅ seal/wrap/open round-trips for both seal forms; hostile wraps rejected in the documented order; the local cache reads back with the group key gone | | M2 | `edition.rs` + `control.rs` genesis | a community is created and published; its two genesis wraps open at a second client sharing the keys; edition hash matches the cross-client vector | | M3 | Control fold + roster + metadata/channels | fold tests for chains, gaps, downgrade refusal, fork tiebreak, compaction dangle; metadata and channel edits visible to a second client | -| M4 | Chat plane | send/receive/edit/delete/reaction across two identities; history pages backwards across an epoch boundary; binding checks reject a foreign channel/epoch | +| M4 | Chat plane | send/receive/edit/delete/reaction across two identities; history pages backwards across an epoch boundary (relay `backfill` lands here); binding checks reject a foreign channel/epoch | | M5 | Guestbook + member list + moderation | join/leave/kick/ban converge to the same memberlist on both clients; every authority rule from §8.1 has a negative test | | M6 | Invites + Community List | link mint → fetch → join round-trips; revoked link refuses; direct invite lands in the recipient's giftwrap inbox via the `k` tag; a second device reconstructs membership from 13302 | | M7 | Rekeys + refounding + dissolution | a removed member stops reading after a rekey; continuity and race rules tested; a tombstone seals the community and a foreign-id tombstone is refused | @@ -576,6 +608,8 @@ Each of these has burned a real implementation, or is a documented cross-client Ordering is deliberately dependency-first: each milestone is usable on its own, and nothing in M2+ depends on a later milestone. +M1 closed with `cargo test -p concord` (5 tests), `cargo clippy -p concord --all-targets`, and `cargo fmt -p concord --check` all clean. `rand` was added to the workspace pinned to the same `0.10.2` instance `nostr` already builds, so `Cargo.lock` gained no package. + ## 14. Open questions and risks 1. **Community List kind.** CORD-02 §8 specifies `13302`, replaceable. Vector has retired it in favour of fragmented `33302`, because a replaceable kind holds one event per pubkey and so cannot shard past the NIP-44 size cap. We implement `13302` per spec, enforce the 50-membership cap and pre-publish size check, and treat `33302` as an interop follow-up. Confirm with Armada before writing the multi-device code. diff --git a/crates/concord/Cargo.toml b/crates/concord/Cargo.toml index dbc2af0a..cb663be4 100644 --- a/crates/concord/Cargo.toml +++ b/crates/concord/Cargo.toml @@ -11,4 +11,10 @@ nostr-sdk.workspace = true hkdf.workspace = true sha2.workspace = true data-encoding.workspace = true +rand.workspace = true anyhow.workspace = true + +[dev-dependencies] +nostr-memory.workspace = true +serde_json.workspace = true +smol.workspace = true diff --git a/crates/concord/src/lib.rs b/crates/concord/src/lib.rs index 0a6dc121..82b4110e 100644 --- a/crates/concord/src/lib.rs +++ b/crates/concord/src/lib.rs @@ -1,4 +1,6 @@ pub mod derive; +pub mod store; +pub mod stream; use std::fmt; use std::str::FromStr; diff --git a/crates/concord/src/store.rs b/crates/concord/src/store.rs new file mode 100644 index 00000000..85ec7dc6 --- /dev/null +++ b/crates/concord/src/store.rs @@ -0,0 +1,156 @@ +use std::collections::BTreeMap; +use std::sync::LazyLock; + +use anyhow::{Result, anyhow}; +use nostr_sdk::prelude::*; + +use crate::ChannelId; +use crate::stream::OpenedStream; + +static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); + +const CHANNEL_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_C; +const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T; +const MARK_VALUE: &str = "concord"; +const WRAP_TAG: &str = "e"; +const KIND_TAG: &str = "k"; + +pub async fn cache_rumor( + database: &dyn NostrDatabase, + channel: &ChannelId, + opened: &OpenedStream, +) -> Result<()> { + let tags = vec![ + Tag::identifier(opened.rumor_id), + Tag::custom(KIND_TAG, [opened.rumor.kind.to_string()]), + Tag::custom(WRAP_TAG, [opened.wrapper_id.to_string()]), + Tag::custom(MARK_TAG.as_str(), [MARK_VALUE]), + Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]), + Tag::public_key(opened.author), + ]; + let at = Timestamp::from_secs(opened.at_ms / 1000); + let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json()) + .tags(tags) + .custom_created_at(at) + .finalize_async(&*LOCAL_KEYS) + .await?; + + database.save_event(&event).await?; + + Ok(()) +} + +/// Read a channel's cached rumors. +pub async fn query_rumors( + database: &dyn NostrDatabase, + channel: &ChannelId, + until: Option, + limit: usize, +) -> Result> { + let mut filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .custom_tag(MARK_TAG, MARK_VALUE) + .custom_tag(CHANNEL_TAG, channel.to_hex()); + + if let Some(until) = until { + filter = filter.until(until); + } + + let mut newest: BTreeMap = BTreeMap::new(); + for event in database.query(filter).await? { + let Some(rumor_id) = event.tags.identifier() else { + continue; + }; + + match newest.get(&rumor_id) { + Some(existing) if existing.created_at >= event.created_at => {} + _ => { + newest.insert(rumor_id, event); + } + } + } + + let mut events: Vec = newest.into_values().collect(); + events.sort_by_key(|event| std::cmp::Reverse(event.created_at)); + events.truncate(limit); + + let mut rumors = Vec::with_capacity(events.len()); + for event in events { + let rumor = UnsignedEvent::from_json(event.content) + .map_err(|error| anyhow!("cached rumor is not a valid event: {error}"))?; + rumors.push(rumor); + } + + Ok(rumors) +} + +#[cfg(test)] +mod tests { + use nostr_memory::MemoryDatabase; + + use super::*; + use crate::Epoch; + use crate::derive::channel_group_key; + use crate::stream::{ + KIND_WRAP, SealForm, build_rumor_ms, build_seal, channel_binding_tags, open_wrap, wrap_seal, + }; + + const SECRET: [u8; 32] = [0x07u8; 32]; + + #[test] + fn rumors_read_back_after_a_restart() { + let database = MemoryDatabase::unbounded(); + let channel = ChannelId::from_bytes([0xabu8; 32]); + let author = Keys::generate(); + + smol::block_on(async { + let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives"); + + for (content, at_ms) in [("first", 1_000_000u64), ("second", 2_000_000)] { + let rumor = build_rumor_ms( + 9, + author.public_key(), + content, + channel_binding_tags(&channel, Epoch(0)), + at_ms, + ); + let seal = build_seal(&rumor, SealForm::Encrypted, &group, &author).expect("seals"); + let (wrap, _) = wrap_seal( + &seal, + &group, + KIND_WRAP, + Timestamp::from_secs(at_ms / 1000), + &[], + ) + .expect("wraps"); + + let opened = open_wrap(&wrap, &group).expect("opens"); + cache_rumor(&database, &channel, &opened) + .await + .expect("caches"); + } + + // The group key is gone; only the local cache stands in for it. + let rumors = query_rumors(&database, &channel, None, 10) + .await + .expect("queries"); + assert_eq!(rumors.len(), 2, "both messages come back"); + assert_eq!(rumors[0].content, "second", "newest first"); + assert_eq!(rumors[1].content, "first"); + + // A page boundary in message time, not in cache time. + let until = Timestamp::from_secs(1_500); + let page = query_rumors(&database, &channel, Some(until), 10) + .await + .expect("queries"); + assert_eq!(page.len(), 1); + assert_eq!(page[0].content, "first"); + + let capped = query_rumors(&database, &channel, None, 1) + .await + .expect("queries"); + assert_eq!(capped.len(), 1); + assert_eq!(capped[0].content, "second"); + }); + } +} diff --git a/crates/concord/src/stream.rs b/crates/concord/src/stream.rs new file mode 100644 index 00000000..040f77e4 --- /dev/null +++ b/crates/concord/src/stream.rs @@ -0,0 +1,660 @@ +use std::fmt; + +use data_encoding::BASE64; +use nostr::nips::nip44::v2::{ConversationKey, decrypt_to_bytes, encrypt_to_bytes_with_nonce}; +use nostr_sdk::prelude::{ + Event, EventBuilder, EventId, FinalizeEvent, Keys, Kind, PublicKey, Tag, Timestamp, + UnsignedEvent, +}; +use rand::TryRng as _; +use rand::rngs::SysRng; + +use crate::derive::GroupKey; +use crate::{ChannelId, Epoch}; + +pub const KIND_WRAP: u16 = 1059; +pub const KIND_WRAP_EPHEMERAL: u16 = 21059; +pub const KIND_SEAL_ENCRYPTED: u16 = 20013; +pub const KIND_SEAL_PLAINTEXT: u16 = 20014; +pub const NIP44_MAX_PLAINTEXT: usize = 65_535; + +const TAG_MS: &str = "ms"; +const TAG_CHANNEL: &str = "channel"; +const TAG_EPOCH: &str = "epoch"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SealForm { + Encrypted, + Plaintext, +} + +impl SealForm { + pub fn kind(self) -> u16 { + match self { + SealForm::Encrypted => KIND_SEAL_ENCRYPTED, + SealForm::Plaintext => KIND_SEAL_PLAINTEXT, + } + } + + fn from_kind(kind: u16) -> Option { + match kind { + KIND_SEAL_ENCRYPTED => Some(SealForm::Encrypted), + KIND_SEAL_PLAINTEXT => Some(SealForm::Plaintext), + _ => None, + } + } +} + +#[derive(Debug)] +pub enum StreamError { + Sign(String), + Encrypt(String), + Decrypt(String), + Parse(String), + Oversize(usize), + BadWrapKind(u16), + WrongStream, + BadWrapSignature, + BadSealKind(u16), + BadSealSignature, + AuthorMismatch, + BadRumorId, + BadMs, + ChannelMismatch, + EpochMismatch, + MissingTag(&'static str), + DuplicateTag(&'static str), + NotRewrappable, +} + +impl fmt::Display for StreamError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + StreamError::Sign(error) => write!(f, "sign: {error}"), + StreamError::Encrypt(error) => write!(f, "encrypt: {error}"), + StreamError::Decrypt(error) => write!(f, "decrypt: {error}"), + StreamError::Parse(error) => write!(f, "parse: {error}"), + StreamError::Oversize(len) => write!(f, "plaintext {len} bytes exceeds NIP-44 cap"), + StreamError::BadWrapKind(kind) => write!(f, "not a wrap kind: {kind}"), + StreamError::WrongStream => write!(f, "wrap author is not this stream"), + StreamError::BadWrapSignature => write!(f, "restricted wrap signature invalid"), + StreamError::BadSealKind(kind) => write!(f, "not a seal kind: {kind}"), + StreamError::BadSealSignature => write!(f, "seal signature invalid"), + StreamError::AuthorMismatch => write!(f, "rumor pubkey != seal pubkey"), + StreamError::BadRumorId => write!(f, "rumor id != computed hash"), + StreamError::BadMs => write!(f, "ms is not a canonical decimal in 0..=999"), + StreamError::ChannelMismatch => write!(f, "channel binding mismatch"), + StreamError::EpochMismatch => write!(f, "epoch binding mismatch"), + StreamError::MissingTag(name) => write!(f, "missing rumor tag: {name}"), + StreamError::DuplicateTag(name) => write!(f, "duplicate rumor tag: {name}"), + StreamError::NotRewrappable => write!(f, "only plaintext seals survive re-wrapping"), + } + } +} + +impl std::error::Error for StreamError {} + +#[derive(Debug, Clone)] +pub struct OpenedStream { + pub rumor_id: EventId, + pub author: PublicKey, + pub seal_form: SealForm, + pub seal: Event, + pub wrapper_id: EventId, + pub at_ms: u64, + pub rumor: UnsignedEvent, +} + +pub fn split_ms(at_ms: u64) -> (u64, u16) { + (at_ms / 1000, (at_ms % 1000) as u16) +} + +/// Build a rumor carrying a full epoch-ms time: `created_at` +/// holds the seconds and an `["ms", 0..=999]` tag the remainder. +pub fn build_rumor_ms( + kind: u16, + author: PublicKey, + content: &str, + mut tags: Vec, + at_ms: u64, +) -> UnsignedEvent { + let (seconds, offset) = split_ms(at_ms); + tags.push(Tag::custom(TAG_MS, [offset.to_string()])); + build_rumor_secs(kind, author, content, tags, seconds) +} + +/// Build a rumor with a plain seconds timestamp and no `ms` tag. +pub fn build_rumor_secs( + kind: u16, + author: PublicKey, + content: &str, + tags: Vec, + at_secs: u64, +) -> UnsignedEvent { + let mut rumor = UnsignedEvent::new( + author, + Timestamp::from_secs(at_secs), + Kind::Custom(kind), + tags, + content, + ); + rumor.ensure_id(); + rumor +} + +/// Resolve a rumor's true millisecond time. +pub fn resolve_ms_strict(rumor: &UnsignedEvent) -> Result { + let seconds = rumor.created_at.as_secs().saturating_mul(1000); + let mut tag: Option> = None; + + for candidate in rumor.tags.iter() { + let fields = candidate.as_slice(); + if fields.first().map(String::as_str) == Some(TAG_MS) { + tag = Some(fields.get(1).cloned()); + break; + } + } + + let Some(raw) = tag else { + return Ok(seconds); + }; + let raw = raw.ok_or(StreamError::BadMs)?; + + if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(StreamError::BadMs); + } + + let offset: u64 = raw.parse().map_err(|_| StreamError::BadMs)?; + + if offset > 999 || (raw.len() > 1 && raw.starts_with('0')) { + return Err(StreamError::BadMs); + } + + Ok(seconds.saturating_add(offset)) +} + +pub fn seal_content( + rumor: &UnsignedEvent, + form: SealForm, + group: &GroupKey, +) -> Result { + let json = rumor.as_json(); + check_plaintext_cap(json.len())?; + + match form { + SealForm::Plaintext => Ok(json), + SealForm::Encrypted => Ok(BASE64.encode(&encrypt(group.conversation(), json.as_bytes())?)), + } +} + +pub fn build_seal( + rumor: &UnsignedEvent, + form: SealForm, + group: &GroupKey, + author: &Keys, +) -> Result { + let content = seal_content(rumor, form, group)?; + EventBuilder::new(Kind::Custom(form.kind()), content) + .custom_created_at(rumor.created_at) + .finalize(author) + .map_err(|error| StreamError::Sign(error.to_string())) +} + +pub fn wrap_seal( + seal: &Event, + group: &GroupKey, + wrap_kind: u16, + at: Timestamp, + extra: &[Tag], +) -> Result<(Event, Keys), StreamError> { + if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL { + return Err(StreamError::BadWrapKind(wrap_kind)); + } + + let json = seal.as_json(); + check_plaintext_cap(json.len())?; + + let content = BASE64.encode(&encrypt(group.conversation(), json.as_bytes())?); + let ephemeral = Keys::generate(); + + let mut tags = vec![Tag::public_key(ephemeral.public_key())]; + tags.extend_from_slice(extra); + + let wrap = EventBuilder::new(Kind::Custom(wrap_kind), content) + .tags(tags) + .custom_created_at(at) + .finalize(group.keys()) + .map_err(|error| StreamError::Sign(error.to_string()))?; + + Ok((wrap, ephemeral)) +} + +pub fn rewrap_seal( + seal: &Event, + new_group: &GroupKey, + at: Timestamp, +) -> Result<(Event, Keys), StreamError> { + if seal.kind.as_u16() != KIND_SEAL_PLAINTEXT { + return Err(StreamError::NotRewrappable); + } + wrap_seal(seal, new_group, KIND_WRAP, at, &[]) +} + +pub fn open_wrap(wrap: &Event, group: &GroupKey) -> Result { + open_wrap_at(wrap, &group.pk(), group.conversation(), false) +} + +/// Open and verify a wrap against a stream read view: the address to check and +/// the conversation key that opens the wraps, with no signing secret required. +pub fn open_wrap_at( + wrap: &Event, + address: &PublicKey, + conversation: &ConversationKey, + verify_wrap_signature: bool, +) -> Result { + let wrap_kind = wrap.kind.as_u16(); + + if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL { + return Err(StreamError::BadWrapKind(wrap_kind)); + } + + if wrap.pubkey != *address { + return Err(StreamError::WrongStream); + } + + if verify_wrap_signature && wrap.verify().is_err() { + return Err(StreamError::BadWrapSignature); + } + + let seal: Event = Event::from_json(decode_content(conversation, &wrap.content)?) + .map_err(|error| StreamError::Parse(error.to_string()))?; + let seal_kind = seal.kind.as_u16(); + let seal_form = SealForm::from_kind(seal_kind).ok_or(StreamError::BadSealKind(seal_kind))?; + seal.verify().map_err(|_| StreamError::BadSealSignature)?; + + let rumor_json = match seal_form { + SealForm::Plaintext => seal.content.clone(), + SealForm::Encrypted => decode_content(conversation, &seal.content)?, + }; + + let mut rumor: UnsignedEvent = UnsignedEvent::from_json(rumor_json.as_bytes()) + .map_err(|error| StreamError::Parse(error.to_string()))?; + + if rumor.pubkey != seal.pubkey { + return Err(StreamError::AuthorMismatch); + } + + let computed = rumor.compute_id(); + if let Some(claimed) = rumor.id + && claimed != computed + { + return Err(StreamError::BadRumorId); + } + rumor.id = Some(computed); + + let at_ms = resolve_ms_strict(&rumor)?; + + Ok(OpenedStream { + rumor_id: computed, + author: seal.pubkey, + seal_form, + seal, + wrapper_id: wrap.id, + at_ms, + rumor, + }) +} + +pub fn channel_binding_tags(channel: &ChannelId, epoch: Epoch) -> Vec { + vec![ + Tag::custom(TAG_CHANNEL, [channel.to_hex()]), + Tag::custom(TAG_EPOCH, [epoch.0.to_string()]), + ] +} + +pub fn check_channel_binding( + rumor: &UnsignedEvent, + channel: &ChannelId, + epoch: Epoch, +) -> Result<(), StreamError> { + match unique_tag(rumor, TAG_CHANNEL)? { + Some(value) if value == channel.to_hex() => {} + Some(_) => return Err(StreamError::ChannelMismatch), + None => return Err(StreamError::MissingTag(TAG_CHANNEL)), + } + + match unique_tag(rumor, TAG_EPOCH)? { + Some(value) if value == epoch.0.to_string() => {} + Some(_) => return Err(StreamError::EpochMismatch), + None => return Err(StreamError::MissingTag(TAG_EPOCH)), + } + + Ok(()) +} + +fn encrypt(conversation: &ConversationKey, plaintext: &[u8]) -> Result, StreamError> { + let mut nonce = [0u8; 32]; + + SysRng + .try_fill_bytes(&mut nonce) + .map_err(|error| StreamError::Encrypt(error.to_string()))?; + + encrypt_to_bytes_with_nonce(conversation, plaintext, nonce) + .map_err(|error| StreamError::Encrypt(error.to_string())) +} + +fn decode_content(conversation: &ConversationKey, content: &str) -> Result { + let payload = BASE64 + .decode(content.as_bytes()) + .map_err(|error| StreamError::Decrypt(error.to_string()))?; + + let plaintext = decrypt_to_bytes(conversation, &payload) + .map_err(|error| StreamError::Decrypt(error.to_string()))?; + + String::from_utf8(plaintext).map_err(|error| StreamError::Parse(error.to_string())) +} + +fn check_plaintext_cap(len: usize) -> Result<(), StreamError> { + if len > NIP44_MAX_PLAINTEXT { + return Err(StreamError::Oversize(len)); + } + + Ok(()) +} + +fn unique_tag(rumor: &UnsignedEvent, name: &'static str) -> Result, StreamError> { + let mut found: Option = None; + + for tag in rumor.tags.iter() { + let fields = tag.as_slice(); + if fields.len() >= 2 && fields[0] == name { + if found.is_some() { + return Err(StreamError::DuplicateTag(name)); + } + found = Some(fields[1].clone()); + } + } + + Ok(found) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::derive::channel_group_key; + + const SECRET: [u8; 32] = [0x07u8; 32]; + const OTHER_SECRET: [u8; 32] = [0x08u8; 32]; + + fn channel() -> ChannelId { + ChannelId::from_bytes([0xabu8; 32]) + } + + fn group(epoch: u64) -> GroupKey { + channel_group_key(&SECRET, &channel(), Epoch(epoch)).expect("derives") + } + + fn wrapper_p_tag(wrap: &Event) -> Option { + wrap.tags + .iter() + .find(|tag| tag.as_slice().first().map(String::as_str) == Some("p")) + .and_then(|tag| tag.as_slice().get(1).cloned()) + } + + fn bound_rumor(content: &str, author: PublicKey, at_ms: u64) -> UnsignedEvent { + build_rumor_ms( + 9, + author, + content, + channel_binding_tags(&channel(), Epoch(0)), + at_ms, + ) + } + + fn sealed(rumor: &UnsignedEvent, form: SealForm, author: &Keys) -> Event { + build_seal(rumor, form, &group(0), author).expect("seals") + } + + fn wrapped(seal: &Event, kind: u16, at_secs: u64) -> Event { + wrap_seal(seal, &group(0), kind, Timestamp::from_secs(at_secs), &[]) + .expect("wraps") + .0 + } + + fn encrypted_wrap(content: &str, author: &Keys, at_ms: u64, kind: u16) -> Event { + let rumor = bound_rumor(content, author.public_key(), at_ms); + wrapped( + &sealed(&rumor, SealForm::Encrypted, author), + kind, + at_ms / 1000, + ) + } + + #[test] + fn both_seal_forms_round_trip() { + let author = Keys::generate(); + let at_ms = 1_686_840_217_417; + let wrap = encrypted_wrap("Hey chat!", &author, at_ms, KIND_WRAP); + + assert_eq!(wrap.kind, Kind::GiftWrap, "the durable wrap is kind 1059"); + assert_eq!(wrap.pubkey, group(0).pk(), "the stream key signs the wrap"); + + let opened = open_wrap(&wrap, &group(0)).expect("opens"); + assert_eq!(opened.author, author.public_key()); + assert_eq!(opened.rumor.content, "Hey chat!"); + assert_eq!(opened.rumor_id, opened.rumor.id.expect("id is set")); + assert_eq!(opened.wrapper_id, wrap.id); + assert_eq!(opened.at_ms, at_ms); + assert_eq!(opened.seal_form, SealForm::Encrypted); + check_channel_binding(&opened.rumor, &channel(), Epoch(0)).expect("binding holds"); + + // The wrap's `p` tag must identify neither the stream nor the author. + let p = wrapper_p_tag(&wrap).expect("the wrap carries a p tag"); + assert_ne!(p, group(0).pk_hex()); + assert_ne!(p, author.public_key().to_hex()); + + // Ephemeral actions ride the same structure at a kind relays must drop. + let typing = encrypted_wrap("typing", &author, 5_000, KIND_WRAP_EPHEMERAL); + assert_eq!(typing.kind.as_u16(), 21059); + assert_eq!( + open_wrap(&typing, &group(0)).expect("opens").rumor.content, + "typing" + ); + + // The plaintext form carries the rumor's bytes verbatim, which is what + // lets a compaction re-wrap the signed edition into a later epoch. + let edition = build_rumor_secs( + 3308, + author.public_key(), + "an edition", + vec![], + 1_700_000_000, + ); + let seal = sealed(&edition, SealForm::Plaintext, &author); + assert_eq!(seal.content, edition.as_json(), "the rumor rides verbatim"); + + let opened = open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)).expect("opens"); + assert_eq!(opened.seal_form, SealForm::Plaintext); + + let (rewrapped, _) = + rewrap_seal(&opened.seal, &group(1), Timestamp::from_secs(2)).expect("rewraps"); + let reopened = open_wrap(&rewrapped, &group(1)).expect("opens"); + assert_eq!(reopened.rumor_id, opened.rumor_id, "the rumor id survives"); + assert_eq!(reopened.author, author.public_key()); + assert_eq!( + reopened.seal.sig, opened.seal.sig, + "the signature rides whole" + ); + assert_ne!(reopened.wrapper_id, opened.wrapper_id); + + assert!(matches!( + rewrap_seal( + &sealed(&edition, SealForm::Encrypted, &author), + &group(1), + Timestamp::from_secs(2) + ), + Err(StreamError::NotRewrappable) + )); + } + + #[test] + fn hostile_wraps_are_dropped_in_order() { + let author = Keys::generate(); + let impostor = Keys::generate(); + + // Kind and address are settled before any decryption is attempted. + let mut wrong_kind = encrypted_wrap("x", &author, 1_000, KIND_WRAP); + wrong_kind.kind = Kind::Custom(1058); + assert!(matches!( + open_wrap(&wrong_kind, &group(0)), + Err(StreamError::BadWrapKind(1058)) + )); + + let foreign = channel_group_key(&OTHER_SECRET, &channel(), Epoch(0)).expect("derives"); + let wrap = encrypted_wrap("x", &author, 1_000, KIND_WRAP); + assert!(matches!( + open_wrap(&wrap, &foreign), + Err(StreamError::WrongStream) + )); + + // A flipped ciphertext byte fails the NIP-44 MAC. + let mut payload = BASE64 + .decode(wrap.content.as_bytes()) + .expect("content is base64"); + payload[40] ^= 0x01; + let mut tampered = wrap.clone(); + tampered.content = BASE64.encode(&payload); + assert!(matches!( + open_wrap(&tampered, &group(0)), + Err(StreamError::Decrypt(_)) + )); + + // A seal claiming an author it holds no signature for. + let seal = sealed( + &bound_rumor("spoof", author.public_key(), 1_000), + SealForm::Encrypted, + &impostor, + ); + let mut swapped: serde_json::Value = serde_json::from_str(&seal.as_json()).expect("json"); + swapped["pubkey"] = serde_json::Value::String(author.public_key().to_hex()); + let seal = Event::from_json(swapped.to_string()).expect("a swapped pubkey still parses"); + assert!(matches!( + open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)), + Err(StreamError::BadSealSignature) + )); + + // A seal that does not vouch for the rumor's author. + let seal = sealed( + &bound_rumor("spoof", impostor.public_key(), 1_000), + SealForm::Encrypted, + &author, + ); + assert!(matches!( + open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)), + Err(StreamError::AuthorMismatch) + )); + + // A claimed id the rumor's own bytes do not hash to. The plaintext seal + // smuggles the forgery through verbatim. + let rumor = bound_rumor("real", author.public_key(), 1_000); + let mut forged: serde_json::Value = serde_json::from_str(&rumor.as_json()).expect("json"); + forged["id"] = serde_json::Value::String("00".repeat(32)); + let seal = EventBuilder::new(Kind::Custom(KIND_SEAL_PLAINTEXT), forged.to_string()) + .custom_created_at(rumor.created_at) + .finalize(&author) + .expect("seals"); + assert!(matches!( + open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)), + Err(StreamError::BadRumorId) + )); + + // Binding splices: another channel, another epoch, a duplicate or none. + let doubled = vec![channel_binding_tags(&channel(), Epoch(0)); 2].concat(); + let rumor = bound_rumor("x", author.public_key(), 1_000); + + assert!(matches!( + check_channel_binding(&rumor, &ChannelId::from_bytes([0xcdu8; 32]), Epoch(0)), + Err(StreamError::ChannelMismatch) + )); + + assert!(matches!( + check_channel_binding(&rumor, &channel(), Epoch(1)), + Err(StreamError::EpochMismatch) + )); + + let duplicate = build_rumor_ms(9, author.public_key(), "x", doubled, 1_000); + assert!(matches!( + check_channel_binding(&duplicate, &channel(), Epoch(0)), + Err(StreamError::DuplicateTag(_)) + )); + + let unbound = build_rumor_ms(9, author.public_key(), "x", vec![], 1_000); + assert!(matches!( + check_channel_binding(&unbound, &channel(), Epoch(0)), + Err(StreamError::MissingTag(_)) + )); + + let oversize = build_rumor_ms( + 9, + author.public_key(), + &"x".repeat(NIP44_MAX_PLAINTEXT + 1), + vec![], + 1_000, + ); + assert!(matches!( + seal_content(&oversize, SealForm::Encrypted, &group(0)), + Err(StreamError::Oversize(_)) + )); + } + + #[test] + fn ms_is_a_drop_gate() { + let author = Keys::generate(); + + let absent = build_rumor_secs(9, author.public_key(), "x", vec![], 1_000); + assert_eq!(resolve_ms_strict(&absent).expect("resolves"), 1_000_000); + + let highest = build_rumor_ms(9, author.public_key(), "x", vec![], 1_000_999); + assert_eq!(resolve_ms_strict(&highest).expect("resolves"), 1_000_999); + + for malformed in ["1000", "007", "abc", "+5", ""] { + let rumor = build_rumor_secs( + 9, + author.public_key(), + "x", + vec![Tag::custom(TAG_MS, [malformed.to_string()])], + 1_000, + ); + assert!( + matches!(resolve_ms_strict(&rumor), Err(StreamError::BadMs)), + "{malformed:?} must be malformed" + ); + } + + // Present but valueless is malformed, not an offset-0 default. + let valueless = build_rumor_secs( + 9, + author.public_key(), + "x", + vec![Tag::custom(TAG_MS, Vec::::new())], + 1_000, + ); + assert!(matches!( + resolve_ms_strict(&valueless), + Err(StreamError::BadMs) + )); + + // A valued duplicate takes the first, matching Armada. + let repeated = build_rumor_secs( + 9, + author.public_key(), + "x", + vec![ + Tag::custom(TAG_MS, ["1".to_string()]), + Tag::custom(TAG_MS, ["2".to_string()]), + ], + 1_000, + ); + assert_eq!(resolve_ms_strict(&repeated).expect("resolves"), 1_000_001); + } +}