use nostr_sdk::nips::nip44::v2::{decrypt_to_bytes, encrypt_to_bytes, ConversationKey};
use nostr_sdk::prelude::{Event, Keys, PublicKey, SecretKey, Tag, TagKind, Timestamp, UnsignedEvent};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use super::super::{ChannelId, CommunityId, Epoch};
use super::derive::{
base_rekey_group_key, channel_rekey_group_key, epoch_key_commitment, recipient_locator, GroupKey,
};
use super::stream::{self, OpenedStream, SealForm, StreamError};
pub const MAX_REKEY_BLOBS_PER_EVENT: usize = 80;
pub const MAX_REKEY_BLOBS_RECEIVED: usize = 120;
const TAG_SCOPE: &str = "scope";
const TAG_NEW_EPOCH: &str = "newepoch";
const TAG_PREV_EPOCH: &str = "prevepoch";
const TAG_PREV_COMMIT: &str = "prevcommit";
const TAG_CHUNK: &str = "chunk";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RekeyScope {
Channel(ChannelId),
Root,
}
impl RekeyScope {
pub fn id32(&self) -> [u8; 32] {
match self {
RekeyScope::Channel(c) => c.0,
RekeyScope::Root => [0u8; 32],
}
}
fn to_hex(self) -> String {
crate::simd::hex::bytes_to_hex_32(&self.id32())
}
fn 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::Root
} else {
RekeyScope::Channel(ChannelId(bytes))
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RekeyBlob {
pub locator: String,
pub wrapped: String,
}
#[derive(Debug)]
pub enum RekeyError {
Stream(StreamError),
Crypto(String),
BadBlobLength(usize),
ScopeSplice,
EpochSplice,
NotARekey(u16),
BadTag(&'static str),
NonMonotonicEpoch,
BadChunkIndex,
TooManyBlobs(usize),
}
impl std::fmt::Display for RekeyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RekeyError::Stream(e) => write!(f, "stream: {e}"),
RekeyError::Crypto(e) => write!(f, "crypto: {e}"),
RekeyError::BadBlobLength(n) => write!(f, "rekey blob plaintext is {n} bytes, expected 72"),
RekeyError::ScopeSplice => write!(f, "rekey blob scope binding mismatch (splice)"),
RekeyError::EpochSplice => write!(f, "rekey blob epoch binding mismatch (splice)"),
RekeyError::NotARekey(k) => write!(f, "rumor kind {k} is not a rekey"),
RekeyError::BadTag(t) => write!(f, "missing/duplicate/malformed rekey tag: {t}"),
RekeyError::NonMonotonicEpoch => write!(f, "rekey new_epoch must exceed prev_epoch"),
RekeyError::BadChunkIndex => write!(f, "rekey chunk index out of range"),
RekeyError::TooManyBlobs(n) => write!(f, "rekey carries {n} blobs, over the cap"),
}
}
}
impl std::error::Error for RekeyError {}
impl From<StreamError> for RekeyError {
fn from(e: StreamError) -> Self {
RekeyError::Stream(e)
}
}
fn bound_plaintext(scope: RekeyScope, epoch: Epoch, new_key: &[u8; 32]) -> [u8; 72] {
let mut pt = [0u8; 72];
pt[..32].copy_from_slice(&scope.id32());
pt[32..40].copy_from_slice(&epoch.0.to_be_bytes());
pt[40..].copy_from_slice(new_key);
pt
}
pub fn bound_plaintext_b64(scope: RekeyScope, epoch: Epoch, new_key: &[u8; 32]) -> String {
base64_simd::STANDARD.encode_to_string(bound_plaintext(scope, epoch, new_key))
}
pub fn parse_bound_plaintext(pt: &[u8], scope: RekeyScope, epoch: Epoch) -> Result<[u8; 32], RekeyError> {
if pt.len() != 72 {
return Err(RekeyError::BadBlobLength(pt.len()));
}
if pt[..32] != scope.id32() {
return Err(RekeyError::ScopeSplice);
}
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(RekeyError::EpochSplice);
}
let mut new_key = [0u8; 32];
new_key.copy_from_slice(&pt[40..72]);
Ok(new_key)
}
pub fn blob_locator(rotator_xonly: &[u8; 32], recipient_xonly: &[u8; 32], scope: RekeyScope, epoch: Epoch) -> String {
crate::simd::hex::bytes_to_hex_32(&recipient_locator(rotator_xonly, recipient_xonly, &scope.id32(), epoch))
}
pub fn build_blob_local(
rotator_sk: &SecretKey,
rotator_xonly: &[u8; 32],
recipient_pk: &PublicKey,
scope: RekeyScope,
epoch: Epoch,
new_key: &[u8; 32],
) -> Result<RekeyBlob, RekeyError> {
let inner_b64 = Zeroizing::new(bound_plaintext_b64(scope, epoch, new_key));
let ck = ConversationKey::derive(rotator_sk, recipient_pk).map_err(|e| RekeyError::Crypto(e.to_string()))?;
let payload = encrypt_to_bytes(&ck, inner_b64.as_bytes()).map_err(|e| RekeyError::Crypto(e.to_string()))?;
Ok(RekeyBlob {
locator: blob_locator(rotator_xonly, &recipient_pk.to_bytes(), scope, epoch),
wrapped: base64_simd::STANDARD.encode_to_string(&payload),
})
}
pub fn open_blob_local(
my_sk: &SecretKey,
rotator_pk: &PublicKey,
scope: RekeyScope,
epoch: Epoch,
blob: &RekeyBlob,
) -> Result<[u8; 32], RekeyError> {
let ck = ConversationKey::derive(my_sk, rotator_pk).map_err(|e| RekeyError::Crypto(e.to_string()))?;
let payload = base64_simd::STANDARD
.decode_to_vec(blob.wrapped.as_bytes())
.map_err(|e| RekeyError::Crypto(e.to_string()))?;
let inner_b64 = Zeroizing::new(decrypt_to_bytes(&ck, &payload).map_err(|e| RekeyError::Crypto(e.to_string()))?);
let pt = Zeroizing::new(
base64_simd::STANDARD
.decode_to_vec(inner_b64.as_slice())
.map_err(|e| RekeyError::Crypto(e.to_string()))?,
);
parse_bound_plaintext(&pt, scope, epoch)
}
pub fn find_my_blob<'a>(
blobs: &'a [RekeyBlob],
rotator_xonly: &[u8; 32],
my_xonly: &[u8; 32],
scope: RekeyScope,
epoch: Epoch,
) -> Option<&'a RekeyBlob> {
let want = blob_locator(rotator_xonly, my_xonly, scope, epoch);
blobs.iter().find(|b| b.locator == want)
}
#[derive(Debug, Clone)]
pub struct RekeyChunk {
pub rotator: PublicKey,
pub scope: RekeyScope,
pub new_epoch: Epoch,
pub prev_epoch: Epoch,
pub prev_commit: [u8; 32],
pub chunk: (u32, u32),
pub blobs: Vec<RekeyBlob>,
}
pub type RotationKey = ([u8; 32], [u8; 32], u64, [u8; 32]);
impl RekeyChunk {
pub fn correlation(&self) -> RotationKey {
(self.rotator.to_bytes(), self.scope.id32(), self.new_epoch.0, self.prev_commit)
}
}
#[allow(clippy::too_many_arguments)]
pub fn build_rekey_rumor(
rotator: PublicKey,
scope: RekeyScope,
new_epoch: Epoch,
prev_epoch: Epoch,
prev_commit: &[u8; 32],
blobs: &[RekeyBlob],
chunk_i: u32,
chunk_n: u32,
at_secs: u64,
) -> Result<UnsignedEvent, RekeyError> {
if new_epoch.0 <= prev_epoch.0 {
return Err(RekeyError::NonMonotonicEpoch);
}
if chunk_n < 1 || chunk_i < 1 || chunk_i > chunk_n {
return Err(RekeyError::BadChunkIndex);
}
if blobs.len() > MAX_REKEY_BLOBS_PER_EVENT {
return Err(RekeyError::TooManyBlobs(blobs.len()));
}
let content = serde_json::to_string(blobs).map_err(|e| RekeyError::Crypto(e.to_string()))?;
let tags = vec![
Tag::custom(TagKind::Custom(TAG_SCOPE.into()), [scope.to_hex()]),
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_commit)]),
Tag::custom(TagKind::Custom(TAG_CHUNK.into()), [chunk_i.to_string(), chunk_n.to_string()]),
];
Ok(stream::build_rumor_secs(super::kind::REKEY, rotator, &content, tags, at_secs))
}
pub fn channel_rekey_group(addressing_root: &[u8; 32], channel_id: &ChannelId, new_epoch: Epoch) -> GroupKey {
channel_rekey_group_key(addressing_root, channel_id, new_epoch)
}
pub fn base_rekey_group(prior_root: &[u8; 32], community_id: &CommunityId, new_epoch: Epoch) -> GroupKey {
base_rekey_group_key(prior_root, community_id, new_epoch)
}
pub fn seal_rekey_chunk(
rumor: &UnsignedEvent,
rekey_group: &GroupKey,
rotator_keys: &Keys,
wrap_at: Timestamp,
) -> Result<(Event, Keys), RekeyError> {
let seal = stream::build_seal(rumor, SealForm::Encrypted, rekey_group, rotator_keys)?;
Ok(stream::wrap_seal(&seal, rekey_group, stream::KIND_WRAP, wrap_at)?)
}
#[allow(clippy::too_many_arguments)]
pub fn build_rekey_chunks_local(
rotator_keys: &Keys,
rekey_group: &GroupKey,
scope: RekeyScope,
new_epoch: Epoch,
prev_epoch: Epoch,
prev_commit: &[u8; 32],
blobs: &[RekeyBlob],
at_secs: u64,
) -> Result<Vec<Event>, RekeyError> {
let groups: Vec<&[RekeyBlob]> = if blobs.is_empty() {
vec![&[]]
} else {
blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect()
};
let n = groups.len() as u32;
let mut out = Vec::with_capacity(groups.len());
for (idx, group_blobs) in groups.iter().enumerate() {
let rumor = build_rekey_rumor(
rotator_keys.public_key(),
scope,
new_epoch,
prev_epoch,
prev_commit,
group_blobs,
idx as u32 + 1,
n,
at_secs,
)?;
let (wrap, _) = seal_rekey_chunk(&rumor, rekey_group, rotator_keys, Timestamp::from_secs(at_secs))?;
out.push(wrap);
}
Ok(out)
}
pub fn parse_rekey_chunk(opened: &OpenedStream) -> Result<RekeyChunk, RekeyError> {
if opened.seal_form != SealForm::Encrypted {
return Err(RekeyError::Stream(StreamError::BadSealKind(stream::KIND_SEAL_PLAINTEXT)));
}
let rumor = &opened.rumor;
if rumor.kind.as_u16() != super::kind::REKEY {
return Err(RekeyError::NotARekey(rumor.kind.as_u16()));
}
let scope = RekeyScope::from_hex(&unique_tag(rumor, TAG_SCOPE)?.ok_or(RekeyError::BadTag(TAG_SCOPE))?)
.ok_or(RekeyError::BadTag(TAG_SCOPE))?;
let new_epoch = Epoch(parse_u64(rumor, TAG_NEW_EPOCH)?);
let prev_epoch = Epoch(parse_u64(rumor, TAG_PREV_EPOCH)?);
if new_epoch.0 <= prev_epoch.0 {
return Err(RekeyError::NonMonotonicEpoch);
}
let prev_hex = unique_tag(rumor, TAG_PREV_COMMIT)?.ok_or(RekeyError::BadTag(TAG_PREV_COMMIT))?;
if prev_hex.len() != 64 || !prev_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(RekeyError::BadTag(TAG_PREV_COMMIT));
}
let prev_commit = crate::simd::hex::hex_to_bytes_32(&prev_hex);
let (chunk_i, chunk_n) = parse_chunk(rumor)?;
let blobs: Vec<RekeyBlob> = serde_json::from_str(&rumor.content).map_err(|_| RekeyError::BadTag("blobs"))?;
if blobs.len() > MAX_REKEY_BLOBS_RECEIVED {
return Err(RekeyError::TooManyBlobs(blobs.len()));
}
Ok(RekeyChunk {
rotator: opened.author,
scope,
new_epoch,
prev_epoch,
prev_commit,
chunk: (chunk_i, chunk_n),
blobs,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Continuity {
Extends,
Gap,
Fork,
}
pub fn check_continuity(chunk: &RekeyChunk, held_epoch: Epoch, held_key: &[u8; 32]) -> Continuity {
if chunk.prev_epoch.0 == held_epoch.0 {
if epoch_key_commitment(held_epoch, held_key) == chunk.prev_commit {
Continuity::Extends
} else {
Continuity::Fork
}
} else if chunk.prev_epoch.0 > held_epoch.0 {
Continuity::Gap
} else {
Continuity::Fork
}
}
#[derive(Debug, Clone)]
pub struct Rotation {
pub rotator: PublicKey,
pub scope: RekeyScope,
pub new_epoch: Epoch,
pub prev_epoch: Epoch,
pub prev_commit: [u8; 32],
pub blobs: Vec<RekeyBlob>,
pub declared_chunks: u32,
pub held_chunks: std::collections::BTreeSet<u32>,
}
impl Rotation {
pub fn is_complete(&self) -> bool {
self.declared_chunks >= 1 && (1..=self.declared_chunks).all(|i| self.held_chunks.contains(&i))
}
pub fn continuity(&self, held_epoch: Epoch, held_key: &[u8; 32]) -> Continuity {
if self.prev_epoch.0 == held_epoch.0 {
if epoch_key_commitment(held_epoch, held_key) == self.prev_commit {
Continuity::Extends
} else {
Continuity::Fork
}
} else if self.prev_epoch.0 > held_epoch.0 {
Continuity::Gap
} else {
Continuity::Fork
}
}
}
pub fn collect_rotations(chunks: &[RekeyChunk]) -> Vec<Rotation> {
use std::collections::BTreeMap;
let mut by_key: BTreeMap<RotationKey, Rotation> = BTreeMap::new();
for c in chunks {
let entry = by_key.entry(c.correlation()).or_insert_with(|| Rotation {
rotator: c.rotator,
scope: c.scope,
new_epoch: c.new_epoch,
prev_epoch: c.prev_epoch,
prev_commit: c.prev_commit,
blobs: Vec::new(),
declared_chunks: c.chunk.1,
held_chunks: std::collections::BTreeSet::new(),
});
if entry.held_chunks.insert(c.chunk.0) {
entry.blobs.extend(c.blobs.iter().cloned());
}
}
by_key.into_values().collect()
}
pub fn am_i_removed(rotation: &Rotation, my_xonly: &[u8; 32]) -> Option<bool> {
if !rotation.is_complete() {
return None;
}
let mine = find_my_blob(&rotation.blobs, &rotation.rotator.to_bytes(), my_xonly, rotation.scope, rotation.new_epoch);
Some(mine.is_none())
}
pub fn lowest_key_winner(candidate_keys: &[[u8; 32]]) -> Option<usize> {
candidate_keys
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| a.cmp(b))
.map(|(i, _)| i)
}
fn unique_tag(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, RekeyError> {
let mut found: Option<String> = None;
for t in rumor.tags.iter() {
let s = t.as_slice();
if s.len() >= 2 && s[0] == name {
if found.is_some() {
return Err(RekeyError::BadTag(name));
}
found = Some(s[1].clone());
}
}
Ok(found)
}
fn parse_u64(rumor: &UnsignedEvent, name: &'static str) -> Result<u64, RekeyError> {
unique_tag(rumor, name)?
.ok_or(RekeyError::BadTag(name))?
.parse::<u64>()
.map_err(|_| RekeyError::BadTag(name))
}
fn parse_chunk(rumor: &UnsignedEvent) -> Result<(u32, u32), RekeyError> {
let mut found: Option<(u32, u32)> = None;
for t in rumor.tags.iter() {
let s = t.as_slice();
if s.len() >= 3 && s[0] == TAG_CHUNK {
if found.is_some() {
return Err(RekeyError::BadTag(TAG_CHUNK));
}
let i: u32 = s[1].parse().map_err(|_| RekeyError::BadTag(TAG_CHUNK))?;
let n: u32 = s[2].parse().map_err(|_| RekeyError::BadTag(TAG_CHUNK))?;
found = Some((i, n));
}
}
let (i, n) = found.ok_or(RekeyError::BadTag(TAG_CHUNK))?;
if n < 1 || i < 1 || i > n {
return Err(RekeyError::BadChunkIndex);
}
Ok((i, n))
}
#[cfg(test)]
mod tests {
use super::*;
use nostr_sdk::prelude::JsonUtil;
fn keys(byte: u8) -> Keys {
Keys::new(SecretKey::from_slice(&[byte; 32]).unwrap())
}
fn xonly(k: &Keys) -> [u8; 32] {
k.public_key().to_bytes()
}
const CHAN: ChannelId = ChannelId([0x42u8; 32]);
#[test]
fn bound_plaintext_layout_is_frozen() {
let pt = bound_plaintext(RekeyScope::Root, 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 blob_round_trips_both_scopes() {
let rotator = keys(7);
let recipient = keys(8);
for (scope, epoch, key) in [
(RekeyScope::Root, Epoch(1), [0xABu8; 32]),
(RekeyScope::Channel(CHAN), Epoch(5), [0xCDu8; 32]),
] {
let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).unwrap();
let got = open_blob_local(recipient.secret_key(), &rotator.public_key(), scope, epoch, &blob).unwrap();
assert_eq!(got, key, "the recipient recovers the fresh key");
}
}
#[test]
fn locator_is_public_and_computable_from_pubkeys_alone() {
let rotator = keys(7);
let recipient = keys(8);
let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(2), &[1u8; 32]).unwrap();
let recomputed = blob_locator(&xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(2));
assert_eq!(blob.locator, recomputed, "both sides compute the same public locator");
}
#[test]
fn a_non_recipient_cannot_open_even_holding_the_public_locator() {
let rotator = keys(7);
let recipient = keys(8);
let outsider = keys(9);
let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[2u8; 32]).unwrap();
assert_eq!(blob.locator, blob_locator(&xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(1)));
assert!(open_blob_local(outsider.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), &blob).is_err());
}
#[test]
fn a_relocated_blob_still_opens_by_decrypt_not_locator() {
let rotator = keys(7);
let recipient = keys(8);
let mut blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[3u8; 32]).unwrap();
blob.locator = "ff".repeat(32);
assert_eq!(
open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), &blob).unwrap(),
[3u8; 32]
);
assert!(find_my_blob(std::slice::from_ref(&blob), &xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(1)).is_none());
}
#[test]
fn scope_and_epoch_splices_are_rejected_on_open() {
let rotator = keys(7);
let recipient = keys(8);
let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[4u8; 32]).unwrap();
assert!(matches!(
open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &blob),
Err(RekeyError::ScopeSplice)
));
assert!(matches!(
open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(2), &blob),
Err(RekeyError::EpochSplice)
));
}
#[test]
fn wrapped_carries_base64_of_the_72_bytes_for_bunker_parity() {
let rotator = keys(7);
let recipient = keys(8);
let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[5u8; 32]).unwrap();
let ck = ConversationKey::derive(recipient.secret_key(), &rotator.public_key()).unwrap();
let payload = base64_simd::STANDARD.decode_to_vec(blob.wrapped.as_bytes()).unwrap();
let inner = decrypt_to_bytes(&ck, &payload).unwrap();
assert_eq!(String::from_utf8(inner).unwrap(), bound_plaintext_b64(RekeyScope::Root, Epoch(1), &[5u8; 32]));
}
#[test]
fn distinct_recipients_and_scopes_get_distinct_locators() {
let rotator = keys(7);
let r1 = keys(8);
let r2 = keys(9);
assert_ne!(
blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Root, Epoch(1)),
blob_locator(&xonly(&rotator), &xonly(&r2), RekeyScope::Root, Epoch(1))
);
assert_ne!(
blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Root, Epoch(1)),
blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Channel(CHAN), Epoch(1))
);
}
fn root() -> [u8; 32] {
[0x55u8; 32]
}
#[test]
fn channel_rekey_round_trips_through_the_stream() {
let rotator = keys(1);
let recipient = keys(8);
let group = channel_rekey_group(&root(), &CHAN, Epoch(1));
let key = [0xABu8; 32];
let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &key).unwrap();
let commit = epoch_key_commitment(Epoch(0), &[0xEEu8; 32]);
let rumor = build_rekey_rumor(rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), Epoch(0), &commit, &[blob.clone()], 1, 1, 100).unwrap();
let (wrap, _) = seal_rekey_chunk(&rumor, &group, &rotator, Timestamp::from_secs(100)).unwrap();
assert_ne!(wrap.pubkey, rotator.public_key());
assert_eq!(wrap.pubkey, group.pk());
let opened = stream::open_wrap(&wrap, &group).unwrap();
let chunk = parse_rekey_chunk(&opened).unwrap();
assert_eq!(chunk.rotator, rotator.public_key(), "rotator recovered from the seal");
assert!(matches!(chunk.scope, RekeyScope::Channel(c) if c.0 == CHAN.0));
assert_eq!(chunk.new_epoch, Epoch(1));
assert_eq!(chunk.prev_epoch, Epoch(0));
assert_eq!(chunk.prev_commit, commit);
assert_eq!(chunk.chunk, (1, 1));
assert_eq!(chunk.blobs, vec![blob]);
}
#[test]
fn base_rekey_addresses_under_the_prior_root() {
let rotator = keys(1);
let recipient = keys(8);
let prior_root = [0x66u8; 32];
let community = CommunityId([0x77u8; 32]);
let new_root = [0x99u8; 32];
let group = base_rekey_group(&prior_root, &community, Epoch(1));
let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &new_root).unwrap();
let commit = epoch_key_commitment(Epoch(0), &prior_root);
let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(1), Epoch(0), &commit, &[blob], 100).unwrap();
assert_eq!(chunks.len(), 1);
let opened = stream::open_wrap(&chunks[0], &group).unwrap();
let chunk = parse_rekey_chunk(&opened).unwrap();
assert!(matches!(chunk.scope, RekeyScope::Root));
let mine = find_my_blob(&chunk.blobs, &chunk.rotator.to_bytes(), &xonly(&recipient), chunk.scope, chunk.new_epoch).unwrap();
assert_eq!(open_blob_local(recipient.secret_key(), &chunk.rotator, chunk.scope, chunk.new_epoch, mine).unwrap(), new_root);
let wrong = base_rekey_group(&[0u8; 32], &community, Epoch(1));
assert!(stream::open_wrap(&chunks[0], &wrong).is_err());
}
#[test]
fn a_full_send_chunk_stays_under_the_relay_size_limit() {
let rotator = keys(1);
let group = base_rekey_group(&root(), &CommunityId([9u8; 32]), Epoch(1));
let blobs: Vec<RekeyBlob> = (0..MAX_REKEY_BLOBS_PER_EVENT)
.map(|i| {
let r = keys((i % 200 + 20) as u8);
build_blob_local(rotator.secret_key(), &xonly(&rotator), &r.public_key(), RekeyScope::Root, Epoch(1), &[0xCDu8; 32]).unwrap()
})
.collect();
let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(1), Epoch(0), &[0u8; 32], &blobs, 100).unwrap();
assert_eq!(chunks.len(), 1, "a full send chunk is exactly one event");
assert!(chunks[0].as_json().len() <= 65_536, "a full chunk must fit a 64KB relay event");
}
#[test]
fn oversize_recipient_set_splits_into_chunks() {
let rotator = keys(1);
let group = base_rekey_group(&root(), &CommunityId([9u8; 32]), Epoch(1));
let blobs: Vec<RekeyBlob> = (0..MAX_REKEY_BLOBS_PER_EVENT + 1)
.map(|_| RekeyBlob { locator: "aa".repeat(32), wrapped: "x".into() })
.collect();
let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(2), Epoch(1), &[0u8; 32], &blobs, 100).unwrap();
assert_eq!(chunks.len(), 2);
let parsed: Vec<RekeyChunk> = chunks.iter().map(|w| parse_rekey_chunk(&stream::open_wrap(w, &group).unwrap()).unwrap()).collect();
assert_eq!(parsed[0].chunk, (1, 2));
assert_eq!(parsed[1].chunk, (2, 2));
assert_eq!(parsed[0].blobs.len(), MAX_REKEY_BLOBS_PER_EVENT);
assert_eq!(parsed[1].blobs.len(), 1);
}
#[test]
fn plaintext_sealed_rekey_is_rejected() {
let rotator = keys(1);
let group = channel_rekey_group(&root(), &CHAN, Epoch(1));
let rumor = build_rekey_rumor(rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), Epoch(0), &[0u8; 32], &[], 1, 1, 100).unwrap();
let seal = stream::build_seal(&rumor, SealForm::Plaintext, &group, &rotator).unwrap();
let (wrap, _) = stream::wrap_seal(&seal, &group, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
let opened = stream::open_wrap(&wrap, &group).unwrap();
assert!(parse_rekey_chunk(&opened).is_err(), "the rekey plane must be encrypted-sealed");
}
#[test]
fn non_monotonic_epoch_is_refused_at_mint_and_on_parse() {
let rotator = keys(1);
assert!(matches!(
build_rekey_rumor(rotator.public_key(), RekeyScope::Root, Epoch(1), Epoch(1), &[0u8; 32], &[], 1, 1, 100),
Err(RekeyError::NonMonotonicEpoch)
));
}
#[test]
fn bad_chunk_indices_are_refused() {
let rotator = keys(1);
for (i, n) in [(0u32, 1u32), (2, 1), (1, 0)] {
assert!(
matches!(build_rekey_rumor(rotator.public_key(), RekeyScope::Root, Epoch(1), Epoch(0), &[0u8; 32], &[], i, n, 100), Err(RekeyError::BadChunkIndex)),
"chunk ({i},{n}) must be rejected"
);
}
}
fn chunk_at(rotator: &Keys, scope: RekeyScope, new_epoch: u64, prev_epoch: u64, prev_key: &[u8; 32], blobs: Vec<RekeyBlob>, i: u32, n: u32) -> RekeyChunk {
RekeyChunk {
rotator: rotator.public_key(),
scope,
new_epoch: Epoch(new_epoch),
prev_epoch: Epoch(prev_epoch),
prev_commit: epoch_key_commitment(Epoch(prev_epoch), prev_key),
chunk: (i, n),
blobs,
}
}
#[test]
fn continuity_extends_gaps_and_forks() {
let rotator = keys(1);
let held = [0x33u8; 32];
let good = chunk_at(&rotator, RekeyScope::Root, 3, 2, &held, vec![], 1, 1);
assert_eq!(check_continuity(&good, Epoch(2), &held), Continuity::Extends);
let ahead = chunk_at(&rotator, RekeyScope::Root, 5, 4, &held, vec![], 1, 1);
assert_eq!(check_continuity(&ahead, Epoch(2), &held), Continuity::Gap);
let fork = chunk_at(&rotator, RekeyScope::Root, 3, 2, &[0x99u8; 32], vec![], 1, 1);
assert_eq!(check_continuity(&fork, Epoch(2), &held), Continuity::Fork);
let stale = chunk_at(&rotator, RekeyScope::Root, 2, 1, &held, vec![], 1, 1);
assert_eq!(check_continuity(&stale, Epoch(2), &held), Continuity::Fork);
}
#[test]
fn a_missing_chunk_is_never_a_removal() {
let rotator = keys(1);
let me = keys(8);
let my_blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &me.public_key(), RekeyScope::Root, Epoch(1), &[0xAAu8; 32]).unwrap();
let c1 = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![RekeyBlob { locator: "bb".repeat(32), wrapped: "x".into() }], 1, 2);
let rots = collect_rotations(&[c1.clone()]);
assert_eq!(rots.len(), 1);
assert!(!rots[0].is_complete(), "one of two chunks held → incomplete");
assert_eq!(am_i_removed(&rots[0], &xonly(&me)), None, "incomplete → keep recovering, never conclude removal");
let c2 = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![my_blob.clone()], 2, 2);
let rots = collect_rotations(&[c1, c2]);
assert!(rots[0].is_complete());
assert_eq!(am_i_removed(&rots[0], &xonly(&me)), Some(false));
let mine = find_my_blob(&rots[0].blobs, &rots[0].rotator.to_bytes(), &xonly(&me), RekeyScope::Root, Epoch(1)).unwrap();
assert_eq!(open_blob_local(me.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), mine).unwrap(), [0xAAu8; 32]);
}
#[test]
fn a_complete_rotation_without_my_blob_is_a_removal() {
let rotator = keys(1);
let me = keys(8);
let other = keys(9);
let their_blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &other.public_key(), RekeyScope::Root, Epoch(1), &[0xBBu8; 32]).unwrap();
let c = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![their_blob], 1, 1);
let rots = collect_rotations(&[c]);
assert!(rots[0].is_complete());
assert_eq!(am_i_removed(&rots[0], &xonly(&me)), Some(true), "complete rotation, no blob for me → removed");
}
#[test]
fn collect_rotations_separates_concurrent_rotators_and_scopes() {
let rot_a = keys(1);
let rot_b = keys(2);
let ca = chunk_at(&rot_a, RekeyScope::Root, 2, 1, &[7u8; 32], vec![], 1, 1);
let cb = chunk_at(&rot_b, RekeyScope::Root, 2, 1, &[7u8; 32], vec![], 1, 1);
let cc = chunk_at(&rot_a, RekeyScope::Channel(CHAN), 2, 1, &[7u8; 32], vec![], 1, 1);
let rots = collect_rotations(&[ca, cb, cc]);
assert_eq!(rots.len(), 3, "different rotator or scope ⇒ different rotation");
}
#[test]
fn duplicate_chunk_delivery_is_idempotent() {
let rotator = keys(1);
let blob = RekeyBlob { locator: "aa".repeat(32), wrapped: "x".into() };
let c = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![blob.clone()], 1, 1);
let rots = collect_rotations(&[c.clone(), c]);
assert_eq!(rots.len(), 1);
assert_eq!(rots[0].blobs.len(), 1, "re-delivering a chunk must not double its blobs");
}
#[test]
fn lowest_key_fork_winner_is_deterministic() {
let keys_a = [[0x03u8; 32], [0x01u8; 32], [0x02u8; 32]];
assert_eq!(lowest_key_winner(&keys_a), Some(1));
let keys_b = [[0x01u8; 32], [0x02u8; 32], [0x03u8; 32]];
assert_eq!(lowest_key_winner(&keys_b), Some(0));
assert_eq!(lowest_key_winner(&[]), None);
}
}