use nostr_sdk::nips::nip44::v2::ConversationKey;
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
use super::cipher;
use super::derive::{base_rekey_pseudonym, recipient_pseudonym, rekey_pseudonym, RekeyScope};
use super::{ChannelId, CommunityId, Epoch, Pseudonym, ServerRootKey};
use crate::stored_event::event_kind;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RekeyBlob {
pub locator: String,
pub wrapped: String,
}
pub fn rekey_pairwise_secret(sk: &SecretKey, pk: &PublicKey) -> Result<[u8; 32], String> {
let ck = ConversationKey::derive(sk, pk).map_err(|e| format!("pairwise ECDH: {e}"))?;
let bytes = ck.as_bytes();
bytes
.try_into()
.map_err(|_| format!("conversation key is {} bytes, expected 32", bytes.len()))
}
fn bound_plaintext(scope: RekeyScope, epoch: Epoch, new_key: &[u8; 32]) -> Vec<u8> {
let mut pt = Vec::with_capacity(72);
pt.extend_from_slice(&scope.id32());
pt.extend_from_slice(&epoch.0.to_be_bytes());
pt.extend_from_slice(new_key);
pt
}
pub fn build_rekey_blob(
sender_sk: &SecretKey,
recipient_pk: &PublicKey,
scope: RekeyScope,
epoch: Epoch,
new_key: &[u8; 32],
) -> Result<RekeyBlob, String> {
let secret = Zeroizing::new(rekey_pairwise_secret(sender_sk, recipient_pk)?);
let locator = recipient_pseudonym(&secret, scope, epoch).to_hex();
let pt = Zeroizing::new(bound_plaintext(scope, epoch, new_key));
let wrapped = cipher::seal(&secret, &pt).map_err(|e| format!("wrap rekey blob: {e}"))?;
Ok(RekeyBlob { locator, wrapped })
}
pub fn open_rekey_blob(
my_sk: &SecretKey,
sender_pk: &PublicKey,
scope: RekeyScope,
epoch: Epoch,
blob: &RekeyBlob,
) -> Result<[u8; 32], String> {
let secret = Zeroizing::new(rekey_pairwise_secret(my_sk, sender_pk)?);
let expected = recipient_pseudonym(&secret, scope, epoch).to_hex();
if blob.locator != expected {
return Err("rekey blob locator does not match this recipient/scope/epoch".to_string());
}
let pt = Zeroizing::new(cipher::open(&secret, &blob.wrapped).map_err(|e| format!("open rekey blob: {e}"))?);
if pt.len() != 72 {
return Err(format!("rekey blob plaintext is {} bytes, expected 72", pt.len()));
}
if pt[..32] != scope.id32() {
return Err("rekey blob scope binding mismatch (splice)".to_string());
}
let mut epoch_be = [0u8; 8];
epoch_be.copy_from_slice(&pt[32..40]);
if u64::from_be_bytes(epoch_be) != epoch.0 {
return Err("rekey blob epoch binding mismatch (splice)".to_string());
}
let mut new_key = [0u8; 32];
new_key.copy_from_slice(&pt[40..72]);
Ok(new_key)
}
const PROTOCOL_VERSION: &str = "1";
const TAG_VERSION: &str = "v";
const TAG_SCOPE: &str = "scope";
const TAG_NEW_EPOCH: &str = "newepoch";
const TAG_PREV_EPOCH: &str = "prevepoch";
const TAG_PREV_COMMIT: &str = "prevcommit";
#[cfg(test)]
const STRFRY_MAX_EVENT_SIZE: usize = 65536;
pub(crate) const MAX_REKEY_BLOBS: usize = 120;
pub fn epoch_key_commitment(prev_epoch: Epoch, prev_key: &[u8; 32]) -> [u8; 32] {
let mut h = Sha256::new();
h.update(b"vector-community/v1/epoch-key-commitment");
h.update(prev_epoch.0.to_be_bytes());
h.update(prev_key);
h.finalize().into()
}
#[derive(Debug, Clone)]
pub struct ParsedRekey {
pub rotator: PublicKey,
pub scope: RekeyScope,
pub new_epoch: Epoch,
pub prev_epoch: Epoch,
pub prev_key_commitment: [u8; 32],
pub blobs: Vec<RekeyBlob>,
}
fn scope_to_hex(scope: RekeyScope) -> String {
crate::simd::hex::bytes_to_hex_32(&scope.id32())
}
fn scope_from_hex(hex: &str) -> Option<RekeyScope> {
if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let bytes = crate::simd::hex::hex_to_bytes_32(hex);
Some(if bytes == [0u8; 32] {
RekeyScope::ServerRoot
} else {
RekeyScope::Channel(ChannelId(bytes))
})
}
fn build_rekey_inner(
rotator: &Keys,
scope: RekeyScope,
new_epoch: Epoch,
prev_epoch: Epoch,
prev_key_commitment: &[u8; 32],
blobs: &[RekeyBlob],
) -> Result<Event, String> {
if new_epoch.0 <= prev_epoch.0 {
return Err(format!("rekey new_epoch {} must exceed prev_epoch {}", new_epoch.0, prev_epoch.0));
}
let blobs_json = serde_json::to_string(blobs).map_err(|e| format!("serialize blobs: {e}"))?;
EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_REKEY), blobs_json)
.tags([
Tag::custom(TagKind::Custom(TAG_SCOPE.into()), [scope_to_hex(scope)]),
Tag::custom(TagKind::Custom(TAG_NEW_EPOCH.into()), [new_epoch.0.to_string()]),
Tag::custom(TagKind::Custom(TAG_PREV_EPOCH.into()), [prev_epoch.0.to_string()]),
Tag::custom(TagKind::Custom(TAG_PREV_COMMIT.into()), [crate::simd::hex::bytes_to_hex_32(prev_key_commitment)]),
])
.sign_with_keys(rotator)
.map_err(|e| format!("sign rekey inner: {e}"))
}
fn seal_rekey_outer(ephemeral: &Keys, inner: &Event, envelope_key: &[u8; 32], address: &Pseudonym) -> Result<Event, String> {
let content = cipher::seal(envelope_key, inner.as_json().as_bytes()).map_err(|e| format!("seal rekey: {e}"))?;
EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_REKEY), content)
.tags([
Tag::custom(TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)), [address.to_hex()]),
Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]),
])
.sign_with_keys(ephemeral)
.map_err(|e| format!("sign rekey outer: {e}"))
}
#[allow(clippy::too_many_arguments)]
pub fn build_channel_rekey_event(
ephemeral: &Keys,
rotator: &Keys,
server_root: &[u8; 32],
channel_id: &ChannelId,
new_epoch: Epoch,
prev_epoch: Epoch,
prev_key_commitment: &[u8; 32],
blobs: &[RekeyBlob],
) -> Result<Event, String> {
let inner = build_rekey_inner(rotator, RekeyScope::Channel(*channel_id), new_epoch, prev_epoch, prev_key_commitment, blobs)?;
let address = rekey_pseudonym(&ServerRootKey(*server_root), channel_id, new_epoch);
seal_rekey_outer(ephemeral, &inner, server_root, &address)
}
#[allow(clippy::too_many_arguments)]
pub fn build_server_root_rekey_event(
ephemeral: &Keys,
rotator: &Keys,
prior_root: &[u8; 32],
community_id: &CommunityId,
new_epoch: Epoch,
prev_epoch: Epoch,
prev_key_commitment: &[u8; 32],
blobs: &[RekeyBlob],
) -> Result<Event, String> {
let inner = build_rekey_inner(rotator, RekeyScope::ServerRoot, new_epoch, prev_epoch, prev_key_commitment, blobs)?;
let address = base_rekey_pseudonym(&ServerRootKey(*prior_root), community_id, new_epoch);
seal_rekey_outer(ephemeral, &inner, prior_root, &address)
}
pub fn open_rekey_event(outer: &Event, server_root: &[u8; 32]) -> Result<ParsedRekey, String> {
if outer.kind.as_u16() != event_kind::COMMUNITY_REKEY {
return Err("not a rekey outer (kind != 3303)".to_string());
}
match find_unique_tag(outer, TAG_VERSION)?.as_deref() {
Some(PROTOCOL_VERSION) => {}
other => return Err(format!("unsupported rekey version: {other:?}")),
}
let plaintext = cipher::open(server_root, &outer.content).map_err(|e| format!("open rekey: {e}"))?;
let json = String::from_utf8(plaintext).map_err(|e| format!("rekey inner utf8: {e}"))?;
let inner = Event::from_json(&json).map_err(|e| format!("rekey inner parse: {e}"))?;
inner.verify().map_err(|_| "rekey inner signature invalid".to_string())?;
if inner.kind.as_u16() != event_kind::COMMUNITY_REKEY {
return Err("rekey inner is not kind 3303".to_string());
}
let scope = find_unique_tag(&inner, TAG_SCOPE)?
.and_then(|h| scope_from_hex(&h))
.ok_or("rekey missing/invalid scope")?;
let new_epoch = Epoch(parse_u64_tag(&inner, TAG_NEW_EPOCH)?);
let prev_epoch = Epoch(parse_u64_tag(&inner, TAG_PREV_EPOCH)?);
let prev_hex = find_unique_tag(&inner, TAG_PREV_COMMIT)?.ok_or("rekey missing prev-commit")?;
if prev_hex.len() != 64 || !prev_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err("rekey prev-commit is not 32-byte hex".to_string());
}
let prev_key_commitment = crate::simd::hex::hex_to_bytes_32(&prev_hex);
let blobs: Vec<RekeyBlob> =
serde_json::from_str(&inner.content).map_err(|e| format!("parse rekey blobs: {e}"))?;
if blobs.len() > MAX_REKEY_BLOBS {
return Err(format!("rekey carries {} blobs, over the cap", blobs.len()));
}
Ok(ParsedRekey {
rotator: inner.pubkey,
scope,
new_epoch,
prev_epoch,
prev_key_commitment,
blobs,
})
}
fn find_unique_tag(event: &Event, name: &str) -> Result<Option<String>, String> {
let mut found = None;
for t in event.tags.iter() {
let s = t.as_slice();
if s.len() >= 2 && s[0] == name {
if found.is_some() {
return Err(format!("duplicate rekey tag: {name}"));
}
found = Some(s[1].clone());
}
}
Ok(found)
}
fn parse_u64_tag(event: &Event, name: &str) -> Result<u64, String> {
find_unique_tag(event, name)?
.ok_or_else(|| format!("rekey missing tag: {name}"))?
.parse::<u64>()
.map_err(|_| format!("rekey tag {name} is not a u64"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn max_rekey_blobs_event_stays_under_relay_size_limit() {
use nostr_sdk::JsonUtil;
let rotator = Keys::generate();
let blobs: Vec<RekeyBlob> = (0..MAX_REKEY_BLOBS)
.map(|_| {
let r = Keys::generate();
build_rekey_blob(rotator.secret_key(), &r.public_key(), RekeyScope::ServerRoot, Epoch(1), &[0xCDu8; 32]).unwrap()
})
.collect();
let outer = build_server_root_rekey_event(
&Keys::generate(), &rotator, &[0x07u8; 32], &CommunityId([0x09u8; 32]), Epoch(1), Epoch(0), &[0u8; 32], &blobs,
)
.unwrap();
let size = outer.as_json().len();
assert!(
size <= STRFRY_MAX_EVENT_SIZE,
"a full {MAX_REKEY_BLOBS}-blob rekey event is {size} bytes; must stay <= {STRFRY_MAX_EVENT_SIZE} (strfry maxEventSize)"
);
}
fn sk(byte: u8) -> SecretKey {
SecretKey::from_slice(&[byte; 32]).unwrap()
}
#[test]
fn bound_plaintext_layout_is_frozen() {
let pt = bound_plaintext(RekeyScope::ServerRoot, Epoch(1), &[0xABu8; 32]);
let expected = format!("{}{}{}", "00".repeat(32), "0000000000000001", "ab".repeat(32));
assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt), expected);
let pt2 = bound_plaintext(RekeyScope::Channel(ChannelId([0x11u8; 32])), Epoch(0x0102), &[0xCDu8; 32]);
let expected2 = format!("{}{}{}", "11".repeat(32), "0000000000000102", "cd".repeat(32));
assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt2), expected2);
}
#[test]
fn pairwise_secret_regression_pin() {
let a = Keys::new(sk(1));
let b = Keys::new(sk(2));
let secret = rekey_pairwise_secret(a.secret_key(), &b.public_key()).unwrap();
assert_eq!(crate::simd::hex::bytes_to_hex_string(&secret), GOLDEN_PAIRWISE_SECRET);
}
const GOLDEN_PAIRWISE_SECRET: &str =
"59c6d24d9c3a7bf8ca4cec54031a3e2ecfaa553452a2b2fa3147e31ee55f33d5";
#[test]
fn pairwise_secret_is_symmetric_and_deterministic() {
let a = Keys::new(sk(1));
let b = Keys::new(sk(2));
let from_a = rekey_pairwise_secret(a.secret_key(), &b.public_key()).unwrap();
let from_b = rekey_pairwise_secret(b.secret_key(), &a.public_key()).unwrap();
assert_eq!(from_a, from_b, "ECDH is symmetric: both sides derive the same secret");
assert_eq!(from_a, rekey_pairwise_secret(a.secret_key(), &b.public_key()).unwrap());
let c = Keys::new(sk(3));
assert_ne!(from_a, rekey_pairwise_secret(a.secret_key(), &c.public_key()).unwrap());
}
#[test]
fn blob_round_trips_server_root_scope() {
let sender = Keys::new(sk(7));
let recipient = Keys::new(sk(8));
let new_key = [0xABu8; 32];
let blob = build_rekey_blob(
sender.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(1), &new_key,
)
.unwrap();
let got = open_rekey_blob(
recipient.secret_key(), &sender.public_key(), RekeyScope::ServerRoot, Epoch(1), &blob,
)
.unwrap();
assert_eq!(got, new_key, "the recipient recovers the fresh key");
}
#[test]
fn blob_round_trips_channel_scope() {
let sender = Keys::new(sk(7));
let recipient = Keys::new(sk(8));
let chan = RekeyScope::Channel(ChannelId([0x42u8; 32]));
let new_key = [0xCDu8; 32];
let blob = build_rekey_blob(sender.secret_key(), &recipient.public_key(), chan, Epoch(5), &new_key).unwrap();
let got = open_rekey_blob(recipient.secret_key(), &sender.public_key(), chan, Epoch(5), &blob).unwrap();
assert_eq!(got, new_key);
}
#[test]
fn locator_is_the_recipient_pseudonym() {
let sender = Keys::new(sk(7));
let recipient = Keys::new(sk(8));
let secret = rekey_pairwise_secret(sender.secret_key(), &recipient.public_key()).unwrap();
let blob = build_rekey_blob(
sender.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(2), &[1u8; 32],
)
.unwrap();
assert_eq!(blob.locator, recipient_pseudonym(&secret, RekeyScope::ServerRoot, Epoch(2)).to_hex());
}
#[test]
fn wrong_sender_cannot_open() {
let sender = Keys::new(sk(7));
let recipient = Keys::new(sk(8));
let impostor = Keys::new(sk(9));
let blob = build_rekey_blob(
sender.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(1), &[2u8; 32],
)
.unwrap();
let err = open_rekey_blob(
recipient.secret_key(), &impostor.public_key(), RekeyScope::ServerRoot, Epoch(1), &blob,
);
assert!(err.is_err(), "pairing against the wrong sender must fail");
}
#[test]
fn non_recipient_cannot_open() {
let sender = Keys::new(sk(7));
let recipient = Keys::new(sk(8));
let other = Keys::new(sk(10));
let blob = build_rekey_blob(
sender.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(1), &[3u8; 32],
)
.unwrap();
assert!(open_rekey_blob(
other.secret_key(), &sender.public_key(), RekeyScope::ServerRoot, Epoch(1), &blob,
)
.is_err());
}
#[test]
fn scope_splice_is_rejected() {
let sender = Keys::new(sk(7));
let recipient = Keys::new(sk(8));
let blob = build_rekey_blob(
sender.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(1), &[4u8; 32],
)
.unwrap();
let chan = RekeyScope::Channel(ChannelId([0x42u8; 32]));
assert!(open_rekey_blob(recipient.secret_key(), &sender.public_key(), chan, Epoch(1), &blob).is_err());
}
#[test]
fn epoch_splice_is_rejected() {
let sender = Keys::new(sk(7));
let recipient = Keys::new(sk(8));
let blob = build_rekey_blob(
sender.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(1), &[5u8; 32],
)
.unwrap();
assert!(open_rekey_blob(
recipient.secret_key(), &sender.public_key(), RekeyScope::ServerRoot, Epoch(2), &blob,
)
.is_err());
}
#[test]
fn relocated_blob_is_rejected() {
let sender = Keys::new(sk(7));
let recipient = Keys::new(sk(8));
let mut blob = build_rekey_blob(
sender.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(1), &[6u8; 32],
)
.unwrap();
blob.locator = "ff".repeat(32);
assert!(open_rekey_blob(
recipient.secret_key(), &sender.public_key(), RekeyScope::ServerRoot, Epoch(1), &blob,
)
.is_err());
}
#[test]
fn tampered_ciphertext_is_rejected() {
let sender = Keys::new(sk(7));
let recipient = Keys::new(sk(8));
let mut blob = build_rekey_blob(
sender.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(1), &[7u8; 32],
)
.unwrap();
let mut bytes = base64_simd::STANDARD.decode_to_vec(blob.wrapped.as_bytes()).unwrap();
let mid = bytes.len() / 2;
bytes[mid] ^= 0xff;
blob.wrapped = base64_simd::STANDARD.encode_to_string(&bytes);
assert!(open_rekey_blob(
recipient.secret_key(), &sender.public_key(), RekeyScope::ServerRoot, Epoch(1), &blob,
)
.is_err());
}
const SR: [u8; 32] = [0x55u8; 32];
const CHAN: [u8; 32] = [0x42u8; 32];
#[test]
fn epoch_key_commitment_golden_and_binds_epoch() {
let c = epoch_key_commitment(Epoch(1), &[0x33u8; 32]);
assert_eq!(crate::simd::hex::bytes_to_hex_32(&c), GOLDEN_EPOCH_COMMITMENT);
assert_ne!(c, epoch_key_commitment(Epoch(2), &[0x33u8; 32]));
assert_ne!(c, epoch_key_commitment(Epoch(1), &[0x34u8; 32]));
}
#[test]
fn channel_rekey_round_trips() {
let rotator = Keys::new(sk(1));
let scope = RekeyScope::Channel(ChannelId(CHAN));
let commit = epoch_key_commitment(Epoch(0), &[0xEEu8; 32]);
let blob = build_rekey_blob(rotator.secret_key(), &Keys::new(sk(8)).public_key(), scope, Epoch(1), &[0xABu8; 32]).unwrap();
let outer = build_channel_rekey_event(
&Keys::generate(), &rotator, &SR, &ChannelId(CHAN), Epoch(1), Epoch(0), &commit, &[blob.clone()],
)
.unwrap();
assert_ne!(outer.pubkey, rotator.public_key());
let parsed = open_rekey_event(&outer, &SR).unwrap();
assert_eq!(parsed.rotator, rotator.public_key(), "rotator recovered from the inner sig");
assert!(matches!(parsed.scope, RekeyScope::Channel(c) if c.0 == CHAN));
assert_eq!(parsed.new_epoch, Epoch(1));
assert_eq!(parsed.prev_epoch, Epoch(0));
assert_eq!(parsed.prev_key_commitment, commit);
assert_eq!(parsed.blobs, vec![blob]);
}
#[test]
fn epochs_are_independently_recoverable_with_only_the_server_root() {
let rotator = Keys::new(sk(1));
let recipient = Keys::new(sk(8));
let scope = RekeyScope::Channel(ChannelId(CHAN));
let mut events = Vec::new();
let mut keys = std::collections::HashMap::new();
for e in 1..=5u64 {
let key = [e as u8; 32];
keys.insert(e, key);
let blob = build_rekey_blob(rotator.secret_key(), &recipient.public_key(), scope, Epoch(e), &key).unwrap();
events.push(build_channel_rekey_event(
&Keys::generate(), &rotator, &SR, &ChannelId(CHAN), Epoch(e), Epoch(e - 1),
&epoch_key_commitment(Epoch(e - 1), &[0u8; 32]), &[blob],
).unwrap());
}
let want = rekey_pseudonym(&ServerRootKey(SR), &ChannelId(CHAN), Epoch(5)).to_hex();
let latest = events.iter().find(|ev| ev.tags.iter().any(|t| {
let s = t.as_slice();
s.len() >= 2 && s[0] == "z" && s[1] == want
})).expect("epoch-5 rekey is addressable from the server root");
let parsed = open_rekey_event(latest, &SR).unwrap();
let secret = rekey_pairwise_secret(recipient.secret_key(), &parsed.rotator).unwrap();
let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
let mine = parsed.blobs.iter().find(|b| b.locator == loc).unwrap();
let got = open_rekey_blob(recipient.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
assert_eq!(got, keys[&5], "recovered the latest key with only the server root, skipping all earlier epochs");
}
#[test]
fn end_to_end_recipient_opens_their_new_key() {
let rotator = Keys::new(sk(1));
let recipient = Keys::new(sk(8));
let scope = RekeyScope::Channel(ChannelId(CHAN));
let new_key = [0xCDu8; 32];
let blob = build_rekey_blob(rotator.secret_key(), &recipient.public_key(), scope, Epoch(1), &new_key).unwrap();
let outer = build_channel_rekey_event(
&Keys::generate(), &rotator, &SR, &ChannelId(CHAN), Epoch(1), Epoch(0),
&epoch_key_commitment(Epoch(0), &[0u8; 32]), &[blob],
)
.unwrap();
let parsed = open_rekey_event(&outer, &SR).unwrap();
let secret = rekey_pairwise_secret(recipient.secret_key(), &parsed.rotator).unwrap();
let my_locator = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
let mine = parsed.blobs.iter().find(|b| b.locator == my_locator).expect("my blob is present");
let got = open_rekey_blob(recipient.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
assert_eq!(got, new_key, "recipient recovers the fresh epoch key end to end");
}
#[test]
fn server_root_rekey_round_trips_under_the_prior_root() {
let rotator = Keys::new(sk(1));
let recipient = Keys::new(sk(8));
let prior_root = [0x66u8; 32];
let community_id = CommunityId([0x77u8; 32]);
let new_root = [0x99u8; 32];
let blob = build_rekey_blob(rotator.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(1), &new_root).unwrap();
let commit = epoch_key_commitment(Epoch(0), &prior_root);
let outer = build_server_root_rekey_event(
&Keys::generate(), &rotator, &prior_root, &community_id, Epoch(1), Epoch(0), &commit, &[blob],
)
.unwrap();
assert_ne!(outer.pubkey, rotator.public_key(), "outer is ephemeral, not the rotator");
let expected = base_rekey_pseudonym(&ServerRootKey(prior_root), &community_id, Epoch(1)).to_hex();
let z = outer.tags.iter().find_map(|t| {
let s = t.as_slice();
(s.len() >= 2 && s[0] == "z").then(|| s[1].clone())
});
assert_eq!(z.as_deref(), Some(expected.as_str()));
let parsed = open_rekey_event(&outer, &prior_root).unwrap();
assert!(matches!(parsed.scope, RekeyScope::ServerRoot));
assert_eq!(parsed.rotator, rotator.public_key());
let secret = rekey_pairwise_secret(recipient.secret_key(), &parsed.rotator).unwrap();
let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
let mine = parsed.blobs.iter().find(|b| b.locator == loc).unwrap();
let got = open_rekey_blob(recipient.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
assert_eq!(got, new_root, "recipient recovers the new server root");
}
#[test]
fn base_and_channel_blobs_to_same_recipient_same_epoch_do_not_collide() {
let sender = Keys::new(sk(1));
let recipient = Keys::new(sk(8));
let chan = RekeyScope::Channel(ChannelId([0x42u8; 32]));
let base = RekeyScope::ServerRoot;
let cb = build_rekey_blob(sender.secret_key(), &recipient.public_key(), chan, Epoch(1), &[0xAAu8; 32]).unwrap();
let bb = build_rekey_blob(sender.secret_key(), &recipient.public_key(), base, Epoch(1), &[0xBBu8; 32]).unwrap();
assert_ne!(cb.locator, bb.locator, "channel-scope and base-scope blobs must not collide");
}
#[test]
fn server_root_rekey_not_openable_without_the_prior_root() {
let rotator = Keys::new(sk(1));
let recipient = Keys::new(sk(8));
let prior_root = [0x66u8; 32];
let community_id = CommunityId([0x77u8; 32]);
let blob = build_rekey_blob(rotator.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(1), &[0x99u8; 32]).unwrap();
let outer = build_server_root_rekey_event(
&Keys::generate(), &rotator, &prior_root, &community_id, Epoch(1), Epoch(0),
&epoch_key_commitment(Epoch(0), &prior_root), &[blob],
)
.unwrap();
assert!(open_rekey_event(&outer, &[0x00u8; 32]).is_err());
}
#[test]
fn outer_address_is_server_root_derived_not_channel_key() {
let rotator = Keys::new(sk(1));
let outer = build_channel_rekey_event(
&Keys::generate(), &rotator, &SR, &ChannelId(CHAN), Epoch(1), Epoch(0), &[0u8; 32], &[],
)
.unwrap();
let expected = rekey_pseudonym(&ServerRootKey(SR), &ChannelId(CHAN), Epoch(1)).to_hex();
let z = outer.tags.iter().find_map(|t| {
let s = t.as_slice();
(s.len() >= 2 && s[0] == "z").then(|| s[1].clone())
});
assert_eq!(z.as_deref(), Some(expected.as_str()));
}
#[test]
fn wrong_server_root_cannot_open() {
let rotator = Keys::new(sk(1));
let outer = build_channel_rekey_event(
&Keys::generate(), &rotator, &SR, &ChannelId(CHAN), Epoch(1), Epoch(0), &[0u8; 32], &[],
)
.unwrap();
assert!(open_rekey_event(&outer, &[0x00u8; 32]).is_err(), "a non-member (wrong server root) can't read it");
}
#[test]
fn forged_inner_signature_is_rejected() {
let rotator = Keys::new(sk(1));
let old_key = SR;
let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_REKEY), "[]")
.tags([
Tag::custom(TagKind::Custom(TAG_SCOPE.into()), [scope_to_hex(RekeyScope::ServerRoot)]),
Tag::custom(TagKind::Custom(TAG_NEW_EPOCH.into()), ["1".to_string()]),
Tag::custom(TagKind::Custom(TAG_PREV_EPOCH.into()), ["0".to_string()]),
Tag::custom(TagKind::Custom(TAG_PREV_COMMIT.into()), ["00".repeat(32)]),
])
.sign_with_keys(&rotator)
.unwrap();
let mut v: serde_json::Value = serde_json::from_str(&inner.as_json()).unwrap();
v["content"] = serde_json::Value::String("[{\"locator\":\"x\",\"wrapped\":\"y\"}]".into());
let tampered = serde_json::to_string(&v).unwrap();
let content = cipher::seal(&old_key, tampered.as_bytes()).unwrap();
let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_REKEY), content)
.tags([Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()])])
.sign_with_keys(&Keys::generate())
.unwrap();
assert!(open_rekey_event(&outer, &old_key).is_err());
}
#[test]
fn wrong_outer_kind_and_bad_version_rejected() {
let old_key = [0x55u8; 32];
let not_rekey = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "x")
.sign_with_keys(&Keys::generate())
.unwrap();
assert!(open_rekey_event(¬_rekey, &old_key).is_err());
let bad_ver = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_REKEY), "x")
.tags([Tag::custom(TagKind::Custom(TAG_VERSION.into()), ["999".to_string()])])
.sign_with_keys(&Keys::generate())
.unwrap();
assert!(open_rekey_event(&bad_ver, &old_key).is_err());
}
#[test]
fn version_is_checked_before_decrypt() {
let real_key = [0x55u8; 32];
let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_REKEY), "[]")
.sign_with_keys(&Keys::generate())
.unwrap();
let content = cipher::seal(&real_key, inner.as_json().as_bytes()).unwrap();
let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_REKEY), content)
.tags([Tag::custom(TagKind::Custom(TAG_VERSION.into()), ["999".to_string()])])
.sign_with_keys(&Keys::generate())
.unwrap();
let err = open_rekey_event(&outer, &[0x00u8; 32]).unwrap_err();
assert!(err.contains("version"), "must reject on version before decrypt, got: {err}");
}
const GOLDEN_EPOCH_COMMITMENT: &str =
"5e706f60f1c6f39208071e914d3284dab5f93a1c8d178260e7daf5d23e26a81f";
#[test]
fn distinct_recipients_get_distinct_locators() {
let sender = Keys::new(sk(7));
let r1 = Keys::new(sk(8));
let r2 = Keys::new(sk(9));
let b1 = build_rekey_blob(sender.secret_key(), &r1.public_key(), RekeyScope::ServerRoot, Epoch(1), &[1u8; 32]).unwrap();
let b2 = build_rekey_blob(sender.secret_key(), &r2.public_key(), RekeyScope::ServerRoot, Epoch(1), &[1u8; 32]).unwrap();
assert_ne!(b1.locator, b2.locator);
}
}