use hkdf::Hkdf;
use sha2::Sha256;
use super::{ChannelId, ChannelKey, CommunityId, Epoch, Pseudonym, ServerRootKey};
use nostr_sdk::prelude::SecretKey;
const LABEL_CHANNEL_PSEUDONYM: &str = "vector-community/v1/channel-pseudonym";
const LABEL_RECIPIENT_PSEUDONYM: &str = "vector-community/v1/recipient-pseudonym";
const LABEL_REKEY_PSEUDONYM: &str = "vector-community/v1/rekey-pseudonym";
const LABEL_BASE_REKEY_PSEUDONYM: &str = "vector-community/v1/base-rekey-pseudonym";
const LABEL_PUBLIC_INVITE_KEY: &str = "vector-community/v1/public-invite-key";
const LABEL_PUBLIC_INVITE_LOCATOR: &str = "vector-community/v1/public-invite-locator";
const LABEL_PUBLIC_INVITE_SIGNER: &str = "vector-community/v1/public-invite-signer";
const LABEL_BANLIST_LOCATOR: &str = "vector-community/v1/banlist-locator";
const LABEL_GRANT_LOCATOR: &str = "vector-community/v1/grant-locator";
const LABEL_INVITE_LINKS_LOCATOR: &str = "vector-community/v1/invite-links-locator";
const LABEL_DISSOLVED_LOCATOR: &str = "vector-community/v1/dissolved-locator";
const LABEL_DISSOLVED_PSEUDONYM: &str = "vector-community/v1/dissolved-pseudonym";
const LABEL_DISSOLVED_ENVELOPE: &str = "vector-community/v1/dissolved-envelope-key";
pub fn banlist_locator(community_id: &CommunityId) -> [u8; 32] {
hkdf_sha256_32(&community_id.0, LABEL_BANLIST_LOCATOR.as_bytes())
}
pub fn dissolved_locator(community_id: &CommunityId) -> [u8; 32] {
hkdf_sha256_32(&community_id.0, LABEL_DISSOLVED_LOCATOR.as_bytes())
}
pub fn dissolved_pseudonym(community_id: &CommunityId) -> String {
crate::simd::hex::bytes_to_hex_32(&hkdf_sha256_32(&community_id.0, LABEL_DISSOLVED_PSEUDONYM.as_bytes()))
}
pub fn dissolved_envelope_key(community_id: &CommunityId) -> [u8; 32] {
hkdf_sha256_32(&community_id.0, LABEL_DISSOLVED_ENVELOPE.as_bytes())
}
pub fn invite_links_locator(community_id: &CommunityId, creator_xonly: &[u8; 32]) -> [u8; 32] {
let info = build_info(LABEL_INVITE_LINKS_LOCATOR, creator_xonly, None);
hkdf_sha256_32(&community_id.0, &info)
}
pub fn grant_locator(community_id: &CommunityId, member_xonly: &[u8; 32]) -> [u8; 32] {
let info = build_info(LABEL_GRANT_LOCATOR, member_xonly, None);
hkdf_sha256_32(&community_id.0, &info)
}
#[derive(Debug, Clone, Copy)]
pub enum RekeyScope {
Channel(ChannelId),
ServerRoot,
}
impl RekeyScope {
pub fn id32(&self) -> [u8; 32] {
match self {
RekeyScope::Channel(c) => c.0,
RekeyScope::ServerRoot => [0u8; 32],
}
}
}
fn build_info(label: &str, id32: &[u8; 32], epoch: Option<Epoch>) -> 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(e) = epoch {
info.extend_from_slice(&e.0.to_be_bytes());
}
info
}
fn hkdf_sha256_32(ikm: &[u8; 32], info: &[u8]) -> [u8; 32] {
let hk = Hkdf::<Sha256>::new(None, ikm);
let mut okm = [0u8; 32];
hk.expand(info, &mut okm)
.expect("HKDF expand of 32 bytes is infallible");
okm
}
pub fn channel_pseudonym(channel_key: &ChannelKey, channel_id: &ChannelId, epoch: Epoch) -> Pseudonym {
let info = build_info(LABEL_CHANNEL_PSEUDONYM, &channel_id.0, Some(epoch));
Pseudonym(hkdf_sha256_32(channel_key.as_bytes(), &info))
}
pub fn rekey_pseudonym(server_root: &ServerRootKey, channel_id: &ChannelId, epoch: Epoch) -> Pseudonym {
let info = build_info(LABEL_REKEY_PSEUDONYM, &channel_id.0, Some(epoch));
Pseudonym(hkdf_sha256_32(server_root.as_bytes(), &info))
}
pub fn base_rekey_pseudonym(prior_root: &ServerRootKey, community_id: &CommunityId, new_epoch: Epoch) -> Pseudonym {
let info = build_info(LABEL_BASE_REKEY_PSEUDONYM, &community_id.0, Some(new_epoch));
Pseudonym(hkdf_sha256_32(prior_root.as_bytes(), &info))
}
pub fn recipient_pseudonym(per_recipient_secret: &[u8; 32], scope: RekeyScope, epoch: Epoch) -> Pseudonym {
let info = build_info(LABEL_RECIPIENT_PSEUDONYM, &scope.id32(), Some(epoch));
Pseudonym(hkdf_sha256_32(per_recipient_secret, &info))
}
fn hkdf_to_secret_key(ikm: &[u8; 32], base_info: Vec<u8>) -> SecretKey {
let mut counter: u8 = 0;
loop {
let info = if counter == 0 {
base_info.clone()
} else {
let mut extended = base_info.clone();
extended.push(counter);
extended
};
let okm = hkdf_sha256_32(ikm, &info);
if let Ok(sk) = SecretKey::from_slice(&okm) {
return sk;
}
counter = counter
.checked_add(1)
.expect("secp256k1 scalar rejection 256 times running is impossible");
}
}
pub fn public_invite_key(token: &[u8; 32]) -> [u8; 32] {
hkdf_sha256_32(token, &build_info(LABEL_PUBLIC_INVITE_KEY, &[0u8; 32], None))
}
pub fn public_invite_locator(token: &[u8; 32]) -> [u8; 32] {
hkdf_sha256_32(token, &build_info(LABEL_PUBLIC_INVITE_LOCATOR, &[0u8; 32], None))
}
pub fn public_invite_signer(token: &[u8; 32]) -> SecretKey {
hkdf_to_secret_key(token, build_info(LABEL_PUBLIC_INVITE_SIGNER, &[0u8; 32], None))
}
#[cfg(test)]
mod tests {
use super::*;
fn test_channel_key() -> ChannelKey {
let mut k = [0u8; 32];
for (i, b) in k.iter_mut().enumerate() {
*b = i as u8;
}
ChannelKey(k)
}
fn test_channel_id() -> ChannelId {
let mut id = [0u8; 32];
for (i, b) in id.iter_mut().enumerate() {
*b = (255 - i) as u8;
}
ChannelId(id)
}
#[test]
fn channel_pseudonym_is_deterministic() {
let key = test_channel_key();
let id = test_channel_id();
let a = channel_pseudonym(&key, &id, Epoch(0));
let b = channel_pseudonym(&key, &id, Epoch(0));
assert_eq!(a, b, "same inputs must yield the same pseudonym");
}
#[test]
fn channel_pseudonym_golden_epoch0() {
let p = channel_pseudonym(&test_channel_key(), &test_channel_id(), Epoch(0));
assert_eq!(p.to_hex(), GOLDEN_CHANNEL_PSEUDONYM_EPOCH0);
}
#[test]
fn channel_pseudonym_golden_epoch1() {
let p = channel_pseudonym(&test_channel_key(), &test_channel_id(), Epoch(1));
assert_eq!(p.to_hex(), GOLDEN_CHANNEL_PSEUDONYM_EPOCH1);
}
const GOLDEN_GRANT_LOCATOR: &str =
"c18d4d5955ecdd258f44240019a493a01fc01d51b5f0b8f7679ae424f8d5bfcc";
#[test]
fn grant_locator_golden() {
let loc = grant_locator(&crate::community::CommunityId([0x11u8; 32]), &[0x22u8; 32]);
assert_eq!(crate::simd::hex::bytes_to_hex_32(&loc), GOLDEN_GRANT_LOCATOR);
}
#[test]
fn invite_links_locator_golden_and_domain_separated() {
let cid = crate::community::CommunityId([0x11u8; 32]);
let alice = [0x22u8; 32];
let bob = [0x33u8; 32];
assert_eq!(
crate::simd::hex::bytes_to_hex_32(&invite_links_locator(&cid, &alice)),
"cf42937a815ec561da6b4ca5ddd0c361634b0d9744693b744d4f5b34ec209ec2"
);
assert_ne!(invite_links_locator(&cid, &alice), invite_links_locator(&cid, &bob));
assert_ne!(invite_links_locator(&cid, &alice), grant_locator(&cid, &alice));
assert_ne!(invite_links_locator(&cid, &alice), banlist_locator(&cid));
assert_ne!(invite_links_locator(&cid, &alice), invite_links_locator(&crate::community::CommunityId([0x99u8; 32]), &alice));
}
#[test]
fn grant_locator_binds_member_and_community() {
let cid = crate::community::CommunityId([0x11u8; 32]);
assert_eq!(grant_locator(&cid, &[0x22u8; 32]), grant_locator(&cid, &[0x22u8; 32]));
assert_ne!(grant_locator(&cid, &[0x22u8; 32]), grant_locator(&cid, &[0x23u8; 32]));
assert_ne!(
grant_locator(&cid, &[0x22u8; 32]),
grant_locator(&crate::community::CommunityId([0x99u8; 32]), &[0x22u8; 32])
);
}
#[test]
fn epoch_changes_the_pseudonym() {
let key = test_channel_key();
let id = test_channel_id();
assert_ne!(
channel_pseudonym(&key, &id, Epoch(0)),
channel_pseudonym(&key, &id, Epoch(1)),
"rotating the epoch must rotate the pseudonym (unlinkability)"
);
}
#[test]
fn different_channel_id_changes_the_pseudonym() {
let key = test_channel_key();
let other = ChannelId([0x42u8; 32]);
assert_ne!(
channel_pseudonym(&key, &test_channel_id(), Epoch(0)),
channel_pseudonym(&key, &other, Epoch(0)),
);
}
#[test]
fn different_label_does_not_collide() {
let secret = test_channel_key();
let id = test_channel_id();
let chan = channel_pseudonym(&secret, &id, Epoch(0));
let recip = recipient_pseudonym(secret.as_bytes(), RekeyScope::Channel(id), Epoch(0));
assert_ne!(chan.0, recip.0, "labels must domain-separate");
}
#[test]
fn recipient_pseudonym_golden() {
let secret = [7u8; 32];
let chan = recipient_pseudonym(&secret, RekeyScope::Channel(test_channel_id()), Epoch(3));
let root = recipient_pseudonym(&secret, RekeyScope::ServerRoot, Epoch(3));
assert_eq!(chan.to_hex(), GOLDEN_RECIPIENT_CHANNEL_EPOCH3);
assert_eq!(root.to_hex(), GOLDEN_RECIPIENT_SERVERROOT_EPOCH3);
}
#[test]
fn rekey_pseudonym_is_server_root_derived_and_distinct() {
let sr = ServerRootKey([0x07u8; 32]);
let chan = test_channel_id();
let p = rekey_pseudonym(&sr, &chan, Epoch(1));
assert_eq!(p, rekey_pseudonym(&sr, &chan, Epoch(1)));
assert_eq!(p.to_hex(), GOLDEN_REKEY_PSEUDONYM);
assert_ne!(p, rekey_pseudonym(&ServerRootKey([0x08u8; 32]), &chan, Epoch(1)));
assert_ne!(p, rekey_pseudonym(&sr, &chan, Epoch(2)));
assert_ne!(p, rekey_pseudonym(&sr, &ChannelId([0x42u8; 32]), Epoch(1)));
let as_chan_key = channel_pseudonym(&ChannelKey(*sr.as_bytes()), &chan, Epoch(1));
assert_ne!(p.0, as_chan_key.0, "label must domain-separate rekey-address from channel-message");
let as_control =
crate::community::roster::control_pseudonym(&sr, &crate::community::CommunityId(chan.0), Epoch(1));
assert_ne!(p.to_hex(), as_control, "label must domain-separate rekey-address from control-plane");
}
#[test]
fn base_rekey_pseudonym_is_prior_root_derived_and_distinct() {
let root = ServerRootKey([0x07u8; 32]);
let community = crate::community::CommunityId([0x09u8; 32]);
let p = base_rekey_pseudonym(&root, &community, Epoch(1));
assert_eq!(p, base_rekey_pseudonym(&root, &community, Epoch(1)));
assert_eq!(p.to_hex(), GOLDEN_BASE_REKEY_PSEUDONYM);
assert_ne!(p, base_rekey_pseudonym(&ServerRootKey([0x08u8; 32]), &community, Epoch(1)));
assert_ne!(p, base_rekey_pseudonym(&root, &community, Epoch(2)));
assert_ne!(p, base_rekey_pseudonym(&root, &crate::community::CommunityId([0x42u8; 32]), Epoch(1)));
let control = super::super::roster::control_pseudonym(&root, &community, Epoch(1));
assert_ne!(p.to_hex(), control, "label must domain-separate base-rekey from control-plane");
}
#[test]
fn server_root_scope_sentinel_matches_rekey_scope() {
assert_eq!(
crate::simd::hex::bytes_to_hex_32(&RekeyScope::ServerRoot.id32()),
crate::community::SERVER_ROOT_SCOPE_HEX
);
}
#[test]
fn recipient_scope_disambiguates() {
let secret = [7u8; 32];
let chan = recipient_pseudonym(&secret, RekeyScope::Channel(test_channel_id()), Epoch(3));
let root = recipient_pseudonym(&secret, RekeyScope::ServerRoot, Epoch(3));
assert_ne!(chan.0, root.0);
}
#[test]
fn channel_pseudonym_golden_multibyte_epoch_is_big_endian() {
let p = channel_pseudonym(&test_channel_key(), &test_channel_id(), Epoch(0x0102030405060708));
assert_eq!(p.to_hex(), GOLDEN_CHANNEL_PSEUDONYM_EPOCH_BE);
}
#[test]
fn public_invite_subkeys_golden() {
let token = [5u8; 32];
assert_eq!(crate::simd::hex::bytes_to_hex_32(&public_invite_key(&token)), GOLDEN_PUBLIC_INVITE_KEY);
assert_eq!(crate::simd::hex::bytes_to_hex_32(&public_invite_locator(&token)), GOLDEN_PUBLIC_INVITE_LOCATOR);
assert_eq!(public_invite_signer(&token).to_secret_hex(), GOLDEN_PUBLIC_INVITE_SIGNER);
}
#[test]
fn public_invite_subkeys_domain_separated_and_token_bound() {
let token = [5u8; 32];
let other = [6u8; 32];
assert_ne!(public_invite_key(&token), public_invite_locator(&token));
assert_ne!(
public_invite_key(&token).to_vec(),
public_invite_signer(&token).as_secret_bytes().to_vec()
);
assert_ne!(public_invite_key(&token), public_invite_key(&other));
assert_ne!(public_invite_locator(&token), public_invite_locator(&other));
}
const GOLDEN_PUBLIC_INVITE_KEY: &str =
"7f02a8a832a1744adf286676038446dc94762c2c8332650c9ad62a0c870e0751";
const GOLDEN_PUBLIC_INVITE_LOCATOR: &str =
"33c098d6e4cddc2b8ee98ab6b5182186794c35f5b71391130a49ae3d88588c2c";
const GOLDEN_PUBLIC_INVITE_SIGNER: &str =
"9154a3a7e4a03e94eaad2f76efeebd43e25ee9df4fbca12454edcee0ef666e8d";
const GOLDEN_REKEY_PSEUDONYM: &str =
"3a848655f79a586510e1113131f078aa1ce0ff8dcb74374507e6af07ff49fd24";
const GOLDEN_BASE_REKEY_PSEUDONYM: &str =
"23ced8fd6cad30a21ded43c96bd040311cf20bcfff935453dc0985b41ff660be";
const GOLDEN_CHANNEL_PSEUDONYM_EPOCH0: &str =
"d55b9f5fad668887d41d46b7c08ba63725a39d7c86b602c7c36e2f2e0eff8c40";
const GOLDEN_CHANNEL_PSEUDONYM_EPOCH1: &str =
"050079d9899c85bebf5c73fd777cdd812132d262e3ceec83c847a056dea41293";
const GOLDEN_RECIPIENT_CHANNEL_EPOCH3: &str =
"971f69d6a948c79704f8077188cded86bd35c82960e88043ebb2c2c3d60a3b71";
const GOLDEN_RECIPIENT_SERVERROOT_EPOCH3: &str =
"e50e5d803fd2edc310be8cd7354586d12fcb8e3f30162553be53da1a34a17c46";
const GOLDEN_CHANNEL_PSEUDONYM_EPOCH_BE: &str =
"cec398094d17688cd127bc609d34fa067331427400b023d0c70ff77fafe17e0b";
}