Skip to main content

coven_crypto/
derivation.rs

1//! HKDF labels are the durable separation between cryptographic purposes (Appendix D).
2
3use hkdf::Hkdf;
4use hmac::{Hmac, KeyInit, Mac};
5use sha2::{Digest, Sha256};
6use zeroize::{Zeroize, Zeroizing};
7
8use crate::{cipher, AuthorshipHasher, CryptoError};
9
10pub(crate) const ENCRYPTION: &[u8] = b"coven/encryption/v1";
11pub(crate) const JOIN_REQUEST: &[u8] = b"coven/join-request/v1";
12pub(crate) const CONFIRMATION_NUMBER: &[u8] = b"coven/confirmation-number/v1";
13pub(crate) const SEALED_BOX: &[u8] = b"coven/sealed-box/v1";
14pub(crate) const AUTHORSHIP: &[u8] = b"coven/row-authorship/v1";
15pub(crate) const KEPT_VALUE: &[u8] = b"coven/kept-value/v1";
16
17pub(crate) fn derive(key: &[u8; 32], context: &[u8]) -> Zeroizing<[u8; 32]> {
18    let mut output = Zeroizing::new([0; 32]);
19    let (mut extracted, hkdf) = Hkdf::<Sha256>::extract(None, key);
20    extracted.as_mut_slice().zeroize();
21    hkdf.expand(context, output.as_mut())
22        .expect("32-byte derived keys fit HKDF-SHA256's output limit");
23    output
24}
25
26pub(crate) fn derive_label(key: &[u8; 32], label: &[u8]) -> Zeroizing<[u8; 32]> {
27    derive(key, &cipher::context(&[label]))
28}
29
30pub(crate) fn mac(key: &[u8; 32]) -> Hmac<Sha256> {
31    Hmac::<Sha256>::new_from_slice(key).expect("HMAC-SHA256 accepts a 32-byte key")
32}
33
34/// A purpose-derived key for objects, including an invite's join request (Appendix D).
35pub struct EncryptionKey(pub(crate) Zeroizing<[u8; 32]>);
36
37impl EncryptionKey {
38    /// Seal an object chunk with a random nonce, binding its whole cleartext prefix.
39    /// Section 0 holds a write's header; part i uses section i + 1. A snapshot
40    /// uses section 0. Indices start at zero within each section.
41    /// Panics if the storage path is empty.
42    pub fn seal_object_chunk(
43        &self,
44        path: &str,
45        prefix: &[u8],
46        section: u64,
47        index: u64,
48        plaintext: &[u8],
49    ) -> Result<Vec<u8>, CryptoError> {
50        cipher::seal_random(&self.0, &object_chunk_aad(path, prefix, section, index), plaintext)
51    }
52
53    /// Open a chunk only at its authenticated path, prefix, section and index.
54    /// Panics if the storage path is empty.
55    pub fn open_object_chunk(
56        &self,
57        path: &str,
58        prefix: &[u8],
59        section: u64,
60        index: u64,
61        sealed: &[u8],
62    ) -> Result<Vec<u8>, CryptoError> {
63        cipher::open_random(&self.0, &object_chunk_aad(path, prefix, section, index), sealed)
64    }
65}
66
67impl std::fmt::Debug for EncryptionKey {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        f.write_str("EncryptionKey([REDACTED])")
70    }
71}
72
73/// Separate encryption and authorship keys for one audience.
74pub struct DerivedKeys {
75    encryption: EncryptionKey,
76    authorship: Zeroizing<[u8; 32]>,
77}
78
79impl DerivedKeys {
80    pub(crate) fn new(key: &[u8; 32]) -> Self {
81        Self {
82            encryption: EncryptionKey(derive_label(key, ENCRYPTION)),
83            authorship: derive_label(key, AUTHORSHIP),
84        }
85    }
86
87    /// Hash a write part's authorship leaves and branches under this
88    /// audience's key, so only its readers can test a row guess (D9, D11).
89    pub fn authorship_hasher(&self) -> AuthorshipHasher {
90        AuthorshipHasher::new(&self.authorship)
91    }
92
93    /// Seal one chunk with this audience's derived encryption key (Appendix D).
94    /// See [`EncryptionKey::seal_object_chunk`] for section and index numbering.
95    pub fn seal_object_chunk(
96        &self,
97        path: &str,
98        prefix: &[u8],
99        section: u64,
100        index: u64,
101        plaintext: &[u8],
102    ) -> Result<Vec<u8>, CryptoError> {
103        self.encryption.seal_object_chunk(path, prefix, section, index, plaintext)
104    }
105
106    /// Re-seal an inner-write or sequence-slot chunk identically on every
107    /// attempt (D9/D11).
108    /// The nonce binds the path, complete cleartext prefix, section, index and
109    /// SHA-256 of the complete chunk plaintext, including any frame bytes.
110    /// Different content at a reused path has different nonce inputs. Retries
111    /// fix plaintext, prefix and key choices to reproduce the same bytes.
112    /// Snapshots, floors and join requests use `seal_object_chunk` instead.
113    pub fn reseal_object_chunk(
114        &self,
115        path: &str,
116        prefix: &[u8],
117        section: u64,
118        index: u64,
119        plaintext: &[u8],
120    ) -> Vec<u8> {
121        let key = &self.encryption.0;
122        let mut nonce = mac(key);
123        nonce.update(&cipher::context(&[
124            b"coven/object-nonce/v2",
125            cipher::storage_path(path),
126            prefix,
127            &section.to_be_bytes(),
128            &index.to_be_bytes(),
129            &Sha256::digest(plaintext),
130        ]));
131        let nonce = nonce.finalize().into_bytes();
132        let nonce: &[u8; 24] = nonce[..24].try_into().expect("24-byte nonce");
133        let mut sealed = nonce.to_vec();
134        sealed.extend(cipher::seal(
135            key,
136            nonce,
137            &object_chunk_aad(path, prefix, section, index),
138            plaintext,
139        ));
140        sealed
141    }
142
143    /// Open a chunk only at its authenticated path, prefix, section and index.
144    /// Panics if the storage path is empty.
145    pub fn open_object_chunk(
146        &self,
147        path: &str,
148        prefix: &[u8],
149        section: u64,
150        index: u64,
151        sealed: &[u8],
152    ) -> Result<Vec<u8>, CryptoError> {
153        self.encryption.open_object_chunk(path, prefix, section, index, sealed)
154    }
155
156    /// Open a header whose authentication identity is inside its plaintext.
157    /// `identity` receives only the fixed routing prefix as an untrusted hint;
158    /// it must only construct the identity, never install or report that hint.
159    /// No plaintext is returned until the ordinary AEAD check succeeds with
160    /// the selected identity, exact prefix, and header coordinates (0, 0).
161    ///
162    /// D9 inner writes use this because their stable write number is sealed.
163    /// ChaCha's keystream does not depend on associated data (RFC 8439 ยง2.8).
164    /// The tentative bytes and cipher state are erased after routing.
165    pub fn open_identified_header<const N: usize>(
166        &self,
167        prefix: &[u8],
168        sealed: &[u8],
169        identity: impl FnOnce(&[u8; N]) -> Result<String, CryptoError>,
170    ) -> Result<Vec<u8>, CryptoError> {
171        use chacha20::{
172            cipher::{StreamCipher, StreamCipherSeek},
173            KeyIvInit, XChaCha20,
174        };
175        let (nonce, ciphertext) = sealed.split_at_checked(24).ok_or(CryptoError::Malformed)?;
176        if ciphertext.len().checked_sub(cipher::FILE_CHUNK_TAG_LEN).is_none_or(|length| length < N)
177        {
178            return Err(CryptoError::Malformed);
179        }
180        let mut tentative = Zeroizing::new([0; N]);
181        tentative.copy_from_slice(&ciphertext[..N]);
182        let key: &[u8; 32] = &self.encryption.0;
183        let nonce: &[u8; 24] = nonce.try_into().expect("24-byte split");
184        let mut stream = XChaCha20::new(key.into(), nonce.into());
185        // Block zero supplies Poly1305's key; payload encryption starts at one.
186        stream.seek(64u64);
187        stream.apply_keystream(tentative.as_mut());
188        let path = identity(&tentative)?;
189        self.open_object_chunk(&path, prefix, 0, 0, sealed)
190    }
191}
192
193impl std::fmt::Debug for DerivedKeys {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        f.write_str("DerivedKeys([REDACTED])")
196    }
197}
198
199fn object_chunk_aad(path: &str, prefix: &[u8], section: u64, index: u64) -> Vec<u8> {
200    cipher::context(&[
201        b"coven/object-chunk/v1",
202        cipher::storage_path(path),
203        prefix,
204        &section.to_be_bytes(),
205        &index.to_be_bytes(),
206    ])
207}
208
209#[cfg(test)]
210#[path = "derivation_tests.rs"]
211mod tests;