Compare commits
4
Commits
319b1038d2
...
4329385abe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4329385abe | ||
|
|
d926c1e3ea | ||
|
|
66f75ad105 | ||
|
|
5f2a5d7a37 |
Generated
+17
@@ -1302,6 +1302,23 @@ version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
|
||||
|
||||
[[package]]
|
||||
name = "concord"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"data-encoding",
|
||||
"hkdf",
|
||||
"nostr",
|
||||
"nostr-memory",
|
||||
"nostr-sdk",
|
||||
"rand 0.10.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"smol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
|
||||
@@ -31,6 +31,9 @@ nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "ni
|
||||
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"
|
||||
|
||||
@@ -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,7 +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:** `hkdf = "0.12"` (already in `Cargo.lock` transitively). Add it to `[workspace.dependencies]` and to the new crate. `sha2` is already a workspace dep.
|
||||
**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". 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, 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
|
||||
|
||||
@@ -71,8 +80,9 @@ crates/concord/
|
||||
src/lib.rs init, ConcordRegistry, ConcordEvent, signal bus, subscriptions, ingest pipeline
|
||||
src/derive.rs frozen HKDF / group_key / locators / commitments + golden vectors
|
||||
src/stream.rs CORD-01: seal + wrap + open (SealForm, OpenedStream), channel/epoch binding
|
||||
src/edition.rs CORD-04 §1: canonical signing bytes, edition hash, parse, fold
|
||||
src/control.rs control plane view, genesis, content types, roster fold, authority checks
|
||||
src/edition.rs CORD-04 §1: edition hash, parse, chain fold, floor-aware head selection
|
||||
src/control.rs control plane: genesis, content types, the control fold, the edition writer
|
||||
src/roles.rs CORD-04 §2–§4: permissions, roles, grants, banlist, delegation fixpoint
|
||||
src/guestbook.rs CORD-02 §5: join/leave/kick/snapshot, coalesce, complete memberlist
|
||||
src/chat.rs CORD-03: channel plane — message/edit/delete/reaction builders + message view
|
||||
src/invite.rs CORD-05: bundle, link, registry, Invite List, Direct Invite
|
||||
@@ -80,9 +90,11 @@ crates/concord/
|
||||
src/store.rs local persistence + opened-rumor cache + history queries
|
||||
```
|
||||
|
||||
`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.
|
||||
`Community` and `Channel` GPUI entities live in `src/lib.rs` next to the registry — they are the public surface, not a separate concern. Eleven 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 M3 the crate depends on `nostr`, `nostr-sdk`, `hkdf`, `sha2`, `data-encoding`, `rand`, `serde`, `serde_json`, `anyhow` (plus `nostr-memory` and `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. `serde`/`serde_json` were already in the graph via `nostr`; promoting `serde_json` from dev to main for the metadata content types added no package either, only the `concord → serde` edge. M3 added no dependency of its own.
|
||||
|
||||
## 5. Core types
|
||||
|
||||
@@ -96,6 +108,7 @@ pub struct Epoch(pub u64);
|
||||
pub struct GroupKey { keys: Keys, conversation: ConversationKey }
|
||||
impl GroupKey {
|
||||
pub fn pk(&self) -> PublicKey;
|
||||
pub fn pk_hex(&self) -> String; // lowercase; Debug prints only this, never key material
|
||||
pub fn keys(&self) -> &Keys;
|
||||
pub fn conversation(&self) -> &ConversationKey;
|
||||
}
|
||||
@@ -117,22 +130,25 @@ Ordering everywhere uses `at_ms`, never `created_at`, and ties break on the lowe
|
||||
|
||||
## 6. Frozen derivations (`derive.rs`)
|
||||
|
||||
Implemented and pinned in `crates/concord/src/derive.rs`.
|
||||
|
||||
```rust
|
||||
fn build_info(label: &str, id: &[u8; 32], epoch: Option<u64>) -> Vec<u8>; // label ‖ 0x00 ‖ id[32] ‖ epoch_be[8]?
|
||||
fn hkdf32(ikm: &[u8], info: &[u8]) -> [u8; 32]; // HKDF-SHA256, zero-length salt, L = 32
|
||||
fn hkdf_to_secret_key(ikm: &[u8], info: &[u8]) -> SecretKey; // A.3 scalar_normalize, counter from 0
|
||||
fn hkdf_to_secret_key(ikm: &[u8], info: &[u8]) -> Result<SecretKey>; // A.3 scalar_normalize, counter from 0
|
||||
|
||||
fn group_key(label: &str, secret: &[u8], id: &[u8; 32], epoch: Option<u64>) -> GroupKey;
|
||||
fn group_key(label: &str, secret: &[u8], id: &[u8; 32], epoch: Option<u64>) -> Result<GroupKey>;
|
||||
|
||||
pub fn channel_group_key(secret: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> GroupKey;
|
||||
pub fn control_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> GroupKey; // read key
|
||||
pub fn control_signer_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> GroupKey; // write key
|
||||
pub fn guestbook_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> GroupKey;
|
||||
pub fn channel_rekey_group_key(root: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> GroupKey;
|
||||
pub fn base_rekey_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> GroupKey;
|
||||
pub fn dissolved_group_key(id: &CommunityId) -> GroupKey; // no epoch field
|
||||
pub fn channel_group_key(secret: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> Result<GroupKey>;
|
||||
pub fn control_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result<GroupKey>; // read key
|
||||
pub fn control_signer_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result<GroupKey>; // write key
|
||||
pub fn guestbook_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result<GroupKey>;
|
||||
pub fn channel_rekey_group_key(root: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> Result<GroupKey>;
|
||||
pub fn base_rekey_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result<GroupKey>;
|
||||
pub fn dissolved_group_key(id: &CommunityId) -> Result<GroupKey>; // no epoch field
|
||||
|
||||
pub fn community_id_of(owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> CommunityId; // plain SHA-256
|
||||
pub fn verify_community_id(id: &CommunityId, owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> bool;
|
||||
pub fn epoch_key_commitment(epoch: Epoch, key: &[u8; 32]) -> [u8; 32]; // plain SHA-256
|
||||
pub fn grant_locator(id: &CommunityId, member: &[u8; 32]) -> [u8; 32];
|
||||
pub fn banlist_locator(id: &CommunityId) -> [u8; 32];
|
||||
@@ -140,18 +156,39 @@ pub fn pins_locator(id: &CommunityId, channel: &ChannelId) -> [u8; 32];
|
||||
pub fn invite_links_locator(id: &CommunityId, creator: &[u8; 32]) -> [u8; 32];
|
||||
pub fn recipient_locator(rotator: &[u8; 32], recipient: &[u8; 32], scope: &[u8; 32], epoch: Epoch) -> [u8; 32];
|
||||
pub fn invite_bundle_key(token: &[u8; 16]) -> [u8; 32]; // raw hkdf32 output; used as a NIP-44 conversation key
|
||||
pub fn clear_memo(); // drop memoised keys on signer change
|
||||
```
|
||||
|
||||
Appendix A.6, as implemented — `ikm` / `id` / `epoch`. The id is *always* present (all-zeroes where a label has no meaningful one); the epoch is the only omittable field.
|
||||
|
||||
| Label | ikm | id | epoch |
|
||||
| --- | --- | --- | --- |
|
||||
| `concord/channel` | channel key or `community_root` | `channel_id` | yes |
|
||||
| `concord/control` | `community_root` | `community_id` | yes |
|
||||
| `concord/control-signer` | `control_root` | `community_id` | yes |
|
||||
| `concord/rekey-pseudonym` | prior `community_root` | `channel_id` | new epoch |
|
||||
| `concord/base-rekey-pseudonym` | prior `community_root` | `community_id` | new epoch |
|
||||
| `concord/recipient-pseudonym` | `rotator_xonly ‖ recipient_xonly` (64 B) | scope id | new epoch |
|
||||
| `concord/guestbook` | `community_root` | `community_id` | yes |
|
||||
| `concord/dissolved` | `community_id` | zeroes | — |
|
||||
| `concord/grant` | `community_id` | member x-only | — |
|
||||
| `concord/banlist` | `community_id` | zeroes | — |
|
||||
| `concord/pins` | `community_id` | `channel_id` | — |
|
||||
| `concord/invite-links` | `community_id` | creator x-only | — |
|
||||
| `concord/invite-key` | token (16 B) | zeroes | — |
|
||||
|
||||
The CORD-07 `concord/voice-*` labels and the retired `concord/invite-locator` / `concord/invite-signer` are reserved and listed here only: they are underived, and the table stays append-only. Every label a derivation does use has a distinct pinned output, so a duplicated label cannot pass the vectors.
|
||||
|
||||
Rules that must be enforced by construction, not by convention:
|
||||
|
||||
- Hex is lowercase everywhere; pubkeys are x-only hex, never bech32; tag numbers are decimal with no leading zeros (`"4"`, never `04`/`+4`).
|
||||
- The epoch field is *omitted*, not zeroed, for labels with no epoch (`concord/dissolved`, locators, `concord/community`).
|
||||
- `scalar_normalize` retries by appending a counter byte to the same `info`, starting at `0`.
|
||||
- Labels and commitments are append-only. A test asserts every label is unique and that the label table matches Appendix A.6 exactly.
|
||||
- The epoch field is *omitted*, not zeroed, for labels with no epoch; a test asserts `dissolved_group_key` differs from the same derivation with `Some(0)`.
|
||||
- `scalar_normalize` retries by appending a counter byte to the same `info`, starting at `0`, and reports exhaustion instead of panicking — so plane keys return `Result<GroupKey>`.
|
||||
- Group keys are memoised by a digest of their inputs, so no deriving secret is a map key, bounded at 1024 entries.
|
||||
|
||||
**Golden vectors.** `derive.rs` carries a `#[cfg(test)]` block pinning every derivation output, seeded from the independent Python vectors published by the Vector implementation (channel/control/control-signer/guestbook at epoch 0 and at `0x0102030405060708`, dissolved, all four locators, invite key, community id, epoch commitment). 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.
|
||||
**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;
|
||||
@@ -160,82 +197,206 @@ 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<u64, StreamError>;
|
||||
|
||||
pub fn seal_content(rumor: &UnsignedEvent, form: SealForm, group: &GroupKey) -> Result<String, StreamError>;
|
||||
pub fn build_seal(rumor: &UnsignedEvent, form: SealForm, group: &GroupKey, author: &Keys) -> Result<Event, StreamError>;
|
||||
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<OpenedStream, StreamError>;
|
||||
pub fn open_wrap_at(wrap: &Event, address: &PublicKey, conversation: &ConversationKey, verify_wrap_sig: bool) -> Result<OpenedStream, StreamError>;
|
||||
pub fn open_wrap_at(wrap: &Event, address: &PublicKey, conversation: &ConversationKey, verify_wrap_signature: bool) -> Result<OpenedStream, StreamError>;
|
||||
|
||||
pub fn build_rumor_ms(kind: u16, author: PublicKey, content: &str, tags: Vec<Tag>, at_ms: u64) -> UnsignedEvent;
|
||||
pub fn build_rumor_secs(kind: u16, author: PublicKey, content: &str, tags: Vec<Tag>, at_secs: u64) -> UnsignedEvent;
|
||||
|
||||
pub fn channel_binding_tags(channel: &ChannelId, epoch: Epoch) -> Vec<Tag>;
|
||||
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<Tag>, at_ms: u64) -> UnsignedEvent; // appends ["ms", n]
|
||||
pub fn resolve_ms_strict(rumor: &UnsignedEvent) -> Result<u64, StreamError>;
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
### 8.1 Editions and authority (`edition.rs`, `control.rs`)
|
||||
### 8.1 Editions, authority and the control fold (`edition.rs`, `roles.rs`)
|
||||
|
||||
```rust
|
||||
pub const EDITION_LABEL: &[u8] = b"vector-community/v1/edition"; // frozen, cross-client
|
||||
pub const EDITION_LABEL: &[u8] = b"vector-community/v1/edition"; // frozen, cross-client (27 bytes)
|
||||
|
||||
// sha256( u64be(len(label)) ‖ label ‖ entity[32] ‖ u64be(version)
|
||||
// ‖ flag[1] ‖ prev[32] ‖ u64be(len(content)) ‖ content )
|
||||
// `prev` is always 33 bytes: 0x01 ‖ hash, or 0x00 ‖ zeroes when absent.
|
||||
// The hash commits to no actor: identity enters only via the rumor id.
|
||||
pub fn edition_hash(entity: &[u8; 32], version: u64, prev: Option<&[u8; 32]>, content: &[u8]) -> [u8; 32];
|
||||
pub struct ParsedEdition { author: PublicKey, vsk: String, entity: [u8; 32], version: u64,
|
||||
prev: Option<[u8; 32]>, content: String, self_hash: [u8; 32] };
|
||||
pub struct ParsedEdition { author: PublicKey, subkind: String, entity: [u8; 32], version: u64,
|
||||
prev: Option<[u8; 32]>, citation: Option<AuthorityCitation>,
|
||||
content: String, self_hash: [u8; 32], rumor_id: EventId };
|
||||
pub fn parse_edition(rumor: &UnsignedEvent) -> Result<ParsedEdition, EditionError>;
|
||||
|
||||
pub struct FoldResult { pub head: Option<usize>, pub gap: bool, pub anchored: bool }
|
||||
pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult;
|
||||
pub fn bootstrap_head(editions: &[EditionMeta], floor: u64) -> Option<usize>;
|
||||
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize>; // highest, contiguity ignored
|
||||
|
||||
// One entity's committed head, and the refuse-downgrade floor a later fold is judged
|
||||
// against.
|
||||
pub struct EntityHead { entity: [u8; 32], version: u64, self_hash: [u8; 32], rumor_id: EventId }
|
||||
pub type Floors = BTreeMap<[u8; 32], EntityHead>;
|
||||
pub struct HeadSelection { pub head: Option<usize>, pub gap: bool }
|
||||
pub fn fold_head(editions: &[EditionMeta], floor: Option<&EntityHead>) -> HeadSelection;
|
||||
```
|
||||
|
||||
- Tag grammar: `["vsk", sub]`, `["eid", hex32]`, `["ev", decimal]`, `["ep", hex32]`, `["vac", eid, version, hash]`. Duplicates of any of the five reject the edition; `ev` must pass a decimal check before parsing.
|
||||
- Tie-break at equal version is the lower **inner rumor id**, never `created_at`.
|
||||
- `gap` is a safety signal: a tracking client (already holds the floor) fails closed for that entity and refetches; a bootstrapping client (floor 0) may accept the highest authority-verified head, which is what makes compaction re-wrapping work.
|
||||
- Entity coordinates are `vsk 0` → `community_id`, `1` → `role_id`, `2` → `channel_id`, `3` → `grant_locator`, `4` → `banlist_locator`, `8` → `invite_links_locator`, `11` → `pins_locator`. All derive from `community_id` only, so a refounding re-wraps heads verbatim.
|
||||
- Tag grammar: `["vsk", sub]`, `["eid", hex32]`, `["ev", decimal]`, `["ep", hex32]`, `["vac", eid, version, hash]`. Duplicates of any of the five reject the edition; `ev` must pass a decimal check before parsing. A version of `0` parses and then reads as a gap — the rule lives in the fold, not the parser.
|
||||
- **Versions start at 1, not 0** (CORD-04 §1: "climbs from 1"). Genesis is `(version 1, prev None)` for both entities.
|
||||
- **The edition hash is not the signature.** The actor's Schnorr signature covers the kind-20014 plaintext seal; `edition_hash` is a separate SHA-256 used only for chaining (`ep`, `vac`). `content` is the rumor's content string byte-verbatim, never re-serialized, which is what lets compaction re-wrap a head and preserve its hash.
|
||||
- The domain label is `vector-community/v1/edition`, not a `concord/…` label. Inconsistent with Appendix A.6, frozen anyway — do not "fix" it.
|
||||
- Tie-break at equal version is the lower **inner rumor id** (the kind-3308 rumor), never the outer wrap id and never `created_at`. Only one of the two implementations that must agree applies to a wrap, so the inner id is the only stable choice.
|
||||
- `gap` is a safety signal: a tracking client (already holds the floor) fails closed for that entity and refetches; a bootstrapping client (floor 0) may accept the highest authority-verified head, which is what makes compaction re-wrapping work. `bootstrap_head` therefore takes no floor: it *is* the floor-zero path. `fold_head` is the composition — floor 0 takes `bootstrap_head`; under a held floor the chain-anchored head wins and any upper gap is reported; everything below the floor is a stale relay, not a gap; and a head detached from the floor converges a same-version fork to its lower rumor id when that is genuinely earlier than what we hold, else fails closed as withholding.
|
||||
- **Owner anchoring is not in the fold.** `fold` is a pure function of chain shape; authority is a pre-filter the caller applies to the candidate set before folding. `community_id` proves the owner, and `is_authorized` short-circuits `owner == actor`, so the owner needs no Grant entity at all.
|
||||
- Entity coordinates are `vsk 0` → `community_id`, `1` → `role_id`, `2` → `channel_id`, `3` → `grant_locator`, `4` → `banlist_locator`, `8` → `invite_links_locator`, `11` → `pins_locator`. `5` is reserved, `6`/`9` belong to the 33301 invite marker, `7` is retired. All derive from `community_id` only, so a refounding re-wraps heads verbatim.
|
||||
- The Control Plane is **plaintext-seal only**. A 20013-encrypted control edition is rejected, because compaction re-wraps a signed plaintext seal byte-verbatim into the new epoch; accepting an encrypted one would let a later compaction fork the chain.
|
||||
- **Genesis is exactly two owner-signed editions** — community metadata (`vsk 0`, `eid = community_id`) and one public `#general` channel (`vsk 2`, fresh random `channel_id`) — at epoch 0, version 1, no `ep`, no `vac`. No default roles, no scaffolding, and no Grant for the owner. Secrets minted: `owner_salt`, `community_root`, `control_root` (deliberately not derived from `community_id`).
|
||||
|
||||
```rust
|
||||
pub const P_MANAGE_ROLES: u64 = 1 << 0; // …bit table from CORD-04 §3, frozen; retired bits are burned
|
||||
pub struct CommunityRoles { roles: BTreeMap<[u8; 32], Role>, grants: BTreeMap<PublicKey, Grant> }
|
||||
impl CommunityRoles {
|
||||
pub fn permissions_of(&self, member: &PublicKey) -> u64; // union of role bits
|
||||
pub fn position_of(&self, member: &PublicKey, owner: &PublicKey) -> u32;
|
||||
pub fn is_authorized(&self, actor: &PublicKey, owner: &PublicKey, bit: u64) -> bool;
|
||||
pub fn is_authorized_in(&self, actor: &PublicKey, owner: &PublicKey, channel: &ChannelId, bit: u64) -> bool;
|
||||
pub fn outranks(&self, actor: &PublicKey, owner: &PublicKey, target_position: u32) -> bool;
|
||||
pub fn can_act_on(&self, actor: &PublicKey, owner: &PublicKey, target: &PublicKey, bit: u64) -> bool;
|
||||
pub fn is_staff(&self, member: &PublicKey, owner: &PublicKey) -> bool; // the six control bits, CORD-04 §3
|
||||
// CORD-04 §3, frozen. 1<<7 was MANAGE_INVITES and is burned, never reassigned.
|
||||
// MANAGE_ROLES 1<<0 · MANAGE_CHANNELS 1<<1 · MANAGE_METADATA 1<<2 · KICK 1<<3 ·
|
||||
// BAN 1<<4 · MANAGE_MESSAGES 1<<5 · CREATE_INVITE 1<<6 · VIEW_AUDIT_LOG 1<<8 ·
|
||||
// MENTION_EVERYONE 1<<9 · PIN_MESSAGES 1<<11 · reserved: MANAGE_EMOJI 1<<10, MANAGE_EVENTS 1<<12
|
||||
pub struct Permissions(pub u64);
|
||||
impl Permissions {
|
||||
pub const STAFF_MASK: u64; // MANAGE_ROLES|MANAGE_CHANNELS|MANAGE_METADATA|BAN|CREATE_INVITE|PIN_MESSAGES
|
||||
pub fn contains(self, bits: u64) -> bool;
|
||||
pub fn union(self, other: Self) -> Self;
|
||||
pub fn is_staff(self) -> bool;
|
||||
}
|
||||
|
||||
pub enum RoleScope { Server, Channel(ChannelId) } // {"kind":"server"} / {"kind":"channel","channel_id":…}
|
||||
pub struct Role { role_id: RoleId, name: String, position: u32, permissions: Permissions,
|
||||
scope: RoleScope, color: u32, extra: Extra }
|
||||
pub struct Grant { member: PublicKey, role_ids: Vec<RoleId>, control_wrap: Option<String>, extra: Extra }
|
||||
|
||||
pub struct CommunityRoles { roles: BTreeMap<RoleId, Role>, grants: BTreeMap<PublicKey, Grant> }
|
||||
impl CommunityRoles {
|
||||
pub fn role(&self, role_id: &RoleId) -> Option<&Role>;
|
||||
pub fn roles_of(&self, member: &PublicKey) -> impl Iterator<Item = &Role>;
|
||||
pub fn effective_permissions(&self, member: &PublicKey) -> Permissions; // union of granted role bits
|
||||
pub fn has_permission(&self, member: &PublicKey, bits: u64) -> bool;
|
||||
pub fn highest_position(&self, member: &PublicKey) -> Option<u32>; // lowest position they hold
|
||||
pub fn is_authorized(&self, actor, owner, permission: u64) -> bool; // owner == actor → true
|
||||
pub fn outranks(&self, actor, owner, target_position: u32) -> bool; // strict `<`
|
||||
pub fn can_act_on_position(&self, actor, owner, target_position: u32, permission: u64) -> bool;
|
||||
pub fn can_act_on_member(&self, actor, owner, target: &PublicKey, permission: u64) -> bool;
|
||||
pub fn is_staff(&self, member, owner) -> bool;
|
||||
}
|
||||
|
||||
// The delegation fixpoint. Content is parsed once, up front: the fixpoint revisits
|
||||
// every candidate on each pass.
|
||||
pub enum AuthorityContent { Role(Role), Grant(Grant), Banlist(Vec<PublicKey>) }
|
||||
pub struct AuthorityEdition { entity: [u8; 32], meta: EditionMeta, author: PublicKey,
|
||||
citation: Option<AuthorityCitation>, content: AuthorityContent }
|
||||
impl AuthorityEdition {
|
||||
pub fn parse(edition: &ParsedEdition, community_id: &CommunityId) -> Option<Self>;
|
||||
}
|
||||
pub struct Roster { roles: CommunityRoles, banned: BTreeSet<PublicKey>, floors: Floors, gapped: bool }
|
||||
pub fn fold_roster(owner, community_id, editions: &[AuthorityEdition], floors: &Floors,
|
||||
held_bans: &BTreeSet<PublicKey>) -> Roster;
|
||||
pub fn citation_ok(owner, community_id, author, citation: Option<&AuthorityCitation>,
|
||||
floors: &Floors) -> bool;
|
||||
```
|
||||
|
||||
Authority rules to encode once and test hard:
|
||||
Authority rules as implemented:
|
||||
|
||||
- The owner is position 0, derived from `community_id`, and is never removable.
|
||||
- No edition may claim a `position` at or above its own signer's, including the owner: no Role may claim 0.
|
||||
- The owner is position 0, proven by `community_id`, supreme, unremovable, and **not a Role**: no Role may claim position 0, and every gate short-circuits `owner == actor`. The owner therefore needs no Grant and cites nothing.
|
||||
- A member's rank is the **lowest** position among their Roles; a roleless member sits at `u32::MAX`. Two Roles may share a position (peers, neither acts on the other); display tie-breaks on the lower `role_id`.
|
||||
- The actor must hold the required bit **and strictly outrank** the target. Equal cannot act on equal.
|
||||
- A `vac` citation is a sync floor, not a verdict: block until the cited Grant version is folded, verify its hash, then judge against the *current* roster.
|
||||
- A staff-making Grant carries `control_wrap`, a NIP-44 pairwise ciphertext of `epoch_be[8] ‖ control_root[32]`, and is adopted **only if it derives to the `control_pk` the member already holds** for the named epoch.
|
||||
- Banlist is one replaced entity; mutations carry a re-heal step (re-fold after publish, re-apply if the addition lost the tiebreak).
|
||||
- `AuthorityEdition::parse` drops, rather than repairs: a `role_id` that is not its own coordinate, a `position` of 0, a Grant whose `member` does not hash to its entity, a `vsk 4` at a coordinate that is not this community's banlist locator, malformed JSON, and any `vsk` this type does not own. A Grant's `role_ids` truncate at 64 on read.
|
||||
- **Refuse-downgrade**: an edition below the persisted floor for its entity is never a candidate.
|
||||
- The fold is a **Jacobi fixed point** — authority propagates one delegation level per pass, bounded by `2 × (entities) + 8`. Convergence compares the roster only, not the heads. Cross-pass state is exactly the accepted roster plus its heads, and `citation_ok` reads the *previous* pass's heads, so the first pass sees none.
|
||||
- **Roles** replay each entity's versions **ascending**, one winner per version group, `admissible` collecting the winners that pass. Gates, in order: not banned; `can_act_on_position(author, owner, position, MANAGE_ROLES)`; if a predecessor was admitted, the same call against *its* position; then the citation. The highest admissible version wins. Replaying ascending is what makes the second gate work: without it an admin at position 5 republishes a position-1 role at position 9, every check passes since 9 is beneath them, and a role that outranked them ends up beneath them along with everyone holding it.
|
||||
- **Grants** take no version-group replay: the first candidate in vector order clearing every gate wins. Role references resolve **partially** — the resolvable subset is carried and the rest fold in on a later pass — because all-or-nothing resolution deadlocks the ordinary growth path (an admin creates a role, the owner grants it to them, and neither can go first, collapsing the entire roster including the owner's own grants). The final gate ranks every resolved position *and* the member.
|
||||
- A **citation** that cannot be resolved parks the edition; a missing one is tolerated only where the rank gates carry the weight. For a Role that is everywhere. For a Grant it is not: a revoke names no position, so its rank test is vacuous — hence an uncited Grant may add authority but **never remove** it.
|
||||
- The **banlist** is folded after a preliminary roster, since a ban only exists once someone authorized to place it does. Its head is the highest edition whose author currently holds `BAN` and does not already sit in the held banlist; each entry is kept only if that author strictly outranks the target, and the list caps at 500. **Withholding retains the held list** rather than un-banning nobody on a relay's word. The final roster is then re-folded with the banned set excluded, so a banned admin loses their authority in the same pass.
|
||||
- A staff-making Grant carries `control_wrap`, a NIP-44 pairwise ciphertext of `epoch_be[8] ‖ control_root[32]`, adopted **only if it derives to the `control_pk` the member already holds** for the named epoch. Delivery, never authority.
|
||||
- Caps: **100 Roles per community**, by the 100 lowest `role_id`, applied *after* authorization so forged low ids cannot evict a real role; the grants then shed the dropped ids. **64 Roles per member**, at parse. **500 banlist entries**, at fold.
|
||||
|
||||
**Two deliberate divergences from the reference implementation** (Vector is an oracle, not a specification):
|
||||
|
||||
1. **The role fork winner.** Vector's role branch walks version groups with `.iter().rev()`, taking each group's *highest* inner id, while its own adjacent comment says forks break on the lowest and the rest of its codebase (`fold_head`, `version::fold`, its invite-registry test) does use the lowest. No test in Vector pins the branch. We implement **lowest inner id**, per CORD-04 §1 — and our tests pin it.
|
||||
2. **Banlist candidates must be `vsk 4`.** Vector collects banlist candidates from every edition sitting at the banlist locator regardless of `vsk`, so a `vsk 1` forged there can win the banlist head, parse to an empty list, and clear the ban. We require `vsk::BANLIST` for a candidate at all.
|
||||
|
||||
One reference limitation we **reproduce and do not fix** (recorded here rather than silently diverging): the grant rank gate reads the *previous* pass's roster, so a mid-rank `MANAGE_ROLES` holder who cites a real, folded grant of their own can revoke a higher-ranked member whose authority is still propagating. Fixing it means resolving a Grant's target rank against the same pass, which changes the fixpoint's convergence argument. Revisit only with a spec amendment.
|
||||
|
||||
### 8.2 Communities, channels, metadata
|
||||
|
||||
`CommunityMetadata` carries `name` (≤ 64 bytes), `description` (≤ 10 000 bytes), `relays` (truncated on read and write to 5), `icon` and `banner` as encrypted-blob pointers (`{url, key, nonce, hash}`), `message_expiration`, and the optional `custom` object. `ChannelMetadata` carries `name`, `private`, optional `voice`, `deleted`, optional `custom`.
|
||||
`CommunityMetadata` carries `name` (≤ 64 bytes), `description` (≤ 10 000 bytes), `relays` (truncated on read and write to 5), `icon` and `banner` as encrypted-blob pointers (`{url, key, nonce, hash}`), and the optional `custom` object. `ChannelMetadata` carries `name`, `private`, optional `voice`, `deleted`, optional `custom`. Every content struct carries `#[serde(flatten)] extra`, so a field one client does not model still round-trips and a rename by an older client cannot wipe another client's `custom` keys.
|
||||
|
||||
Every content struct uses `#[serde(flatten)] extra: serde_json::Map<String, Value>` and round-trips unknown fields. A name edit by an older client must not wipe another client's `custom` keys. Round-trip discipline gets its own test.
|
||||
The Control Plane's whole projection is one call:
|
||||
|
||||
Channel keying follows CORD-03 §1: a public channel derives from `community_root` at the base epoch, a private one from its own random key at its own epoch. Public→private is a rekey at the next channel epoch (monotonic, never reset); private→public derives from the base again and the prior private history stays unreadable to later joiners.
|
||||
```rust
|
||||
pub struct ControlFold {
|
||||
pub roles: CommunityRoles,
|
||||
pub banned: BTreeSet<PublicKey>,
|
||||
pub community: Option<CommunityMetadata>,
|
||||
pub channels: BTreeMap<ChannelId, ChannelMetadata>,
|
||||
pub floors: Floors,
|
||||
pub gapped: bool,
|
||||
}
|
||||
pub fn fold_control(owner: &PublicKey, community_id: &CommunityId, editions: &[ParsedEdition],
|
||||
floors: &Floors, held_bans: &BTreeSet<PublicKey>) -> ControlFold;
|
||||
```
|
||||
|
||||
- The roster is folded first, and `vsk 0` / `vsk 2` are then judged against it: the head of each entity is the highest edition whose author *currently* holds `MANAGE_METADATA` / `MANAGE_CHANNELS`, is not banned, and either is the owner or cites their own folded Grant. Pre-filtering before the chain fold is what stops a demoted admin's later, higher-version edition from being the head.
|
||||
- A `vsk 2` whose entity is the community's own id is excluded, and a `vsk 0` at any other coordinate with it: the floor row keys on the entity alone, so the two would otherwise share and corrupt one chain.
|
||||
- `None` means "this client saw no authorized edition", never "the value is gone": a caller keeps what it holds rather than walking the community backwards. That is also how a withheld or downgraded entity reads.
|
||||
- A `deleted` channel is reported as metadata with `deleted: true`; the policy of dropping it belongs to the store.
|
||||
|
||||
Writes go through one primitive, so every edition names the head it supersedes and a client cannot silently fork a chain it cannot see:
|
||||
|
||||
```rust
|
||||
pub struct Edition<'a> { subkind: &'a str, entity: [u8; 32], content: &'a str,
|
||||
head: Option<&'a EntityHead>, citation: Option<AuthorityCitation> }
|
||||
pub struct ControlWriter { pub author: PublicKey, pub read: GroupKey, pub signer: GroupKey }
|
||||
impl ControlWriter {
|
||||
pub fn publish(&self, keys: &Keys, edition: Edition<'_>, at_secs: u64) -> Result<(Event, EntityHead)>;
|
||||
pub fn set_community_metadata(&self, keys, community_id, metadata, head, at_secs) -> Result<(Event, EntityHead)>;
|
||||
pub fn set_channel_metadata(&self, keys, channel, metadata, head, at_secs) -> Result<(Event, EntityHead)>;
|
||||
}
|
||||
```
|
||||
|
||||
`keys` is the acting member's own signer: the seal carries their signature, while the wrap is signed by the plane's published `control_pk`. Roles, grants and banlists ride the same `publish`, and their wrappers land with the moderation API (M5). A remote signer (NIP-46) is not yet plumbed — `publish` takes `&Keys`, not a `NostrSigner`.
|
||||
|
||||
Channel keying follows CORD-03 §1: a public channel derives from `community_root` at the base epoch, a private one from its own random key at its own epoch. Public→private is a rekey at the next channel epoch (monotonic, never reset); private→public derives from the base again and the prior private history stays unreadable to later joiners. The store applies only the public declaration and the deletion: the public-to-private flip is ignored until the convert flow (key mint plus cursor rebase) lands, and a channel this client holds no key for is not added at all — it arrives with the invite that carries the key.
|
||||
|
||||
### 8.3 Guestbook and member list (`guestbook.rs`)
|
||||
|
||||
@@ -339,14 +500,26 @@ pub fn compact(fold, epoch, new_control_root, ...) -> Vec<Event>; // 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, state document in M2, fold bridge in M3
|
||||
|
||||
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<Timestamp>, limit: usize) -> Result<Vec<UnsignedEvent>>;
|
||||
```
|
||||
|
||||
`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/<community_id>"]`:
|
||||
|
||||
```rust
|
||||
@@ -357,32 +530,31 @@ pub struct CommunityState {
|
||||
pub community_root: [u8; 32],
|
||||
pub root_epoch: Epoch,
|
||||
pub control_root: Option<[u8; 32]>, // present iff the holder is staff
|
||||
pub control_pks: BTreeMap<Epoch, PublicKey>,
|
||||
pub channels: Vec<ChannelKeyRef>, // id, key, epoch, name, private
|
||||
pub epoch_keys: Vec<([u8; 32], Epoch, [u8; 32])>, // (scope, epoch, key) — the history backfill index
|
||||
pub control_pks: BTreeMap<u64, PublicKey>, // epoch → the plane's signer address
|
||||
pub channels: Vec<ChannelKeyRef>, // id, name, private, epoch
|
||||
pub relays: Vec<RelayUrl>,
|
||||
pub heads: BTreeMap<[u8; 32], (u64, [u8; 32], EventId)>, // entity → (version, self_hash, inner id)
|
||||
pub guestbook: Vec<GuestbookEvent>,
|
||||
pub observed: BTreeMap<PublicKey, u64>,
|
||||
pub banned: BTreeSet<PublicKey>,
|
||||
pub dissolved: bool,
|
||||
pub heads: Vec<EntityHead>, // entity, version, self_hash, inner id
|
||||
pub added_at_ms: u64,
|
||||
}
|
||||
```
|
||||
|
||||
Landed in M2 with exactly the fields genesis can populate: `save_state`/`load_state` and `CommunityState::from_genesis`. Three fields the plan sketched are deliberately absent until something can fill them — `epoch_keys` (needs rekeys, M7), and `guestbook`/`observed`/`banned`/`dissolved` (need the guestbook, M5). `control_pks` keyed by `u64` rather than `Epoch` and `heads` as a `Vec` rather than a `BTreeMap<[u8; 32], _>`, because serde_json cannot use a byte-array map key.
|
||||
|
||||
M3 added the two bridges between this document and the fold:
|
||||
|
||||
```rust
|
||||
impl CommunityState {
|
||||
pub fn floors(&self) -> Floors; // the fold's input
|
||||
pub fn apply_fold(&mut self, fold: &ControlFold); // the fold's output
|
||||
}
|
||||
```
|
||||
|
||||
`apply_fold` merges channels rather than replacing them, so a locally-held key survives a metadata edit. `banned` is not yet persisted here: `fold_control` takes the held list as an argument and returns the folded one, and the field lands with the moderation API (M5) that first writes it.
|
||||
|
||||
Writes are debounced (a fold head changes on every edition); reads load once at init.
|
||||
|
||||
**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<Timestamp>, limit: usize) -> Result<Vec<Event>, Error>;
|
||||
pub async fn backfill(&self, plane_authors: &[PublicKey], relays: &[RelayUrl], until: Option<Timestamp>, limit: usize) -> Result<Vec<Event>, 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<Community>` handles, a `flume` signal bus, one background notification listener, one foreground consumer, and task slots that are cleared when the signer changes.
|
||||
@@ -503,7 +675,7 @@ pub fn pin(&self, id: EventId, cx: &App) -> Task<Result<(), Error>>; // 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, moved from M2 to the milestone that first subscribes.** `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. **M2 and M3 did not apply it**: the crate has no subscription and no `ConcordRegistry` yet, so no concord wrap can reach that handler and the change would be untestable. It lands with the sync engine (§10), as does the `concord::init` wiring in `desktop` and `web`.
|
||||
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`.
|
||||
@@ -516,7 +688,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.
|
||||
@@ -525,16 +697,17 @@ Each of these has burned a real implementation, or is a documented cross-client
|
||||
- Refuse to write a Pin List from a list the writer could not read.
|
||||
- Enforce the NIP-44 65 535-byte cap at every layer before publishing, and the 5-relay / 256-channel / 50-membership / 100-roles / 64-roles-per-member / 500-banlist / 25-pin caps at their ingest and write points.
|
||||
- Lowercase hex only; x-only pubkeys only; no version tag anywhere.
|
||||
- **Enforced in M3:** a Role's `role_id` is its own coordinate and never 0; a Grant's `member` hashes to its coordinate; a `vsk 4` sits at this community's banlist locator; a banned npub's editions are dropped and a grant naming them carries no rank; a revocation carries a citation; the 100-role cap keeps the lowest ids *after* authorization; a below-floor edition is never a candidate. Still owed to M8's audit: the byte caps, the 256-channel and 25-pin caps, and the write-side counterparts.
|
||||
|
||||
## 13. Milestones
|
||||
|
||||
| # | 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 |
|
||||
| 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 |
|
||||
| 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 + `store.rs` state document | ✅ `cargo test -p concord` (7 tests): `edition_hash` reproduces the cross-client vector `2daf42e6…`, and a community minted by one holder has both genesis wraps open for a second holder holding only the invite keys, folding to version 1 |
|
||||
| M3 | Control fold + roster + metadata/channels | ✅ `cargo test -p concord` (15 tests): the chain fold, its gaps, fork tiebreak, downgrade refusal and compaction dangle are pinned; the delegation fixpoint resolves outward from the owner and refuses escalation, an unauthorized higher version, rank inversion by republish and an uncited revoke; a community minted by one holder has its metadata and channel edits fold for a second holder from the invite keys alone |
|
||||
| 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 |
|
||||
@@ -542,15 +715,28 @@ 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.
|
||||
|
||||
M2 closed the same way at 7 tests, with `serde` added to the crate's dependencies (`serde_json` promoted from dev to main for the metadata content types) — `Cargo.lock` gained no package again, only the `concord → serde` edge.
|
||||
|
||||
M3 closed at 15 tests with no dependency change at all, and `Cargo.lock` untouched. New: `src/roles.rs` (permissions, Role/Grant/banlist content, `CommunityRoles`, the delegation fixpoint) and, in `src/control.rs`, `ControlFold` / `fold_control`, the metadata-and-channel fold, `ControlWriter` and its `Edition` input. `EntityHead` and `Floors` moved from `store.rs` into `edition.rs`, where `fold_head` now composes `fold` and `bootstrap_head` for the floor-aware case.
|
||||
|
||||
What M3 still defers, and to what: the **sync engine's paging** driven by `ControlFold.gapped` and the **`chat::handle_notifications` routing fix** (both §10, together with the `concord::init` wiring — no concord wrap can reach that handler until the subscription exists); the **persisted banlist** and `CommunityState.banned` (M5, with the moderation API that writes it); the **role/grant/banlist write wrappers** (M5 — `ControlWriter::publish` already carries them, only the convenience surface is pending); and the **NIP-46 remote signer**, since `publish` takes `&Keys` rather than a `NostrSigner`.
|
||||
|
||||
**M2's "created and published" is verified offline**: "published" is the two wraps existing and being openable by the invite keys, not a relay round-trip. There is no registry to publish through until §10, and a relay test would be testing the SDK, not the protocol.
|
||||
|
||||
## 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.
|
||||
2. **NIP-42 for stream-authored REQs.** Relays that gate kind 1059 by author (for example `ditto-relay`'s `AUTH_KINDS`) need an AUTH event signed by that plane's derived key. `nostr-sdk`'s `Authenticator` is per-client and returns one identity, while a Concord client holds many plane keys — so this cannot be solved by swapping the authenticator. Options: contribute a per-REQ auth hook upstream, or accept that such relays are unsupported and prefer relays without the gate. Decide before M7; the default is "documented limitation" plus a relay-capability check.
|
||||
3. **`invite_bundle_key`.** Appendix A.6 says the label "yields the public-invite decrypt key" without stating whether that is the raw HKDF output used as a NIP-44 conversation key or the `conv_key` of a normalized keypair. The reference implementation uses the raw output. Pin a vector and verify against Armada early — this one decides whether links open at all.
|
||||
4. **Missing golden vector for `pins_locator`.** Upstream publishes none. Ours will be self-referential; flag it in the test.
|
||||
2. **NIP-42 for stream-authored REQs.** Relays that gate kind 1059 by author (for example `ditto-relay`'s `AUTH_KINDS`) need an AUTH event signed by that plane's derived key. `nostr-sdk`'s `Authenticator` is per-client and returns one identity, while a Concord client holds many plane keys — so this cannot be solved by swapping the authenticator. Vector's answer is a dedicated stream-auth responder installed on the client (`community/v2/streamauth`), primed before any relay interaction, which retains the relay's challenge so plane keys registering later can still answer it: a gating relay challenges once per connection and will not re-challenge an authed one, so a responder attached later never gets the chance. Read that module before deciding; the alternative options remain a per-REQ auth hook upstream or documenting the limitation.
|
||||
3. **`invite_bundle_key` — resolved in M0.** Appendix A.6 was read in full: the raw HKDF output *is* the NIP-44 conversation key, and the derivation is now pinned by a vector.
|
||||
4. **`pins_locator` has no upstream vector.** Resolved in M0 by minting one from our own implementation and flagging it self-referential in the test.
|
||||
5. **Relay set.** Up to 5 recommended, and both reads and writes fan out across them. Coop's client is a gossip client with `no_background_refresh`, so community relays must be added explicitly and re-added on metadata change.
|
||||
6. **Local plaintext state.** §9 records the decision. Revisit only if the local database stops being treated as trusted.
|
||||
7. **Was a `community_id` ever hashed into a tag?** No — it must never appear on the wire. A lint-style test should assert it appears in no published event other than inside an invite bundle and a direct invite.
|
||||
8. **The Pin List's message-key disclosure has no public API (M8).** CORD-04 §7 Pins let a keyless reader verify a disclosure, which means revealing one message's NIP-44 keys rather than the plane's conversation key. `nostr`'s `nip44::v2::get_message_keys(conversation_key, nonce)` is a private `fn`, and both public entry points (`encrypt_to_bytes_with_nonce`, `decrypt_to_bytes`) take the whole conversation key — so the expansion has to be reproduced as `hkdf::expand_into(conversation_key, nonce, 76 bytes)` plus ChaCha20 and an HMAC-SHA256, exactly as Vector does, and round-tripped against nostr's own `encrypt` in a test. Read CORD-04 §7 in full at M8 before writing it: the reproduction is only worth it once the exact verification the pin must support is settled, and the alternative is contributing a `pub` message-key accessor upstream (we already track git master, so a patch branch or an upstream PR is viable and strictly better than a reproduction we must keep in sync).
|
||||
9. **A remote signer is not plumbed.** `ControlWriter::publish` and `stream`'s seal builders all take `&Keys`. NIP-46 is a stated Vector feature (§10's task slots are already cleared on signer change), but making the writers async over a `NostrSigner` is a change to every builder, so it should be one deliberate pass rather than a patch per milestone. Nothing in M4–M8 depends on it except the UX of using a remote signer at all.
|
||||
10. **The fold is not incremental.** `fold_control` re-parses and re-folds the whole control edition window on every call, and each fold is up to `2 × entities + 8` passes. That is fine at the caps the spec sets (100 roles, 400-odd grants) and it is the simplest thing that is correct, but if the sync engine ends up calling it per event rather than per batch, the candidate maps and their parse belong in a cache keyed by edition id. Measure before optimizing.
|
||||
|
||||
## 15. Test strategy
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "concord"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nostr.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
hkdf.workspace = true
|
||||
sha2.workspace = true
|
||||
data-encoding.workspace = true
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
nostr-memory.workspace = true
|
||||
smol.workspace = true
|
||||
@@ -0,0 +1,814 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::derive::{
|
||||
community_id_of, control_group_key, control_signer_group_key, verify_community_id,
|
||||
};
|
||||
use crate::edition::{
|
||||
AuthorityCitation, EditionFields, EditionMeta, EntityHead, Floors, ParsedEdition,
|
||||
build_edition, fold_head, parse_edition, vsk,
|
||||
};
|
||||
use crate::roles::{
|
||||
AuthorityEdition, CommunityRoles, Permissions, Roster, citation_ok, fold_roster,
|
||||
};
|
||||
use crate::stream::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
|
||||
use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, random_32};
|
||||
|
||||
pub const MAX_NAME_BYTES: usize = 64;
|
||||
pub const MAX_DESCRIPTION_BYTES: usize = 10_000;
|
||||
pub const MAX_RELAYS: usize = 5;
|
||||
|
||||
pub const GENERAL_CHANNEL: &str = "general";
|
||||
pub const ROOT_EPOCH: Epoch = Epoch(0);
|
||||
|
||||
const GENESIS_VERSION: u64 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ImageRef {
|
||||
pub url: String,
|
||||
pub key: String,
|
||||
pub nonce: String,
|
||||
pub hash: String,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CommunityMetadata {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub relays: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<ImageRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub banner: Option<ImageRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom: Option<Extra>,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ChannelMetadata {
|
||||
pub name: String,
|
||||
pub private: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub voice: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub deleted: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom: Option<Extra>,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CommunityIdentity {
|
||||
pub community_id: CommunityId,
|
||||
pub owner: PublicKey,
|
||||
pub owner_salt: [u8; 32],
|
||||
}
|
||||
|
||||
impl CommunityIdentity {
|
||||
pub fn verify(&self) -> bool {
|
||||
verify_community_id(&self.community_id, &self.owner.to_bytes(), &self.owner_salt)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommunityGenesis {
|
||||
pub identity: CommunityIdentity,
|
||||
pub community_root: [u8; 32],
|
||||
pub control_root: [u8; 32],
|
||||
pub channel_id: ChannelId,
|
||||
pub wraps: Vec<Event>,
|
||||
}
|
||||
|
||||
pub fn genesis(
|
||||
owner: &Keys,
|
||||
metadata: &CommunityMetadata,
|
||||
at_secs: u64,
|
||||
) -> Result<CommunityGenesis> {
|
||||
let metadata_content = encode_metadata(metadata)?;
|
||||
let owner_salt = random_32()?;
|
||||
|
||||
let identity = CommunityIdentity {
|
||||
community_id: community_id_of(&owner.public_key().to_bytes(), &owner_salt),
|
||||
owner: owner.public_key(),
|
||||
owner_salt,
|
||||
};
|
||||
|
||||
let community_root = random_32()?;
|
||||
let control_root = random_32()?;
|
||||
let channel_id = ChannelId::from_bytes(random_32()?);
|
||||
|
||||
let read = control_group_key(&community_root, &identity.community_id, ROOT_EPOCH)?;
|
||||
let signer = control_signer_group_key(&control_root, &identity.community_id, ROOT_EPOCH)?;
|
||||
|
||||
let channel_content = serde_json::to_string(&ChannelMetadata {
|
||||
name: GENERAL_CHANNEL.to_owned(),
|
||||
private: false,
|
||||
..ChannelMetadata::default()
|
||||
})?;
|
||||
|
||||
let editions = [
|
||||
build_edition(EditionFields {
|
||||
author: identity.owner,
|
||||
subkind: vsk::COMMUNITY_METADATA,
|
||||
entity: *identity.community_id.as_bytes(),
|
||||
version: GENESIS_VERSION,
|
||||
prev: None,
|
||||
citation: None,
|
||||
content: &metadata_content,
|
||||
at_secs,
|
||||
}),
|
||||
build_edition(EditionFields {
|
||||
author: identity.owner,
|
||||
subkind: vsk::CHANNEL_METADATA,
|
||||
entity: *channel_id.as_bytes(),
|
||||
version: GENESIS_VERSION,
|
||||
prev: None,
|
||||
citation: None,
|
||||
content: &channel_content,
|
||||
at_secs,
|
||||
}),
|
||||
];
|
||||
|
||||
let mut wraps = Vec::with_capacity(editions.len());
|
||||
|
||||
for edition in &editions {
|
||||
wraps.push(seal_edition(edition, owner, &read, &signer, at_secs)?);
|
||||
}
|
||||
|
||||
Ok(CommunityGenesis {
|
||||
identity,
|
||||
community_root,
|
||||
control_root,
|
||||
channel_id,
|
||||
wraps,
|
||||
})
|
||||
}
|
||||
|
||||
/// Opens a Control Plane wrap from its reading key alone.
|
||||
pub fn open_edition(
|
||||
wrap: &Event,
|
||||
read: &GroupKey,
|
||||
address: &PublicKey,
|
||||
verify_wrap_signature: bool,
|
||||
) -> Result<ParsedEdition> {
|
||||
let opened = open_wrap_at(wrap, address, read.conversation(), verify_wrap_signature)?;
|
||||
|
||||
if opened.seal_form != SealForm::Plaintext {
|
||||
bail!("control editions require a plaintext seal");
|
||||
}
|
||||
|
||||
Ok(parse_edition(&opened.rumor)?)
|
||||
}
|
||||
|
||||
/// Appends editions to entity chains.
|
||||
pub struct ControlWriter {
|
||||
pub author: PublicKey,
|
||||
pub read: GroupKey,
|
||||
pub signer: GroupKey,
|
||||
}
|
||||
|
||||
pub struct Edition<'a> {
|
||||
pub subkind: &'a str,
|
||||
pub entity: [u8; 32],
|
||||
pub content: &'a str,
|
||||
/// The head this edition supersedes.
|
||||
///
|
||||
/// `None` starts the chain.
|
||||
pub head: Option<&'a EntityHead>,
|
||||
pub citation: Option<AuthorityCitation>,
|
||||
}
|
||||
|
||||
impl ControlWriter {
|
||||
pub fn publish(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
edition: Edition<'_>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
let rumor = build_edition(EditionFields {
|
||||
author: self.author,
|
||||
subkind: edition.subkind,
|
||||
entity: edition.entity,
|
||||
version: edition
|
||||
.head
|
||||
.map_or(GENESIS_VERSION, |head| head.version + 1),
|
||||
prev: edition.head.map(|head| head.self_hash),
|
||||
citation: edition.citation,
|
||||
content: edition.content,
|
||||
at_secs,
|
||||
});
|
||||
|
||||
let parsed = parse_edition(&rumor)?;
|
||||
let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs)?;
|
||||
|
||||
Ok((wrap, EntityHead::from(&parsed)))
|
||||
}
|
||||
|
||||
pub fn set_community_metadata(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
community_id: &CommunityId,
|
||||
metadata: &CommunityMetadata,
|
||||
head: Option<&EntityHead>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
let content = encode_metadata(metadata)?;
|
||||
|
||||
self.publish(
|
||||
keys,
|
||||
Edition {
|
||||
subkind: vsk::COMMUNITY_METADATA,
|
||||
entity: *community_id.as_bytes(),
|
||||
content: &content,
|
||||
head,
|
||||
citation: None,
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_channel_metadata(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
channel: &ChannelId,
|
||||
metadata: &ChannelMetadata,
|
||||
head: Option<&EntityHead>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
let content = serde_json::to_string(metadata)?;
|
||||
|
||||
self.publish(
|
||||
keys,
|
||||
Edition {
|
||||
subkind: vsk::CHANNEL_METADATA,
|
||||
entity: *channel.as_bytes(),
|
||||
content: &content,
|
||||
head,
|
||||
citation: None,
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_metadata(metadata: &CommunityMetadata) -> Result<String> {
|
||||
if metadata.name.len() > MAX_NAME_BYTES {
|
||||
bail!("community name exceeds {MAX_NAME_BYTES} bytes");
|
||||
}
|
||||
|
||||
if metadata
|
||||
.description
|
||||
.as_ref()
|
||||
.is_some_and(|description| description.len() > MAX_DESCRIPTION_BYTES)
|
||||
{
|
||||
bail!("community description exceeds {MAX_DESCRIPTION_BYTES} bytes");
|
||||
}
|
||||
|
||||
let mut metadata = metadata.clone();
|
||||
metadata.relays.truncate(MAX_RELAYS);
|
||||
|
||||
Ok(serde_json::to_string(&metadata)?)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ControlFold {
|
||||
pub roles: CommunityRoles,
|
||||
pub banned: BTreeSet<PublicKey>,
|
||||
pub community: Option<CommunityMetadata>,
|
||||
pub channels: BTreeMap<ChannelId, ChannelMetadata>,
|
||||
pub floors: Floors,
|
||||
pub gapped: bool,
|
||||
}
|
||||
|
||||
pub fn fold_control(
|
||||
owner: &PublicKey,
|
||||
community_id: &CommunityId,
|
||||
editions: &[ParsedEdition],
|
||||
floors: &Floors,
|
||||
held_bans: &BTreeSet<PublicKey>,
|
||||
) -> ControlFold {
|
||||
let authority: Vec<AuthorityEdition> = editions
|
||||
.iter()
|
||||
.filter_map(|edition| AuthorityEdition::parse(edition, community_id))
|
||||
.collect();
|
||||
|
||||
let roster = fold_roster(owner, community_id, &authority, floors, held_bans);
|
||||
let metadata = fold_metadata(owner, community_id, editions, &roster, floors);
|
||||
|
||||
let mut floors = roster.floors;
|
||||
floors.extend(metadata.floors);
|
||||
|
||||
ControlFold {
|
||||
roles: roster.roles,
|
||||
banned: roster.banned,
|
||||
community: metadata.community,
|
||||
channels: metadata.channels,
|
||||
floors,
|
||||
gapped: roster.gapped || metadata.gapped,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct MetadataFold {
|
||||
community: Option<CommunityMetadata>,
|
||||
channels: BTreeMap<ChannelId, ChannelMetadata>,
|
||||
floors: Floors,
|
||||
gapped: bool,
|
||||
}
|
||||
|
||||
fn fold_metadata(
|
||||
owner: &PublicKey,
|
||||
community_id: &CommunityId,
|
||||
editions: &[ParsedEdition],
|
||||
roster: &Roster,
|
||||
floors: &Floors,
|
||||
) -> MetadataFold {
|
||||
let judge = Judge {
|
||||
owner,
|
||||
community_id,
|
||||
roster,
|
||||
floors,
|
||||
};
|
||||
let community_entity = *community_id.as_bytes();
|
||||
let mut community: Vec<&ParsedEdition> = Vec::new();
|
||||
let mut channels: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
|
||||
|
||||
for edition in editions {
|
||||
match edition.subkind.as_str() {
|
||||
// A channel addressed at the community's own coordinate would share, and
|
||||
// corrupt, the metadata chain's floor.
|
||||
vsk::COMMUNITY_METADATA if edition.entity == community_entity => {
|
||||
community.push(edition)
|
||||
}
|
||||
vsk::CHANNEL_METADATA if edition.entity != community_entity => {
|
||||
channels.entry(edition.entity).or_default().push(edition);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut fold = MetadataFold::default();
|
||||
|
||||
if let Some(head) = authorized_head(
|
||||
&judge,
|
||||
community_entity,
|
||||
&community,
|
||||
Permissions::MANAGE_METADATA,
|
||||
&mut fold.gapped,
|
||||
) {
|
||||
fold.community = serde_json::from_str(&head.content).ok();
|
||||
fold.floors.insert(head.entity, EntityHead::from(head));
|
||||
}
|
||||
|
||||
for (entity, candidates) in &channels {
|
||||
let Some(head) = authorized_head(
|
||||
&judge,
|
||||
*entity,
|
||||
candidates,
|
||||
Permissions::MANAGE_CHANNELS,
|
||||
&mut fold.gapped,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
fold.floors.insert(*entity, EntityHead::from(head));
|
||||
|
||||
if let Ok(metadata) = serde_json::from_str::<ChannelMetadata>(&head.content) {
|
||||
fold.channels
|
||||
.insert(ChannelId::from_bytes(*entity), metadata);
|
||||
}
|
||||
}
|
||||
|
||||
fold
|
||||
}
|
||||
|
||||
struct Judge<'a> {
|
||||
owner: &'a PublicKey,
|
||||
community_id: &'a CommunityId,
|
||||
roster: &'a Roster,
|
||||
floors: &'a Floors,
|
||||
}
|
||||
|
||||
fn authorized_head<'a>(
|
||||
judge: &Judge<'_>,
|
||||
entity: [u8; 32],
|
||||
candidates: &[&'a ParsedEdition],
|
||||
permission: u64,
|
||||
gapped: &mut bool,
|
||||
) -> Option<&'a ParsedEdition> {
|
||||
let authorized: Vec<&ParsedEdition> = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|edition| {
|
||||
// A banned npub's edits are dropped even while a grant naming them still carries the bit.
|
||||
!judge.roster.banned.contains(&edition.author)
|
||||
&& judge
|
||||
.roster
|
||||
.roles
|
||||
.is_authorized(&edition.author, judge.owner, permission)
|
||||
&& citation_ok(
|
||||
judge.owner,
|
||||
judge.community_id,
|
||||
&edition.author,
|
||||
edition.citation.as_ref(),
|
||||
&judge.roster.floors,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if authorized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let metas: Vec<EditionMeta> = authorized
|
||||
.iter()
|
||||
.map(|edition| EditionMeta::from(*edition))
|
||||
.collect();
|
||||
|
||||
let selection = fold_head(&metas, judge.floors.get(&entity));
|
||||
*gapped |= selection.gap;
|
||||
|
||||
selection.head.map(|index| authorized[index])
|
||||
}
|
||||
|
||||
fn seal_edition(
|
||||
edition: &UnsignedEvent,
|
||||
owner: &Keys,
|
||||
read: &GroupKey,
|
||||
signer: &GroupKey,
|
||||
at_secs: u64,
|
||||
) -> Result<Event> {
|
||||
let seal = build_seal(edition, SealForm::Plaintext, read, owner)?;
|
||||
|
||||
let (wrap, _) = wrap_seal_with(
|
||||
&seal,
|
||||
read.conversation(),
|
||||
signer.keys(),
|
||||
KIND_WRAP,
|
||||
Timestamp::from_secs(at_secs),
|
||||
&[],
|
||||
)?;
|
||||
|
||||
Ok(wrap)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nostr_memory::MemoryDatabase;
|
||||
|
||||
use super::*;
|
||||
use crate::derive::grant_locator;
|
||||
use crate::edition::fold;
|
||||
use crate::roles::{Grant, Role, RoleScope};
|
||||
use crate::store::{CommunityState, load_state, save_state};
|
||||
use crate::{Extra, RoleId};
|
||||
|
||||
const AT: u64 = 1_700_000_000;
|
||||
|
||||
fn holder(minted: &CommunityGenesis) -> (GroupKey, GroupKey) {
|
||||
let community_id = minted.identity.community_id;
|
||||
|
||||
(
|
||||
control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives"),
|
||||
control_signer_group_key(&minted.control_root, &community_id, ROOT_EPOCH)
|
||||
.expect("derives"),
|
||||
)
|
||||
}
|
||||
|
||||
fn open_all(wraps: &[Event], read: &GroupKey, address: &PublicKey) -> Vec<ParsedEdition> {
|
||||
wraps
|
||||
.iter()
|
||||
.map(|wrap| open_edition(wrap, read, address, true).expect("opens"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn metadata(name: &str) -> CommunityMetadata {
|
||||
CommunityMetadata {
|
||||
name: name.to_owned(),
|
||||
..CommunityMetadata::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genesis_reopens_for_a_second_holder() {
|
||||
let owner = Keys::generate();
|
||||
let community_metadata = CommunityMetadata {
|
||||
name: "coop".to_owned(),
|
||||
relays: vec!["wss://relay.example".to_owned()],
|
||||
..CommunityMetadata::default()
|
||||
};
|
||||
|
||||
let minted = genesis(&owner, &community_metadata, AT).expect("mints");
|
||||
assert!(minted.identity.verify(), "identity is self-certifying");
|
||||
|
||||
// Only what an invite hands over: the roots, the community id and the owner salt.
|
||||
let (read, signer) = holder(&minted);
|
||||
let editions = open_all(&minted.wraps, &read, &signer.pk());
|
||||
|
||||
assert_eq!(editions.len(), 2);
|
||||
|
||||
let community = &editions[0];
|
||||
assert_eq!(community.subkind, vsk::COMMUNITY_METADATA);
|
||||
assert_eq!(community.entity, *minted.identity.community_id.as_bytes());
|
||||
assert_eq!(community.author, owner.public_key());
|
||||
assert_eq!((community.version, community.prev), (1, None));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<CommunityMetadata>(&community.content)
|
||||
.expect("parses")
|
||||
.name,
|
||||
"coop"
|
||||
);
|
||||
|
||||
let channel = &editions[1];
|
||||
assert_eq!(channel.subkind, vsk::CHANNEL_METADATA);
|
||||
assert_eq!(channel.entity, *minted.channel_id.as_bytes());
|
||||
|
||||
for edition in &editions {
|
||||
let folded = fold(&[EditionMeta::from(edition)], 0, None);
|
||||
assert_eq!(folded.head, Some(0));
|
||||
assert!(
|
||||
folded.anchored && !folded.gap,
|
||||
"genesis anchors at its floor"
|
||||
);
|
||||
}
|
||||
|
||||
let state = CommunityState::from_genesis(&minted, &editions, AT * 1_000).expect("projects");
|
||||
|
||||
smol::block_on(async {
|
||||
let database = MemoryDatabase::unbounded();
|
||||
save_state(&database, &state).await.expect("saves");
|
||||
let loaded = load_state(&database, &minted.identity.community_id)
|
||||
.await
|
||||
.expect("loads")
|
||||
.expect("present");
|
||||
|
||||
assert_eq!(loaded.community_root, minted.community_root);
|
||||
assert_eq!(loaded.control_root, Some(minted.control_root));
|
||||
assert_eq!(loaded.channels.len(), 1);
|
||||
assert_eq!(loaded.heads.len(), 2);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_and_channel_edits_reach_a_second_client() {
|
||||
let owner = Keys::generate();
|
||||
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
|
||||
let community_id = minted.identity.community_id;
|
||||
let owner_pk = owner.public_key();
|
||||
let (read, signer) = holder(&minted);
|
||||
|
||||
let genesis_editions = open_all(&minted.wraps, &read, &signer.pk());
|
||||
let roster = fold_control(
|
||||
&owner_pk,
|
||||
&community_id,
|
||||
&genesis_editions,
|
||||
&Floors::new(),
|
||||
&BTreeSet::new(),
|
||||
);
|
||||
assert_eq!(
|
||||
roster.community.as_ref().map(|meta| meta.name.as_str()),
|
||||
Some("coop")
|
||||
);
|
||||
|
||||
let writer = ControlWriter {
|
||||
author: owner_pk,
|
||||
read: read.clone(),
|
||||
signer: signer.clone(),
|
||||
};
|
||||
let community_head = roster.floors.get(community_id.as_bytes()).expect("head");
|
||||
let channel_head = roster
|
||||
.floors
|
||||
.get(minted.channel_id.as_bytes())
|
||||
.expect("head");
|
||||
|
||||
let (community_wrap, _) = writer
|
||||
.set_community_metadata(
|
||||
&owner,
|
||||
&community_id,
|
||||
&CommunityMetadata {
|
||||
relays: vec!["wss://relay.example".to_owned()],
|
||||
..metadata("coop two")
|
||||
},
|
||||
Some(community_head),
|
||||
AT + 1,
|
||||
)
|
||||
.expect("publishes");
|
||||
let (channel_wrap, _) = writer
|
||||
.set_channel_metadata(
|
||||
&owner,
|
||||
&minted.channel_id,
|
||||
&ChannelMetadata {
|
||||
name: "lobby".to_owned(),
|
||||
private: false,
|
||||
..ChannelMetadata::default()
|
||||
},
|
||||
Some(channel_head),
|
||||
AT + 2,
|
||||
)
|
||||
.expect("publishes");
|
||||
|
||||
let mut edited = genesis_editions.clone();
|
||||
edited.extend(open_all(
|
||||
&[community_wrap, channel_wrap],
|
||||
&read,
|
||||
&signer.pk(),
|
||||
));
|
||||
|
||||
let folded = fold_control(
|
||||
&owner_pk,
|
||||
&community_id,
|
||||
&edited,
|
||||
&Floors::new(),
|
||||
&BTreeSet::new(),
|
||||
);
|
||||
assert_eq!(
|
||||
folded.community.as_ref().map(|meta| meta.name.as_str()),
|
||||
Some("coop two")
|
||||
);
|
||||
assert_eq!(
|
||||
folded
|
||||
.channels
|
||||
.get(&minted.channel_id)
|
||||
.map(|channel| channel.name.as_str()),
|
||||
Some("lobby")
|
||||
);
|
||||
|
||||
// A relay serving only the editions a client already folded past must not walk
|
||||
// the community backwards.
|
||||
let stale = fold_control(
|
||||
&owner_pk,
|
||||
&community_id,
|
||||
&genesis_editions,
|
||||
&folded.floors,
|
||||
&BTreeSet::new(),
|
||||
);
|
||||
assert!(stale.community.is_none());
|
||||
assert!(stale.channels.is_empty());
|
||||
|
||||
let mut state =
|
||||
CommunityState::from_genesis(&minted, &genesis_editions, AT * 1_000).expect("projects");
|
||||
state.apply_fold(&folded);
|
||||
assert_eq!(state.channels.len(), 1);
|
||||
assert_eq!(state.channels[0].name, "lobby");
|
||||
assert_eq!(state.relays.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delegated_member_edits_metadata_only_under_its_own_grant() {
|
||||
let owner = Keys::generate();
|
||||
let member = Keys::generate();
|
||||
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
|
||||
let community_id = minted.identity.community_id;
|
||||
let owner_pk = owner.public_key();
|
||||
let (read, signer) = holder(&minted);
|
||||
|
||||
let writer = ControlWriter {
|
||||
author: owner_pk,
|
||||
read: read.clone(),
|
||||
signer: signer.clone(),
|
||||
};
|
||||
let role_id = RoleId::from_bytes([0x07; 32]);
|
||||
let role = Role {
|
||||
role_id,
|
||||
name: "Mod".to_owned(),
|
||||
position: 1,
|
||||
permissions: Permissions(Permissions::MANAGE_METADATA),
|
||||
scope: RoleScope::Server,
|
||||
color: 0,
|
||||
extra: Extra::default(),
|
||||
};
|
||||
|
||||
let (role_wrap, _) = writer
|
||||
.publish(
|
||||
&owner,
|
||||
Edition {
|
||||
subkind: vsk::ROLE,
|
||||
entity: *role_id.as_bytes(),
|
||||
content: &role.to_content().expect("serializes"),
|
||||
head: None,
|
||||
citation: None,
|
||||
},
|
||||
AT + 1,
|
||||
)
|
||||
.expect("publishes");
|
||||
let (grant_wrap, _) = writer
|
||||
.publish(
|
||||
&owner,
|
||||
Edition {
|
||||
subkind: vsk::GRANT,
|
||||
entity: grant_locator(&community_id, &member.public_key().to_bytes()),
|
||||
content: &Grant {
|
||||
member: member.public_key(),
|
||||
role_ids: vec![role_id],
|
||||
control_wrap: None,
|
||||
extra: Extra::default(),
|
||||
}
|
||||
.to_content()
|
||||
.expect("serializes"),
|
||||
head: None,
|
||||
citation: None,
|
||||
},
|
||||
AT + 2,
|
||||
)
|
||||
.expect("publishes");
|
||||
|
||||
let mut base = open_all(&minted.wraps, &read, &signer.pk());
|
||||
base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk()));
|
||||
|
||||
let roster = fold_control(
|
||||
&owner_pk,
|
||||
&community_id,
|
||||
&base,
|
||||
&Floors::new(),
|
||||
&BTreeSet::new(),
|
||||
);
|
||||
assert!(roster.roles.is_staff(&member.public_key(), &owner_pk));
|
||||
|
||||
let grant = roster
|
||||
.floors
|
||||
.get(&grant_locator(
|
||||
&community_id,
|
||||
&member.public_key().to_bytes(),
|
||||
))
|
||||
.expect("the member's grant folded");
|
||||
let head = roster.floors.get(community_id.as_bytes()).expect("head");
|
||||
|
||||
// The member seals with their own keys and wraps with the staff write key.
|
||||
let member_writer = ControlWriter {
|
||||
author: member.public_key(),
|
||||
read,
|
||||
signer: signer.clone(),
|
||||
};
|
||||
let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes");
|
||||
|
||||
let (uncited, _) = member_writer
|
||||
.publish(
|
||||
&member,
|
||||
Edition {
|
||||
subkind: vsk::COMMUNITY_METADATA,
|
||||
entity: *community_id.as_bytes(),
|
||||
content: &content,
|
||||
head: Some(head),
|
||||
citation: None,
|
||||
},
|
||||
AT + 3,
|
||||
)
|
||||
.expect("publishes");
|
||||
let (cited, _) = member_writer
|
||||
.publish(
|
||||
&member,
|
||||
Edition {
|
||||
subkind: vsk::COMMUNITY_METADATA,
|
||||
entity: *community_id.as_bytes(),
|
||||
content: &content,
|
||||
head: Some(head),
|
||||
citation: Some(AuthorityCitation {
|
||||
entity: grant.entity,
|
||||
version: grant.version,
|
||||
hash: grant.self_hash,
|
||||
}),
|
||||
},
|
||||
AT + 4,
|
||||
)
|
||||
.expect("publishes");
|
||||
|
||||
// Uncited, the edit claims an authority the member never showed.
|
||||
let mut forged = base.clone();
|
||||
forged.extend(open_all(&[uncited], &member_writer.read, &signer.pk()));
|
||||
let folded = fold_control(
|
||||
&owner_pk,
|
||||
&community_id,
|
||||
&forged,
|
||||
&Floors::new(),
|
||||
&BTreeSet::new(),
|
||||
);
|
||||
assert_eq!(
|
||||
folded.community.as_ref().map(|meta| meta.name.as_str()),
|
||||
Some("coop")
|
||||
);
|
||||
|
||||
let mut edited_editions = base;
|
||||
edited_editions.extend(open_all(&[cited], &member_writer.read, &signer.pk()));
|
||||
let folded = fold_control(
|
||||
&owner_pk,
|
||||
&community_id,
|
||||
&edited_editions,
|
||||
&Floors::new(),
|
||||
&BTreeSet::new(),
|
||||
);
|
||||
assert_eq!(
|
||||
folded.community.as_ref().map(|meta| meta.name.as_str()),
|
||||
Some("coop by mod")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{LazyLock, Mutex, PoisonError};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use hkdf::Hkdf;
|
||||
use nostr::nips::nip44::v2::ConversationKey;
|
||||
use nostr_sdk::prelude::{Keys, PublicKey, SecretKey};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{ChannelId, CommunityId, Epoch};
|
||||
|
||||
pub const TOKEN_LEN: usize = 16;
|
||||
|
||||
const LABEL_CHANNEL: &str = "concord/channel";
|
||||
const LABEL_CONTROL: &str = "concord/control";
|
||||
const LABEL_CONTROL_SIGNER: &str = "concord/control-signer";
|
||||
const LABEL_REKEY_PSEUDONYM: &str = "concord/rekey-pseudonym";
|
||||
const LABEL_BASE_REKEY_PSEUDONYM: &str = "concord/base-rekey-pseudonym";
|
||||
const LABEL_RECIPIENT_PSEUDONYM: &str = "concord/recipient-pseudonym";
|
||||
const LABEL_GUESTBOOK: &str = "concord/guestbook";
|
||||
const LABEL_DISSOLVED: &str = "concord/dissolved";
|
||||
const LABEL_GRANT: &str = "concord/grant";
|
||||
const LABEL_BANLIST: &str = "concord/banlist";
|
||||
const LABEL_PINS: &str = "concord/pins";
|
||||
const LABEL_INVITE_LINKS: &str = "concord/invite-links";
|
||||
const LABEL_INVITE_KEY: &str = "concord/invite-key";
|
||||
|
||||
const LABEL_COMMUNITY: &str = "concord/community";
|
||||
const LABEL_EPOCH_COMMITMENT: &str = "concord/epoch-key-commitment";
|
||||
|
||||
const ZERO32: [u8; 32] = [0u8; 32];
|
||||
|
||||
fn build_info(label: &str, id32: &[u8; 32], epoch: Option<u64>) -> Vec<u8> {
|
||||
let mut info = Vec::with_capacity(label.len() + 1 + 32 + 8);
|
||||
info.extend_from_slice(label.as_bytes());
|
||||
info.push(0x00);
|
||||
info.extend_from_slice(id32);
|
||||
|
||||
if let Some(epoch) = epoch {
|
||||
info.extend_from_slice(&epoch.to_be_bytes());
|
||||
}
|
||||
|
||||
info
|
||||
}
|
||||
|
||||
fn hkdf32(ikm: &[u8], info: &[u8]) -> [u8; 32] {
|
||||
let mut okm = [0u8; 32];
|
||||
Hkdf::<Sha256>::new(None, ikm)
|
||||
.expand(info, &mut okm)
|
||||
.expect("expanding HKDF to 32 bytes is below the 255*32 ceiling");
|
||||
okm
|
||||
}
|
||||
|
||||
fn hkdf_to_secret_key(ikm: &[u8], base_info: &[u8]) -> Result<SecretKey> {
|
||||
if let Ok(secret_key) = SecretKey::from_slice(&hkdf32(ikm, base_info)) {
|
||||
return Ok(secret_key);
|
||||
}
|
||||
|
||||
for counter in 0u8..=u8::MAX {
|
||||
let mut info = Vec::with_capacity(base_info.len() + 1);
|
||||
info.extend_from_slice(base_info);
|
||||
info.push(counter);
|
||||
|
||||
if let Ok(secret_key) = SecretKey::from_slice(&hkdf32(ikm, &info)) {
|
||||
return Ok(secret_key);
|
||||
}
|
||||
}
|
||||
|
||||
bail!("seed stayed out of the secp256k1 scalar range across all 256 counters")
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GroupKey {
|
||||
keys: Keys,
|
||||
conversation: ConversationKey,
|
||||
}
|
||||
|
||||
impl GroupKey {
|
||||
fn derive(label: &str, secret: &[u8], id32: &[u8; 32], epoch: Option<u64>) -> Result<Self> {
|
||||
let key = memo_key(label, secret, id32, epoch);
|
||||
|
||||
if let Some(hit) = lock_memo().get(&key) {
|
||||
return Ok(hit.clone());
|
||||
}
|
||||
|
||||
let info = build_info(label, id32, epoch);
|
||||
let secret_key = hkdf_to_secret_key(secret, &info)?;
|
||||
let keys = Keys::new(secret_key);
|
||||
let conversation = ConversationKey::derive(keys.secret_key(), &keys.public_key())?;
|
||||
let group_key = Self { keys, conversation };
|
||||
|
||||
let mut memo = lock_memo();
|
||||
|
||||
if memo.len() >= 1024 {
|
||||
memo.clear();
|
||||
}
|
||||
|
||||
memo.insert(key, group_key.clone());
|
||||
|
||||
Ok(group_key)
|
||||
}
|
||||
|
||||
pub fn pk(&self) -> PublicKey {
|
||||
self.keys.public_key()
|
||||
}
|
||||
|
||||
pub fn pk_hex(&self) -> String {
|
||||
self.keys.public_key().to_hex()
|
||||
}
|
||||
|
||||
pub fn keys(&self) -> &Keys {
|
||||
&self.keys
|
||||
}
|
||||
|
||||
pub fn conversation(&self) -> &ConversationKey {
|
||||
&self.conversation
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GroupKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("GroupKey")
|
||||
.field("pk", &self.pk_hex())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
static MEMO: LazyLock<Mutex<HashMap<[u8; 32], GroupKey>>> = LazyLock::new(Default::default);
|
||||
|
||||
fn lock_memo() -> std::sync::MutexGuard<'static, HashMap<[u8; 32], GroupKey>> {
|
||||
MEMO.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
pub fn clear_memo() {
|
||||
lock_memo().clear()
|
||||
}
|
||||
|
||||
fn memo_key(label: &str, secret: &[u8], id32: &[u8; 32], epoch: Option<u64>) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(label.as_bytes());
|
||||
hasher.update([0x00]);
|
||||
hasher.update(secret);
|
||||
hasher.update(id32);
|
||||
hasher.update(epoch.unwrap_or(u64::MAX).to_be_bytes());
|
||||
hasher.update([epoch.is_some() as u8]);
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// `secret` is the `community_root` for a public channel.
|
||||
pub fn channel_group_key(secret: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> Result<GroupKey> {
|
||||
GroupKey::derive(LABEL_CHANNEL, secret, channel.as_bytes(), Some(epoch.0))
|
||||
}
|
||||
|
||||
/// The plane's read key: its conversation key encrypts the wraps for every member.
|
||||
pub fn control_group_key(
|
||||
community_root: &[u8; 32],
|
||||
community_id: &CommunityId,
|
||||
epoch: Epoch,
|
||||
) -> Result<GroupKey> {
|
||||
GroupKey::derive(
|
||||
LABEL_CONTROL,
|
||||
community_root,
|
||||
community_id.as_bytes(),
|
||||
Some(epoch.0),
|
||||
)
|
||||
}
|
||||
|
||||
/// The plane's address and wrap signer, held only by staff.
|
||||
/// Wraps still encrypt under [`control_group_key`].
|
||||
pub fn control_signer_group_key(
|
||||
control_root: &[u8; 32],
|
||||
community_id: &CommunityId,
|
||||
epoch: Epoch,
|
||||
) -> Result<GroupKey> {
|
||||
GroupKey::derive(
|
||||
LABEL_CONTROL_SIGNER,
|
||||
control_root,
|
||||
community_id.as_bytes(),
|
||||
Some(epoch.0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Member-writable, unlike the Control Plane:
|
||||
///
|
||||
/// - A join or a leave is each member's own word.
|
||||
pub fn guestbook_group_key(
|
||||
community_root: &[u8; 32],
|
||||
community_id: &CommunityId,
|
||||
epoch: Epoch,
|
||||
) -> Result<GroupKey> {
|
||||
GroupKey::derive(
|
||||
LABEL_GUESTBOOK,
|
||||
community_root,
|
||||
community_id.as_bytes(),
|
||||
Some(epoch.0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Keyed by the prior `community_root` rather than the channel key,
|
||||
/// so any retained member recovers any epoch's rekey without a ratchet.
|
||||
pub fn channel_rekey_group_key(
|
||||
prior_root: &[u8; 32],
|
||||
channel: &ChannelId,
|
||||
new_epoch: Epoch,
|
||||
) -> Result<GroupKey> {
|
||||
GroupKey::derive(
|
||||
LABEL_REKEY_PSEUDONYM,
|
||||
prior_root,
|
||||
channel.as_bytes(),
|
||||
Some(new_epoch.0),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn base_rekey_group_key(
|
||||
prior_root: &[u8; 32],
|
||||
community_id: &CommunityId,
|
||||
new_epoch: Epoch,
|
||||
) -> Result<GroupKey> {
|
||||
GroupKey::derive(
|
||||
LABEL_BASE_REKEY_PSEUDONYM,
|
||||
prior_root,
|
||||
community_id.as_bytes(),
|
||||
Some(new_epoch.0),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn dissolved_group_key(community_id: &CommunityId) -> Result<GroupKey> {
|
||||
GroupKey::derive(LABEL_DISSOLVED, community_id.as_bytes(), &ZERO32, None)
|
||||
}
|
||||
|
||||
pub fn community_id_of(owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> CommunityId {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(LABEL_COMMUNITY.as_bytes());
|
||||
hasher.update(owner_xonly);
|
||||
hasher.update(owner_salt);
|
||||
CommunityId::from_bytes(hasher.finalize().into())
|
||||
}
|
||||
|
||||
pub fn verify_community_id(
|
||||
community_id: &CommunityId,
|
||||
owner_xonly: &[u8; 32],
|
||||
owner_salt: &[u8; 32],
|
||||
) -> bool {
|
||||
community_id_of(owner_xonly, owner_salt) == *community_id
|
||||
}
|
||||
|
||||
/// The continuity a rekey blob must satisfy against the key currently held.
|
||||
pub fn epoch_key_commitment(previous_epoch: Epoch, previous_key: &[u8; 32]) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(LABEL_EPOCH_COMMITMENT.as_bytes());
|
||||
hasher.update(previous_epoch.0.to_be_bytes());
|
||||
hasher.update(previous_key);
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// Bound to the `community_id`, so a member's Grant coordinate survives every refounding.
|
||||
pub fn grant_locator(community_id: &CommunityId, member_xonly: &[u8; 32]) -> [u8; 32] {
|
||||
hkdf32(
|
||||
community_id.as_bytes(),
|
||||
&build_info(LABEL_GRANT, member_xonly, None),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn banlist_locator(community_id: &CommunityId) -> [u8; 32] {
|
||||
hkdf32(
|
||||
community_id.as_bytes(),
|
||||
&build_info(LABEL_BANLIST, &ZERO32, None),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn pins_locator(community_id: &CommunityId, channel: &ChannelId) -> [u8; 32] {
|
||||
hkdf32(
|
||||
community_id.as_bytes(),
|
||||
&build_info(LABEL_PINS, channel.as_bytes(), None),
|
||||
)
|
||||
}
|
||||
|
||||
/// Bound to the creator, so each creator owns exactly their own registry.
|
||||
pub fn invite_links_locator(community_id: &CommunityId, creator_xonly: &[u8; 32]) -> [u8; 32] {
|
||||
hkdf32(
|
||||
community_id.as_bytes(),
|
||||
&build_info(LABEL_INVITE_LINKS, creator_xonly, None),
|
||||
)
|
||||
}
|
||||
|
||||
/// Built from public inputs only, so a locator match proves nothing about authenticity
|
||||
pub fn recipient_locator(
|
||||
rotator_xonly: &[u8; 32],
|
||||
recipient_xonly: &[u8; 32],
|
||||
scope_id: &[u8; 32],
|
||||
new_epoch: Epoch,
|
||||
) -> [u8; 32] {
|
||||
let mut ikm = [0u8; 64];
|
||||
ikm[..32].copy_from_slice(rotator_xonly);
|
||||
ikm[32..].copy_from_slice(recipient_xonly);
|
||||
hkdf32(
|
||||
&ikm,
|
||||
&build_info(LABEL_RECIPIENT_PSEUDONYM, scope_id, Some(new_epoch.0)),
|
||||
)
|
||||
}
|
||||
|
||||
/// The raw output is the NIP-44 conversation key (CORD-05 §2).
|
||||
pub fn invite_bundle_key(token: &[u8; TOKEN_LEN]) -> [u8; 32] {
|
||||
hkdf32(token, &build_info(LABEL_INVITE_KEY, &ZERO32, None))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const CHANNEL_E0_SEED: &str =
|
||||
"1a99a5958bf9fcc5336e6e19db42aabf36ffbfa12f38a1d5fbde2ae383ed751b";
|
||||
const CHANNEL_E0_PK: &str = "7a5c5dff759a63f1fc2779864487432bae3d1ea72c4ffabd39f4c1fdaf62097a";
|
||||
const CHANNEL_EMULTI_PK: &str =
|
||||
"f20c7d192cc87615d7341e86f38f85303f4708b40232d4fea521ab8217767391";
|
||||
const CONTROL_E0_PK: &str = "c43df20bf4d6eeaea5149619662ffe9b211f31e11bb4a59f56b6e906f702d46f";
|
||||
const CONTROL_SIGNER_E0_SEED: &str =
|
||||
"c4a3e8354d95137132087356412b67b53e025d127d45de45cff9ecf45b0c24f6";
|
||||
const CONTROL_SIGNER_E0_PK: &str =
|
||||
"718aef388257f3fd9f1bfae5cf2cbd0594a2ffc31adb5c1fe22c502c046acaee";
|
||||
const CONTROL_SIGNER_EMULTI_PK: &str =
|
||||
"e27235cc13be2f9ad65648e01ff2b63402846469c8638b5386c625688194ec7d";
|
||||
const GUESTBOOK_E0_PK: &str =
|
||||
"ad09de582026fa7a052db18bb5827fa24c15e929d59aadcc91efb8508f5368ad";
|
||||
const CHANNEL_REKEY_E1_PK: &str =
|
||||
"7c55cdb957e9db2b4800d687b2a07d3f7066b1a35824a1e86ba871f55e87e8b5";
|
||||
const BASE_REKEY_E1_PK: &str =
|
||||
"fb2fa44fba66ba15595f784255a1cb569531db8784432ac0e4fe838498dd9dea";
|
||||
const DISSOLVED_PK: &str = "4d3d55d88fdf9d9c2089651e5cbb0dfa93b6b9b10cdcb2319b0dce1a1398096a";
|
||||
const GRANT_LOCATOR: &str = "fd2f88cc7f1eb8d7d862c91dc22afe700c358d1845158b3f353b769ce4898e35";
|
||||
const BANLIST_LOCATOR: &str =
|
||||
"88089214afae6d3c412fd817ada44d6df4d485a53565646471e74476397693c9";
|
||||
const INVITE_LINKS_LOCATOR: &str =
|
||||
"f4ae29994165767bac23e8dce630f81b926d2c8aa150e5cbf0bdf75865e8379a";
|
||||
const RECIPIENT_LOCATOR: &str =
|
||||
"342deb400e191f0f52c81f27600934552550beb85aa9bf169f02d0e7f826cf74";
|
||||
const INVITE_KEY: &str = "94bf8b0d89e579ddaeccf8d9db3f5de5c86a1259c597f2560ff0120173bc5e1f";
|
||||
const COMMUNITY_ID: &str = "2b790bd59df98bdc52092b74ebd6933a89ef8eaeecc9030861cbdeae7c814c46";
|
||||
const EPOCH_COMMITMENT: &str =
|
||||
"3e6d6a3c9973c16d1ca7c5602d36979927c55c21a7e2c840f883af3f047e80a4";
|
||||
const PINS_LOCATOR: &str = "3b4529395a35c981ed409b588af3c4cd3081992958a485347356a173c3146c52";
|
||||
const EPOCH_MULTI: u64 = 0x0102030405060708;
|
||||
|
||||
/// `0x00..0x1f` / `0xff..0xe0` / `0x11` x32 — the inputs every vector uses.
|
||||
fn secret() -> [u8; 32] {
|
||||
let mut key = [0u8; 32];
|
||||
for (index, byte) in key.iter_mut().enumerate() {
|
||||
*byte = index as u8;
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
fn id32() -> [u8; 32] {
|
||||
let mut id = [0u8; 32];
|
||||
for (index, byte) in id.iter_mut().enumerate() {
|
||||
*byte = 255 - index as u8;
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
data_encoding::HEXLOWER.encode(bytes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_vectors() {
|
||||
let secret = secret();
|
||||
let id = id32();
|
||||
let alt = [0x11u8; 32];
|
||||
let community_id = CommunityId::from_bytes(id);
|
||||
let channel = ChannelId::from_bytes(id);
|
||||
|
||||
let channel_e0 = channel_group_key(&secret, &channel, Epoch(0)).expect("derives");
|
||||
assert_eq!(
|
||||
hex(channel_e0.keys().secret_key().as_secret_bytes()),
|
||||
CHANNEL_E0_SEED
|
||||
);
|
||||
assert_eq!(channel_e0.pk_hex(), CHANNEL_E0_PK);
|
||||
assert_eq!(
|
||||
channel_group_key(&secret, &channel, Epoch(EPOCH_MULTI))
|
||||
.expect("derives")
|
||||
.pk_hex(),
|
||||
CHANNEL_EMULTI_PK
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
control_group_key(&secret, &community_id, Epoch(0))
|
||||
.expect("derives")
|
||||
.pk_hex(),
|
||||
CONTROL_E0_PK
|
||||
);
|
||||
|
||||
let signer = control_signer_group_key(&secret, &community_id, Epoch(0)).expect("derives");
|
||||
assert_eq!(
|
||||
hex(signer.keys().secret_key().as_secret_bytes()),
|
||||
CONTROL_SIGNER_E0_SEED
|
||||
);
|
||||
assert_eq!(signer.pk_hex(), CONTROL_SIGNER_E0_PK);
|
||||
assert_eq!(
|
||||
control_signer_group_key(&secret, &community_id, Epoch(EPOCH_MULTI))
|
||||
.expect("derives")
|
||||
.pk_hex(),
|
||||
CONTROL_SIGNER_EMULTI_PK
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
guestbook_group_key(&secret, &community_id, Epoch(0))
|
||||
.expect("derives")
|
||||
.pk_hex(),
|
||||
GUESTBOOK_E0_PK
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
channel_rekey_group_key(&secret, &channel, Epoch(1))
|
||||
.expect("derives")
|
||||
.pk_hex(),
|
||||
CHANNEL_REKEY_E1_PK
|
||||
);
|
||||
assert_eq!(
|
||||
base_rekey_group_key(&secret, &community_id, Epoch(1))
|
||||
.expect("derives")
|
||||
.pk_hex(),
|
||||
BASE_REKEY_E1_PK
|
||||
);
|
||||
assert_eq!(
|
||||
dissolved_group_key(&community_id)
|
||||
.expect("derives")
|
||||
.pk_hex(),
|
||||
DISSOLVED_PK
|
||||
);
|
||||
|
||||
assert_eq!(hex(&grant_locator(&community_id, &alt)), GRANT_LOCATOR);
|
||||
assert_eq!(hex(&banlist_locator(&community_id)), BANLIST_LOCATOR);
|
||||
assert_eq!(
|
||||
hex(&invite_links_locator(&community_id, &alt)),
|
||||
INVITE_LINKS_LOCATOR
|
||||
);
|
||||
assert_eq!(hex(&pins_locator(&community_id, &channel)), PINS_LOCATOR);
|
||||
assert_eq!(
|
||||
hex(&recipient_locator(&secret, &alt, &id, Epoch(3))),
|
||||
RECIPIENT_LOCATOR
|
||||
);
|
||||
assert_eq!(hex(&invite_bundle_key(&[0x07u8; TOKEN_LEN])), INVITE_KEY);
|
||||
|
||||
assert_eq!(hex(community_id_of(&secret, &alt).as_bytes()), COMMUNITY_ID);
|
||||
assert_eq!(
|
||||
hex(&epoch_key_commitment(Epoch(2), &secret)),
|
||||
EPOCH_COMMITMENT
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
|
||||
use data_encoding::HEXLOWER;
|
||||
use nostr_sdk::prelude::{EventId, PublicKey, Tag, UnsignedEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::decode_hex_32;
|
||||
use crate::stream::build_rumor_secs;
|
||||
|
||||
pub const KIND_CONTROL: u16 = 3308;
|
||||
|
||||
const EDITION_LABEL: &[u8] = b"vector-community/v1/edition";
|
||||
|
||||
/// Entity types an edition can address (CORD-02 Appendix B).
|
||||
pub mod vsk {
|
||||
pub const COMMUNITY_METADATA: &str = "0";
|
||||
pub const ROLE: &str = "1";
|
||||
pub const CHANNEL_METADATA: &str = "2";
|
||||
pub const GRANT: &str = "3";
|
||||
pub const BANLIST: &str = "4";
|
||||
pub const INVITE_LIVE: &str = "6";
|
||||
pub const INVITE_LINKS: &str = "8";
|
||||
pub const INVITE_REVOKED: &str = "9";
|
||||
pub const DISSOLVED: &str = "10";
|
||||
pub const PINS: &str = "11";
|
||||
}
|
||||
|
||||
const TAG_SUBKIND: &str = "vsk";
|
||||
const TAG_ENTITY: &str = "eid";
|
||||
const TAG_VERSION: &str = "ev";
|
||||
const TAG_PREV: &str = "ep";
|
||||
const TAG_CITATION: &str = "vac";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum EditionError {
|
||||
BadKind(u16),
|
||||
BadField(&'static str),
|
||||
Duplicate(&'static str),
|
||||
Missing(&'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for EditionError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
EditionError::BadKind(kind) => write!(f, "not an edition kind: {kind}"),
|
||||
EditionError::BadField(name) => write!(f, "malformed edition field: {name}"),
|
||||
EditionError::Duplicate(name) => write!(f, "duplicate edition field: {name}"),
|
||||
EditionError::Missing(name) => write!(f, "missing edition field: {name}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EditionError {}
|
||||
|
||||
/// A `vac` citation: the Grant edition an actor claims rank under, pinned by
|
||||
/// coordinate, version and hash. It is a sync floor, not the verdict.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AuthorityCitation {
|
||||
pub entity: [u8; 32],
|
||||
pub version: u64,
|
||||
pub hash: [u8; 32],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedEdition {
|
||||
pub author: PublicKey,
|
||||
pub subkind: String,
|
||||
pub entity: [u8; 32],
|
||||
pub version: u64,
|
||||
pub prev: Option<[u8; 32]>,
|
||||
pub citation: Option<AuthorityCitation>,
|
||||
pub content: String,
|
||||
pub self_hash: [u8; 32],
|
||||
pub rumor_id: EventId,
|
||||
}
|
||||
|
||||
pub struct EditionFields<'a> {
|
||||
pub author: PublicKey,
|
||||
pub subkind: &'a str,
|
||||
pub entity: [u8; 32],
|
||||
pub version: u64,
|
||||
pub prev: Option<[u8; 32]>,
|
||||
pub citation: Option<AuthorityCitation>,
|
||||
pub content: &'a str,
|
||||
pub at_secs: u64,
|
||||
}
|
||||
|
||||
fn signing_bytes(
|
||||
entity: &[u8; 32],
|
||||
version: u64,
|
||||
prev: Option<&[u8; 32]>,
|
||||
content: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let mut bytes =
|
||||
Vec::with_capacity(8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + content.len());
|
||||
|
||||
bytes.extend_from_slice(&(EDITION_LABEL.len() as u64).to_be_bytes());
|
||||
bytes.extend_from_slice(EDITION_LABEL);
|
||||
bytes.extend_from_slice(entity);
|
||||
bytes.extend_from_slice(&version.to_be_bytes());
|
||||
|
||||
match prev {
|
||||
Some(prev) => {
|
||||
bytes.push(1);
|
||||
bytes.extend_from_slice(prev);
|
||||
}
|
||||
None => {
|
||||
bytes.push(0);
|
||||
bytes.extend_from_slice(&[0u8; 32]);
|
||||
}
|
||||
}
|
||||
|
||||
bytes.extend_from_slice(&(content.len() as u64).to_be_bytes());
|
||||
bytes.extend_from_slice(content);
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn edition_hash(
|
||||
entity: &[u8; 32],
|
||||
version: u64,
|
||||
prev: Option<&[u8; 32]>,
|
||||
content: &[u8],
|
||||
) -> [u8; 32] {
|
||||
Sha256::digest(signing_bytes(entity, version, prev, content)).into()
|
||||
}
|
||||
|
||||
pub fn build_edition(fields: EditionFields<'_>) -> UnsignedEvent {
|
||||
let mut tags = vec![
|
||||
Tag::custom(TAG_SUBKIND, [fields.subkind]),
|
||||
Tag::custom(TAG_ENTITY, [HEXLOWER.encode(&fields.entity)]),
|
||||
Tag::custom(TAG_VERSION, [fields.version.to_string()]),
|
||||
];
|
||||
|
||||
if let Some(prev) = fields.prev {
|
||||
tags.push(Tag::custom(TAG_PREV, [HEXLOWER.encode(&prev)]));
|
||||
}
|
||||
|
||||
if let Some(citation) = fields.citation {
|
||||
tags.push(Tag::custom(
|
||||
TAG_CITATION,
|
||||
[
|
||||
HEXLOWER.encode(&citation.entity),
|
||||
citation.version.to_string(),
|
||||
HEXLOWER.encode(&citation.hash),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
build_rumor_secs(
|
||||
KIND_CONTROL,
|
||||
fields.author,
|
||||
fields.content,
|
||||
tags,
|
||||
fields.at_secs,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn parse_edition(rumor: &UnsignedEvent) -> Result<ParsedEdition, EditionError> {
|
||||
let kind = rumor.kind.as_u16();
|
||||
|
||||
if kind != KIND_CONTROL {
|
||||
return Err(EditionError::BadKind(kind));
|
||||
}
|
||||
|
||||
let subkind = value(rumor, TAG_SUBKIND)?
|
||||
.ok_or(EditionError::Missing(TAG_SUBKIND))?
|
||||
.to_owned();
|
||||
|
||||
if canonical_decimal(&subkind).is_none() {
|
||||
return Err(EditionError::BadField(TAG_SUBKIND));
|
||||
}
|
||||
|
||||
let entity = hex32(
|
||||
value(rumor, TAG_ENTITY)?.ok_or(EditionError::Missing(TAG_ENTITY))?,
|
||||
TAG_ENTITY,
|
||||
)?;
|
||||
|
||||
let version =
|
||||
canonical_decimal(value(rumor, TAG_VERSION)?.ok_or(EditionError::Missing(TAG_VERSION))?)
|
||||
.ok_or(EditionError::BadField(TAG_VERSION))?;
|
||||
|
||||
let prev = match value(rumor, TAG_PREV)? {
|
||||
Some(raw) => Some(hex32(raw, TAG_PREV)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let citation = match fields(rumor, TAG_CITATION)? {
|
||||
Some(fields) if fields.len() == 4 => Some(AuthorityCitation {
|
||||
entity: hex32(&fields[1], TAG_CITATION)?,
|
||||
version: canonical_decimal(&fields[2]).ok_or(EditionError::BadField(TAG_CITATION))?,
|
||||
hash: hex32(&fields[3], TAG_CITATION)?,
|
||||
}),
|
||||
Some(_) => return Err(EditionError::BadField(TAG_CITATION)),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let self_hash = edition_hash(&entity, version, prev.as_ref(), rumor.content.as_bytes());
|
||||
|
||||
Ok(ParsedEdition {
|
||||
author: rumor.pubkey,
|
||||
subkind,
|
||||
entity,
|
||||
version,
|
||||
prev,
|
||||
citation,
|
||||
content: rumor.content.clone(),
|
||||
self_hash,
|
||||
rumor_id: rumor.id.unwrap_or_else(|| rumor.compute_id()),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct EditionMeta {
|
||||
pub version: u64,
|
||||
pub self_hash: [u8; 32],
|
||||
pub prev: Option<[u8; 32]>,
|
||||
pub tiebreak_id: EventId,
|
||||
}
|
||||
|
||||
impl From<&ParsedEdition> for EditionMeta {
|
||||
fn from(edition: &ParsedEdition) -> Self {
|
||||
Self {
|
||||
version: edition.version,
|
||||
self_hash: edition.self_hash,
|
||||
prev: edition.prev,
|
||||
tiebreak_id: edition.rumor_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct FoldResult {
|
||||
pub head: Option<usize>,
|
||||
pub gap: bool,
|
||||
pub anchored: bool,
|
||||
}
|
||||
|
||||
/// The highest version whose chain is intact, given a held floor.
|
||||
pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult {
|
||||
let mut by_version: BTreeMap<u64, usize> = BTreeMap::new();
|
||||
|
||||
for (index, edition) in editions.iter().enumerate() {
|
||||
if edition.version < floor {
|
||||
continue;
|
||||
}
|
||||
|
||||
match by_version.get(&edition.version) {
|
||||
Some(¤t) if editions[current].tiebreak_id <= edition.tiebreak_id => {}
|
||||
_ => {
|
||||
by_version.insert(edition.version, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some((&lowest_version, &lowest_index)) = by_version.first_key_value() else {
|
||||
return FoldResult::default();
|
||||
};
|
||||
|
||||
let lowest = editions[lowest_index];
|
||||
|
||||
let anchored = if floor == 0 {
|
||||
lowest_version == 1 && lowest.prev.is_none()
|
||||
} else if lowest_version == floor {
|
||||
floor_hash == Some(&lowest.self_hash)
|
||||
} else if lowest_version == floor + 1 {
|
||||
floor_hash.is_some() && lowest.prev.as_ref() == floor_hash
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let mut head = Some(lowest_index);
|
||||
let mut gap = !anchored;
|
||||
let mut previous_version = lowest_version;
|
||||
let mut previous_hash = lowest.self_hash;
|
||||
|
||||
for (&version, &index) in by_version.range(lowest_version + 1..) {
|
||||
let edition = editions[index];
|
||||
|
||||
if version == previous_version + 1 && edition.prev == Some(previous_hash) {
|
||||
head = Some(index);
|
||||
previous_version = version;
|
||||
previous_hash = edition.self_hash;
|
||||
} else {
|
||||
gap = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
FoldResult {
|
||||
head,
|
||||
gap,
|
||||
anchored,
|
||||
}
|
||||
}
|
||||
|
||||
/// The highest version overall, ignoring contiguity.
|
||||
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
|
||||
editions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.reduce(|(best_index, best), (index, candidate)| {
|
||||
let supersedes = candidate.version > best.version
|
||||
|| (candidate.version == best.version && candidate.tiebreak_id < best.tiebreak_id);
|
||||
|
||||
if supersedes {
|
||||
(index, candidate)
|
||||
} else {
|
||||
(best_index, best)
|
||||
}
|
||||
})
|
||||
.map(|(index, _)| index)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct HeadSelection {
|
||||
pub head: Option<usize>,
|
||||
pub gap: bool,
|
||||
}
|
||||
|
||||
/// The head to prefer for one entity, given what this client already committed to.
|
||||
pub fn fold_head(editions: &[EditionMeta], floor: Option<&EntityHead>) -> HeadSelection {
|
||||
let Some(floor) = floor else {
|
||||
return HeadSelection {
|
||||
head: bootstrap_head(editions),
|
||||
gap: false,
|
||||
};
|
||||
};
|
||||
|
||||
let anchored = fold(editions, floor.version, Some(&floor.self_hash));
|
||||
|
||||
if anchored.anchored {
|
||||
return HeadSelection {
|
||||
head: anchored.head,
|
||||
gap: anchored.gap,
|
||||
};
|
||||
}
|
||||
|
||||
if anchored.head.is_none() && !anchored.gap {
|
||||
return HeadSelection::default();
|
||||
}
|
||||
|
||||
let fork = editions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, edition)| edition.version == floor.version)
|
||||
.min_by_key(|(_, edition)| edition.tiebreak_id);
|
||||
|
||||
let winner = match fork {
|
||||
Some((_, edition))
|
||||
if edition.self_hash != floor.self_hash && edition.tiebreak_id < floor.rumor_id =>
|
||||
{
|
||||
edition.self_hash
|
||||
}
|
||||
_ => {
|
||||
return HeadSelection {
|
||||
head: None,
|
||||
gap: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let refolded = fold(editions, floor.version, Some(&winner));
|
||||
|
||||
if refolded.anchored {
|
||||
HeadSelection {
|
||||
head: refolded.head,
|
||||
gap: refolded.gap,
|
||||
}
|
||||
} else {
|
||||
HeadSelection {
|
||||
head: None,
|
||||
gap: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A committed head, and the refuse-downgrade floor a later fold is judged against.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EntityHead {
|
||||
pub entity: [u8; 32],
|
||||
pub version: u64,
|
||||
pub self_hash: [u8; 32],
|
||||
pub rumor_id: EventId,
|
||||
}
|
||||
|
||||
impl From<&ParsedEdition> for EntityHead {
|
||||
fn from(edition: &ParsedEdition) -> Self {
|
||||
Self {
|
||||
entity: edition.entity,
|
||||
version: edition.version,
|
||||
self_hash: edition.self_hash,
|
||||
rumor_id: edition.rumor_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every entity's committed head, keyed by coordinate.
|
||||
pub type Floors = BTreeMap<[u8; 32], EntityHead>;
|
||||
|
||||
fn canonical_decimal(raw: &str) -> Option<u64> {
|
||||
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if raw.len() > 1 && raw.starts_with('0') {
|
||||
return None;
|
||||
}
|
||||
|
||||
raw.parse().ok()
|
||||
}
|
||||
|
||||
fn hex32(raw: &str, name: &'static str) -> Result<[u8; 32], EditionError> {
|
||||
decode_hex_32(raw).map_err(|_| EditionError::BadField(name))
|
||||
}
|
||||
|
||||
fn fields<'a>(
|
||||
rumor: &'a UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<Option<&'a [String]>, EditionError> {
|
||||
let mut found: Option<&[String]> = None;
|
||||
|
||||
for tag in rumor.tags.iter() {
|
||||
let tag_fields = tag.as_slice();
|
||||
|
||||
if tag_fields.first().map(String::as_str) != Some(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if found.is_some() {
|
||||
return Err(EditionError::Duplicate(name));
|
||||
}
|
||||
|
||||
found = Some(tag_fields);
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn value<'a>(
|
||||
rumor: &'a UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<Option<&'a str>, EditionError> {
|
||||
match fields(rumor, name)? {
|
||||
Some(fields) if fields.len() == 2 => Ok(Some(fields[1].as_str())),
|
||||
Some(_) => Err(EditionError::BadField(name)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn meta(version: u64, prev: Option<[u8; 32]>, hash: u8, tiebreak: u8) -> EditionMeta {
|
||||
EditionMeta {
|
||||
version,
|
||||
self_hash: [hash; 32],
|
||||
prev,
|
||||
tiebreak_id: EventId::from_byte_array([tiebreak; 32]),
|
||||
}
|
||||
}
|
||||
|
||||
fn head(version: u64, hash: u8, rumor: u8) -> EntityHead {
|
||||
EntityHead {
|
||||
entity: [0x11; 32],
|
||||
version,
|
||||
self_hash: [hash; 32],
|
||||
rumor_id: EventId::from_byte_array([rumor; 32]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_picks_the_head_from_the_chain_and_the_floor() {
|
||||
let chain = [
|
||||
meta(1, None, 0xa1, 1),
|
||||
meta(2, Some([0xa1; 32]), 0xa2, 2),
|
||||
meta(3, Some([0xa2; 32]), 0xa3, 3),
|
||||
];
|
||||
|
||||
let folded = fold(&chain, 0, None);
|
||||
assert_eq!(folded.head, Some(2));
|
||||
assert!(!folded.gap && folded.anchored);
|
||||
|
||||
// A missing link stops the walk at the last contiguous edition.
|
||||
let gapped = fold(&[chain[0], chain[2]], 0, None);
|
||||
assert_eq!(gapped.head, Some(0));
|
||||
assert!(gapped.gap && gapped.anchored);
|
||||
|
||||
// Everything below the held floor is a stale relay, not a gap.
|
||||
let stale = fold(&chain[..2], 3, Some(&[0xa3; 32]));
|
||||
assert_eq!(stale.head, None);
|
||||
assert!(!stale.gap && !stale.anchored);
|
||||
|
||||
// A fork at a version breaks on the lower inner rumor id, and the chain resumes.
|
||||
let fork = [meta(1, None, 0xb1, 9), meta(1, None, 0xa1, 1)];
|
||||
assert_eq!(
|
||||
fold(&fork, 0, None).head,
|
||||
Some(1),
|
||||
"the lower rumor id wins"
|
||||
);
|
||||
let forked = [fork[0], fork[1], chain[1], chain[2]];
|
||||
assert_eq!(fold(&forked, 0, None).head, Some(3));
|
||||
|
||||
// A re-wrap onto the head we hold is the legitimate case; one whose `prev` no
|
||||
// longer resolves is a withholding.
|
||||
let rewrapped = meta(5, Some([0x99; 32]), 0xc5, 5);
|
||||
assert_eq!(
|
||||
fold_head(&[rewrapped], Some(&head(4, 0x99, 4))).head,
|
||||
Some(0)
|
||||
);
|
||||
let dangling = meta(5, Some([0x88; 32]), 0xc5, 5);
|
||||
let refused = fold_head(&[dangling], Some(&head(4, 0x99, 4)));
|
||||
assert_eq!(refused.head, None);
|
||||
assert!(refused.gap);
|
||||
|
||||
// A bootstrap takes it anyway: a compaction would leave a joiner with nothing.
|
||||
assert_eq!(bootstrap_head(&[dangling]), Some(0));
|
||||
assert_eq!(fold_head(&[dangling], None).head, Some(0));
|
||||
|
||||
// A fork at the floor's own version converges to the lower rumor id when that is
|
||||
// genuinely earlier than what we hold, and the chain above it re-anchors.
|
||||
let forked = [
|
||||
meta(2, Some([0xa1; 32]), 0xb2, 3),
|
||||
meta(3, Some([0xb2; 32]), 0xb3, 4),
|
||||
];
|
||||
let converged = fold_head(&forked, Some(&head(2, 0xaa, 9)));
|
||||
assert_eq!(converged.head, Some(1));
|
||||
assert!(!converged.gap);
|
||||
|
||||
// A fork that is not earlier than the held head is refused.
|
||||
assert_eq!(fold_head(&forked, Some(&head(2, 0xaa, 2))).head, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edition_hash_matches_the_cross_client_vector() {
|
||||
let entity = [0x11u8; 32];
|
||||
|
||||
assert_eq!(
|
||||
HEXLOWER.encode(&edition_hash(&entity, 1, None, b"hello")),
|
||||
"2daf42e65a6bc259a4c99fac6df754a5d3d92310607cf13e2a1e8c94d42f6303"
|
||||
);
|
||||
|
||||
// The golden vector only exercises the absent-prev encoding; pin the
|
||||
// present-prev branch structurally so a swapped flag stays visible.
|
||||
let bytes = signing_bytes(&entity, 1, Some(&entity), b"hello");
|
||||
assert_eq!(
|
||||
bytes.len(),
|
||||
8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + 5
|
||||
);
|
||||
assert_eq!(&bytes[8..8 + EDITION_LABEL.len()], EDITION_LABEL);
|
||||
assert_eq!(
|
||||
bytes[8 + EDITION_LABEL.len() + 32..][..8],
|
||||
1u64.to_be_bytes()
|
||||
);
|
||||
assert_eq!(bytes[8 + EDITION_LABEL.len() + 32 + 8], 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
pub mod control;
|
||||
pub mod derive;
|
||||
pub mod edition;
|
||||
pub mod roles;
|
||||
pub mod store;
|
||||
pub mod stream;
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use data_encoding::HEXLOWER;
|
||||
pub use derive::GroupKey;
|
||||
use rand::TryRng as _;
|
||||
use rand::rngs::SysRng;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
/// Unknown fields a content struct does not model, so a republish cannot wipe them.
|
||||
pub(crate) type Extra = serde_json::Map<String, serde_json::Value>;
|
||||
|
||||
macro_rules! hex_id {
|
||||
($(#[$meta:meta])* $name:ident) => {
|
||||
$(#[$meta])*
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct $name([u8; 32]);
|
||||
|
||||
impl $name {
|
||||
pub fn from_bytes(bytes: [u8; 32]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
pub fn as_bytes(&self) -> &[u8; 32] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn to_hex(&self) -> String {
|
||||
HEXLOWER.encode(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<[u8; 32]> for $name {
|
||||
fn from(bytes: [u8; 32]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.to_hex())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for $name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}({})", stringify!($name), self.to_hex())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for $name {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self> {
|
||||
Ok(Self(decode_hex_32(value)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for $name {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.to_hex())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for $name {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
value.parse().map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
hex_id! {
|
||||
/// A self-certifying commitment to the owner's key, carried inside invites and
|
||||
/// never on the wire.
|
||||
CommunityId
|
||||
}
|
||||
|
||||
hex_id! {
|
||||
ChannelId
|
||||
}
|
||||
|
||||
hex_id! {
|
||||
/// Both a Role's entity coordinate and the field it repeats in its own content.
|
||||
RoleId
|
||||
}
|
||||
|
||||
/// A key-rotation counter; it bumps only on a Rekey that removes somebody.
|
||||
#[derive(
|
||||
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default, Serialize, Deserialize,
|
||||
)]
|
||||
pub struct Epoch(pub u64);
|
||||
|
||||
impl From<u64> for Epoch {
|
||||
fn from(value: u64) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Epoch> for u64 {
|
||||
fn from(value: Epoch) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Epoch {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Uppercase and other non-canonical spellings are rejected.
|
||||
pub(crate) fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
|
||||
let bytes = HEXLOWER
|
||||
.decode(value.as_bytes())
|
||||
.map_err(|error| anyhow!("invalid hex: {error}"))?;
|
||||
|
||||
let decoded: [u8; 32] = bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("expected 32 bytes, got {}", bytes.len()))?;
|
||||
|
||||
if HEXLOWER.encode(&decoded) != value {
|
||||
bail!("hex must be lowercase and canonical");
|
||||
}
|
||||
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
pub(crate) fn fill_random(bytes: &mut [u8]) -> Result<()> {
|
||||
SysRng
|
||||
.try_fill_bytes(bytes)
|
||||
.map_err(|error| anyhow!("os rng: {error}"))
|
||||
}
|
||||
|
||||
pub(crate) fn random_32() -> Result<[u8; 32]> {
|
||||
let mut bytes = [0u8; 32];
|
||||
fill_random(&mut bytes)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,337 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::control::{
|
||||
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
|
||||
};
|
||||
use crate::derive::control_signer_group_key;
|
||||
use crate::edition::{EntityHead, Floors, ParsedEdition, vsk};
|
||||
use crate::stream::OpenedStream;
|
||||
use crate::{ChannelId, CommunityId, Epoch};
|
||||
|
||||
static LOCAL_KEYS: LazyLock<Keys> = 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";
|
||||
const STATE_PREFIX: &str = "concord/";
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
pub async fn query_rumors(
|
||||
database: &dyn NostrDatabase,
|
||||
channel: &ChannelId,
|
||||
until: Option<Timestamp>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<UnsignedEvent>> {
|
||||
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<String, Event> = 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<Event> = 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)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChannelKeyRef {
|
||||
pub id: ChannelId,
|
||||
pub name: String,
|
||||
pub private: bool,
|
||||
pub epoch: Epoch,
|
||||
}
|
||||
|
||||
/// One local document per community, keyed by `concord/<community_id>`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CommunityState {
|
||||
pub id: CommunityId,
|
||||
pub owner: PublicKey,
|
||||
pub owner_salt: [u8; 32],
|
||||
pub community_root: [u8; 32],
|
||||
pub root_epoch: Epoch,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub control_root: Option<[u8; 32]>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub control_pks: BTreeMap<u64, PublicKey>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub channels: Vec<ChannelKeyRef>,
|
||||
pub relays: Vec<RelayUrl>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub heads: Vec<EntityHead>,
|
||||
pub added_at_ms: u64,
|
||||
}
|
||||
|
||||
impl CommunityState {
|
||||
pub fn from_genesis(
|
||||
genesis: &CommunityGenesis,
|
||||
editions: &[ParsedEdition],
|
||||
added_at_ms: u64,
|
||||
) -> Result<Self> {
|
||||
let mut channels = Vec::new();
|
||||
let mut heads = Vec::with_capacity(editions.len());
|
||||
let mut relays = Vec::new();
|
||||
|
||||
for edition in editions {
|
||||
heads.push(EntityHead {
|
||||
entity: edition.entity,
|
||||
version: edition.version,
|
||||
self_hash: edition.self_hash,
|
||||
rumor_id: edition.rumor_id,
|
||||
});
|
||||
|
||||
match edition.subkind.as_str() {
|
||||
vsk::COMMUNITY_METADATA => {
|
||||
let metadata: CommunityMetadata = serde_json::from_str(&edition.content)?;
|
||||
relays.extend(
|
||||
metadata
|
||||
.relays
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok()),
|
||||
);
|
||||
}
|
||||
vsk::CHANNEL_METADATA => {
|
||||
let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?;
|
||||
channels.push(ChannelKeyRef {
|
||||
id: ChannelId::from_bytes(edition.entity),
|
||||
name: metadata.name,
|
||||
private: metadata.private,
|
||||
epoch: ROOT_EPOCH,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let control_pks = BTreeMap::from([(
|
||||
ROOT_EPOCH.0,
|
||||
control_signer_group_key(
|
||||
&genesis.control_root,
|
||||
&genesis.identity.community_id,
|
||||
ROOT_EPOCH,
|
||||
)?
|
||||
.pk(),
|
||||
)]);
|
||||
|
||||
Ok(Self {
|
||||
id: genesis.identity.community_id,
|
||||
owner: genesis.identity.owner,
|
||||
owner_salt: genesis.identity.owner_salt,
|
||||
community_root: genesis.community_root,
|
||||
root_epoch: ROOT_EPOCH,
|
||||
control_root: Some(genesis.control_root),
|
||||
control_pks,
|
||||
channels,
|
||||
relays,
|
||||
heads,
|
||||
added_at_ms,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn identifier(&self) -> String {
|
||||
state_identifier(&self.id)
|
||||
}
|
||||
|
||||
pub fn floors(&self) -> Floors {
|
||||
self.heads
|
||||
.iter()
|
||||
.map(|head| (head.entity, head.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn apply_fold(&mut self, fold: &ControlFold) {
|
||||
self.heads = fold.floors.values().cloned().collect();
|
||||
|
||||
if let Some(community) = &fold.community {
|
||||
self.relays = community
|
||||
.relays
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
||||
.collect();
|
||||
}
|
||||
|
||||
for (id, metadata) in &fold.channels {
|
||||
if metadata.deleted.unwrap_or(false) {
|
||||
self.channels.retain(|channel| channel.id != *id);
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.channels.iter_mut().find(|channel| channel.id == *id) {
|
||||
Some(channel) => {
|
||||
channel.name = metadata.name.clone();
|
||||
|
||||
if !metadata.private {
|
||||
channel.private = false;
|
||||
}
|
||||
}
|
||||
None if !metadata.private => self.channels.push(ChannelKeyRef {
|
||||
id: *id,
|
||||
name: metadata.name.clone(),
|
||||
private: false,
|
||||
epoch: self.root_epoch,
|
||||
}),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn state_identifier(id: &CommunityId) -> String {
|
||||
format!("{STATE_PREFIX}{}", id.to_hex())
|
||||
}
|
||||
|
||||
pub async fn save_state<D>(database: &D, state: &CommunityState) -> Result<()>
|
||||
where
|
||||
D: NostrDatabase,
|
||||
{
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||
.tags([Tag::identifier(state.identifier())])
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
database.save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_state<D>(database: &D, id: &CommunityId) -> Result<Option<CommunityState>>
|
||||
where
|
||||
D: NostrDatabase,
|
||||
{
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(state_identifier(id))
|
||||
.limit(1);
|
||||
|
||||
match database.query(filter).await?.into_iter().next() {
|
||||
Some(event) => Ok(Some(serde_json::from_str(&event.content)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
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 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<Self> {
|
||||
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<Tag>,
|
||||
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<Tag>,
|
||||
at_secs: u64,
|
||||
) -> UnsignedEvent {
|
||||
let mut rumor = UnsignedEvent::new(
|
||||
author,
|
||||
Timestamp::from_secs(at_secs),
|
||||
Kind::Custom(kind),
|
||||
tags,
|
||||
content,
|
||||
);
|
||||
rumor.ensure_id();
|
||||
rumor
|
||||
}
|
||||
|
||||
pub fn resolve_ms_strict(rumor: &UnsignedEvent) -> Result<u64, StreamError> {
|
||||
let seconds = rumor.created_at.as_secs().saturating_mul(1000);
|
||||
let mut tag: Option<Option<String>> = 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<String, StreamError> {
|
||||
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<Event, StreamError> {
|
||||
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> {
|
||||
wrap_seal_with(
|
||||
seal,
|
||||
group.conversation(),
|
||||
group.keys(),
|
||||
wrap_kind,
|
||||
at,
|
||||
extra,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn wrap_seal_with(
|
||||
seal: &Event,
|
||||
conversation: &ConversationKey,
|
||||
signer: &Keys,
|
||||
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(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(signer)
|
||||
.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<OpenedStream, StreamError> {
|
||||
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<OpenedStream, StreamError> {
|
||||
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<Tag> {
|
||||
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<Vec<u8>, StreamError> {
|
||||
let mut nonce = [0u8; 32];
|
||||
|
||||
crate::fill_random(&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<String, StreamError> {
|
||||
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<Option<String>, StreamError> {
|
||||
let mut found: Option<String> = 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<String> {
|
||||
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::<String>::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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user