Skip to main content

aranya_crypto/
groupkey.rs

1#![forbid(unsafe_code)]
2
3use core::{cell::OnceCell, iter, marker::PhantomData, result::Result};
4
5use derive_where::derive_where;
6use spideroak_crypto::{
7    aead::{Aead, BufferTooSmallError, KeyData, OpenError, SealError, Tag},
8    csprng::{Csprng, Random},
9    ctutils::{Choice, CtEq},
10    hash::{Digest, Hash},
11    import::Import,
12    typenum::U64,
13    zeroize::{Zeroize as _, ZeroizeOnDrop},
14};
15
16use crate::{
17    aranya::VerifyingKey,
18    ciphersuite::{CipherSuite, CipherSuiteExt as _},
19    engine::unwrapped,
20    error::Error,
21    hybrid_array::Array,
22    id::{IdError, Identified, custom_id},
23    policy::CmdId,
24};
25
26/// Key material used to derive per-event encryption keys.
27pub struct GroupKey<CS> {
28    seed: [u8; 64],
29    id: OnceCell<Result<GroupKeyId, IdError>>,
30    _cs: PhantomData<CS>,
31}
32
33impl<CS> ZeroizeOnDrop for GroupKey<CS> {}
34impl<CS> Drop for GroupKey<CS> {
35    fn drop(&mut self) {
36        self.seed.zeroize();
37    }
38}
39
40impl<CS> Clone for GroupKey<CS> {
41    fn clone(&self) -> Self {
42        Self {
43            seed: self.seed,
44            id: OnceCell::new(),
45            _cs: PhantomData,
46        }
47    }
48}
49
50impl<CS: CipherSuite> GroupKey<CS> {
51    /// Creates a new, random `GroupKey`.
52    pub fn new<R: Csprng>(rng: R) -> Self {
53        Self::from_seed(Random::random(rng))
54    }
55
56    /// Uniquely identifies the [`GroupKey`].
57    ///
58    /// Two keys with the same ID are the same key.
59    #[inline]
60    pub fn id(&self) -> Result<GroupKeyId, IdError> {
61        self.id
62            .get_or_init(|| {
63                // prk = LabeledExtract(
64                //     "GroupKeyId-v1",
65                //     {0}^n,
66                //     "prk",
67                //     seed,
68                // )
69                // GroupKey = LabeledExpand(
70                //     "GroupKeyId-v1",
71                //     prk,
72                //     "id",
73                //     {0}^0,
74                // )
75                const DOMAIN: &[u8] = b"GroupKeyId-v1";
76                let prk = CS::labeled_extract(DOMAIN, &[], b"prk", iter::once::<&[u8]>(&self.seed));
77                CS::labeled_expand(DOMAIN, &prk, b"id", [])
78                    .map_err(|_| IdError::new("unable to expand PRK"))
79                    .map(GroupKeyId::from_bytes)
80            })
81            .clone()
82    }
83
84    /// The size in bytes of the overhead added to plaintexts
85    /// encrypted with [`seal`][Self::seal].
86    pub const OVERHEAD: usize = CS::Aead::NONCE_SIZE + CS::Aead::OVERHEAD;
87
88    /// Returns the size in bytes of the overhead added to
89    /// plaintexts encrypted with [`seal`][Self::seal].
90    ///
91    /// Same as [`OVERHEAD`][Self::OVERHEAD].
92    pub const fn overhead(&self) -> usize {
93        Self::OVERHEAD
94    }
95
96    /// Encrypts and authenticates `plaintext` in a particular
97    /// context.
98    ///
99    /// The resulting ciphertext is written to `dst`, which must
100    /// be at least [`overhead`][Self::overhead] bytes longer
101    /// than `plaintext.len()`.
102    ///
103    /// # Example
104    ///
105    /// ```rust
106    /// # #[cfg(all(feature = "alloc", not(feature = "trng")))]
107    /// # {
108    /// use aranya_crypto::{
109    ///     Context, GroupKey, Rng, SigningKey,
110    ///     default::{DefaultCipherSuite, DefaultEngine},
111    ///     policy::CmdId,
112    /// };
113    ///
114    /// const MESSAGE: &[u8] = b"hello, world!";
115    /// const LABEL: &str = "doc test";
116    /// const PARENT: CmdId = CmdId::default();
117    /// let author = SigningKey::<DefaultCipherSuite>::new(Rng)
118    ///     .public()
119    ///     .expect("signing key should be valid");
120    ///
121    /// let key = GroupKey::new(Rng);
122    ///
123    /// let ciphertext = {
124    ///     let mut dst = vec![0u8; MESSAGE.len() + key.overhead()];
125    ///     key.seal(
126    ///         Rng,
127    ///         &mut dst,
128    ///         MESSAGE,
129    ///         Context {
130    ///             label: LABEL,
131    ///             parent: PARENT,
132    ///             author_sign_pk: &author,
133    ///         },
134    ///     )
135    ///     .expect("should not fail");
136    ///     dst
137    /// };
138    /// let plaintext = {
139    ///     let mut dst = vec![0u8; ciphertext.len() - key.overhead()];
140    ///     key.open(
141    ///         &mut dst,
142    ///         &ciphertext,
143    ///         Context {
144    ///             label: LABEL,
145    ///             parent: PARENT,
146    ///             author_sign_pk: &author,
147    ///         },
148    ///     )
149    ///     .expect("should not fail");
150    ///     dst
151    /// };
152    /// assert_eq!(&plaintext, MESSAGE);
153    /// # }
154    /// ```
155    pub fn seal<R: Csprng>(
156        &self,
157        rng: R,
158        dst: &mut [u8],
159        plaintext: &[u8],
160        ctx: Context<'_, CS>,
161    ) -> Result<(), Error> {
162        if dst.len() < self.overhead() {
163            // Not enough room in `dst`.
164            return Err(Error::Seal(SealError::BufferTooSmall(BufferTooSmallError(
165                self.overhead().checked_add(plaintext.len()),
166            ))));
167        }
168        let (nonce, out) = dst.split_at_mut(CS::Aead::NONCE_SIZE);
169        rng.fill_bytes(nonce);
170        let info = ctx.to_bytes()?;
171        let key = self.derive_key(&info)?;
172        Ok(CS::Aead::new(&key).seal(out, nonce, plaintext, &info)?)
173    }
174
175    /// Decrypts and authenticates `ciphertext` in a particular
176    /// context.
177    ///
178    /// The resulting plaintext is written to `dst`, which must
179    /// be at least as long as the original plaintext (i.e.,
180    /// `ciphertext.len()` - [`overhead`][Self::overhead] bytes
181    /// long).
182    pub fn open(
183        &self,
184        dst: &mut [u8],
185        ciphertext: &[u8],
186        ctx: Context<'_, CS>,
187    ) -> Result<(), Error> {
188        if ciphertext.len() < self.overhead() {
189            // Can't find the nonce and/or tag, so it's obviously
190            // invalid.
191            return Err(OpenError::Authentication.into());
192        }
193        let (nonce, ciphertext) = ciphertext.split_at(CS::Aead::NONCE_SIZE);
194        let info = ctx.to_bytes()?;
195        let key = self.derive_key(&info)?;
196        Ok(CS::Aead::new(&key).open(dst, nonce, ciphertext, &info)?)
197    }
198
199    /// Derives a key for [`Self::open`] and [`Self::seal`].
200    fn derive_key(&self, info: &[u8]) -> Result<<CS::Aead as Aead>::Key, Error> {
201        // prk = LabeledExtract(
202        //     "kdf-ext-v1",
203        //     {0}^n,
204        //     "EventKey_prk",
205        //     seed,
206        // )
207        // GroupKey = LabeledExpand(
208        //     "kdf-exp-v1",
209        //     prk,
210        //     "EventKey_key",
211        //     info,
212        // )
213        let prk = CS::labeled_extract(
214            b"kdf-ext-v1",
215            &[],
216            b"EventKey_prk",
217            iter::once::<&[u8]>(&self.seed),
218        );
219        let key: KeyData<CS::Aead> =
220            CS::labeled_expand(b"kdr-exp-v1", &prk, b"EventKey_key", [info])?;
221        Ok(<<CS::Aead as Aead>::Key as Import<_>>::import(
222            key.as_bytes(),
223        )?)
224    }
225
226    // Utility routines for other modules.
227
228    /// Returns the underlying seed.
229    pub(crate) const fn raw_seed(&self) -> &[u8; 64] {
230        &self.seed
231    }
232
233    /// Creates itself from the seed.
234    pub(crate) const fn from_seed(seed: [u8; 64]) -> Self {
235        Self {
236            seed,
237            id: OnceCell::new(),
238            _cs: PhantomData,
239        }
240    }
241}
242
243unwrapped! {
244    name: GroupKey;
245    type: Seed;
246    into: |key: Self| { key.seed };
247    from: |seed: [u8;64] | { Self::from_seed(seed) };
248}
249
250impl<CS: CipherSuite> Identified for GroupKey<CS> {
251    type Id = GroupKeyId;
252
253    #[inline]
254    fn id(&self) -> Result<Self::Id, IdError> {
255        self.id()
256    }
257}
258
259impl<CS: CipherSuite> CtEq for GroupKey<CS> {
260    #[inline]
261    fn ct_eq(&self, other: &Self) -> Choice {
262        self.seed.ct_eq(&other.seed)
263    }
264}
265
266/// Contextual binding for [`GroupKey::seal`] and
267/// [`GroupKey::open`].
268pub struct Context<'a, CS: CipherSuite> {
269    /// Describes what is being encrypted.
270    ///
271    /// For example, it could be an event name.
272    pub label: &'a str,
273    /// The stable ID of the parent event.
274    pub parent: CmdId,
275    /// The public key of the author of the encrypted data.
276    pub author_sign_pk: &'a VerifyingKey<CS>,
277}
278
279impl<CS: CipherSuite> Context<'_, CS> {
280    /// Converts the [`Context`] to its byte representation.
281    fn to_bytes(&self) -> Result<Digest<<CS::Hash as Hash>::DigestSize>, Error> {
282        // Ideally, this would simple be the actual concatenation
283        // of `Context`'s fields. However, we need to be
284        // `no_alloc` and without `const_generic_exprs` it's
285        // quite difficult to concatenate the fields into
286        // a fixed-size buffer.
287        //
288        // So, we instead hash the fields into a fixed-size
289        // buffer. We use `tuple_hash` out of paranoia, but
290        // a regular hash should also suffice.
291        Ok(CS::tuple_hash(
292            b"GroupKey",
293            [
294                self.label.as_bytes(),
295                self.parent.as_ref(),
296                self.author_sign_pk.id()?.as_bytes(),
297            ],
298        ))
299    }
300}
301
302custom_id! {
303    /// Uniquely identifies a [`GroupKey`].
304    pub struct GroupKeyId;
305}
306
307/// An encrypted [`GroupKey`].
308#[derive_where(Clone, Debug, Serialize, Deserialize)]
309pub struct EncryptedGroupKey<CS: CipherSuite> {
310    pub(crate) ciphertext: Array<u8, U64>,
311    pub(crate) tag: Tag<CS::Aead>,
312}
313
314#[cfg(test)]
315mod tests {
316    #![allow(clippy::arithmetic_side_effects)]
317
318    use super::*;
319    use crate::{Rng, SigningKey, default::DefaultCipherSuite};
320
321    type CS = DefaultCipherSuite;
322
323    fn author() -> VerifyingKey<CS> {
324        SigningKey::<CS>::new(Rng)
325            .public()
326            .expect("signing key should be valid")
327    }
328
329    fn ctx<'a>(author: &'a VerifyingKey<CS>) -> Context<'a, CS> {
330        Context {
331            label: "test",
332            parent: CmdId::default(),
333            author_sign_pk: author,
334        }
335    }
336
337    /// Exercises both the happy path and the "`dst` too small"
338    /// error path of [`GroupKey::seal`].
339    #[test]
340    fn test_seal_dst_too_small() {
341        let author = author();
342        let key = GroupKey::<CS>::new(Rng);
343        const MESSAGE: &[u8] = b"hello, world!";
344
345        // Happy path: `dst` is large enough.
346        let mut dst = vec![0u8; MESSAGE.len() + key.overhead()];
347        key.seal(Rng, &mut dst, MESSAGE, ctx(&author))
348            .expect("`seal` should succeed");
349
350        // Error path: `dst` is shorter than `overhead()`.
351        let mut small = [0u8; 1];
352        let err = key
353            .seal(Rng, &mut small, MESSAGE, ctx(&author))
354            .expect_err("`seal` should fail when `dst` is too small");
355        assert!(matches!(err, Error::Seal(_)), "got {err:?}");
356    }
357
358    /// Exercises both the happy path and the "`ciphertext` too
359    /// short" error path of [`GroupKey::open`].
360    #[test]
361    fn test_open_ciphertext_too_short() {
362        let author = author();
363        let key = GroupKey::<CS>::new(Rng);
364        const MESSAGE: &[u8] = b"hello, world!";
365
366        let mut ciphertext = vec![0u8; MESSAGE.len() + key.overhead()];
367        key.seal(Rng, &mut ciphertext, MESSAGE, ctx(&author))
368            .expect("`seal` should succeed");
369
370        // Happy path: decrypt the full ciphertext.
371        let mut plaintext = vec![0u8; ciphertext.len() - key.overhead()];
372        key.open(&mut plaintext, &ciphertext, ctx(&author))
373            .expect("`open` should succeed");
374        assert_eq!(plaintext, MESSAGE);
375
376        // Error path: `ciphertext` is shorter than `overhead()`.
377        let err = key
378            .open(&mut plaintext, b"short", ctx(&author))
379            .expect_err("`open` should fail when `ciphertext` is too short");
380        assert!(matches!(err, Error::Open(_)), "got {err:?}");
381    }
382}