Skip to main content

basil_cose/
encrypt.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! The seal-only construction: a bare `COSE_Encrypt` (ECDH-ES + HKDF-256 to
6//! one X25519 recipient, AEAD content encryption).
7//!
8//! Tamper evidence comes from the AEAD only; there is no sender identity
9//! here (see the sealed construction for signed messages). Claims are not
10//! part of the seal-only construction in v1.
11
12use alloc::vec::Vec;
13
14use aes_gcm::Aes256Gcm;
15use aes_gcm::aead::{Aead, KeyInit, Payload};
16use chacha20poly1305::ChaCha20Poly1305 as ChaChaCipher;
17use x25519_dalek::{PublicKey, StaticSecret};
18use zeroize::Zeroizing;
19
20use crate::alg::ContentAlgorithm;
21use crate::claims::Claims;
22use crate::codec::{self, ClaimsExpectation, NONCE_LEN};
23use crate::error::{BuildError, DecodeError, OpenError};
24use crate::kdf::{self, KdfParties};
25use crate::keys::X25519RecipientPublic;
26use crate::traits::{OpenRequest, Recipient};
27use crate::types::{ContentType, CoseBytes, ExternalAad, KeyId};
28
29/// Parameters for [`build_encrypted`].
30#[derive(Debug, Clone)]
31pub struct EncryptParams<'a> {
32    /// The plaintext content type (protected header 3).
33    pub content_type: ContentType,
34    /// The plaintext to seal.
35    pub plaintext: &'a [u8],
36    /// The recipient's static X25519 public key.
37    pub recipient: X25519RecipientPublic,
38    /// The content-encryption algorithm.
39    pub content_algorithm: ContentAlgorithm,
40    /// The `Enc_structure` external AAD.
41    pub external_aad: ExternalAad,
42    /// KDF party identities (ride in the recipient protected header).
43    pub kdf_parties: KdfParties,
44}
45
46/// Deterministic parts for fixture builds: the ephemeral private key and the
47/// AEAD nonce that production builds generate randomly.
48#[cfg(feature = "fixtures")]
49#[derive(Debug)]
50pub struct SealParts {
51    /// The ephemeral X25519 private key bytes.
52    pub ephemeral_private: Zeroizing<[u8; 32]>,
53    /// The AEAD nonce.
54    pub nonce: [u8; NONCE_LEN],
55}
56
57/// Fill a fixed-size buffer with OS randomness.
58pub fn random_array<const N: usize>() -> Result<Zeroizing<[u8; N]>, BuildError> {
59    let mut buf = Zeroizing::new([0u8; N]);
60    getrandom::fill(buf.as_mut_slice()).map_err(|_| BuildError::Rng)?;
61    Ok(buf)
62}
63
64/// AEAD-seal `plaintext` under `key`/`nonce`, binding `aad`.
65pub fn aead_seal(
66    alg: ContentAlgorithm,
67    key: &Zeroizing<[u8; 32]>,
68    nonce: &[u8; NONCE_LEN],
69    plaintext: &[u8],
70    aad: &[u8],
71) -> Result<Vec<u8>, BuildError> {
72    let payload = Payload {
73        msg: plaintext,
74        aad,
75    };
76    match alg {
77        ContentAlgorithm::A256Gcm => Aes256Gcm::new_from_slice(key.as_slice())
78            .map_err(|_| BuildError::SealFailed)?
79            .encrypt(aes_gcm::Nonce::from_slice(nonce), payload)
80            .map_err(|_| BuildError::SealFailed),
81        ContentAlgorithm::ChaCha20Poly1305 => ChaChaCipher::new_from_slice(key.as_slice())
82            .map_err(|_| BuildError::SealFailed)?
83            .encrypt(chacha20poly1305::Nonce::from_slice(nonce), payload)
84            .map_err(|_| BuildError::SealFailed),
85    }
86}
87
88/// AEAD-open `ciphertext` under `key`/`nonce`, binding `aad`. All
89/// authentication failures are the single opaque [`OpenError::OpenFailed`].
90pub fn aead_open(
91    alg: ContentAlgorithm,
92    key: &Zeroizing<[u8; 32]>,
93    nonce: &[u8; NONCE_LEN],
94    ciphertext: &[u8],
95    aad: &[u8],
96) -> Result<Zeroizing<Vec<u8>>, OpenError> {
97    let payload = Payload {
98        msg: ciphertext,
99        aad,
100    };
101    let plaintext = match alg {
102        ContentAlgorithm::A256Gcm => Aes256Gcm::new_from_slice(key.as_slice())
103            .map_err(|_| OpenError::OpenFailed)?
104            .decrypt(aes_gcm::Nonce::from_slice(nonce), payload)
105            .map_err(|_| OpenError::OpenFailed)?,
106        ContentAlgorithm::ChaCha20Poly1305 => ChaChaCipher::new_from_slice(key.as_slice())
107            .map_err(|_| OpenError::OpenFailed)?
108            .decrypt(chacha20poly1305::Nonce::from_slice(nonce), payload)
109            .map_err(|_| OpenError::OpenFailed)?,
110    };
111    Ok(Zeroizing::new(plaintext))
112}
113
114/// Everything the encrypt core needs; shared by the seal-only and sealed
115/// constructions.
116pub struct EncryptCore<'a> {
117    /// The content-encryption algorithm.
118    pub content_algorithm: ContentAlgorithm,
119    /// The plaintext content type.
120    pub content_type: &'a ContentType,
121    /// Claims for the content protected header (sealed construction only).
122    pub claims: Option<&'a Claims>,
123    /// The plaintext.
124    pub plaintext: &'a [u8],
125    /// The recipient's static public key.
126    pub recipient: &'a X25519RecipientPublic,
127    /// The `Enc_structure` external AAD.
128    pub external_aad: &'a ExternalAad,
129    /// KDF party identities.
130    pub kdf_parties: &'a KdfParties,
131}
132
133/// Build the complete tagged `COSE_Encrypt` bytes from explicit ephemeral
134/// and nonce material. Production paths generate both randomly.
135pub fn build_encrypt_core(
136    core: &EncryptCore<'_>,
137    ephemeral_private: &Zeroizing<[u8; 32]>,
138    nonce: [u8; NONCE_LEN],
139) -> Result<Vec<u8>, BuildError> {
140    let ephemeral_secret = StaticSecret::from(**ephemeral_private);
141    let ephemeral_pub = PublicKey::from(&ephemeral_secret).to_bytes();
142
143    let recipient_pub = PublicKey::from(core.recipient.public);
144    let shared_secret = ephemeral_secret.diffie_hellman(&recipient_pub);
145    // Reject a low-order / all-zero shared secret before deriving: a
146    // degenerate recipient public would force a known AEAD key.
147    if !shared_secret.was_contributory() {
148        return Err(BuildError::SealFailed);
149    }
150    let shared = Zeroizing::new(shared_secret.to_bytes());
151
152    let recipient_protected = codec::encode_recipient_protected(core.kdf_parties)
153        .map_err(|codec::CodecError| BuildError::Codec)?;
154    let info = codec::kdf_context(
155        core.content_algorithm,
156        core.kdf_parties,
157        &recipient_protected,
158    )
159    .map_err(|codec::CodecError| BuildError::Codec)?;
160    let cek = kdf::derive_cek(&shared, &info).map_err(|kdf::KdfFailed| BuildError::SealFailed)?;
161
162    let protected =
163        codec::encode_encrypt_protected(core.content_algorithm, core.content_type, core.claims)
164            .map_err(|codec::CodecError| BuildError::Codec)?;
165    let aad = codec::enc_structure(&protected, core.external_aad.as_bytes())
166        .map_err(|codec::CodecError| BuildError::Codec)?;
167    let ciphertext = aead_seal(core.content_algorithm, &cek, &nonce, core.plaintext, &aad)?;
168
169    codec::assemble_encrypt(&codec::EncryptAssembly {
170        protected: &protected,
171        iv: &nonce,
172        ciphertext: &ciphertext,
173        recipient_protected: &recipient_protected,
174        recipient_kid: &core.recipient.key_id,
175        ephemeral_x: &ephemeral_pub,
176    })
177    .map_err(|codec::CodecError| BuildError::Codec)
178}
179
180impl EncryptParams<'_> {
181    const fn core(&self) -> EncryptCore<'_> {
182        EncryptCore {
183            content_algorithm: self.content_algorithm,
184            content_type: &self.content_type,
185            claims: None,
186            plaintext: self.plaintext,
187            recipient: &self.recipient,
188            external_aad: &self.external_aad,
189            kdf_parties: &self.kdf_parties,
190        }
191    }
192}
193
194/// Seal `plaintext` to one X25519 recipient as a bare tagged `COSE_Encrypt`.
195///
196/// Fully local and synchronous: sealing needs only the recipient public key,
197/// no signer and no broker. The library generates the 12-byte nonce and
198/// the ephemeral X25519 keypair internally (fresh per message, zeroized);
199/// there is no caller-supplied-nonce path in the public API.
200///
201/// # Errors
202/// [`BuildError::Rng`] when OS randomness is unavailable;
203/// [`BuildError::SealFailed`]/[`BuildError::Codec`] on crypto-internal
204/// failures that should not occur for in-range inputs.
205pub fn build_encrypted(params: &EncryptParams<'_>) -> Result<CoseBytes, BuildError> {
206    let ephemeral = random_array::<32>()?;
207    let nonce = random_array::<NONCE_LEN>()?;
208    build_encrypt_core(&params.core(), &ephemeral, *nonce).map(CoseBytes::new)
209}
210
211/// [`build_encrypted`] with caller-supplied ephemeral/nonce parts, for
212/// deterministic test vectors only.
213///
214/// # Errors
215/// As [`build_encrypted`], minus [`BuildError::Rng`].
216#[cfg(feature = "fixtures")]
217pub fn build_encrypted_with_parts(
218    params: &EncryptParams<'_>,
219    parts: &SealParts,
220) -> Result<CoseBytes, BuildError> {
221    build_encrypt_core(&params.core(), &parts.ephemeral_private, parts.nonce).map(CoseBytes::new)
222}
223
224/// A strictly decoded (not yet opened) seal-only message.
225#[derive(Debug, Clone)]
226pub struct EncryptedMessage {
227    /// The plaintext content type from the protected header.
228    pub content_type: ContentType,
229    /// The recipient static key id this message is sealed to.
230    pub recipient_key_id: KeyId,
231    /// The content-encryption algorithm.
232    pub content_algorithm: ContentAlgorithm,
233    /// The KDF party identities from the recipient protected header.
234    pub parties: KdfParties,
235    /// The exact tagged bytes (the `Enc_structure` binds the exact protected
236    /// header serialization, so openers always work from these).
237    bytes: Vec<u8>,
238}
239
240/// Strict-decode a seal-only tagged `COSE_Encrypt` (no opening, no claims).
241///
242/// # Errors
243/// Any [`DecodeError`] the strict profile decoder emits; claims labels in a
244/// seal-only message are rejected.
245pub fn decode_encrypted(bytes: &[u8]) -> Result<EncryptedMessage, DecodeError> {
246    let decoded = codec::decode_encrypt_strict(bytes, ClaimsExpectation::Forbidden)?;
247    Ok(EncryptedMessage {
248        content_type: decoded.content_type,
249        recipient_key_id: decoded.recipient_kid,
250        content_algorithm: decoded.content_algorithm,
251        parties: decoded.parties,
252        bytes: bytes.to_vec(),
253    })
254}
255
256/// An opened (decrypted) message.
257#[derive(Debug)]
258pub struct Opened {
259    /// The recovered plaintext, in a zeroizing buffer.
260    pub plaintext: Zeroizing<Vec<u8>>,
261    /// The plaintext content type.
262    pub content_type: ContentType,
263}
264
265impl EncryptedMessage {
266    /// Open this message with `recipient`, binding `aad`, optionally pinning
267    /// the KDF party identities.
268    ///
269    /// # Errors
270    /// [`OpenError::RecipientKeyMismatch`] when the message is addressed to
271    /// a different key; [`OpenError::PartyMismatch`] when pinned parties
272    /// disagree with the wire; [`OpenError::OpenFailed`] (opaque) on any
273    /// authentication failure.
274    pub async fn open<R: Recipient>(
275        &self,
276        recipient: &R,
277        aad: &ExternalAad,
278        expected_parties: Option<&KdfParties>,
279    ) -> Result<Opened, OpenError> {
280        if recipient.key_id() != &self.recipient_key_id {
281            return Err(OpenError::RecipientKeyMismatch);
282        }
283        if let Some(expected) = expected_parties
284            && *expected != self.parties
285        {
286            return Err(OpenError::PartyMismatch);
287        }
288        let request = OpenRequest {
289            cose_encrypt: &self.bytes,
290            external_aad: aad,
291            expected_parties,
292        };
293        let plaintext = recipient.open(&request).await?;
294        Ok(Opened {
295            plaintext,
296            content_type: self.content_type.clone(),
297        })
298    }
299}