Skip to main content

coven_crypto/
member.rs

1//! A member's signing and sealing key pairs, kept together (Appendix D).
2
3use crate::{cipher, derivation, randomness, wire};
4use crate::{CircleKey, CryptoError, MaterialError, ObjectDigest, SecretBytes, StoreKey};
5use coven_foundation::id_source::MemberId;
6use ed25519_dalek::{Signer, VerifyingKey};
7use std::fmt;
8use x25519_dalek::{PublicKey, StaticSecret};
9use zeroize::Zeroizing;
10
11/// A member's validated Ed25519 public signing key (Appendix D). It is bound to a
12/// stable [`MemberId`] by admin entries and is never that identity.
13#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
14pub struct SigningKey([u8; 32]);
15
16impl SigningKey {
17    /// Validate a public signing key, refusing invalid and weak Ed25519 points.
18    #[expect(
19        clippy::map_err_ignore,
20        reason = "Ed25519's key error is opaque by design; InvalidSigningKey is all it says"
21    )]
22    pub fn from_bytes(bytes: [u8; 32]) -> Result<Self, CryptoError> {
23        let key = VerifyingKey::from_bytes(&bytes).map_err(|_| CryptoError::InvalidSigningKey)?;
24        if key.is_weak() {
25            return Err(CryptoError::InvalidSigningKey);
26        }
27        Ok(Self(bytes))
28    }
29
30    /// The validated Ed25519 public key's 32 bytes.
31    pub fn as_bytes(&self) -> &[u8; 32] {
32        &self.0
33    }
34
35    /// Verify a detached signature against this public key (§12).
36    #[expect(
37        clippy::map_err_ignore,
38        reason = "Ed25519's verification error is opaque by design; Signature is all it says"
39    )]
40    pub fn verify(&self, message: &[u8], signature: &Signature) -> Result<(), CryptoError> {
41        let key = VerifyingKey::from_bytes(&self.0)
42            .expect("SigningKey contains a validated Ed25519 public key");
43        key.verify_strict(message, &ed25519_dalek::Signature::from_bytes(&signature.0))
44            .map_err(|_| CryptoError::Signature)
45    }
46
47    /// Verify an object's signature after hashing all bytes before it (Appendix D.3).
48    /// The domain and path are authenticated along with the SHA-256 digest.
49    /// Panics if the storage path is empty.
50    pub fn verify_object(
51        &self,
52        path: &str,
53        digest: &ObjectDigest,
54        signature: &Signature,
55    ) -> Result<(), CryptoError> {
56        self.verify(&object_message(path, digest), signature)
57    }
58
59    /// Verify a snapshot prefix independently of its encrypted data (D9).
60    /// `prefix` is exactly kind, version and routing fields, without signatures.
61    /// Panics if the storage path is empty.
62    pub fn verify_prefix(
63        &self,
64        path: &str,
65        prefix: &[u8],
66        signature: &Signature,
67    ) -> Result<(), CryptoError> {
68        self.verify(&prefix_message(path, prefix), signature)
69    }
70
71    /// Verify a write's header signature without its part chunks (D9): the
72    /// exact cleartext prefix, the complete sealed header chunk and the part
73    /// roots. Snapshots carry these to authenticate another member's leaves.
74    /// Panics if the storage path is empty.
75    pub fn verify_write_header(
76        &self,
77        path: &str,
78        prefix: &[u8],
79        header: &[u8],
80        roots: &[[u8; 32]],
81        signature: &Signature,
82    ) -> Result<(), CryptoError> {
83        self.verify(&header_message(path, prefix, header, roots), signature)
84    }
85}
86
87/// The public half of a member's X25519 pair, used for anonymous sealed keys.
88/// A copy names the exact sealing key it is sealed to (D9).
89#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
90pub struct SealingKey([u8; 32]);
91
92impl SealingKey {
93    /// Read a public sealing key. Low-order keys are refused when sealing.
94    pub fn from_bytes(bytes: [u8; 32]) -> Self {
95        Self(bytes)
96    }
97    /// The public bytes carried with the member's join request (§13.2).
98    pub fn as_bytes(&self) -> &[u8; 32] {
99        &self.0
100    }
101}
102
103/// A member's public signing and sealing keys, independent of their stable
104/// [`MemberId`] (§10, D2). Creation, addition and replacement bind a pair to an id.
105#[derive(Clone, Copy, Debug, PartialEq, Eq, Ord, PartialOrd)]
106pub struct MemberPublicKeys {
107    /// The Ed25519 key that verifies the member's signatures.
108    pub signing: SigningKey,
109    /// The X25519 key to which store and circle keys are sealed.
110    pub sealing: SealingKey,
111}
112
113/// The bytes of a detached Ed25519 signature.
114pub const SIGNATURE_LEN: usize = 64;
115
116/// A detached Ed25519 signature on bytes authored by a member (§12).
117#[derive(Clone, Debug, Eq, PartialEq)]
118pub struct Signature([u8; SIGNATURE_LEN]);
119
120impl Signature {
121    /// Read a detached signature; authenticity is checked by `SigningKey::verify`.
122    pub fn from_bytes(bytes: [u8; SIGNATURE_LEN]) -> Self {
123        Self(bytes)
124    }
125    /// The signature bytes written beside the signed message.
126    pub fn as_bytes(&self) -> &[u8; SIGNATURE_LEN] {
127        &self.0
128    }
129}
130
131/// A member's Ed25519 signing pair and independent X25519 sealing pair (Appendix D).
132/// Both secrets erase on drop. Clone is needed by in-memory custody to keep
133/// its keys while returning an independently owned unlocked pair.
134#[derive(Clone)]
135pub struct MemberKeys {
136    signing: ed25519_dalek::SigningKey,
137    sealing: StaticSecret,
138}
139
140impl MemberKeys {
141    /// A pair whose signing and sealing seeds are both `seed`: public test
142    /// data, never a person's keys.
143    #[cfg(any(test, feature = "test-utils"))]
144    pub fn from_seed(seed: u8) -> Self {
145        Self {
146            signing: ed25519_dalek::SigningKey::from_bytes(&[seed; 32]),
147            sealing: StaticSecret::from([seed; 32]),
148        }
149    }
150
151    /// Make both member key pairs using the operating system's randomness.
152    pub fn generate() -> Result<Self, CryptoError> {
153        let signing = randomness::secret_bytes()?;
154        let sealing = randomness::secret_bytes()?;
155        Ok(Self {
156            signing: ed25519_dalek::SigningKey::from_bytes(&signing),
157            sealing: StaticSecret::from(*sealing),
158        })
159    }
160
161    /// The public pair admin entries bind to a stable member id (§10).
162    pub fn public_keys(&self) -> MemberPublicKeys {
163        MemberPublicKeys {
164            signing: SigningKey(self.signing.verifying_key().to_bytes()),
165            sealing: SealingKey(PublicKey::from(&self.sealing).to_bytes()),
166        }
167    }
168
169    /// Sign bytes with the member's Ed25519 pair (§12).
170    pub fn sign(&self, bytes: &[u8]) -> Signature {
171        Signature(self.signing.sign(bytes).to_bytes())
172    }
173
174    /// Sign the digest of every object byte before the signature (Appendix D.3).
175    /// Feed the prefix, length fields and sealed chunks to [`crate::ObjectHasher`]
176    /// in storage order. The signed message binds its domain, path and digest.
177    /// Panics if the storage path is empty.
178    ///
179    /// A file's content hash cannot stand in for an object's digest.
180    /// ```compile_fail
181    /// use coven_crypto::{ContentHasher, MemberKeys};
182    /// let member = MemberKeys::generate().unwrap();
183    /// member.sign_object("10101010-1010-1010-1010-101010101010/seq/1", &ContentHasher::new().finish());
184    /// ```
185    pub fn sign_object(&self, path: &str, digest: &ObjectDigest) -> Signature {
186        self.sign(&object_message(path, digest))
187    }
188
189    /// Sign a snapshot's exact kind, version and routing fields (D9).
190    /// The prefix domain and path are bound separately from whole-object signing.
191    /// Panics if the storage path is empty.
192    pub fn sign_prefix(&self, path: &str, prefix: &[u8]) -> Signature {
193        self.sign(&prefix_message(path, prefix))
194    }
195
196    /// Sign a write's cleartext prefix, sealed header chunk and part roots
197    /// (D9), separately from the whole-object signature.
198    /// Panics if the storage path is empty.
199    pub fn sign_write_header(
200        &self,
201        path: &str,
202        prefix: &[u8],
203        header: &[u8],
204        roots: &[[u8; 32]],
205    ) -> Signature {
206        self.sign(&header_message(path, prefix, header, roots))
207    }
208
209    /// Encode both private seeds for custody or the person's restore code (§13.1).
210    pub fn to_secret_bytes(&self) -> SecretBytes {
211        let mut bytes = Zeroizing::new(Vec::with_capacity(MEMBER_KEYS_LENGTH));
212        bytes.extend_from_slice(MEMBER_KEYS_MAGIC);
213        bytes.extend_from_slice(self.signing.as_bytes());
214        bytes.extend_from_slice(self.sealing.as_bytes());
215        SecretBytes(bytes)
216    }
217
218    /// Restore both pairs from custody or the person's restore code (§13.1).
219    pub fn from_secret_bytes(mut bytes: &[u8]) -> Result<Self, MaterialError> {
220        wire::prefix(&mut bytes, MEMBER_KEYS_MAGIC)?;
221        let signing = Zeroizing::new(wire::array(&mut bytes)?);
222        let sealing = Zeroizing::new(wire::array(&mut bytes)?);
223        wire::end(bytes)?;
224        Ok(Self {
225            signing: ed25519_dalek::SigningKey::from_bytes(&signing),
226            sealing: StaticSecret::from(*sealing),
227        })
228    }
229
230    /// Open a store-key box, authenticating the exact path and opaque clear prefix.
231    /// Format checks the recipient and opened identity; the caller checks the
232    /// introduction commitment before accepting the candidate key.
233    /// Panics if the storage path is empty.
234    pub fn open_store_key(
235        &self,
236        path: &str,
237        prefix: &[u8],
238        sealed: &[u8],
239    ) -> Result<StoreKey, CryptoError> {
240        let plaintext =
241            self.open_box::<{ StoreKey::ENCODED_LEN }>(b"store", path, prefix, sealed)?;
242        Ok(StoreKey::from_encoded(&plaintext))
243    }
244
245    /// Open a circle-key box, authenticating the exact path and opaque clear prefix.
246    /// Format checks the recipient and opened identity; the caller checks the
247    /// introduction commitment before accepting the candidate key.
248    /// Panics if the storage path is empty.
249    pub fn open_circle_key(
250        &self,
251        path: &str,
252        prefix: &[u8],
253        sealed: &[u8],
254    ) -> Result<CircleKey, CryptoError> {
255        let plaintext =
256            self.open_box::<{ CircleKey::ENCODED_LEN }>(b"circle", path, prefix, sealed)?;
257        Ok(CircleKey::from_encoded(&plaintext))
258    }
259
260    /// Open a box holding exactly `N` plaintext bytes; any other length is
261    /// malformed.
262    fn open_box<const N: usize>(
263        &self,
264        kind: &[u8],
265        path: &str,
266        prefix: &[u8],
267        sealed: &[u8],
268    ) -> Result<Zeroizing<[u8; N]>, CryptoError> {
269        let (sender, rest) = sealed.split_first_chunk::<32>().ok_or(CryptoError::Malformed)?;
270        let (nonce, rest) =
271            rest.split_first_chunk::<{ cipher::NONCE_LEN }>().ok_or(CryptoError::Malformed)?;
272        let (ciphertext, rest) = rest.split_first_chunk::<N>().ok_or(CryptoError::Malformed)?;
273        let (tag, rest) =
274            rest.split_first_chunk::<KEY_BOX_TAG_LEN>().ok_or(CryptoError::Malformed)?;
275        if !rest.is_empty() {
276            return Err(CryptoError::Malformed);
277        }
278        let recipient = self.public_keys().sealing;
279        let shared = self.sealing.diffie_hellman(&PublicKey::from(*sender));
280        if !shared.was_contributory() {
281            return Err(CryptoError::WeakSealingKey);
282        }
283        let context = box_context(kind, path, prefix, sender, recipient.as_bytes());
284        let key = derivation::derive(shared.as_bytes(), &context);
285        cipher::open_exact(&key, nonce, &context, ciphertext, tag)
286    }
287}
288
289impl fmt::Debug for MemberKeys {
290    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291        f.write_str("MemberKeys([REDACTED])")
292    }
293}
294
295/// This device's member: the stable id and every private pair it holds
296/// (E1, E11). Identity custody keeps it; the id never derives from the keys.
297/// The active pair is the one the last installed binding made current. A
298/// self-replacement keeps its candidate here before publishing it, and every
299/// pair a kept replacement retired stays held, because copies name the exact
300/// credential they open with (§12.1). Which pair may sign is replay's decision,
301/// never this record's (§12.3).
302#[derive(Clone)]
303pub struct MemberIdentity {
304    member: MemberId,
305    keys: MemberKeys,
306    retained: Vec<MemberKeys>,
307    candidate: Option<MemberKeys>,
308}
309
310impl MemberIdentity {
311    /// Bind a private pair to the stable id its admin entry names.
312    pub fn new(member: MemberId, keys: MemberKeys) -> Self {
313        Self { member, keys, retained: Vec::new(), candidate: None }
314    }
315
316    /// Make both key pairs for an independently generated id (§10).
317    pub fn generate(member: MemberId) -> Result<Self, CryptoError> {
318        Ok(Self::new(member, MemberKeys::generate()?))
319    }
320
321    /// The stable member id.
322    pub fn member(&self) -> MemberId {
323        self.member
324    }
325
326    /// The active private pair: the one this device signs with and a
327    /// restore code exports.
328    pub fn keys(&self) -> &MemberKeys {
329        &self.keys
330    }
331
332    /// A self-replacement's fresh pair, kept before its binding is published.
333    pub fn candidate(&self) -> Option<&MemberKeys> {
334        self.candidate.as_ref()
335    }
336
337    /// Every held pair: the active one, retired ones, then the candidate.
338    fn held(&self) -> impl Iterator<Item = &MemberKeys> {
339        std::iter::once(&self.keys).chain(&self.retained).chain(&self.candidate)
340    }
341
342    /// The held private pair whose sealing key is `sealing`. A copy names the
343    /// exact credential it is sealed to, and opens only with that one, never
344    /// with whichever pair is latest (§12.1).
345    pub fn credential(&self, sealing: &SealingKey) -> Option<&MemberKeys> {
346        self.held().find(|keys| keys.public_keys().sealing == *sealing)
347    }
348
349    /// Whether some held pair is exactly `keys`.
350    pub fn holds(&self, keys: &MemberPublicKeys) -> bool {
351        self.held().any(|held| held.public_keys() == *keys)
352    }
353
354    /// This identity keeping `candidate` for a self-replacement. A candidate
355    /// already held stays held as a retired credential: its copies may exist.
356    pub fn with_candidate(&self, candidate: MemberKeys) -> Self {
357        let mut next = self.clone();
358        next.retained.extend(next.candidate.replace(candidate));
359        next
360    }
361
362    /// This identity with the held pair `current` active, as replay's current
363    /// binding selects it (§12.3). The previously active pair and any other
364    /// candidate stay held as retired credentials. `None` when no held pair
365    /// is `current`.
366    pub fn activate(&self, current: &MemberPublicKeys) -> Option<Self> {
367        if self.keys.public_keys() == *current && self.candidate.is_none() {
368            return Some(self.clone());
369        }
370        let mut pairs: Vec<MemberKeys> = self.held().cloned().collect();
371        let index = pairs.iter().position(|keys| keys.public_keys() == *current)?;
372        let keys = pairs.remove(index);
373        let mut retained = Vec::new();
374        for pair in pairs {
375            if !retained.iter().any(|held: &MemberKeys| held.public_keys() == pair.public_keys()) {
376                retained.push(pair);
377            }
378        }
379        Some(Self { member: self.member, keys, retained, candidate: None })
380    }
381
382    /// This identity with its candidate retired: a replacement that never
383    /// became current leaves its copies, so its pair stays held (§12.4).
384    pub fn retire_candidate(&self) -> Self {
385        let mut next = self.clone();
386        next.retained.extend(next.candidate.take());
387        next
388    }
389
390    /// This identity without its candidate: a self-replacement cancelled
391    /// before reserving anything never sent a copy to it.
392    pub fn without_candidate(&self) -> Self {
393        let mut next = self.clone();
394        next.candidate = None;
395        next
396    }
397
398    /// Encode the id and every held private pair for identity custody: the
399    /// active pair, the retired ones, then the candidate if any. This custody
400    /// encoding is not a restore code (D13).
401    pub fn to_secret_bytes(&self) -> SecretBytes {
402        // Allocate once so growth cannot leave an unerased copy of the seeds.
403        let pairs = 1 + self.retained.len() + usize::from(self.candidate.is_some());
404        // Magic, id, the active pair, the retired count and pairs, and the
405        // candidate's flag and pair.
406        let mut bytes = Zeroizing::new(Vec::with_capacity(
407            IDENTITY_MAGIC.len() + 16 + 8 + 1 + pairs * MEMBER_KEYS_LENGTH,
408        ));
409        bytes.extend_from_slice(IDENTITY_MAGIC);
410        bytes.extend_from_slice(self.member.0.as_bytes());
411        bytes.extend_from_slice(self.keys.to_secret_bytes().as_bytes());
412        bytes.extend_from_slice(&(self.retained.len() as u64).to_le_bytes());
413        for keys in &self.retained {
414            bytes.extend_from_slice(keys.to_secret_bytes().as_bytes());
415        }
416        match &self.candidate {
417            Some(keys) => {
418                bytes.push(1);
419                bytes.extend_from_slice(keys.to_secret_bytes().as_bytes());
420            }
421            None => bytes.push(0),
422        }
423        SecretBytes(bytes)
424    }
425
426    /// Restore the id and every held pair from identity custody.
427    pub fn from_secret_bytes(mut bytes: &[u8]) -> Result<Self, MaterialError> {
428        fn pair(bytes: &mut &[u8]) -> Result<MemberKeys, MaterialError> {
429            MemberKeys::from_secret_bytes(wire::take(bytes, MEMBER_KEYS_LENGTH)?)
430        }
431        wire::prefix(&mut bytes, IDENTITY_MAGIC)?;
432        let member = MemberId(uuid::Uuid::from_bytes(wire::array(&mut bytes)?));
433        let keys = pair(&mut bytes)?;
434        let count = wire::number(&mut bytes)?;
435        if count > (bytes.len() / MEMBER_KEYS_LENGTH) as u64 {
436            return Err(MaterialError::Encoding);
437        }
438        let retained = (0..count).map(|_| pair(&mut bytes)).collect::<Result<Vec<_>, _>>()?;
439        let candidate = match wire::array(&mut bytes)? {
440            [0] => None,
441            [1] => Some(pair(&mut bytes)?),
442            _ => return Err(MaterialError::Encoding),
443        };
444        wire::end(bytes)?;
445        let identity = Self { member, keys, retained, candidate };
446        // Each credential is held once.
447        let mut public: Vec<_> = identity.held().map(MemberKeys::public_keys).collect();
448        public.sort();
449        public.dedup();
450        if public.len() != identity.held().count() {
451            return Err(MaterialError::Encoding);
452        }
453        Ok(identity)
454    }
455}
456
457/// The first bytes of a private pair's encoding: its magic and version.
458const MEMBER_KEYS_MAGIC: &[u8] = b"CVMK\x01";
459/// The first bytes of an identity's custody encoding: its magic and version.
460const IDENTITY_MAGIC: &[u8] = b"CVMI\x01";
461/// The length of a private-pair blob: its magic and both 32-byte secrets.
462const MEMBER_KEYS_LENGTH: usize = MEMBER_KEYS_MAGIC.len() + 2 * 32;
463
464impl fmt::Debug for MemberIdentity {
465    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
466        f.debug_struct("MemberIdentity")
467            .field("member", &self.member)
468            .field("retained", &self.retained.len())
469            .field("candidate", &self.candidate.is_some())
470            .finish_non_exhaustive()
471    }
472}
473
474/// A key box's bytes before its ciphertext: the ephemeral public key, then
475/// the nonce.
476pub const KEY_BOX_HEAD_LEN: usize = 32 + cipher::NONCE_LEN;
477/// A key box's authentication tag, after its ciphertext.
478pub const KEY_BOX_TAG_LEN: usize = cipher::FILE_CHUNK_TAG_LEN;
479
480/// Seal a store key anonymously, authenticating its path and exact clear prefix (D11).
481/// Returns ephemeral key, nonce, ciphertext and tag; format supplies the prefix.
482/// Panics if the storage path is empty.
483pub fn seal_store_key(
484    key: &StoreKey,
485    recipient: &SealingKey,
486    path: &str,
487    prefix: &[u8],
488) -> Result<Vec<u8>, CryptoError> {
489    let mut bytes = Zeroizing::new(Vec::with_capacity(StoreKey::ENCODED_LEN));
490    key.encode_into(&mut bytes);
491    seal_box(b"store", recipient, path, prefix, &bytes)
492}
493
494/// Seal a circle key anonymously, authenticating its path and exact clear prefix (D11).
495/// Returns ephemeral key, nonce, ciphertext and tag; format supplies the prefix.
496/// Panics if the storage path is empty.
497pub fn seal_circle_key(
498    key: &CircleKey,
499    recipient: &SealingKey,
500    path: &str,
501    prefix: &[u8],
502) -> Result<Vec<u8>, CryptoError> {
503    let mut bytes = Zeroizing::new(Vec::with_capacity(CircleKey::ENCODED_LEN));
504    key.encode_into(&mut bytes);
505    seal_box(b"circle", recipient, path, prefix, &bytes)
506}
507
508fn object_message(path: &str, digest: &ObjectDigest) -> Vec<u8> {
509    cipher::context(&[b"coven/object-signature/v1", cipher::storage_path(path), digest.as_bytes()])
510}
511
512fn prefix_message(path: &str, prefix: &[u8]) -> Vec<u8> {
513    cipher::context(&[b"coven/prefix-signature/v1", cipher::storage_path(path), prefix])
514}
515
516fn header_message(path: &str, prefix: &[u8], header: &[u8], roots: &[[u8; 32]]) -> Vec<u8> {
517    let roots: Vec<u8> = roots.iter().flatten().copied().collect();
518    cipher::context(&[
519        b"coven/write-header-signature/v1",
520        cipher::storage_path(path),
521        prefix,
522        header,
523        &roots,
524    ])
525}
526
527fn box_context(
528    kind: &[u8],
529    path: &str,
530    prefix: &[u8],
531    sender: &[u8; 32],
532    recipient: &[u8; 32],
533) -> Vec<u8> {
534    cipher::context(&[
535        derivation::SEALED_BOX,
536        kind,
537        cipher::storage_path(path),
538        prefix,
539        sender,
540        recipient,
541    ])
542}
543
544fn seal_box(
545    kind: &[u8],
546    recipient: &SealingKey,
547    path: &str,
548    prefix: &[u8],
549    plaintext: &[u8],
550) -> Result<Vec<u8>, CryptoError> {
551    let seed = randomness::secret_bytes()?;
552    let ephemeral = StaticSecret::from(*seed);
553    let sender = PublicKey::from(&ephemeral).to_bytes();
554    let shared = ephemeral.diffie_hellman(&PublicKey::from(recipient.0));
555    if !shared.was_contributory() {
556        return Err(CryptoError::WeakSealingKey);
557    }
558    let context = box_context(kind, path, prefix, &sender, &recipient.0);
559    let key = derivation::derive(shared.as_bytes(), &context);
560    let body = cipher::seal_random(&key, &context, plaintext)?;
561    let mut bytes = Vec::with_capacity(32 + body.len());
562    bytes.extend_from_slice(&sender);
563    bytes.extend_from_slice(&body);
564    Ok(bytes)
565}
566
567#[cfg(test)]
568#[path = "member_tests.rs"]
569mod tests;