Skip to main content

coven_protocol/
circle.rs

1//! Circle identities, audience routing, and control coordinates.
2
3use std::fmt;
4use std::str::FromStr;
5
6use hkdf::Hkdf;
7use hmac::{Hmac, KeyInit, Mac};
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use sha2::Sha256;
10
11use super::membership::MembershipGrantId;
12use super::store_commit::ObjectHash;
13use coven_keys::encryption::EncryptionService;
14
15pub use super::circle_control::*;
16pub use super::circle_roster::*;
17
18const CIRCLE_ID_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
19const CIRCLE_ID_LENGTH: usize = 26;
20const ROW_ROUTING_KEY_DOMAIN: &[u8] = b"coven.row-routing.v1";
21const ROW_ROUTING_ID_DOMAIN: &[u8] = b"coven.row-routing-id.v1\0";
22const CIRCLE_ID_FOUNDER_DOMAIN: &str = "coven.circle-id-founder.v1";
23const CIRCLE_EPOCH_ID_GENERATION_DOMAIN: &[u8] = b"coven.circle-epoch-id-generation.v1\0";
24const ACCESS_LEAF_ID_GENERATION_DOMAIN: &[u8] = b"coven.circle-access-leaf-id-generation.v1\0";
25
26/// A self-certifying 128-bit circle identity encoded as canonical lowercase base32.
27#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
28pub struct CircleId([u8; 16]);
29
30impl CircleId {
31    pub fn founder(
32        store_root_hash: ObjectHash,
33        author_pubkey: &str,
34        owner_grant: &MembershipGrantId,
35    ) -> Self {
36        #[derive(Serialize)]
37        struct Founder<'a> {
38            domain: &'static str,
39            store_root_hash: ObjectHash,
40            author_pubkey: &'a str,
41            owner_grant: &'a MembershipGrantId,
42        }
43        let digest = ObjectHash::digest(
44            &serde_json::to_vec(&Founder {
45                domain: CIRCLE_ID_FOUNDER_DOMAIN,
46                store_root_hash,
47                author_pubkey,
48                owner_grant,
49            })
50            .expect("Circle ID founder serialization cannot fail"),
51        );
52        let mut bytes = [0_u8; 16];
53        bytes.copy_from_slice(&digest.as_bytes()[..16]);
54        Self(bytes)
55    }
56
57    pub fn from_bytes(bytes: [u8; 16]) -> Self {
58        Self(bytes)
59    }
60
61    pub fn as_bytes(&self) -> &[u8; 16] {
62        &self.0
63    }
64}
65
66impl fmt::Debug for CircleId {
67    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68        fmt::Display::fmt(self, formatter)
69    }
70}
71
72impl fmt::Display for CircleId {
73    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
74        formatter.write_str(&encode_base32(&self.0))
75    }
76}
77
78impl FromStr for CircleId {
79    type Err = CircleIdError;
80
81    fn from_str(value: &str) -> Result<Self, Self::Err> {
82        let bytes = decode_base32(value)?;
83        let id = Self(bytes);
84        if id.to_string() != value || value == "local" {
85            return Err(CircleIdError(value.to_string()));
86        }
87        Ok(id)
88    }
89}
90
91impl Serialize for CircleId {
92    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
93    where
94        S: Serializer,
95    {
96        serializer.serialize_str(&self.to_string())
97    }
98}
99
100impl<'de> Deserialize<'de> for CircleId {
101    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
102    where
103        D: Deserializer<'de>,
104    {
105        String::deserialize(deserializer)?
106            .parse()
107            .map_err(serde::de::Error::custom)
108    }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
112#[error("circle id must be canonical 128-bit lowercase base32: {0:?}")]
113pub struct CircleIdError(String);
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum CircleRole {
118    Owner,
119    Member,
120}
121
122/// One Circle as the local application sees it. A Circle with a single resolved
123/// control is `Active`; a Circle whose control history forked into concurrent
124/// valid successors is `Conflicted` and carries no name, role, or key until an
125/// Owner resolves it. A conflicted Circle refuses authoring and package
126/// publication, so it has no single resolved roster or metadata to report.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum CircleInfo {
129    Active {
130        id: CircleId,
131        name: String,
132        role: CircleRole,
133        /// The resolved roster names a Store identity that is no longer an
134        /// active Store member. Publishing new Circle content is blocked until
135        /// an Owner closes the epoch and activates a successor roster without
136        /// that identity.
137        rotation_required: bool,
138    },
139    Conflicted {
140        id: CircleId,
141        /// Every retained concurrent control successor, in canonical order. The
142        /// Owner resolves the conflict by naming this complete set and a chosen
143        /// successor state.
144        branches: Vec<CircleControlCoord>,
145    },
146    /// The Circle's control history terminated in an Owner-signed deletion. Its
147    /// rows and access are gone locally; only the authority spine remains.
148    Deleted { id: CircleId },
149}
150
151impl CircleInfo {
152    pub fn id(&self) -> CircleId {
153        match self {
154            Self::Active { id, .. } | Self::Conflicted { id, .. } | Self::Deleted { id } => *id,
155        }
156    }
157
158    /// The Circle's display name, or `None` while its control is conflicted and
159    /// has no single resolved metadata.
160    pub fn name(&self) -> Option<&str> {
161        match self {
162            Self::Active { name, .. } => Some(name),
163            Self::Conflicted { .. } | Self::Deleted { .. } => None,
164        }
165    }
166
167    /// Whether publishing new content is blocked because the resolved roster
168    /// names a removed Store member. Always `false` for a conflicted Circle,
169    /// which blocks all authoring until it is resolved.
170    pub fn rotation_required(&self) -> bool {
171        matches!(
172            self,
173            Self::Active {
174                rotation_required: true,
175                ..
176            }
177        )
178    }
179}
180
181/// The public derived state of one Circle. Mapped once from the internal current
182/// state; `Circles::list` reports it per Circle.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum CircleState {
185    /// A single resolved active-epoch control whose roster names only current
186    /// Store members.
187    Active,
188    /// The local identity holds no active access — never granted, or revoked by a
189    /// removal it has not re-joined past.
190    Inactive,
191    /// An epoch close is in flight; new-content authoring under the old epoch is
192    /// frozen until the successor activates.
193    Closing,
194    /// The resolved roster names Store identities that are no longer active Store
195    /// members. New Circle content is refused until an Owner closes the epoch and
196    /// activates a successor roster without them.
197    RotationRequired { removed_members: Vec<String> },
198    /// The control history forked into concurrent valid successors awaiting Owner
199    /// resolution. Carries the complete retained branch set.
200    ControlConflict { branches: Vec<CircleControlCoord> },
201    /// The control history terminated in an Owner-signed deletion.
202    Deleted,
203}
204
205/// One Circle as `Circles::list` reports it: its id, display name, the local
206/// identity's role when it holds active access, and the derived state. The name
207/// is absent for a Circle with no resolved metadata (inactive, conflicted, or
208/// deleted); the role is present only when the local identity holds active roster
209/// membership.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct Circle {
212    pub id: CircleId,
213    pub name: Option<String>,
214    pub role: Option<CircleRole>,
215    pub state: CircleState,
216}
217
218/// The settlement of one participant device's create-once epoch-close response
219/// slot: it published its own applied frontier, an Owner excluded it, or the slot
220/// is still empty.
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum CircleCloseSettlement {
223    Responded,
224    Excluded,
225    Pending,
226}
227
228/// One participant in an in-flight epoch close and its slot settlement.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct CircleCloseParticipant {
231    pub device_id: crate::store_commit::StoreDeviceId,
232    pub settlement: CircleCloseSettlement,
233}
234
235/// The read-only status of a Circle's in-flight epoch close: which participant
236/// slots hold responses, exclusions, or nothing.
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub struct CircleCloseStatus {
239    pub circle_id: CircleId,
240    pub close_id: CircleEpochCloseId,
241    pub participants: Vec<CircleCloseParticipant>,
242}
243
244/// Why publishing new content into a Circle is refused. Carried as the durable
245/// typed reason on a blocked host write and on a refused Circle lifecycle
246/// operation.
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub enum CirclePublicationBlocked {
249    RotationRequired {
250        circle_id: CircleId,
251        removed_members: Vec<String>,
252    },
253}
254
255impl std::fmt::Display for CirclePublicationBlocked {
256    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257        match self {
258            Self::RotationRequired {
259                circle_id,
260                removed_members,
261            } => write!(
262                formatter,
263                "Circle {circle_id} requires rotation: its roster names removed Store members {removed_members:?}"
264            ),
265        }
266    }
267}
268
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub struct CircleMemberInfo {
271    pub pubkey: String,
272    pub role: CircleRole,
273    pub is_self: bool,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
277#[serde(transparent)]
278pub struct CircleOperationId(crate::write::WriteId);
279
280impl CircleOperationId {
281    pub fn from_write_id(write_id: crate::write::WriteId) -> Self {
282        Self(write_id)
283    }
284
285    /// A well-formed operation id that names no real operation, for API dispatch
286    /// tests that only need a value to send through the command channel.
287    #[cfg(any(test, feature = "test-utils"))]
288    pub fn placeholder(seed: &str) -> Self {
289        Self::from_write_id(crate::write::WriteId::from_generated(seed.to_string()))
290    }
291
292    pub fn as_str(&self) -> &str {
293        self.0.as_str()
294    }
295
296    pub fn finalization_write_id(&self) -> crate::write::WriteId {
297        crate::write::WriteId::from_generated(
298            crate::store_commit::ObjectHash::digest(
299                &[
300                    b"coven.circle-epoch-close-finalization-write.v1\0".as_slice(),
301                    self.as_str().as_bytes(),
302                ]
303                .concat(),
304            )
305            .to_string(),
306        )
307    }
308
309    pub fn cancellation_write_id(&self) -> crate::write::WriteId {
310        crate::write::WriteId::from_generated(
311            crate::store_commit::ObjectHash::digest(
312                &[
313                    b"coven.circle-epoch-close-cancellation-write.v1\0".as_slice(),
314                    self.as_str().as_bytes(),
315                ]
316                .concat(),
317            )
318            .to_string(),
319        )
320    }
321}
322
323impl fmt::Display for CircleOperationId {
324    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
325        formatter.write_str(self.as_str())
326    }
327}
328
329/// The stable identity of one Circle epoch close, derived from the durable
330/// operation that opened it. It names the close a `Circles::close_status` inspects
331/// and the reserved response and outcome slots that settle it; a close's identity
332/// is fixed for its lifetime and survives cancellation and retry.
333#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
334#[serde(transparent)]
335pub struct CircleEpochCloseId(ObjectHash);
336
337impl CircleEpochCloseId {
338    pub fn from_operation_id(operation_id: &CircleOperationId) -> Self {
339        Self(ObjectHash::digest(
340            &[
341                b"coven.circle-epoch-close-id.v1\0".as_slice(),
342                operation_id.as_str().as_bytes(),
343            ]
344            .concat(),
345        ))
346    }
347}
348
349impl fmt::Display for CircleEpochCloseId {
350    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
351        fmt::Display::fmt(&self.0, formatter)
352    }
353}
354
355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356pub enum CircleOperationKind {
357    Create,
358    Rename,
359    AddMember,
360    RemoveMember,
361    ResolveControl,
362    Delete,
363}
364
365/// Why a durable Circle operation cannot currently publish. One variant per
366/// production block site; each future block site adds its own.
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368#[serde(rename_all = "snake_case", deny_unknown_fields)]
369pub enum CircleOperationBlock {
370    /// The author's exact grant no longer holds current Store write authority.
371    AuthorityLost {
372        grant_id: crate::membership::MembershipGrantId,
373    },
374    /// Another writer took this device's stream position between the operation's
375    /// composition and its publication. The candidate commit is bound to that
376    /// create-once head slot, so it can never activate there and no re-publish
377    /// can succeed: the operation is over, and its initiator discards it and
378    /// re-issues.
379    PositionLost {
380        winner_commit: crate::store_commit::ObjectHash,
381    },
382}
383
384impl std::fmt::Display for CircleOperationBlock {
385    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386        match self {
387            Self::AuthorityLost { grant_id } => write!(
388                formatter,
389                "author grant {grant_id} no longer has current Store write authority"
390            ),
391            Self::PositionLost { winner_commit } => write!(
392                formatter,
393                "Store commit {winner_commit} took this device's stream position \
394                 before the operation published"
395            ),
396        }
397    }
398}
399
400#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
401#[serde(rename_all = "snake_case", deny_unknown_fields)]
402pub enum CircleOperationState {
403    Pending,
404    WaitingForCloseResponses,
405    Finalizing,
406    Blocked {
407        block: CircleOperationBlock,
408    },
409    /// A verified nonactivation proof was accepted; the candidate's exclusive
410    /// objects are being exact-deleted and the durable row cleared. Restart
411    /// resumes the same cleanup from this state.
412    Discarding,
413}
414
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct CircleOperationInfo {
417    pub operation_id: CircleOperationId,
418    pub circle_id: CircleId,
419    pub kind: CircleOperationKind,
420    pub state: CircleOperationState,
421}
422
423/// The one audience a synced row belongs to.
424#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
425pub enum Audience {
426    Store,
427    Circle(CircleId),
428    Local,
429}
430
431impl Audience {
432    pub fn from_column(value: Option<&str>) -> Result<Self, CircleIdError> {
433        match value {
434            None => Ok(Self::Store),
435            Some("local") => Ok(Self::Local),
436            Some(circle) => circle.parse().map(Self::Circle),
437        }
438    }
439
440    pub fn column_value(&self) -> Option<String> {
441        match self {
442            Self::Store => None,
443            Self::Circle(circle) => Some(circle.to_string()),
444            Self::Local => Some("local".to_string()),
445        }
446    }
447}
448
449macro_rules! generated_hex_id {
450    ($name:ident, $domain:ident) => {
451        #[derive(
452            Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
453        )]
454        #[serde(transparent)]
455        pub struct $name([u8; 16]);
456
457        impl $name {
458            pub fn generate(ids: &dyn coven_foundation::id_provider::IdProvider) -> Self {
459                Self(generated_id_bytes(ids, $domain))
460            }
461        }
462
463        impl fmt::Display for $name {
464            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
465                formatter.write_str(&hex::encode(self.0))
466            }
467        }
468    };
469}
470
471generated_hex_id!(CircleEpochId, CIRCLE_EPOCH_ID_GENERATION_DOMAIN);
472generated_hex_id!(AccessLeafId, ACCESS_LEAF_ID_GENERATION_DOMAIN);
473
474pub(crate) fn generated_id_digest(
475    ids: &dyn coven_foundation::id_provider::IdProvider,
476    domain: &[u8],
477) -> ObjectHash {
478    let id = ids.new_id();
479    let mut material = Vec::with_capacity(domain.len() + id.len());
480    material.extend_from_slice(domain);
481    material.extend_from_slice(id.as_bytes());
482    ObjectHash::digest(&material)
483}
484
485fn generated_id_bytes(
486    ids: &dyn coven_foundation::id_provider::IdProvider,
487    domain: &[u8],
488) -> [u8; 16] {
489    generated_id_digest(ids, domain).as_bytes()[..16]
490        .try_into()
491        .expect("SHA-256 digest prefix has fixed length")
492}
493
494/// HMAC identity of one scoped row. It is stable across audience moves and
495/// Store-key rotations because it derives from the unique generation-1 key.
496#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
497pub struct RowRoutingId([u8; 32]);
498
499impl fmt::Debug for RowRoutingId {
500    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
501        fmt::Display::fmt(self, formatter)
502    }
503}
504
505impl fmt::Display for RowRoutingId {
506    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
507        formatter.write_str(&hex::encode(self.0))
508    }
509}
510
511impl FromStr for RowRoutingId {
512    type Err = RowRoutingIdError;
513
514    fn from_str(value: &str) -> Result<Self, Self::Err> {
515        if value.len() != 64
516            || value
517                .bytes()
518                .any(|byte| !byte.is_ascii_digit() && !(b'a'..=b'f').contains(&byte))
519        {
520            return Err(RowRoutingIdError(value.to_string()));
521        }
522        let bytes: [u8; 32] = hex::decode(value)
523            .map_err(|_| RowRoutingIdError(value.to_string()))?
524            .try_into()
525            .map_err(|_| RowRoutingIdError(value.to_string()))?;
526        Ok(Self(bytes))
527    }
528}
529
530impl Serialize for RowRoutingId {
531    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
532    where
533        S: Serializer,
534    {
535        serializer.serialize_str(&self.to_string())
536    }
537}
538
539impl<'de> Deserialize<'de> for RowRoutingId {
540    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
541    where
542        D: Deserializer<'de>,
543    {
544        String::deserialize(deserializer)?
545            .parse()
546            .map_err(serde::de::Error::custom)
547    }
548}
549
550#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
551#[error("row routing id must be exactly 64 lowercase hexadecimal characters: {0:?}")]
552pub struct RowRoutingIdError(String);
553
554#[derive(Clone)]
555pub struct RowRoutingKey([u8; 32]);
556
557#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
558pub enum RowRoutingKeyError {
559    #[error("Store keyring has no generation-1 key")]
560    MissingGenerationOne,
561    #[error("Store keyring has more than one generation-1 key")]
562    AmbiguousGenerationOne,
563}
564
565pub fn derive_row_routing_key(
566    encryption: &EncryptionService,
567    store_root_hash: ObjectHash,
568) -> Result<RowRoutingKey, RowRoutingKeyError> {
569    let mut generation_one = encryption
570        .keyring_entries()
571        .into_iter()
572        .filter_map(|(generation, key)| (generation == 1).then_some(key));
573    let key = generation_one
574        .next()
575        .ok_or(RowRoutingKeyError::MissingGenerationOne)?;
576    if generation_one.next().is_some() {
577        return Err(RowRoutingKeyError::AmbiguousGenerationOne);
578    }
579    let hkdf = Hkdf::<Sha256>::new(Some(ROW_ROUTING_KEY_DOMAIN), &key);
580    let mut derived = [0u8; 32];
581    hkdf.expand(store_root_hash.as_bytes(), &mut derived)
582        .expect("32 bytes is a valid HKDF output length");
583    Ok(RowRoutingKey(derived))
584}
585
586pub fn row_routing_id(key: &RowRoutingKey, table: &str, row_id: &str) -> RowRoutingId {
587    let mut mac = Hmac::<Sha256>::new_from_slice(&key.0).expect("HMAC accepts a 32-byte key");
588    mac.update(ROW_ROUTING_ID_DOMAIN);
589    mac.update(&(table.len() as u64).to_be_bytes());
590    mac.update(table.as_bytes());
591    mac.update(&(row_id.len() as u64).to_be_bytes());
592    mac.update(row_id.as_bytes());
593    RowRoutingId(mac.finalize().into_bytes().into())
594}
595
596fn encode_base32(bytes: &[u8; 16]) -> String {
597    let mut output = String::with_capacity(CIRCLE_ID_LENGTH);
598    let mut buffer = 0u32;
599    let mut bits = 0u8;
600    for byte in bytes {
601        buffer = (buffer << 8) | u32::from(*byte);
602        bits += 8;
603        while bits >= 5 {
604            bits -= 5;
605            output.push(CIRCLE_ID_ALPHABET[((buffer >> bits) & 0x1f) as usize] as char);
606            buffer &= (1u32 << bits).wrapping_sub(1);
607        }
608    }
609    if bits != 0 {
610        output.push(CIRCLE_ID_ALPHABET[((buffer << (5 - bits)) & 0x1f) as usize] as char);
611    }
612    output
613}
614
615fn decode_base32(value: &str) -> Result<[u8; 16], CircleIdError> {
616    if value.len() != CIRCLE_ID_LENGTH {
617        return Err(CircleIdError(value.to_string()));
618    }
619    let mut output = Vec::with_capacity(16);
620    let mut buffer = 0u32;
621    let mut bits = 0u8;
622    for byte in value.bytes() {
623        let digit = CIRCLE_ID_ALPHABET
624            .iter()
625            .position(|candidate| *candidate == byte)
626            .ok_or_else(|| CircleIdError(value.to_string()))? as u32;
627        buffer = (buffer << 5) | digit;
628        bits += 5;
629        while bits >= 8 {
630            bits -= 8;
631            output.push(((buffer >> bits) & 0xff) as u8);
632            buffer &= (1u32 << bits).wrapping_sub(1);
633        }
634    }
635    if output.len() != 16 || buffer != 0 {
636        return Err(CircleIdError(value.to_string()));
637    }
638    output
639        .try_into()
640        .map_err(|_| CircleIdError(value.to_string()))
641}
642
643#[cfg(test)]
644#[path = "circle_tests.rs"]
645mod tests;