add concord crate and basic cryptography

This commit is contained in:
2026-09-16 16:21:38 +07:00
parent 319b1038d2
commit 5f2a5d7a37
6 changed files with 643 additions and 17 deletions
Generated
+12
View File
@@ -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"
+1
View File
@@ -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"
+52 -17
View File
@@ -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<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,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<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`)
@@ -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
+14
View File
@@ -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
+454
View File
@@ -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<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),
)
}
/// 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> {
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> {
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
);
}
}
+110
View File
@@ -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<Self> {
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<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.
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)
}