Skip to main content

coven_protocol/
wrapped_store_key.rs

1//! Owner-signed wrapped store keys.
2
3use serde::{Deserialize, Serialize};
4
5use crate::objects::{ExactObjectRef, PreparedExactObject, StorageError};
6use crate::store_commit::ObjectHash;
7use coven_keys::keys::{self, UserKeypair};
8
9/// A Store encryption keyring sealed to one member and signed by an Owner.
10///
11/// Membership authority names the immutable exact object
12/// through [`WrappedStoreKeyRef`]. The sealed box authenticates no sender, so
13/// the Owner signature additionally binds the Store, recipient, generation,
14/// author, and sealed bytes. The reader verifies both the exact reference and
15/// that signature before opening the keyring.
16///
17/// `recipient_pubkey` is part of the signed payload and exact path rather than
18/// duplicated in this value, so a wrap cannot be relocated to another member.
19///
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(deny_unknown_fields)]
22pub struct WrappedStoreKey {
23    /// Hex-encoded Ed25519 public key of the Owner that signed this wrapped key.
24    pub author_pubkey: String,
25    /// The keyring's current generation, covered by the Owner signature and the
26    /// exact reference.
27    pub generation: u64,
28    /// Hex-encoded sealed box (`seal_box_encrypt` output) carrying the store key.
29    pub sealed: String,
30    /// Hex-encoded detached signature over `WrappedKeyFields`, produced by the owner.
31    pub signature: String,
32}
33
34/// The wrapped-key fields the signature covers, in declaration order. Excludes
35/// `signature` (the signature's own output). Includes `store_id` (so a key
36/// can't be replayed into a different store) and `recipient_pubkey` (the slot,
37/// so a key can't be relocated to another member).
38#[derive(Serialize)]
39struct WrappedKeyFields<'a> {
40    store_id: &'a str,
41    recipient_pubkey: &'a str,
42    generation: u64,
43    author_pubkey: &'a str,
44    sealed: &'a str,
45}
46
47/// Why a [`WrappedStoreKey`] could not be authenticated and unwrapped. Named
48/// per reason so the caller can surface *why* an adoption was refused — a
49/// substituted/forged key (the signature does not verify against the pinned
50/// owner) is distinct from a corrupt object (the sealed box is not valid hex) —
51/// rather than collapsing both into one opaque failure.
52#[derive(Debug, thiserror::Error)]
53pub enum WrappedKeyError {
54    /// The signature does not verify against an authorized Owner over
55    /// `(store_id, recipient_pubkey, author_pubkey, sealed)`. Covers a box
56    /// signed by anyone outside the authorized set, a payload tampered after
57    /// signing (different store, slot, author, or sealed bytes), and a
58    /// malformed signature or owner pubkey — all indistinguishable here and all
59    /// meaning "not authentically signed by an authorized Owner".
60    #[error("signature does not verify against an authorized store owner")]
61    SignatureMismatch,
62    /// The signature verified, but the sealed-box field is not valid hex, so
63    /// there are no bytes to decrypt — a corrupt object, not an attack.
64    #[error("sealed box is not valid hex")]
65    MalformedSealed,
66}
67
68#[derive(Debug, thiserror::Error)]
69pub enum WrappedKeyringError {
70    #[error("{0}")]
71    Authentication(#[from] WrappedKeyError),
72    #[error("decrypt sealed Store keyring: {0}")]
73    Decryption(#[from] coven_keys::keys::KeyError),
74    #[error("decode Store keyring payload: {0}")]
75    Payload(#[from] coven_keys::encryption::EncryptionError),
76    #[error(
77        "wrapped Store-key ref declares generation {reference}, but its keyring declares {payload}"
78    )]
79    GenerationMismatch { reference: u64, payload: u64 },
80}
81
82impl WrappedStoreKey {
83    pub fn seal_keyring(
84        store_id: &str,
85        recipient_pubkey: &str,
86        recipient_x25519_pk: &[u8; keys::CURVE25519_PUBLICKEYBYTES],
87        encryption: &coven_keys::encryption::EncryptionService,
88        owner: &UserKeypair,
89    ) -> Result<Self, coven_keys::encryption::EncryptionError> {
90        let payload = encryption.to_keyring_payload()?;
91        Ok(Self::signed(
92            store_id,
93            recipient_pubkey,
94            encryption.current_generation(),
95            keys::seal_box_encrypt(&payload, recipient_x25519_pk),
96            owner,
97        ))
98    }
99
100    /// Wrap `sealed` (a sealed box of the store key, already encrypted to
101    /// `recipient_pubkey`) and sign the binding with `owner`: fills `signature`
102    /// with the owner's detached signature over the canonical payload.
103    pub fn signed(
104        store_id: &str,
105        recipient_pubkey: &str,
106        generation: u64,
107        sealed: Vec<u8>,
108        owner: &UserKeypair,
109    ) -> Self {
110        let author_pubkey = hex::encode(owner.public_key());
111        let sealed_hex = hex::encode(sealed);
112        let payload = wrapped_key_signing_payload(
113            store_id,
114            recipient_pubkey,
115            generation,
116            &author_pubkey,
117            &sealed_hex,
118        );
119        let (_, signature) = keys::sign_hex(owner, &payload);
120        WrappedStoreKey {
121            author_pubkey,
122            generation,
123            sealed: sealed_hex,
124            signature,
125        }
126    }
127
128    /// Verify this wrapped key was authentically produced by one of
129    /// `expected_owners` for `recipient_pubkey` in `store_id`, and return the
130    /// sealed-box bytes to decrypt. Verifies the signature against the authorized
131    /// Owner set for this context over the binding `(store_id,
132    /// recipient_pubkey, author_pubkey, sealed)`. Fails closed, naming why, if the
133    /// signature doesn't verify against that set (a substituted, forged,
134    /// replayed, or relocated key) or the sealed box is malformed; neither must
135    /// be adopted.
136    pub fn verify_and_unwrap<'a>(
137        &self,
138        store_id: &str,
139        recipient_pubkey: &str,
140        expected_owners: impl IntoIterator<Item = &'a str>,
141    ) -> Result<Vec<u8>, WrappedKeyError> {
142        let payload = wrapped_key_signing_payload(
143            store_id,
144            recipient_pubkey,
145            self.generation,
146            &self.author_pubkey,
147            &self.sealed,
148        );
149        // Any way this fails to verify against the named authorized owner is one
150        // outcome: not authentically an authorized key, refuse it.
151        if !expected_owners
152            .into_iter()
153            .any(|owner| owner == self.author_pubkey)
154            || !keys::verify_signature_hex(&self.author_pubkey, &self.signature, &payload)
155        {
156            return Err(WrappedKeyError::SignatureMismatch);
157        }
158        // Verified as the owner's bytes; an un-decodable sealed field is a corrupt
159        // object, a distinct failure.
160        hex::decode(&self.sealed).map_err(|_| WrappedKeyError::MalformedSealed)
161    }
162
163    pub fn verify_and_open_keyring<'a>(
164        &self,
165        store_id: &str,
166        recipient_pubkey: &str,
167        expected_owners: impl IntoIterator<Item = &'a str>,
168        expected_generation: u64,
169        recipient: &dyn coven_keys::keys::IdentityKeyAuthority,
170    ) -> Result<coven_keys::encryption::EncryptionService, WrappedKeyringError> {
171        let sealed = self.verify_and_unwrap(store_id, recipient_pubkey, expected_owners)?;
172        let plaintext = keys::seal_box_decrypt(&sealed, &recipient.to_x25519_secret_key())?;
173        let keyring = coven_keys::encryption::EncryptionService::from_keyring_payload(plaintext)?;
174        if keyring.current_generation() != expected_generation {
175            return Err(WrappedKeyringError::GenerationMismatch {
176                reference: expected_generation,
177                payload: keyring.current_generation(),
178            });
179        }
180        Ok(keyring)
181    }
182}
183
184fn wrapped_key_signing_payload(
185    store_id: &str,
186    recipient_pubkey: &str,
187    generation: u64,
188    author_pubkey: &str,
189    sealed_hex: &str,
190) -> Vec<u8> {
191    let fields = WrappedKeyFields {
192        store_id,
193        recipient_pubkey,
194        generation,
195        author_pubkey,
196        sealed: sealed_hex,
197    };
198    serde_json::to_vec(&fields).expect("wrapped key fields serialization cannot fail")
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
202#[serde(deny_unknown_fields)]
203pub struct WrappedStoreKeyRef {
204    pub owner_pubkey: String,
205    pub recipient_pubkey: String,
206    pub generation: u64,
207    pub wrap_hash: ObjectHash,
208    pub object: ExactObjectRef,
209}
210
211impl WrappedStoreKeyRef {
212    pub fn semantic_prefix(&self) -> String {
213        format!(
214            "keys/{}/{}/{}/{}",
215            self.owner_pubkey, self.recipient_pubkey, self.generation, self.wrap_hash
216        )
217    }
218
219    pub fn validate_identity(&self) -> Result<(), StorageError> {
220        for pubkey in [&self.owner_pubkey, &self.recipient_pubkey] {
221            coven_foundation::store_dir::validate_path_token(pubkey)?;
222            let bytes = hex::decode(pubkey).map_err(|_| {
223                StorageError::InvalidContent(
224                    "wrapped Store-key ref contains an invalid public key".to_string(),
225                )
226            })?;
227            if bytes.len() != keys::SIGN_PUBLICKEYBYTES || hex::encode(&bytes) != *pubkey {
228                return Err(StorageError::InvalidContent(
229                    "wrapped Store-key ref contains an invalid public key".to_string(),
230                ));
231            }
232        }
233        if self.generation == 0
234            || self.object.slot().logical_key() != format!("{}.json", self.semantic_prefix())
235        {
236            return Err(StorageError::InvalidContent(
237                "wrapped Store-key ref has an invalid semantic identity".to_string(),
238            ));
239        }
240        Ok(())
241    }
242
243    pub fn validate_value(
244        &self,
245        value: &WrappedStoreKey,
246        bytes: &[u8],
247    ) -> Result<(), StorageError> {
248        self.validate_identity()?;
249        if value.author_pubkey != self.owner_pubkey
250            || value.generation != self.generation
251            || ObjectHash::digest(bytes) != self.wrap_hash
252        {
253            return Err(StorageError::InvalidContent(
254                "wrapped Store-key ref does not match its exact value".to_string(),
255            ));
256        }
257        Ok(())
258    }
259}
260
261#[derive(Clone, Debug, Serialize, Deserialize)]
262#[serde(deny_unknown_fields)]
263pub struct PreparedWrappedStoreKey {
264    pub reference: WrappedStoreKeyRef,
265    pub object: PreparedExactObject,
266}
267
268impl PreparedWrappedStoreKey {
269    pub fn validate(&self) -> Result<WrappedStoreKey, StorageError> {
270        if self.reference.object != *self.object.reference() {
271            return Err(StorageError::InvalidContent(
272                "prepared wrapped Store key carries a different exact reference".to_string(),
273            ));
274        }
275        let value: WrappedStoreKey = serde_json::from_slice(self.object.stored_bytes())?;
276        self.reference
277            .validate_value(&value, self.object.stored_bytes())?;
278        Ok(value)
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn wrapped_key_round_trips_and_returns_sealed_bytes() {
288        let owner = UserKeypair::generate();
289        let owner_hex = hex::encode(owner.public_key());
290        let sealed = vec![1u8, 2, 3, 4, 5];
291        let wrapped = WrappedStoreKey::signed("lib", "recipient-pk", 1, sealed.clone(), &owner);
292
293        // Round-trips through JSON and yields the sealed bytes back.
294        let json = serde_json::to_vec(&wrapped).expect("serialize wrapped key");
295        let parsed: WrappedStoreKey = serde_json::from_slice(&json).expect("parse wrapped key");
296        assert_eq!(
297            parsed
298                .verify_and_unwrap("lib", "recipient-pk", std::iter::once(owner_hex.as_str()))
299                .unwrap(),
300            sealed,
301        );
302    }
303
304    #[test]
305    fn wrapped_key_signed_by_non_owner_is_refused() {
306        // The object is signed by some key, but the joiner verifies against the
307        // owner it pins (the chain founder). A box the owner did not sign — here
308        // signed by a different key, the shape of a bucket writer substituting an
309        // attacker-chosen key — fails to verify against that owner and is refused.
310        let signer = UserKeypair::generate();
311        let pinned_owner = UserKeypair::generate();
312        let pinned_owner_hex = hex::encode(pinned_owner.public_key());
313        let sealed = vec![9u8; 32];
314        let wrapped = WrappedStoreKey::signed("lib", "recipient-pk", 1, sealed, &signer);
315
316        assert!(
317            matches!(
318                wrapped.verify_and_unwrap(
319                    "lib",
320                    "recipient-pk",
321                    std::iter::once(pinned_owner_hex.as_str())
322                ),
323                Err(WrappedKeyError::SignatureMismatch),
324            ),
325            "a key not signed by the pinned owner must be refused",
326        );
327    }
328
329    #[test]
330    fn wrapped_key_rejects_rebinding() {
331        let owner = UserKeypair::generate();
332        let owner_hex = hex::encode(owner.public_key());
333        let sealed = vec![9u8; 32];
334        let wrapped = WrappedStoreKey::signed("lib", "recipient-pk", 1, sealed, &owner);
335
336        // The signature binds the store and the recipient slot: changing either
337        // at verify time fails, so a key can't be replayed cross-store or
338        // relocated to another member's slot.
339        assert!(
340            matches!(
341                wrapped.verify_and_unwrap(
342                    "other-lib",
343                    "recipient-pk",
344                    std::iter::once(owner_hex.as_str())
345                ),
346                Err(WrappedKeyError::SignatureMismatch),
347            ),
348            "must reject a key replayed into a different store",
349        );
350        assert!(
351            matches!(
352                wrapped.verify_and_unwrap(
353                    "lib",
354                    "other-recipient",
355                    std::iter::once(owner_hex.as_str())
356                ),
357                Err(WrappedKeyError::SignatureMismatch),
358            ),
359            "must reject a key relocated to another recipient's slot",
360        );
361    }
362
363    #[test]
364    fn wrapped_key_rejects_author_tamper() {
365        let owner = UserKeypair::generate();
366        let other_owner = UserKeypair::generate();
367        let owner_hex = hex::encode(owner.public_key());
368        let other_owner_hex = hex::encode(other_owner.public_key());
369        let sealed = vec![9u8; 32];
370        let mut wrapped = WrappedStoreKey::signed("lib", "recipient-pk", 1, sealed, &owner);
371        assert!(
372            wrapped
373                .verify_and_unwrap("lib", "recipient-pk", std::iter::once(owner_hex.as_str()))
374                .is_ok(),
375            "freshly signed author verifies",
376        );
377
378        wrapped.author_pubkey = other_owner_hex.clone();
379        assert!(
380            matches!(
381                wrapped.verify_and_unwrap(
382                    "lib",
383                    "recipient-pk",
384                    [owner_hex.as_str(), other_owner_hex.as_str()]
385                ),
386                Err(WrappedKeyError::SignatureMismatch),
387            ),
388            "tampering with the named author invalidates the signature",
389        );
390    }
391
392    #[test]
393    fn wrapped_key_rejects_generation_tamper() {
394        let owner = UserKeypair::generate();
395        let owner_hex = hex::encode(owner.public_key());
396        let mut wrapped = WrappedStoreKey::signed("lib", "recipient-pk", 3, vec![9u8; 32], &owner);
397
398        // The generation is covered by the signature, so a bucket writer cannot
399        // raise it to wedge a member into pausing under a generation the owner
400        // never committed.
401        wrapped.generation = 99;
402        assert!(
403            matches!(
404                wrapped.verify_and_unwrap(
405                    "lib",
406                    "recipient-pk",
407                    std::iter::once(owner_hex.as_str())
408                ),
409                Err(WrappedKeyError::SignatureMismatch),
410            ),
411            "tampering with the claimed generation invalidates the signature",
412        );
413    }
414
415    #[test]
416    fn wrapped_key_malformed_signature_fails_closed() {
417        let owner = UserKeypair::generate();
418        let owner_hex = hex::encode(owner.public_key());
419        let mut wrapped = WrappedStoreKey::signed("lib", "recipient-pk", 1, vec![1u8; 4], &owner);
420
421        // A signature that isn't valid hex can't verify against the owner.
422        wrapped.signature = "not-hex!!".to_string();
423        assert!(matches!(
424            wrapped.verify_and_unwrap("lib", "recipient-pk", std::iter::once(owner_hex.as_str())),
425            Err(WrappedKeyError::SignatureMismatch),
426        ));
427    }
428
429    #[test]
430    fn wrapped_key_malformed_sealed_is_distinguished() {
431        // A correctly owner-signed object whose sealed field is not valid hex:
432        // the signature verifies (it is taken over the malformed bytes), but
433        // there is nothing to decrypt. This is a corrupt object, surfaced as a
434        // reason distinct from a signature mismatch.
435        let owner = UserKeypair::generate();
436        let owner_hex = hex::encode(owner.public_key());
437
438        let mut wrapped = WrappedStoreKey {
439            author_pubkey: owner_hex.clone(),
440            generation: 1,
441            sealed: "not-hex!!".to_string(),
442            signature: String::new(),
443        };
444        let payload = wrapped_key_signing_payload(
445            "lib",
446            "recipient-pk",
447            wrapped.generation,
448            &wrapped.author_pubkey,
449            &wrapped.sealed,
450        );
451        let (_, signature) = keys::sign_hex(&owner, &payload);
452        wrapped.signature = signature;
453
454        assert!(matches!(
455            wrapped.verify_and_unwrap("lib", "recipient-pk", std::iter::once(owner_hex.as_str())),
456            Err(WrappedKeyError::MalformedSealed),
457        ));
458    }
459
460    #[test]
461    fn wrapped_ref_generation_must_match_its_decrypted_keyring() {
462        let owner = UserKeypair::generate();
463        let recipient = UserKeypair::generate();
464        let recipient_pubkey = keys::public_key_hex(&recipient);
465        let keyring = coven_keys::encryption::EncryptionService::from_key([7; 32]);
466        let sealed = keys::seal_box_encrypt(
467            &keyring.to_keyring_payload().expect("serialize keyring"),
468            &recipient.to_x25519_public_key(),
469        );
470        let wrapped = WrappedStoreKey::signed(
471            "wrapped-generation-store",
472            &recipient_pubkey,
473            2,
474            sealed,
475            &owner,
476        );
477
478        assert!(matches!(
479            wrapped.verify_and_open_keyring(
480                "wrapped-generation-store",
481                &recipient_pubkey,
482                std::iter::once(keys::public_key_hex(&owner).as_str()),
483                2,
484                &recipient,
485            ),
486            Err(WrappedKeyringError::GenerationMismatch {
487                reference: 2,
488                payload: 1,
489            }),
490        ));
491    }
492}