Skip to main content

derec_library/protocol/
types.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 DeRec Alliance. All rights reserved.
3
4//! Protocol-layer types.
5//!
6//! Everything in this module is "post-pairing" — the channel and store
7//! shapes the orchestrator manages once a pair handshake completes. The
8//! primitives layer never touches these (it operates on raw wire bytes
9//! and the cross-layer [`crate::types::ChannelId`] / [`crate::types::SharedKey`]
10//! aliases).
11//!
12//! Re-exported at [`crate::protocol`] for ergonomic access, so callers
13//! can write `use derec_library::protocol::Channel;` rather than
14//! `use derec_library::protocol::types::Channel;`.
15
16use crate::types::ChannelId;
17use derec_cryptography::pairing::PairingSecretKeyMaterial;
18use derec_proto::ContactMessage;
19#[cfg(any(feature = "serde", target_arch = "wasm32"))]
20use serde::{Deserialize, Serialize};
21use zeroize::Zeroizing;
22
23/// Selects which channels to target for a discovery request.
24#[derive(Debug, Clone)]
25pub enum Target {
26    /// Send to all paired channels (most common case).
27    All,
28    /// Send to a single channel.
29    Single(ChannelId),
30    /// Send to a specific set of channels.
31    Many(Vec<ChannelId>),
32}
33
34/// Status of a channel in the protocol lifecycle.
35///
36/// Replica channels start as `Pending` after pairing completes and transition
37/// to `Paired` once fingerprint verification succeeds. Helper/Owner channels
38/// are `Paired` immediately after pairing.
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
40#[cfg_attr(
41    any(feature = "serde", target_arch = "wasm32"),
42    derive(Serialize, Deserialize)
43)]
44pub enum ChannelStatus {
45    /// Channel is awaiting fingerprint verification (replica only).
46    Pending,
47    /// Channel is fully paired and ready for protocol messages.
48    #[default]
49    Paired,
50}
51
52/// A channel — the post-pairing representation of a peer.
53///
54/// Stored by [`crate::protocol::DeRecChannelStore`] and returned by its
55/// `channels()` method.
56///
57/// `Serialize` / `Deserialize` are derived for the FFI and WASM bridges,
58/// which ship channels to host languages as JSON over the language
59/// boundary. Library consumers writing a Rust `DeRecChannelStore` see
60/// only the typed value and never observe the serde representation;
61/// the wire format is not part of the public API and may change
62/// independently. `#[serde(default)]` annotations let bridges decode
63/// legacy bytes that predate later-added fields without erroring.
64#[derive(Clone, Debug)]
65#[cfg_attr(
66    any(feature = "serde", target_arch = "wasm32"),
67    derive(Serialize, Deserialize)
68)]
69pub struct Channel {
70    /// Unique identifier for this channel.
71    pub id: ChannelId,
72    /// The peer's transport endpoint.
73    pub transport: derec_proto::TransportProtocol,
74    /// Application-level identity metadata for the peer on this channel.
75    ///
76    /// Free-form key/value pairs — the protocol treats this as opaque and
77    /// never inspects keys or values. Anything an app wants to remember
78    /// about *who* is on the other end (display name, account id, avatar
79    /// URI, ...) lives here. App-level identity logic (e.g. auto-linking
80    /// by display name) reads from this map; the protocol does not.
81    ///
82    /// On the initiator side, this is whatever the caller supplied when
83    /// starting [`crate::protocol::DeRecFlow::Pairing`]. On the responder
84    /// side, it is the peer's own `communication_info` extracted from the
85    /// wire pair-request — the same map that surfaces in
86    /// [`crate::protocol::DeRecEvent::PairingCompleted::peer_communication_info`].
87    #[cfg_attr(any(feature = "serde", target_arch = "wasm32"), serde(default))]
88    pub communication_info: std::collections::HashMap<String, String>,
89    /// Lifecycle status. Messages on `Pending` channels are ignored.
90    #[cfg_attr(any(feature = "serde", target_arch = "wasm32"), serde(default))]
91    pub status: ChannelStatus,
92    /// Unix timestamp (seconds) when the channel was created.
93    #[cfg_attr(any(feature = "serde", target_arch = "wasm32"), serde(default))]
94    pub created_at: u64,
95    /// This node's role on this channel, fixed at pairing time.
96    ///
97    /// The orchestrator enforces flow directionality against this value: an
98    /// `Owner` may initiate `ProtectSecret` / `VerifyShares` / `Discovery` /
99    /// `RecoverSecret`; a `Helper` may not. Inbound messages are gated the
100    /// other way around — a `StoreShareRequest` is only honored on a channel
101    /// where this node is the `Helper`, and so on.
102    pub role: derec_proto::SenderKind,
103    /// The peer's replica identity, populated only when `role` is
104    /// `ReplicaSource` or `ReplicaDestination`.
105    ///
106    /// Extracted from the peer's `derec.replica_id` entry in
107    /// `CommunicationInfo` during the pair handshake. `None` on
108    /// Helper/Owner channels and as a defensive default on
109    /// freshly-paired Replica channels where the peer did not advertise
110    /// one.
111    #[cfg_attr(any(feature = "serde", target_arch = "wasm32"), serde(default))]
112    pub replica_id: Option<u64>,
113}
114
115/// Per-helper metadata stored inside the secret bag for recovery.
116///
117/// Each entry records the pairing state of a Helper so that recovery can
118/// re-establish communication channels without external configuration.
119#[derive(Clone, PartialEq, ::prost::Message)]
120pub struct HelperInfo {
121    /// Unique channel identifier assigned during pairing.
122    #[prost(uint64, tag = "1")]
123    pub channel_id: u64,
124    /// The Helper's message endpoint URI.
125    #[prost(string, tag = "2")]
126    pub transport_uri: ::prost::alloc::string::String,
127    /// Symmetric key negotiated during pairing (32 bytes).
128    #[prost(bytes = "vec", tag = "4")]
129    pub shared_key: ::prost::alloc::vec::Vec<u8>,
130    /// App-level identity metadata for this helper. Free-form key/value
131    /// pairs — the protocol treats it as opaque, never inspects keys or
132    /// values, and copies it verbatim from [`Channel::communication_info`]
133    /// at protect-time. A recovering owner who decodes the bag can use
134    /// this to recognise each helper (e.g. by a `"name"` key the app set
135    /// on pairing).
136    ///
137    /// **Wire stability**: the now-removed `name: String` was previously at
138    /// tag 3. Using tag 5 lets prost silently drop the old `name` field
139    /// when decoding legacy bags (empty `communication_info`), and lets
140    /// older codebases silently drop this new field when decoding new
141    /// bags. Degraded but not broken in either direction.
142    #[prost(map = "string, string", tag = "5")]
143    pub communication_info:
144        ::std::collections::HashMap<::prost::alloc::string::String, ::prost::alloc::string::String>,
145}
146
147/// A single user-facing secret within the bag.
148///
149/// The Owner can store multiple logical secrets (credentials, keys, notes)
150/// inside a single secret bag. Each `UserSecret` is independently
151/// identifiable so the application can present, add, or remove individual
152/// entries while the protocol treats the entire bag as one opaque blob.
153#[derive(Clone, PartialEq, ::prost::Message)]
154pub struct UserSecret {
155    /// Application-defined identifier.
156    #[prost(bytes = "vec", tag = "1")]
157    pub id: ::prost::alloc::vec::Vec<u8>,
158    /// Human-readable label.
159    #[prost(string, tag = "2")]
160    pub name: ::prost::alloc::string::String,
161    /// Raw secret bytes.
162    #[prost(bytes = "vec", tag = "3")]
163    pub data: ::prost::alloc::vec::Vec<u8>,
164}
165
166/// Snapshot of the user-facing secret contents persisted by
167/// [`crate::protocol::DeRecUserSecretStore`] for one `secret_id`.
168///
169/// Written every time the application calls
170/// `start(FlowKind::ProtectSecret)`; read by the pair-completion
171/// auto-publish hook so a freshly-paired Helper or Replica receives the
172/// current state without an explicit re-publish from the app.
173#[derive(Clone, Debug, PartialEq)]
174pub struct UserSecrets {
175    /// Secret version this snapshot represents. Monotonically increasing
176    /// per `secret_id` — the protocol bumps it on every publish.
177    pub version: u32,
178    /// User-facing secret entries. Same wire shape as
179    /// [`Secret::secrets`].
180    pub secrets: Vec<UserSecret>,
181    /// Optional human-readable label for this version, forwarded to
182    /// helpers in `StoreShareRequest.description`.
183    pub description: Option<String>,
184    /// Owner-side cached replica composite for this version, populated
185    /// after the VSS split completes. Lets the Owner resume future
186    /// `ProtectSecret` rounds without re-deriving share material, and
187    /// surfaces under [`Secret::replicas`] on the next snapshot rebuild.
188    /// `None` when this `secret_id` has no replica setup (or before
189    /// the first sharing round commits).
190    pub replicas: Option<Replicas>,
191}
192
193/// Per-replica metadata stored inside the [`Secret`] — mirrors
194/// [`HelperInfo`] but for the replica role and carries the extra
195/// `replica_id` + `sender_kind` fields needed by the replica model.
196///
197/// **No per-pair key**: all replica channels for a given `secret_id`
198/// converge on a single group key (see [`ReplicaSecretPayload::shared_key`]
199/// for how that key is handed off to a new joiner). Each replica's
200/// `(secret_id, channel_id)` entry in
201/// [`crate::protocol::DeRecSecretStore`] holds that same group key, so
202/// any replica can address any other replica's peers by loading the
203/// channel key from its own secret store — this struct does not need to
204/// carry it.
205#[derive(Clone, PartialEq, ::prost::Message)]
206pub struct ReplicaInfo {
207    /// Channel identifier the originator uses to address this peer.
208    #[prost(uint64, tag = "1")]
209    pub channel_id: u64,
210    /// The peer's message endpoint URI.
211    #[prost(string, tag = "2")]
212    pub transport_uri: ::prost::alloc::string::String,
213    /// App-level identity metadata for this peer. Same opacity contract
214    /// as [`HelperInfo::communication_info`].
215    #[prost(map = "string, string", tag = "4")]
216    pub communication_info:
217        ::std::collections::HashMap<::prost::alloc::string::String, ::prost::alloc::string::String>,
218    /// The peer's `replica_id` — global stable identity of the replica
219    /// device, separate from the per-channel `channel_id`.
220    #[prost(uint64, tag = "5")]
221    pub replica_id: u64,
222    /// Raw `SenderKind` value the peer played in this pair (typically
223    /// `REPLICA_SOURCE` or `REPLICA_DESTINATION`). Carried for future
224    /// conflict-resolution flows; not used by the protocol layer today.
225    #[prost(int32, tag = "6")]
226    pub sender_kind: i32,
227}
228
229/// The protocol's `secret` — serialized into `DeRecSecret.secret_data`.
230///
231/// This is the actual payload that gets protobuf-encoded, then placed into
232/// the `secret_data` bytes field of the canonical `DeRecSecret` protobuf
233/// message before encryption and distribution. Matches the DeRec
234/// specification's `secret` term (distinct from a `UserSecret` entry,
235/// which is one application-defined item *inside* this struct).
236#[derive(Clone, PartialEq, ::prost::Message)]
237pub struct Secret {
238    /// Snapshot of all paired Helpers at the time of distribution.
239    #[prost(message, repeated, tag = "1")]
240    pub helpers: ::prost::alloc::vec::Vec<HelperInfo>,
241    /// The user-facing secrets the Owner wishes to protect.
242    #[prost(message, repeated, tag = "2")]
243    pub secrets: ::prost::alloc::vec::Vec<UserSecret>,
244    /// Replica composite: the destination peers, the per-helper share
245    /// map, and the group key. `None` when this `secret_id` has no
246    /// replica setup. See [`Replicas`] for field semantics.
247    #[prost(message, optional, tag = "3")]
248    pub replicas: ::core::option::Option<Replicas>,
249    /// The `replica_id` of the device that created or last updated this
250    /// version of the secret. Used by Destinations to attribute origin
251    /// and will drive future conflict-resolution logic.
252    #[prost(uint64, tag = "4")]
253    pub owner_replica_id: u64,
254}
255
256/// Replica composite carried inside [`Secret`] — the destination
257/// roster + the 32-byte group key shared by every replica channel.
258///
259/// The per-helper share map is *not* part of this composite: VSS
260/// shares are derived from the encoded `Secret` bytes and so cannot
261/// be embedded inside the `Secret` itself. The wire-level share map
262/// rides on [`ReplicaSecretPayload`] alongside the encoded `Secret`
263/// instead.
264///
265/// `shared_key` must be 32 bytes when [`Self::replicas`] is
266/// non-empty. The library enforces this invariant on the producer
267/// side during sharing round construction and on the consumer side in
268/// [`crate::protocol::DeRecProtocol::restore`].
269#[derive(Clone, PartialEq, ::prost::Message)]
270pub struct Replicas {
271    /// Snapshot of all paired Replica Destinations at protect time.
272    #[prost(message, repeated, tag = "1")]
273    pub replicas: ::prost::alloc::vec::Vec<ReplicaInfo>,
274    /// 32-byte replica group key.
275    #[prost(bytes = "vec", tag = "2")]
276    pub shared_key: ::prost::alloc::vec::Vec<u8>,
277}
278
279/// A single helper's share of the current secret bag — wire-pairs a
280/// `channel_id` with the serialized `CommittedDeRecShare` bytes that
281/// were sent to that helper. Part of [`ReplicaSecretPayload`].
282#[derive(Clone, PartialEq, ::prost::Message)]
283pub struct ChannelShare {
284    /// Channel id of the helper that holds this share.
285    #[prost(uint64, tag = "1")]
286    pub channel_id: u64,
287    /// Serialized `CommittedDeRecShare` bytes — the same payload the
288    /// helper received in their `StoreShareRequest`.
289    #[prost(bytes = "vec", tag = "2")]
290    pub committed_share: ::prost::alloc::vec::Vec<u8>,
291}
292
293/// The composite payload sent to each Replica Destination on a
294/// `ProtectSecret` round. Carries the full [`Secret`] plus the map of
295/// `(channel_id → committed_share)` for the same round, so the
296/// Destination can recover via either path — read the secret directly,
297/// or contact each helper using `secret.helpers[i].shared_key` and
298/// request their stored share.
299///
300/// # Group-key handover
301///
302/// All replica channels for a given `secret_id` converge on a single
303/// symmetric "group" key. The `shared_key` field carries that group key
304/// inside the encrypted payload **only** when the sender knows the
305/// receiver doesn't have it yet — i.e. on the first round to a newly
306/// paired Destination. Both sides swap their stored channel key
307/// (`(secret_id, channel_id)` in [`crate::protocol::DeRecSecretStore`])
308/// from the per-pair ephemeral handshake key to the group key:
309///
310/// - **Sender**: swap immediately after the request envelope is sent.
311///   The ack response from the new joiner will already be encrypted
312///   with the group key.
313/// - **Receiver**: swap before encrypting the ack response, so the
314///   ack uses the group key and matches what the sender expects.
315///
316/// On the first-ever replica pair, the group key is implicitly the
317/// pair-handshake key — `shared_key` is left empty, no swap happens,
318/// and the channel-key entry both sides already saved is the group key.
319#[derive(Clone, PartialEq, ::prost::Message)]
320pub struct ReplicaSecretPayload {
321    /// The full secret the sender is committing to this version.
322    #[prost(message, optional, tag = "1")]
323    pub secret: ::core::option::Option<Secret>,
324    /// One entry per helper that received a VSS share on this round.
325    #[prost(message, repeated, tag = "2")]
326    pub shares: ::prost::alloc::vec::Vec<ChannelShare>,
327    /// 32-byte replica-group key. Present only on the first-sync round
328    /// to a newly-paired Destination; empty on every subsequent round
329    /// (since the receiving channel already holds the group key) and
330    /// empty when the receiving Destination is the very first pair for
331    /// this `secret_id` (the pair-handshake key is implicitly the group
332    /// key). See type-level docs for the swap protocol.
333    #[prost(bytes = "vec", tag = "3")]
334    pub shared_key: ::prost::alloc::vec::Vec<u8>,
335}
336
337/// Kind of secret material stored by [`crate::protocol::DeRecSecretStore`].
338///
339/// Each variant has its own lifecycle (see per-variant docs). Used as the
340/// `kind` argument to [`crate::protocol::DeRecSecretStore::load`] and
341/// [`crate::protocol::DeRecSecretStore::remove`]; on
342/// [`crate::protocol::DeRecSecretStore::save`] the kind is inferred from
343/// the [`SecretValue`] variant and need not be passed.
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub enum SecretKind {
346    /// The post-pairing symmetric channel key (see [`SecretValue::SharedKey`]).
347    SharedKey = 0,
348    /// The ephemeral ECIES / ML-KEM key material used during pairing.
349    PairingSecret = 1,
350    /// The initiator's [`ContactMessage`] stored transiently between
351    /// `start` and pairing completion. Removed once the shared key
352    /// is derived.
353    PairingContact = 2,
354}
355
356/// How [`crate::protocol::DeRecSecretStore::load_many`] handles channels
357/// with no stored secret of the requested [`SecretKind`].
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
359pub enum MissingPolicy {
360    /// Silently drop missing channels from the returned vector.
361    ///
362    /// Use when missing entries are an expected outcome — e.g. a `Target::Many`
363    /// list that mixes paired and unpaired channels.
364    Skip,
365    /// Return [`crate::protocol::SecretStoreError::MissingEntries`] carrying
366    /// the channel ids that had no entry.
367    ///
368    /// Use when every input id is expected to have an entry — e.g. after
369    /// filtering to channels already known to
370    /// [`crate::protocol::DeRecChannelStore`]. A miss signals a cross-store
371    /// invariant violation.
372    Fail,
373}
374
375/// Opaque, serialized pairing key material as held by
376/// [`crate::protocol::DeRecSecretStore`] under [`SecretValue::PairingSecret`].
377///
378/// This is the store-boundary form of the ephemeral key pair the pairing
379/// handshake produces. The protocol deliberately exposes it as an opaque
380/// byte blob rather than a cryptographic type, so a store implementation can
381/// persist and reload it without depending on `derec-cryptography` or any
382/// serialization framework: call [`as_bytes`](Self::as_bytes) to obtain the
383/// bytes to persist on `save`, and hand the same bytes back to
384/// [`from_bytes`](Self::from_bytes) on `load`.
385///
386/// The byte layout is a library-internal detail, is not part of the public
387/// API, and may change between versions; treat the blob as opaque and never
388/// interpret it.
389///
390/// The bytes are held in [`zeroize::Zeroizing`] so the plaintext key material
391/// is wiped from memory on drop.
392#[derive(Clone)]
393pub struct PairingKeyMaterial(Zeroizing<Vec<u8>>);
394
395impl PairingKeyMaterial {
396    /// Wrap raw bytes previously obtained from [`as_bytes`](Self::as_bytes)
397    /// and persisted by a store.
398    pub fn from_bytes(bytes: Vec<u8>) -> Self {
399        Self(Zeroizing::new(bytes))
400    }
401
402    /// The opaque bytes to persist. Round-trips through
403    /// [`from_bytes`](Self::from_bytes).
404    pub fn as_bytes(&self) -> &[u8] {
405        self.0.as_slice()
406    }
407
408    /// Serialize live pairing secret key material into the store-boundary
409    /// form.
410    ///
411    /// Library-internal: the pairing handlers call this before handing the
412    /// value to the secret store, keeping the `ark-serialize` encoding an
413    /// implementation detail that never crosses the public API.
414    pub(crate) fn from_secret(material: &PairingSecretKeyMaterial) -> Self {
415        use ark_serialize::CanonicalSerialize as _;
416        let mut buf = Vec::with_capacity(material.compressed_size());
417        material
418            .serialize_compressed(&mut buf)
419            .expect("ark serialization of PairingSecretKeyMaterial is infallible");
420        Self(Zeroizing::new(buf))
421    }
422
423    /// Reconstruct live pairing secret key material from the store-boundary
424    /// form.
425    ///
426    /// Library-internal: the pairing handlers call this after loading the
427    /// value from the secret store. A decode failure means the persisted
428    /// bytes were corrupted or truncated, which is an internal invariant
429    /// violation rather than valid caller input.
430    pub(crate) fn to_secret(&self) -> crate::Result<PairingSecretKeyMaterial> {
431        use ark_serialize::CanonicalDeserialize as _;
432        PairingSecretKeyMaterial::deserialize_compressed(self.0.as_slice())
433            .map_err(|_| crate::Error::Invariant("stored PairingSecret bytes failed to decode"))
434    }
435}
436
437#[cfg(feature = "serde")]
438impl Serialize for PairingKeyMaterial {
439    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
440    where
441        S: serde::Serializer,
442    {
443        self.0.as_slice().serialize(serializer)
444    }
445}
446
447#[cfg(feature = "serde")]
448impl<'de> Deserialize<'de> for PairingKeyMaterial {
449    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
450    where
451        D: serde::Deserializer<'de>,
452    {
453        let bytes = Vec::<u8>::deserialize(deserializer)?;
454        Ok(Self(Zeroizing::new(bytes)))
455    }
456}
457
458/// Serde adapter for the prost [`ContactMessage`] carried by
459/// [`SecretValue::PairingContact`]. prost messages have no native serde
460/// support, so the value is (de)serialized through its canonical protobuf
461/// byte encoding.
462#[cfg(feature = "serde")]
463mod contact_serde {
464    use super::ContactMessage;
465    use prost::Message as _;
466    use serde::{Deserialize as _, Deserializer, Serialize as _, Serializer};
467
468    pub(super) fn serialize<S>(contact: &ContactMessage, serializer: S) -> Result<S::Ok, S::Error>
469    where
470        S: Serializer,
471    {
472        contact.encode_to_vec().serialize(serializer)
473    }
474
475    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<ContactMessage, D::Error>
476    where
477        D: Deserializer<'de>,
478    {
479        let bytes = Vec::<u8>::deserialize(deserializer)?;
480        ContactMessage::decode(bytes.as_slice()).map_err(serde::de::Error::custom)
481    }
482}
483
484/// The payload returned by [`crate::protocol::DeRecSecretStore::load`] and
485/// passed to [`crate::protocol::DeRecSecretStore::save`].
486///
487/// Variants are 1:1 with [`SecretKind`].
488///
489/// With the `serde` feature enabled, `Serialize` / `Deserialize` are
490/// derived so a store implementation can persist an entry with any serde
491/// format instead of hand-rolling a codec. This is an alternative to the
492/// byte-level accessors on the individual payloads (e.g.
493/// [`PairingKeyMaterial::as_bytes`] / [`PairingKeyMaterial::from_bytes`]);
494/// implementors pick whichever fits their backend, and consumers that do
495/// not use serde pay no dependency for it. The serde wire format is not
496/// part of the public API and may change independently.
497#[derive(Clone)]
498#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
499pub enum SecretValue {
500    /// The post-pairing symmetric channel key. Established by pairing and used
501    /// to authenticate and encrypt every subsequent message on the channel.
502    SharedKey(crate::types::SharedKey),
503    /// The ephemeral ECIES / ML-KEM key material created by `start` and
504    /// consumed when the pairing response arrives. Removed once the shared
505    /// key is derived. Held as an opaque [`PairingKeyMaterial`] blob so
506    /// store implementors never touch a cryptography primitive.
507    PairingSecret(PairingKeyMaterial),
508    /// The initiator's [`ContactMessage`], needed by
509    /// [`crate::primitives::pairing::response::process`] to derive the shared
510    /// key. Ephemeral — removed after pairing completes.
511    PairingContact(#[cfg_attr(feature = "serde", serde(with = "contact_serde"))] ContactMessage),
512}
513
514/// Tag identifying which kind of in-flight orchestrator state an entry in
515/// [`crate::protocol::DeRecStateStore`] holds. Used by
516/// [`crate::protocol::DeRecStateStore::load_all`] to filter by category.
517#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
518pub enum StateKind {
519    /// Outstanding [`derec_proto::VerifyShareRequestMessage`], one per
520    /// channel. Load-bearing for the replay-defence binding gate.
521    PendingVerification,
522    /// Recovery accumulator, one per `(secret_id, version)`. Holds every
523    /// [`derec_proto::GetShareResponseMessage`] received so far for that
524    /// reconstruction target.
525    PendingRecovery,
526    /// Outstanding unpair acknowledgement, one per channel. Carries the
527    /// `started_at` unix-seconds timestamp so the orchestrator can time
528    /// out unresponsive peers.
529    PendingUnpair,
530    /// Active sharing round. At most one entry exists per `secret_id`
531    /// (a new `start(ProtectSecret)` overwrites any prior round). Holds
532    /// the per-channel tallies (`pending` / `confirmed` / `failed`) and
533    /// the `started_at` timestamp used to time out unresponsive helpers.
534    SharingRound,
535}
536
537/// Secondary-key selector identifying a single row within a given
538/// [`StateKind`] under a `secret_id`. Passed to
539/// [`crate::protocol::DeRecStateStore::load`] and
540/// [`crate::protocol::DeRecStateStore::remove`].
541#[derive(Debug, Clone, PartialEq, Eq, Hash)]
542pub enum StateKey {
543    /// Row is scoped to one channel.
544    PendingVerification { channel_id: ChannelId },
545    /// Row is scoped to one reconstruction target.
546    PendingRecovery { version: u32 },
547    /// Row is scoped to one channel.
548    PendingUnpair { channel_id: ChannelId },
549    /// At most one row per `secret_id`. No secondary key.
550    SharingRound,
551}
552
553impl StateKey {
554    /// The [`StateKind`] this key selects. Used by store implementations
555    /// that persist rows in a `(secret_id, kind, secondary_key)` schema.
556    pub fn kind(&self) -> StateKind {
557        match self {
558            StateKey::PendingVerification { .. } => StateKind::PendingVerification,
559            StateKey::PendingRecovery { .. } => StateKind::PendingRecovery,
560            StateKey::PendingUnpair { .. } => StateKind::PendingUnpair,
561            StateKey::SharingRound => StateKind::SharingRound,
562        }
563    }
564}
565
566/// The payload of one row in the [`crate::protocol::DeRecStateStore`].
567///
568/// # Write pattern
569///
570/// The library treats [`crate::protocol::DeRecStateStore::save`] as
571/// **full-replacement upsert** — there is no per-item merge or append
572/// semantic at the store level. Accumulator-style state
573/// ([`StateItem::PendingRecovery`] and [`StateItem::SharingRound`]) grows
574/// via load-modify-save cycles from the library. Backends do not need to
575/// implement any append primitive; a naive replace-on-save is correct.
576#[derive(Debug, Clone)]
577pub enum StateItem {
578    /// The full outstanding [`derec_proto::VerifyShareRequestMessage`] the
579    /// orchestrator sent for this channel. Retained so the corresponding
580    /// inbound [`derec_proto::VerifyShareResponseMessage`] can be validated
581    /// against the exact `(nonce, secret_id, version)` triple that was
582    /// minted at request time.
583    ///
584    /// Overwritten in place by a subsequent `save` for the same
585    /// `(secret_id, channel_id)`; the most recent challenge wins.
586    PendingVerification {
587        channel_id: ChannelId,
588        request: derec_proto::VerifyShareRequestMessage,
589    },
590
591    /// Accumulator for one in-progress recovery target.
592    ///
593    /// The library writes this variant one share at a time as each inbound
594    /// [`derec_proto::GetShareResponseMessage`] arrives. The write sequence
595    /// under a single `(secret_id, version)` is:
596    ///
597    /// 1. First response arrives. Library calls `save` with a `shares`
598    ///    vector containing exactly one element.
599    /// 2. Second response arrives. Library `load`s the accumulator,
600    ///    appends the new share to the returned Vec, and `save`s the
601    ///    grown Vec back.
602    /// 3. …repeat until threshold. On threshold met, library `remove`s
603    ///    the accumulator.
604    ///
605    /// Implementations MUST accept `shares` vectors of any length,
606    /// including one. Every `save` replaces the stored value in place
607    /// with the caller-supplied Vec; no append primitive is required.
608    ///
609    /// # Concurrency
610    ///
611    /// See [`crate::protocol::DeRecStateStore`] for the multi-instance
612    /// concurrency contract. Concurrent inbound shares racing on the same
613    /// accumulator will clobber each other via a naive load-modify-save;
614    /// the application layer is responsible for serializing concurrent
615    /// `process()` calls that touch the same `(secret_id, version)` if
616    /// this matters.
617    PendingRecovery {
618        version: u32,
619        shares: Vec<derec_proto::GetShareResponseMessage>,
620    },
621
622    /// Outstanding unpair acknowledgement window. `started_at` is the
623    /// unix-seconds timestamp stamped when the request was sent; the
624    /// orchestrator sweeps expired entries via
625    /// [`crate::protocol::DeRecStateStore::load_all`].
626    PendingUnpair {
627        channel_id: ChannelId,
628        started_at: u64,
629    },
630
631    /// Active sharing round for `secret_id`. Created by
632    /// `start(ProtectSecret)` and cleared by the orchestrator once every
633    /// targeted helper has responded (confirmed, rejected, or timed
634    /// out). At most one entry exists per `secret_id`; a fresh
635    /// `start(ProtectSecret)` overwrites any prior in-flight round.
636    ///
637    /// `pending` / `confirmed` / `failed` partition the round's target
638    /// channels; the union is invariant across the round's lifetime.
639    /// `started_at` is the unix-seconds timestamp used to time out
640    /// unresponsive helpers.
641    SharingRound {
642        version: u32,
643        pending: std::collections::HashSet<ChannelId>,
644        confirmed: std::collections::HashSet<ChannelId>,
645        failed: std::collections::HashSet<ChannelId>,
646        started_at: u64,
647    },
648}
649
650impl StateItem {
651    /// The [`StateKind`] this item is an instance of.
652    pub fn kind(&self) -> StateKind {
653        match self {
654            StateItem::PendingVerification { .. } => StateKind::PendingVerification,
655            StateItem::PendingRecovery { .. } => StateKind::PendingRecovery,
656            StateItem::PendingUnpair { .. } => StateKind::PendingUnpair,
657            StateItem::SharingRound { .. } => StateKind::SharingRound,
658        }
659    }
660
661    /// The [`StateKey`] identifying this item within its `(secret_id, kind)`
662    /// partition. Convenience so callers don't have to hand-construct a
663    /// key that matches the payload.
664    pub fn key(&self) -> StateKey {
665        match self {
666            StateItem::PendingVerification { channel_id, .. } => StateKey::PendingVerification {
667                channel_id: *channel_id,
668            },
669            StateItem::PendingRecovery { version, .. } => {
670                StateKey::PendingRecovery { version: *version }
671            }
672            StateItem::PendingUnpair { channel_id, .. } => StateKey::PendingUnpair {
673                channel_id: *channel_id,
674            },
675            StateItem::SharingRound { .. } => StateKey::SharingRound,
676        }
677    }
678}
679
680/// A single stored share entry, fully self-describing.
681#[derive(Debug, Clone)]
682pub struct Share {
683    /// Numeric identifier of the secret this share belongs to.
684    pub secret_id: u64,
685    /// Version number of the secret.
686    pub version: u32,
687    /// Stable per-device identifier of the replica that produced this
688    /// share, copied from
689    /// [`derec_proto::StoreShareRequestMessage::replica_id`] when the
690    /// helper persisted the write.
691    ///
692    /// `None` when the writer was a non-replica `Owner`. `Some(id)` when
693    /// the writer was `ReplicaSource`. Two distinct replicas may produce
694    /// the same `(secret_id, channel_id, version)` independently
695    /// — see [`crate::protocol::DeRecShareStore::save`] for the
696    /// conceptual storage key and the disambiguation contract.
697    pub replica_id: Option<u64>,
698    /// Opaque protobuf bytes — see [`crate::protocol::DeRecShareStore`] for
699    /// the per-side format.
700    pub bytes: Vec<u8>,
701}
702
703#[cfg(all(test, feature = "serde"))]
704mod tests {
705    use super::*;
706
707    /// Every `SecretValue` variant round-trips through serde — the path a
708    /// store implementation takes when it opts for serde over the
709    /// byte-level accessors. Exercises the custom `PairingKeyMaterial`
710    /// impls and the prost `ContactMessage` adapter.
711    #[test]
712    fn secret_value_serde_round_trips_all_variants() {
713        let cases = [
714            SecretValue::SharedKey([7u8; 32]),
715            SecretValue::PairingSecret(PairingKeyMaterial::from_bytes(vec![1, 2, 3, 4, 5])),
716            SecretValue::PairingContact(ContactMessage {
717                nonce: 42,
718                ..Default::default()
719            }),
720        ];
721
722        for value in cases {
723            let json = serde_json::to_vec(&value).expect("serialize");
724            let decoded: SecretValue = serde_json::from_slice(&json).expect("deserialize");
725            match (&value, &decoded) {
726                (SecretValue::SharedKey(a), SecretValue::SharedKey(b)) => assert_eq!(a, b),
727                (SecretValue::PairingSecret(a), SecretValue::PairingSecret(b)) => {
728                    assert_eq!(a.as_bytes(), b.as_bytes())
729                }
730                (SecretValue::PairingContact(a), SecretValue::PairingContact(b)) => {
731                    assert_eq!(a, b)
732                }
733                _ => panic!("variant changed across serde round-trip"),
734            }
735        }
736    }
737
738    /// The serde form and the `from_bytes`/`as_bytes` form describe the
739    /// same opaque blob, so a value serialized one way decodes the other.
740    #[test]
741    fn pairing_key_material_serde_matches_byte_accessors() {
742        let material = PairingKeyMaterial::from_bytes(vec![9, 8, 7, 6]);
743        let json = serde_json::to_vec(&material).expect("serialize");
744        let decoded: PairingKeyMaterial = serde_json::from_slice(&json).expect("deserialize");
745        assert_eq!(material.as_bytes(), decoded.as_bytes());
746    }
747}