derec-library 0.0.1-alpha.8

Rust SDK for the DeRec protocol, including native and WebAssembly bindings.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 DeRec Alliance. All rights reserved.

//! Protocol-layer types.
//!
//! Everything in this module is "post-pairing" — the channel and store
//! shapes the orchestrator manages once a pair handshake completes. The
//! primitives layer never touches these (it operates on raw wire bytes
//! and the cross-layer [`crate::types::ChannelId`] / [`crate::types::SharedKey`]
//! aliases).
//!
//! Re-exported at [`crate::protocol`] for ergonomic access, so callers
//! can write `use derec_library::protocol::Channel;` rather than
//! `use derec_library::protocol::types::Channel;`.

use crate::types::ChannelId;
use derec_cryptography::pairing::PairingSecretKeyMaterial;
use derec_proto::ContactMessage;
#[cfg(any(feature = "serde", target_arch = "wasm32"))]
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;

/// Selects which channels to target for a discovery request.
#[derive(Debug, Clone)]
pub enum Target {
    /// Send to all paired channels (most common case).
    All,
    /// Send to a single channel.
    Single(ChannelId),
    /// Send to a specific set of channels.
    Many(Vec<ChannelId>),
}

/// Status of a channel in the protocol lifecycle.
///
/// Replica channels start as `Pending` after pairing completes and transition
/// to `Paired` once fingerprint verification succeeds. Helper/Owner channels
/// are `Paired` immediately after pairing.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
#[cfg_attr(
    any(feature = "serde", target_arch = "wasm32"),
    derive(Serialize, Deserialize)
)]
pub enum ChannelStatus {
    /// Channel is awaiting fingerprint verification (replica only).
    Pending,
    /// Channel is fully paired and ready for protocol messages.
    #[default]
    Paired,
}

/// A channel — the post-pairing representation of a peer.
///
/// Stored by [`crate::protocol::DeRecChannelStore`] and returned by its
/// `channels()` method.
///
/// `Serialize` / `Deserialize` are derived for the FFI and WASM bridges,
/// which ship channels to host languages as JSON over the language
/// boundary. Library consumers writing a Rust `DeRecChannelStore` see
/// only the typed value and never observe the serde representation;
/// the wire format is not part of the public API and may change
/// independently. `#[serde(default)]` annotations let bridges decode
/// legacy bytes that predate later-added fields without erroring.
#[derive(Clone, Debug)]
#[cfg_attr(
    any(feature = "serde", target_arch = "wasm32"),
    derive(Serialize, Deserialize)
)]
pub struct Channel {
    /// Unique identifier for this channel.
    pub id: ChannelId,
    /// The peer's transport endpoint.
    pub transport: derec_proto::TransportProtocol,
    /// Application-level identity metadata for the peer on this channel.
    ///
    /// Free-form key/value pairs — the protocol treats this as opaque and
    /// never inspects keys or values. Anything an app wants to remember
    /// about *who* is on the other end (display name, account id, avatar
    /// URI, ...) lives here. App-level identity logic (e.g. auto-linking
    /// by display name) reads from this map; the protocol does not.
    ///
    /// On the initiator side, this is whatever the caller supplied when
    /// starting [`crate::protocol::DeRecFlow::Pairing`]. On the responder
    /// side, it is the peer's own `communication_info` extracted from the
    /// wire pair-request — the same map that surfaces in
    /// [`crate::protocol::DeRecEvent::PairingCompleted::peer_communication_info`].
    #[cfg_attr(any(feature = "serde", target_arch = "wasm32"), serde(default))]
    pub communication_info: std::collections::HashMap<String, String>,
    /// Lifecycle status. Messages on `Pending` channels are ignored.
    #[cfg_attr(any(feature = "serde", target_arch = "wasm32"), serde(default))]
    pub status: ChannelStatus,
    /// Unix timestamp (seconds) when the channel was created.
    #[cfg_attr(any(feature = "serde", target_arch = "wasm32"), serde(default))]
    pub created_at: u64,
    /// This node's role on this channel, fixed at pairing time.
    ///
    /// The orchestrator enforces flow directionality against this value: an
    /// `Owner` may initiate `ProtectSecret` / `VerifyShares` / `Discovery` /
    /// `RecoverSecret`; a `Helper` may not. Inbound messages are gated the
    /// other way around — a `StoreShareRequest` is only honored on a channel
    /// where this node is the `Helper`, and so on.
    pub role: derec_proto::SenderKind,
    /// The peer's replica identity, populated only when `role` is
    /// `ReplicaSource` or `ReplicaDestination`.
    ///
    /// Extracted from the peer's `derec.replica_id` entry in
    /// `CommunicationInfo` during the pair handshake. `None` on
    /// Helper/Owner channels and as a defensive default on
    /// freshly-paired Replica channels where the peer did not advertise
    /// one.
    #[cfg_attr(any(feature = "serde", target_arch = "wasm32"), serde(default))]
    pub replica_id: Option<u64>,
}

/// Per-helper metadata stored inside the secret bag for recovery.
///
/// Each entry records the pairing state of a Helper so that recovery can
/// re-establish communication channels without external configuration.
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct HelperInfo {
    /// Unique channel identifier assigned during pairing.
    #[prost(uint64, tag = "1")]
    pub channel_id: u64,
    /// The Helper's message endpoint URI.
    #[prost(string, tag = "2")]
    pub transport_uri: ::prost::alloc::string::String,
    /// Symmetric key negotiated during pairing (32 bytes).
    #[prost(bytes = "vec", tag = "4")]
    pub shared_key: ::prost::alloc::vec::Vec<u8>,
    /// App-level identity metadata for this helper. Free-form key/value
    /// pairs — the protocol treats it as opaque, never inspects keys or
    /// values, and copies it verbatim from [`Channel::communication_info`]
    /// at protect-time. A recovering owner who decodes the bag can use
    /// this to recognise each helper (e.g. by a `"name"` key the app set
    /// on pairing).
    ///
    /// **Wire stability**: the now-removed `name: String` was previously at
    /// tag 3. Using tag 5 lets prost silently drop the old `name` field
    /// when decoding legacy bags (empty `communication_info`), and lets
    /// older codebases silently drop this new field when decoding new
    /// bags. Degraded but not broken in either direction.
    #[prost(map = "string, string", tag = "5")]
    pub communication_info:
        ::std::collections::HashMap<::prost::alloc::string::String, ::prost::alloc::string::String>,
}

/// A single user-facing secret within the bag.
///
/// The Owner can store multiple logical secrets (credentials, keys, notes)
/// inside a single secret bag. Each `UserSecret` is independently
/// identifiable so the application can present, add, or remove individual
/// entries while the protocol treats the entire bag as one opaque blob.
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct UserSecret {
    /// Application-defined identifier.
    #[prost(bytes = "vec", tag = "1")]
    pub id: ::prost::alloc::vec::Vec<u8>,
    /// Human-readable label.
    #[prost(string, tag = "2")]
    pub name: ::prost::alloc::string::String,
    /// Raw secret bytes.
    #[prost(bytes = "vec", tag = "3")]
    pub data: ::prost::alloc::vec::Vec<u8>,
}

/// Snapshot of the user-facing secret contents persisted by
/// [`crate::protocol::DeRecUserSecretStore`] for one `secret_id`.
///
/// Written every time the application calls
/// `start(FlowKind::ProtectSecret)`; read by the pair-completion
/// auto-publish hook so a freshly-paired Helper or Replica receives the
/// current state without an explicit re-publish from the app.
#[derive(Clone, Debug, PartialEq)]
pub struct UserSecrets {
    /// Secret version this snapshot represents. Monotonically increasing
    /// per `secret_id` — the protocol bumps it on every publish.
    pub version: u32,
    /// User-facing secret entries. Same wire shape as
    /// [`Secret::secrets`].
    pub secrets: Vec<UserSecret>,
    /// Optional human-readable label for this version, forwarded to
    /// helpers in `StoreShareRequest.description`.
    pub description: Option<String>,
    /// Owner-side cached replica composite for this version, populated
    /// after the VSS split completes. Lets the Owner resume future
    /// `ProtectSecret` rounds without re-deriving share material, and
    /// surfaces under [`Secret::replicas`] on the next snapshot rebuild.
    /// `None` when this `secret_id` has no replica setup (or before
    /// the first sharing round commits).
    pub replicas: Option<Replicas>,
}

/// Per-replica metadata stored inside the [`Secret`] — mirrors
/// [`HelperInfo`] but for the replica role and carries the extra
/// `replica_id` + `sender_kind` fields needed by the replica model.
///
/// **No per-pair key**: all replica channels for a given `secret_id`
/// converge on a single group key (see [`ReplicaSecretPayload::shared_key`]
/// for how that key is handed off to a new joiner). Each replica's
/// `(secret_id, channel_id)` entry in
/// [`crate::protocol::DeRecSecretStore`] holds that same group key, so
/// any replica can address any other replica's peers by loading the
/// channel key from its own secret store — this struct does not need to
/// carry it.
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ReplicaInfo {
    /// Channel identifier the originator uses to address this peer.
    #[prost(uint64, tag = "1")]
    pub channel_id: u64,
    /// The peer's message endpoint URI.
    #[prost(string, tag = "2")]
    pub transport_uri: ::prost::alloc::string::String,
    /// App-level identity metadata for this peer. Same opacity contract
    /// as [`HelperInfo::communication_info`].
    #[prost(map = "string, string", tag = "4")]
    pub communication_info:
        ::std::collections::HashMap<::prost::alloc::string::String, ::prost::alloc::string::String>,
    /// The peer's `replica_id` — global stable identity of the replica
    /// device, separate from the per-channel `channel_id`.
    #[prost(uint64, tag = "5")]
    pub replica_id: u64,
    /// Raw `SenderKind` value the peer played in this pair (typically
    /// `REPLICA_SOURCE` or `REPLICA_DESTINATION`). Carried for future
    /// conflict-resolution flows; not used by the protocol layer today.
    #[prost(int32, tag = "6")]
    pub sender_kind: i32,
}

/// The protocol's `secret` — serialized into `DeRecSecret.secret_data`.
///
/// This is the actual payload that gets protobuf-encoded, then placed into
/// the `secret_data` bytes field of the canonical `DeRecSecret` protobuf
/// message before encryption and distribution. Matches the DeRec
/// specification's `secret` term (distinct from a `UserSecret` entry,
/// which is one application-defined item *inside* this struct).
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Secret {
    /// Snapshot of all paired Helpers at the time of distribution.
    #[prost(message, repeated, tag = "1")]
    pub helpers: ::prost::alloc::vec::Vec<HelperInfo>,
    /// The user-facing secrets the Owner wishes to protect.
    #[prost(message, repeated, tag = "2")]
    pub secrets: ::prost::alloc::vec::Vec<UserSecret>,
    /// Replica composite: the destination peers, the per-helper share
    /// map, and the group key. `None` when this `secret_id` has no
    /// replica setup. See [`Replicas`] for field semantics.
    #[prost(message, optional, tag = "3")]
    pub replicas: ::core::option::Option<Replicas>,
    /// The `replica_id` of the device that created or last updated this
    /// version of the secret. Used by Destinations to attribute origin
    /// and will drive future conflict-resolution logic.
    #[prost(uint64, tag = "4")]
    pub owner_replica_id: u64,
}

/// Replica composite carried inside [`Secret`] — the destination
/// roster + the 32-byte group key shared by every replica channel.
///
/// The per-helper share map is *not* part of this composite: VSS
/// shares are derived from the encoded `Secret` bytes and so cannot
/// be embedded inside the `Secret` itself. The wire-level share map
/// rides on [`ReplicaSecretPayload`] alongside the encoded `Secret`
/// instead.
///
/// `shared_key` must be 32 bytes when [`Self::replicas`] is
/// non-empty. The library enforces this invariant on the producer
/// side during sharing round construction and on the consumer side in
/// [`crate::protocol::DeRecProtocol::restore`].
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Replicas {
    /// Snapshot of all paired Replica Destinations at protect time.
    #[prost(message, repeated, tag = "1")]
    pub replicas: ::prost::alloc::vec::Vec<ReplicaInfo>,
    /// 32-byte replica group key.
    #[prost(bytes = "vec", tag = "2")]
    pub shared_key: ::prost::alloc::vec::Vec<u8>,
}

/// A single helper's share of the current secret bag — wire-pairs a
/// `channel_id` with the serialized `CommittedDeRecShare` bytes that
/// were sent to that helper. Part of [`ReplicaSecretPayload`].
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ChannelShare {
    /// Channel id of the helper that holds this share.
    #[prost(uint64, tag = "1")]
    pub channel_id: u64,
    /// Serialized `CommittedDeRecShare` bytes — the same payload the
    /// helper received in their `StoreShareRequest`.
    #[prost(bytes = "vec", tag = "2")]
    pub committed_share: ::prost::alloc::vec::Vec<u8>,
}

/// The composite payload sent to each Replica Destination on a
/// `ProtectSecret` round. Carries the full [`Secret`] plus the map of
/// `(channel_id → committed_share)` for the same round, so the
/// Destination can recover via either path — read the secret directly,
/// or contact each helper using `secret.helpers[i].shared_key` and
/// request their stored share.
///
/// # Group-key handover
///
/// All replica channels for a given `secret_id` converge on a single
/// symmetric "group" key. The `shared_key` field carries that group key
/// inside the encrypted payload **only** when the sender knows the
/// receiver doesn't have it yet — i.e. on the first round to a newly
/// paired Destination. Both sides swap their stored channel key
/// (`(secret_id, channel_id)` in [`crate::protocol::DeRecSecretStore`])
/// from the per-pair ephemeral handshake key to the group key:
///
/// - **Sender**: swap immediately after the request envelope is sent.
///   The ack response from the new joiner will already be encrypted
///   with the group key.
/// - **Receiver**: swap before encrypting the ack response, so the
///   ack uses the group key and matches what the sender expects.
///
/// On the first-ever replica pair, the group key is implicitly the
/// pair-handshake key — `shared_key` is left empty, no swap happens,
/// and the channel-key entry both sides already saved is the group key.
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ReplicaSecretPayload {
    /// The full secret the sender is committing to this version.
    #[prost(message, optional, tag = "1")]
    pub secret: ::core::option::Option<Secret>,
    /// One entry per helper that received a VSS share on this round.
    #[prost(message, repeated, tag = "2")]
    pub shares: ::prost::alloc::vec::Vec<ChannelShare>,
    /// 32-byte replica-group key. Present only on the first-sync round
    /// to a newly-paired Destination; empty on every subsequent round
    /// (since the receiving channel already holds the group key) and
    /// empty when the receiving Destination is the very first pair for
    /// this `secret_id` (the pair-handshake key is implicitly the group
    /// key). See type-level docs for the swap protocol.
    #[prost(bytes = "vec", tag = "3")]
    pub shared_key: ::prost::alloc::vec::Vec<u8>,
}

/// Kind of secret material stored by [`crate::protocol::DeRecSecretStore`].
///
/// Each variant has its own lifecycle (see per-variant docs). Used as the
/// `kind` argument to [`crate::protocol::DeRecSecretStore::load`] and
/// [`crate::protocol::DeRecSecretStore::remove`]; on
/// [`crate::protocol::DeRecSecretStore::save`] the kind is inferred from
/// the [`SecretValue`] variant and need not be passed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecretKind {
    /// The post-pairing symmetric channel key (see [`SecretValue::SharedKey`]).
    SharedKey = 0,
    /// The ephemeral ECIES / ML-KEM key material used during pairing.
    PairingSecret = 1,
    /// The initiator's [`ContactMessage`] stored transiently between
    /// `start` and pairing completion. Removed once the shared key
    /// is derived.
    PairingContact = 2,
}

/// How [`crate::protocol::DeRecSecretStore::load_many`] handles channels
/// with no stored secret of the requested [`SecretKind`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MissingPolicy {
    /// Silently drop missing channels from the returned vector.
    ///
    /// Use when missing entries are an expected outcome — e.g. a `Target::Many`
    /// list that mixes paired and unpaired channels.
    Skip,
    /// Return [`crate::protocol::SecretStoreError::MissingEntries`] carrying
    /// the channel ids that had no entry.
    ///
    /// Use when every input id is expected to have an entry — e.g. after
    /// filtering to channels already known to
    /// [`crate::protocol::DeRecChannelStore`]. A miss signals a cross-store
    /// invariant violation.
    Fail,
}

/// Opaque, serialized pairing key material as held by
/// [`crate::protocol::DeRecSecretStore`] under [`SecretValue::PairingSecret`].
///
/// This is the store-boundary form of the ephemeral key pair the pairing
/// handshake produces. The protocol deliberately exposes it as an opaque
/// byte blob rather than a cryptographic type, so a store implementation can
/// persist and reload it without depending on `derec-cryptography` or any
/// serialization framework: call [`as_bytes`](Self::as_bytes) to obtain the
/// bytes to persist on `save`, and hand the same bytes back to
/// [`from_bytes`](Self::from_bytes) on `load`.
///
/// The byte layout is a library-internal detail, is not part of the public
/// API, and may change between versions; treat the blob as opaque and never
/// interpret it.
///
/// The bytes are held in [`zeroize::Zeroizing`] so the plaintext key material
/// is wiped from memory on drop.
#[derive(Clone)]
pub struct PairingKeyMaterial(Zeroizing<Vec<u8>>);

impl PairingKeyMaterial {
    /// Wrap raw bytes previously obtained from [`as_bytes`](Self::as_bytes)
    /// and persisted by a store.
    pub fn from_bytes(bytes: Vec<u8>) -> Self {
        Self(Zeroizing::new(bytes))
    }

    /// The opaque bytes to persist. Round-trips through
    /// [`from_bytes`](Self::from_bytes).
    pub fn as_bytes(&self) -> &[u8] {
        self.0.as_slice()
    }

    /// Serialize live pairing secret key material into the store-boundary
    /// form.
    ///
    /// Library-internal: the pairing handlers call this before handing the
    /// value to the secret store, keeping the `ark-serialize` encoding an
    /// implementation detail that never crosses the public API.
    pub(crate) fn from_secret(material: &PairingSecretKeyMaterial) -> Self {
        use ark_serialize::CanonicalSerialize as _;
        let mut buf = Vec::with_capacity(material.compressed_size());
        material
            .serialize_compressed(&mut buf)
            .expect("ark serialization of PairingSecretKeyMaterial is infallible");
        Self(Zeroizing::new(buf))
    }

    /// Reconstruct live pairing secret key material from the store-boundary
    /// form.
    ///
    /// Library-internal: the pairing handlers call this after loading the
    /// value from the secret store. A decode failure means the persisted
    /// bytes were corrupted or truncated, which is an internal invariant
    /// violation rather than valid caller input.
    pub(crate) fn to_secret(&self) -> crate::Result<PairingSecretKeyMaterial> {
        use ark_serialize::CanonicalDeserialize as _;
        PairingSecretKeyMaterial::deserialize_compressed(self.0.as_slice())
            .map_err(|_| crate::Error::Invariant("stored PairingSecret bytes failed to decode"))
    }
}

#[cfg(feature = "serde")]
impl Serialize for PairingKeyMaterial {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        self.0.as_slice().serialize(serializer)
    }
}

#[cfg(feature = "serde")]
impl<'de> Deserialize<'de> for PairingKeyMaterial {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let bytes = Vec::<u8>::deserialize(deserializer)?;
        Ok(Self(Zeroizing::new(bytes)))
    }
}

/// Serde adapter for the prost [`ContactMessage`] carried by
/// [`SecretValue::PairingContact`]. prost messages have no native serde
/// support, so the value is (de)serialized through its canonical protobuf
/// byte encoding.
#[cfg(feature = "serde")]
mod contact_serde {
    use super::ContactMessage;
    use prost::Message as _;
    use serde::{Deserialize as _, Deserializer, Serialize as _, Serializer};

    pub(super) fn serialize<S>(contact: &ContactMessage, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        contact.encode_to_vec().serialize(serializer)
    }

    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<ContactMessage, D::Error>
    where
        D: Deserializer<'de>,
    {
        let bytes = Vec::<u8>::deserialize(deserializer)?;
        ContactMessage::decode(bytes.as_slice()).map_err(serde::de::Error::custom)
    }
}

/// The payload returned by [`crate::protocol::DeRecSecretStore::load`] and
/// passed to [`crate::protocol::DeRecSecretStore::save`].
///
/// Variants are 1:1 with [`SecretKind`].
///
/// With the `serde` feature enabled, `Serialize` / `Deserialize` are
/// derived so a store implementation can persist an entry with any serde
/// format instead of hand-rolling a codec. This is an alternative to the
/// byte-level accessors on the individual payloads (e.g.
/// [`PairingKeyMaterial::as_bytes`] / [`PairingKeyMaterial::from_bytes`]);
/// implementors pick whichever fits their backend, and consumers that do
/// not use serde pay no dependency for it. The serde wire format is not
/// part of the public API and may change independently.
#[derive(Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum SecretValue {
    /// The post-pairing symmetric channel key. Established by pairing and used
    /// to authenticate and encrypt every subsequent message on the channel.
    SharedKey(crate::types::SharedKey),
    /// The ephemeral ECIES / ML-KEM key material created by `start` and
    /// consumed when the pairing response arrives. Removed once the shared
    /// key is derived. Held as an opaque [`PairingKeyMaterial`] blob so
    /// store implementors never touch a cryptography primitive.
    PairingSecret(PairingKeyMaterial),
    /// The initiator's [`ContactMessage`], needed by
    /// [`crate::primitives::pairing::response::process`] to derive the shared
    /// key. Ephemeral — removed after pairing completes.
    PairingContact(#[cfg_attr(feature = "serde", serde(with = "contact_serde"))] ContactMessage),
}

/// Tag identifying which kind of in-flight orchestrator state an entry in
/// [`crate::protocol::DeRecStateStore`] holds. Used by
/// [`crate::protocol::DeRecStateStore::load_all`] to filter by category.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StateKind {
    /// Outstanding [`derec_proto::VerifyShareRequestMessage`], one per
    /// channel. Load-bearing for the replay-defence binding gate.
    PendingVerification,
    /// Recovery accumulator, one per `(secret_id, version)`. Holds every
    /// [`derec_proto::GetShareResponseMessage`] received so far for that
    /// reconstruction target.
    PendingRecovery,
    /// Outstanding unpair acknowledgement, one per channel. Carries the
    /// `started_at` unix-seconds timestamp so the orchestrator can time
    /// out unresponsive peers.
    PendingUnpair,
    /// Active sharing round. At most one entry exists per `secret_id`
    /// (a new `start(ProtectSecret)` overwrites any prior round). Holds
    /// the per-channel tallies (`pending` / `confirmed` / `failed`) and
    /// the `started_at` timestamp used to time out unresponsive helpers.
    SharingRound,
}

/// Secondary-key selector identifying a single row within a given
/// [`StateKind`] under a `secret_id`. Passed to
/// [`crate::protocol::DeRecStateStore::load`] and
/// [`crate::protocol::DeRecStateStore::remove`].
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum StateKey {
    /// Row is scoped to one channel.
    PendingVerification { channel_id: ChannelId },
    /// Row is scoped to one reconstruction target.
    PendingRecovery { version: u32 },
    /// Row is scoped to one channel.
    PendingUnpair { channel_id: ChannelId },
    /// At most one row per `secret_id`. No secondary key.
    SharingRound,
}

impl StateKey {
    /// The [`StateKind`] this key selects. Used by store implementations
    /// that persist rows in a `(secret_id, kind, secondary_key)` schema.
    pub fn kind(&self) -> StateKind {
        match self {
            StateKey::PendingVerification { .. } => StateKind::PendingVerification,
            StateKey::PendingRecovery { .. } => StateKind::PendingRecovery,
            StateKey::PendingUnpair { .. } => StateKind::PendingUnpair,
            StateKey::SharingRound => StateKind::SharingRound,
        }
    }
}

/// The payload of one row in the [`crate::protocol::DeRecStateStore`].
///
/// # Write pattern
///
/// The library treats [`crate::protocol::DeRecStateStore::save`] as
/// **full-replacement upsert** — there is no per-item merge or append
/// semantic at the store level. Accumulator-style state
/// ([`StateItem::PendingRecovery`] and [`StateItem::SharingRound`]) grows
/// via load-modify-save cycles from the library. Backends do not need to
/// implement any append primitive; a naive replace-on-save is correct.
#[derive(Debug, Clone)]
pub enum StateItem {
    /// The full outstanding [`derec_proto::VerifyShareRequestMessage`] the
    /// orchestrator sent for this channel. Retained so the corresponding
    /// inbound [`derec_proto::VerifyShareResponseMessage`] can be validated
    /// against the exact `(nonce, secret_id, version)` triple that was
    /// minted at request time.
    ///
    /// Overwritten in place by a subsequent `save` for the same
    /// `(secret_id, channel_id)`; the most recent challenge wins.
    PendingVerification {
        channel_id: ChannelId,
        request: derec_proto::VerifyShareRequestMessage,
    },

    /// Accumulator for one in-progress recovery target.
    ///
    /// The library writes this variant one share at a time as each inbound
    /// [`derec_proto::GetShareResponseMessage`] arrives. The write sequence
    /// under a single `(secret_id, version)` is:
    ///
    /// 1. First response arrives. Library calls `save` with a `shares`
    ///    vector containing exactly one element.
    /// 2. Second response arrives. Library `load`s the accumulator,
    ///    appends the new share to the returned Vec, and `save`s the
    ///    grown Vec back.
    /// 3. …repeat until threshold. On threshold met, library `remove`s
    ///    the accumulator.
    ///
    /// Implementations MUST accept `shares` vectors of any length,
    /// including one. Every `save` replaces the stored value in place
    /// with the caller-supplied Vec; no append primitive is required.
    ///
    /// # Concurrency
    ///
    /// See [`crate::protocol::DeRecStateStore`] for the multi-instance
    /// concurrency contract. Concurrent inbound shares racing on the same
    /// accumulator will clobber each other via a naive load-modify-save;
    /// the application layer is responsible for serializing concurrent
    /// `process()` calls that touch the same `(secret_id, version)` if
    /// this matters.
    PendingRecovery {
        version: u32,
        shares: Vec<derec_proto::GetShareResponseMessage>,
    },

    /// Outstanding unpair acknowledgement window. `started_at` is the
    /// unix-seconds timestamp stamped when the request was sent; the
    /// orchestrator sweeps expired entries via
    /// [`crate::protocol::DeRecStateStore::load_all`].
    PendingUnpair {
        channel_id: ChannelId,
        started_at: u64,
    },

    /// Active sharing round for `secret_id`. Created by
    /// `start(ProtectSecret)` and cleared by the orchestrator once every
    /// targeted helper has responded (confirmed, rejected, or timed
    /// out). At most one entry exists per `secret_id`; a fresh
    /// `start(ProtectSecret)` overwrites any prior in-flight round.
    ///
    /// `pending` / `confirmed` / `failed` partition the round's target
    /// channels; the union is invariant across the round's lifetime.
    /// `started_at` is the unix-seconds timestamp used to time out
    /// unresponsive helpers.
    SharingRound {
        version: u32,
        pending: std::collections::HashSet<ChannelId>,
        confirmed: std::collections::HashSet<ChannelId>,
        failed: std::collections::HashSet<ChannelId>,
        started_at: u64,
    },
}

impl StateItem {
    /// The [`StateKind`] this item is an instance of.
    pub fn kind(&self) -> StateKind {
        match self {
            StateItem::PendingVerification { .. } => StateKind::PendingVerification,
            StateItem::PendingRecovery { .. } => StateKind::PendingRecovery,
            StateItem::PendingUnpair { .. } => StateKind::PendingUnpair,
            StateItem::SharingRound { .. } => StateKind::SharingRound,
        }
    }

    /// The [`StateKey`] identifying this item within its `(secret_id, kind)`
    /// partition. Convenience so callers don't have to hand-construct a
    /// key that matches the payload.
    pub fn key(&self) -> StateKey {
        match self {
            StateItem::PendingVerification { channel_id, .. } => StateKey::PendingVerification {
                channel_id: *channel_id,
            },
            StateItem::PendingRecovery { version, .. } => {
                StateKey::PendingRecovery { version: *version }
            }
            StateItem::PendingUnpair { channel_id, .. } => StateKey::PendingUnpair {
                channel_id: *channel_id,
            },
            StateItem::SharingRound { .. } => StateKey::SharingRound,
        }
    }
}

/// A single stored share entry, fully self-describing.
#[derive(Debug, Clone)]
pub struct Share {
    /// Numeric identifier of the secret this share belongs to.
    pub secret_id: u64,
    /// Version number of the secret.
    pub version: u32,
    /// Stable per-device identifier of the replica that produced this
    /// share, copied from
    /// [`derec_proto::StoreShareRequestMessage::replica_id`] when the
    /// helper persisted the write.
    ///
    /// `None` when the writer was a non-replica `Owner`. `Some(id)` when
    /// the writer was `ReplicaSource`. Two distinct replicas may produce
    /// the same `(secret_id, channel_id, version)` independently
    /// — see [`crate::protocol::DeRecShareStore::save`] for the
    /// conceptual storage key and the disambiguation contract.
    pub replica_id: Option<u64>,
    /// Opaque protobuf bytes — see [`crate::protocol::DeRecShareStore`] for
    /// the per-side format.
    pub bytes: Vec<u8>,
}

#[cfg(all(test, feature = "serde"))]
mod tests {
    use super::*;

    /// Every `SecretValue` variant round-trips through serde — the path a
    /// store implementation takes when it opts for serde over the
    /// byte-level accessors. Exercises the custom `PairingKeyMaterial`
    /// impls and the prost `ContactMessage` adapter.
    #[test]
    fn secret_value_serde_round_trips_all_variants() {
        let cases = [
            SecretValue::SharedKey([7u8; 32]),
            SecretValue::PairingSecret(PairingKeyMaterial::from_bytes(vec![1, 2, 3, 4, 5])),
            SecretValue::PairingContact(ContactMessage {
                nonce: 42,
                ..Default::default()
            }),
        ];

        for value in cases {
            let json = serde_json::to_vec(&value).expect("serialize");
            let decoded: SecretValue = serde_json::from_slice(&json).expect("deserialize");
            match (&value, &decoded) {
                (SecretValue::SharedKey(a), SecretValue::SharedKey(b)) => assert_eq!(a, b),
                (SecretValue::PairingSecret(a), SecretValue::PairingSecret(b)) => {
                    assert_eq!(a.as_bytes(), b.as_bytes())
                }
                (SecretValue::PairingContact(a), SecretValue::PairingContact(b)) => {
                    assert_eq!(a, b)
                }
                _ => panic!("variant changed across serde round-trip"),
            }
        }
    }

    /// The serde form and the `from_bytes`/`as_bytes` form describe the
    /// same opaque blob, so a value serialized one way decodes the other.
    #[test]
    fn pairing_key_material_serde_matches_byte_accessors() {
        let material = PairingKeyMaterial::from_bytes(vec![9, 8, 7, 6]);
        let json = serde_json::to_vec(&material).expect("serialize");
        let decoded: PairingKeyMaterial = serde_json::from_slice(&json).expect("deserialize");
        assert_eq!(material.as_bytes(), decoded.as_bytes());
    }
}