This commit is contained in:
2026-09-22 20:34:01 +07:00
parent 706cc72c1f
commit 08c1687d87
6 changed files with 433 additions and 36 deletions
+19 -6
View File
@@ -446,7 +446,15 @@ impl Community {
*seen = (*seen).max(count);
}
fn missing_authority(&self) -> bool {
self.state.refounders.is_empty() && self.state.roots().len() > 1
}
pub(crate) fn tick(&mut self, cx: &mut Context<Self>) {
if self.missing_authority() {
self.rekey(cx);
}
let Some(channel) = self.active_channel() else {
return;
};
@@ -776,10 +784,6 @@ impl Community {
self.state.removed_at = None;
self.state.stranded = false;
// Who minted these epochs is only ever stated by the rotation itself,
// and a Guestbook snapshot is honored on that authority.
self.state.refounders.extend(base.refounders);
// Every channel's plane moved with the root.
touched.extend(self.state.channels.iter().map(|channel| channel.id));
}
@@ -814,6 +818,13 @@ impl Community {
self.state.cursors.remove(&channel);
}
// A rotation that delivered us no key still names the npub that minted the epoch.
let learned = adoptions
.refounders
.into_iter()
.filter(|refounder| self.state.refounders.insert(*refounder))
.count();
if let Some(epoch) = adoptions.removed_at {
self.state.removed_at = Some(epoch);
}
@@ -822,8 +833,6 @@ impl Community {
self.state.stranded = true;
}
// A rotation re-opens the region its planes now cover: an exhausted
// verdict earned under the old keys cannot be trusted under the new.
for channel in &touched {
if let Some(cursor) = self.state.cursors.get_mut(channel) {
cursor.exhausted = false;
@@ -834,6 +843,10 @@ impl Community {
cx.notify();
cx.emit(CommunityEvent::Updated(self.state.id));
if learned > 0 {
self.refresh(cx);
}
let Some(channel) = self
.active
.or_else(|| self.state.channels.first().map(|channel| channel.id))
+90 -22
View File
@@ -140,6 +140,8 @@ pub struct Adoptions {
/// The base epoch a complete rotation excluded us at.
pub removed_at: Option<Epoch>,
pub stranded: bool,
/// The npubs whose rotations minted an epoch of this community.
pub refounders: BTreeSet<PublicKey>,
}
impl Adoptions {
@@ -149,6 +151,7 @@ impl Adoptions {
&& self.cuts.is_empty()
&& self.removed_at.is_none()
&& !self.stranded
&& self.refounders.is_empty()
}
}
@@ -160,8 +163,6 @@ pub struct BaseAdoption {
/// The new Control Plane signing root, delivered to staff only.
pub control_root: Option<[u8; 32]>,
pub stepped: Vec<HeldKey>,
/// The npubs whose rotations minted the epochs this walk passed through.
pub refounders: BTreeSet<PublicKey>,
}
#[derive(Debug, Clone)]
@@ -247,11 +248,7 @@ pub async fn adopt(
)
.await?;
let mut refounders: BTreeSet<PublicKey> = base
.adopted
.as_ref()
.map(|adopted| adopted.refounders.clone())
.unwrap_or_default();
let mut refounders: BTreeSet<PublicKey> = base.refounders.clone();
for root in state.roots().into_iter().skip(1) {
let prior = walk(
@@ -268,12 +265,12 @@ pub async fn adopt(
)
.await?;
refounders.extend(prior.refounders.iter().copied());
let Some(adopted) = prior.adopted else {
continue;
};
refounders.extend(adopted.refounders.iter().copied());
if base
.adopted
.as_ref()
@@ -290,10 +287,10 @@ pub async fn adopt(
control_pk: step.control_pk,
control_root: step.control_root,
stepped: step.stepped,
refounders,
});
}
adoptions.refounders = refounders;
adoptions.removed_at = base.removed_at;
adoptions.stranded = base.stranded;
@@ -589,6 +586,9 @@ struct Step {
adopted: Option<Adopted>,
removed_at: Option<Epoch>,
stranded: bool,
/// The rotators of every base rotation this walk verified, which is who may
/// seed the Guestbook snapshot of an epoch they minted.
refounders: BTreeSet<PublicKey>,
}
#[derive(Debug, Clone)]
@@ -598,9 +598,6 @@ struct Adopted {
control_pk: Option<PublicKey>,
control_root: Option<[u8; 32]>,
stepped: Vec<HeldKey>,
/// The rotator of every base epoch this walk passed through,
/// which is who may seed the Guestbook snapshot of the epoch it minted.
refounders: BTreeSet<PublicKey>,
}
/// What one rotation offered this client, and when it was published.
@@ -610,7 +607,6 @@ struct Delivery {
control_pk: Option<PublicKey>,
control_root: Option<[u8; 32]>,
at_ms: u64,
rotator: PublicKey,
}
/// Walk a scope's rotations forward, one epoch at a time, off the key held.
@@ -629,7 +625,6 @@ async fn walk(
) -> Result<Step> {
let mut step = Step::default();
let mut stepped: Vec<HeldKey> = Vec::new();
let mut refounders: BTreeSet<PublicKey> = BTreeSet::new();
let ceiling = held_epoch.0 + REKEY_LOOKAHEAD;
loop {
@@ -663,6 +658,16 @@ async fn walk(
.iter()
.filter(|rotation| rotation.continuity(held_epoch, &held_key) == Continuity::Extends)
{
// Who minted an epoch is proven by the rotation itself, not by a blob:
// a rotation is only a candidate after continuity against a key we
// hold, so its rotator minted this epoch whether or not it addressed
// us. A member who joined on a stale bundle never held the epochs
// between, and the snapshot that seeds them is only honored on this
// npub's authority (CORD-02 §5).
if scope == RekeyScope::Base {
step.refounders.insert(rotation.rotator);
}
let at_ms = published
.get(&rotation_key(rotation))
.copied()
@@ -709,7 +714,6 @@ async fn walk(
control_pk,
control_root: delivered.control_root,
at_ms,
rotator: rotation.rotator,
});
} else if let Some(held) = delivery.as_mut() {
held.at_ms = held.at_ms.min(at_ms);
@@ -730,17 +734,12 @@ async fn walk(
held_epoch = target;
held_key = delivered.key;
if scope == RekeyScope::Base {
refounders.insert(delivered.rotator);
}
step.adopted = Some(Adopted {
epoch: target,
key: delivered.key,
control_pk: delivered.control_pk,
control_root: delivered.control_root,
stepped: stepped.clone(),
refounders: refounders.clone(),
});
continue;
@@ -1022,7 +1021,76 @@ mod tests {
assert_eq!(base.key, NEWER_ROOT);
assert_eq!(base.stepped.len(), 1, "the root it stepped off");
assert_eq!(base.stepped[0].epoch, Epoch(1));
assert!(base.refounders.contains(&owner.public_key()));
assert!(adoptions.refounders.contains(&owner.public_key()));
});
}
/// A rotation that addresses somebody else still names the npub who minted the
/// epoch: continuity against a key we hold is what proves it. That authority is
/// what a Guestbook snapshot is honored on, so a member who joined on a stale
/// bundle can still read the roster the Refounding seeded.
#[test]
fn a_rotation_that_delivered_us_no_key_still_names_its_refounder() {
smol::block_on(async {
let client = client();
let owner = Keys::generate();
let me = Keys::generate();
let other = Keys::generate();
let id = CommunityId::from_bytes([0x42; 32]);
let channel = ChannelId::from_bytes([0x9c; 32]);
// At epoch 2, holding the root the rotation stepped off but never
// having walked it: the shape a stale join bundle produces.
let mut state = state(owner.public_key(), id, channel);
state.root_epoch = Epoch(2);
state.community_root = NEWER_ROOT;
state.held_roots = vec![HeldRoot {
epoch: Epoch(1),
key: NEW_ROOT,
control_pk: None,
retired_at: None,
}];
let blob = smol::block_on(build_blob(
&owner,
&other.public_key(),
RekeyScope::Base,
Epoch(2),
&NEWER_ROOT,
None,
None,
))
.expect("builds");
let group = base_rekey_group_key(&NEW_ROOT, &id, Epoch(2)).expect("derives");
let wraps = smol::block_on(build_rekey_chunks(
&owner,
&group,
RekeyScope::Base,
Epoch(2),
Epoch(1),
&epoch_key_commitment(Epoch(1), &NEW_ROOT),
&[blob],
None,
false,
AT_MS / 1000,
))
.expect("builds");
store(&client, &wraps).await;
let signer = UniversalSigner::new(me.clone());
let adoptions = adopt(
&client,
&state,
&CommunityRoles::default(),
&signer,
me.public_key(),
)
.await
.expect("reads");
assert!(adoptions.base.is_none(), "nothing to adopt");
assert!(!adoptions.is_empty(), "the minter is still learned");
assert!(adoptions.refounders.contains(&owner.public_key()));
});
}
+67
View File
@@ -388,6 +388,8 @@ pub async fn load(
};
retain_join_root(&mut state, &entry.seed);
adopt_list_material(&mut state, &entry.seed);
adopt_list_material(&mut state, &entry.current);
cache::save_state(client, &state).await?;
held.insert(entry.community_id, state);
@@ -396,6 +398,27 @@ pub async fn load(
Ok(held.into_values().collect())
}
/// Take the snapshot authority and retained roots a List entry names.
fn adopt_list_material(state: &mut CommunityState, material: &JoinMaterial) {
if material.root_epoch.0 > 0
&& let Some(refounder) = material.refounder()
{
state.refounders.insert(refounder);
}
for root in material.held_roots() {
if root.epoch >= state.root_epoch || root.epoch.0 == 0 {
continue;
}
if let Some(refounder) = root.refounder {
state.refounders.insert(refounder);
}
retain_root(state, root.epoch, root.key, root.control_pk);
}
}
fn retain_join_root(state: &mut CommunityState, seed: &JoinMaterial) {
if seed.root_epoch >= state.root_epoch {
return;
@@ -967,6 +990,50 @@ mod tests {
});
}
/// A List entry that names the npub whose Refounding minted its epoch hands
/// this client the authority its Guestbook snapshot is honored on, which is
/// how a device that never held the rotation still reads the seeded roster.
/// (What the fold then does with that authority is the neighboring test.)
#[test]
fn a_list_entrys_refounder_becomes_the_snapshot_authority() {
smol::block_on(async {
let client = client();
let keys = Keys::generate();
let signer = UniversalSigner::new(keys.clone());
let refounder = Keys::generate();
let joined = held(
CommunityId::from_bytes([0x42; 32]),
Keys::generate().public_key(),
);
let mut rotated = joined.clone();
rotated.root_epoch = Epoch(2);
rotated.community_root = [0x44; 32];
let mut entry = list_entry(&rotated, "coop");
entry.seed = list_entry(&joined, "coop").seed;
entry.added_at = joined.added_at_ms;
entry.current.extra.insert(
"refounder".to_owned(),
serde_json::Value::String(refounder.public_key().to_hex()),
);
let list = CommunityList::default().joined(entry);
store_fragment(&client, &signer, &list).await;
let loaded = load(&client, &signer, keys.public_key())
.await
.expect("loads");
let state = loaded
.iter()
.find(|state| state.id == joined.id)
.expect("loaded");
assert_eq!(state.root_epoch, Epoch(2));
assert!(state.refounders.contains(&refounder.public_key()));
});
}
/// A List that has rotated on keeps the root of our join: the material is
/// the community as we were given it, and the planes that root addressed
/// stay readable only while it is held.
+123 -1
View File
@@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::cord01::{self, NIP44_MAX_PLAINTEXT};
use crate::cord05::{ChannelGrant, CommunityInvite};
use crate::utils::{base64_to_hex32, base64url, canonical, hex32_to_base64, union};
use crate::{ChannelId, CommunityId, Epoch, Extra};
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
pub const KIND_COMMUNITY_LIST: u16 = 33302;
pub const MAX_MEMBERSHIPS: usize = 50;
@@ -171,6 +171,70 @@ impl CommunityList {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetainedRoot {
pub epoch: Epoch,
pub key: [u8; 32],
pub control_pk: Option<PublicKey>,
/// The npub whose Refounding minted this epoch.
pub refounder: Option<PublicKey>,
/// The epoch-seconds the superseding rotation published.
pub retired_at: Option<u64>,
}
impl JoinMaterial {
/// The npub whose Refounding minted `root_epoch`, when the document names one.
pub fn refounder(&self) -> Option<PublicKey> {
self.extra.get("refounder").and_then(named_public_key)
}
/// The prior root epochs the entry retains, with what the document knows of each.
pub fn held_roots(&self) -> Vec<RetainedRoot> {
let Some(roots) = self
.extra
.get("held_roots")
.and_then(|roots| roots.as_array())
else {
return Vec::new();
};
let mut retained = Vec::with_capacity(roots.len());
for root in roots {
let Some(fields) = root.as_object() else {
continue;
};
let Some(epoch) = fields.get("epoch").and_then(|epoch| epoch.as_u64()) else {
continue;
};
let Some(key) = fields
.get("key")
.and_then(|key| key.as_str())
.and_then(|key| decode_hex_32(&key.to_ascii_lowercase()).ok())
else {
continue;
};
retained.push(RetainedRoot {
epoch: Epoch(epoch),
key,
control_pk: fields.get("control_pk").and_then(named_public_key),
refounder: fields.get("refounder").and_then(named_public_key),
retired_at: fields.get("retired_at").and_then(|at| at.as_u64()),
});
}
retained
}
}
/// A hex npub the document names, or nothing when the field is not one.
fn named_public_key(value: &serde_json::Value) -> Option<PublicKey> {
PublicKey::from_hex(value.as_str()?).ok()
}
pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>) -> JoinMaterial {
JoinMaterial {
community_id: invite.community_id,
@@ -644,6 +708,8 @@ fn crypto_error(error: impl fmt::Display) -> ListError {
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use super::*;
fn id(byte: u8) -> CommunityId {
@@ -704,6 +770,62 @@ mod tests {
}
}
/// A peer's refounder and retained roots ride in the unknown-field map —
/// they are extensions, not §8 named fields — so the round trip has to keep
/// them, and the accessors have to read them out of it.
#[test]
fn the_wire_round_trips_a_refounder_and_the_retained_roots() {
let owner = Keys::generate().public_key();
let refounder = Keys::generate().public_key();
let mut current = material(id(0x11), owner, "Room", 3);
current
.extra
.insert("refounder".to_owned(), Value::String(refounder.to_hex()));
current.extra.insert(
"held_roots".to_owned(),
json!([
{
"epoch": 2,
"key": "77".repeat(32),
"refounder": refounder.to_hex(),
"retired_at": 1_700_000_000,
},
{ "epoch": 1, "key": "88".repeat(32) },
{ "epoch": 4 },
]),
);
let wire = serde_json::to_string(&WireSnapshot::encode(&current).expect("encodes"))
.expect("serializes");
let decoded = serde_json::from_str::<WireSnapshot>(&wire)
.expect("parses")
.decode(id(0x11))
.expect("decodes");
assert_eq!(decoded.refounder(), Some(refounder));
assert_eq!(
decoded.held_roots(),
vec![
RetainedRoot {
epoch: Epoch(2),
key: [0x77; 32],
control_pk: None,
refounder: Some(refounder),
retired_at: Some(1_700_000_000),
},
RetainedRoot {
epoch: Epoch(1),
key: [0x88; 32],
control_pk: None,
refounder: None,
retired_at: None,
},
],
"a root without a key is not a root"
);
}
#[test]
fn merge_keeps_the_earlier_seed_and_the_later_current_either_way_round() {
let owner = Keys::generate().public_key();