diff --git a/Cargo.lock b/Cargo.lock index bebad170..fc9c70f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1302,6 +1302,18 @@ 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-sdk", + "sha2 0.10.9", +] + [[package]] name = "concurrent-queue" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index a50022e0..3eac7717 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "ni aes-gcm = "0.10" sha2 = "0.10" data-encoding = "2" +hkdf = "0.12" # Others anyhow = "1.0.44" diff --git a/PLAN.md b/PLAN.md index 048a6a01..fbda8e66 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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. +**Add one dependency now:** `hkdf = "0.12"` (already in `Cargo.lock` transitively). Add it to `[workspace.dependencies]` and to the new crate. `sha2` is already a workspace dep. **Two more at M8:** the Pin List's per-message key disclosure needs `chacha20 = "0.9"` (already in the tree because we enable nostr's `nip44`, which is where `chacha20` comes from) and `hmac = "0.12"` (already in the tree via `hkdf`) — see §14.8. Zero new crates, all three are direct-dependency lines only. + +**Why not depend on Vector's crates.** `vector-core` (published, MIT) holds the only other Rust Concord implementation, in `src/community/v2/*`. It is not reusable as a dependency, and the "reuse their crypto" argument does not hold: + +- **No shared types.** It exact-pins the nostr family (`nostr = "=0.45.1"`, `nostr-sdk = "=0.45.1"`, `nostr-connect = "=0.45.1"`, `nostr-blossom = "=0.45.0"`) with the note that a caret range would let a consumer resolve a mixed set, while we track git master (`b230cec`, 0.45.4 / 0.45.2). A registry 0.45.1 and a git 0.45.4 cannot unify, so a build linking both carries two `nostr` crates whose `Event`/`Keys`/`PublicKey`/`Client` are unrelated types. +- **Not wasm-buildable.** `rusqlite` (bundled C SQLite), `libc`, `rustls`, `reqwest`, `image`, `bip39`, and a `tokio` `net` + `rt-multi-thread` requirement; `VectorCore::init` installs a process-global rustls provider and raises the fd limit. Coop's `web` target is wasm32. +- **It is an application core, not a Concord library.** 80k+ lines over 111 files, built on process-global singletons (`state::STATE`, `MY_SECRET_KEY`, one app-data dir, one live account, `traits::set_event_emitter`) and its own SQLite schema, relay pool and blocking `listen()` loop. Adopting it means handing it the nsec and letting it own the client, the database and the event loop — replacing `state`, `chat` and `person` rather than reusing a component. Its `login` stores raw secret-key bytes in that global vault, so an account whose key lives in a signer cannot drive it. +- **There is no cryptography to share.** Both implementations call the same audited crates — `hkdf`, `sha2`, nostr's secp256k1 keypair, and nostr's NIP-44 v2. Vector's comment on that same dependency is "audited RustCrypto crate rather than a hand-rolled construction". Our `derive.rs` has no curve arithmetic, AEAD or randomness of its own: it holds the frozen `info` layout and label table, which are the wire format, not a primitive. + +So Vector's crates earn their place as an **oracle, not a dependency**: the golden vectors in `derive.rs` are their published data, produced by an independent implementation. ## 4. Crate layout @@ -96,6 +105,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 +127,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) -> Vec; // 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; // A.3 scalar_normalize, counter from 0 -fn group_key(label: &str, secret: &[u8], id: &[u8; 32], epoch: Option) -> GroupKey; +fn group_key(label: &str, secret: &[u8], id: &[u8; 32], epoch: Option) -> Result; -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; +pub fn control_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result; // read key +pub fn control_signer_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result; // write key +pub fn guestbook_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result; +pub fn channel_rekey_group_key(root: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> Result; +pub fn base_rekey_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result; +pub fn dissolved_group_key(id: &CommunityId) -> Result; // 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,16 +153,37 @@ 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`. +- 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`) @@ -545,12 +579,13 @@ Ordering is deliberately dependency-first: each milestone is usable on its own, ## 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). ## 15. Test strategy diff --git a/crates/concord/Cargo.toml b/crates/concord/Cargo.toml new file mode 100644 index 00000000..dbc2af0a --- /dev/null +++ b/crates/concord/Cargo.toml @@ -0,0 +1,14 @@ +[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 +anyhow.workspace = true diff --git a/crates/concord/src/derive.rs b/crates/concord/src/derive.rs new file mode 100644 index 00000000..02ec00a6 --- /dev/null +++ b/crates/concord/src/derive.rs @@ -0,0 +1,454 @@ +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) -> Vec { + 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::::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 { + 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) -> Result { + 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>> = 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) -> [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::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::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::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::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::derive( + LABEL_REKEY_PSEUDONYM, + prior_root, + channel.as_bytes(), + Some(new_epoch.0), + ) +} + +/// Keyed by the prior `community_root`: the base has no stable key above it +pub fn base_rekey_group_key( + prior_root: &[u8; 32], + community_id: &CommunityId, + new_epoch: Epoch, +) -> Result { + GroupKey::derive( + LABEL_BASE_REKEY_PSEUDONYM, + prior_root, + community_id.as_bytes(), + Some(new_epoch.0), + ) +} + +/// Keyed by the `community_id` alone, so every member past or present resolves +/// the same address and a Refounding cannot strand the grave. +pub fn dissolved_group_key(community_id: &CommunityId) -> Result { + GroupKey::derive(LABEL_DISSOLVED, community_id.as_bytes(), &ZERO32, None) +} + +/// A plain SHA-256 commitment +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 + ); + } +} diff --git a/crates/concord/src/lib.rs b/crates/concord/src/lib.rs new file mode 100644 index 00000000..0a6dc121 --- /dev/null +++ b/crates/concord/src/lib.rs @@ -0,0 +1,110 @@ +pub mod derive; + +use std::fmt; +use std::str::FromStr; + +use anyhow::{Result, anyhow, bail}; +use data_encoding::HEXLOWER; +pub use derive::GroupKey; + +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 { + Ok(Self(decode_hex_32(value)?)) + } + } + }; +} + +hex_id! { + /// A Community's permanent identity: a self-certifying commitment to its + /// owner's key. It travels inside invites and is itself never on the wire + /// (CORD-02 §1). + CommunityId +} + +hex_id! { + /// A Channel's identity within its Community (CORD-03). + ChannelId +} + +/// A key-rotation counter attached to each Community key. +/// +/// It bumps only on a Rekey, a membership change where somebody is removed. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)] +pub struct Epoch(pub u64); + +impl From for Epoch { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl From 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. +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) +}