basil_cose/kdf.rs
1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! KDF party identities and the ECDH-ES + HKDF-256 content-key derivation.
6//!
7//! The HKDF info is the RFC 9053 §5.2 `COSE_KDF_Context` (serialized by the
8//! codec seam), never a bespoke label. Party identities ride in the recipient
9//! protected headers (`-21`/`-24`) so any opener, including one that is not
10//! the sealer, like the broker unseal RPC, can rebuild the context from the
11//! message alone.
12
13use alloc::vec::Vec;
14
15use hkdf::Hkdf;
16use sha2::Sha256;
17use zeroize::Zeroizing;
18
19use crate::error::ProfileError;
20
21/// One party's identity slot in the `COSE_KDF_Context` (RFC 9053 §5.2).
22///
23/// v1 exposes identity only; the nonce and "other" slots are pinned nil by
24/// the profile (their header parameters are rejected on decode).
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct PartyIdentity(Option<Vec<u8>>);
27
28impl PartyIdentity {
29 /// The nil identity (anonymous party).
30 #[must_use]
31 pub const fn nil() -> Self {
32 Self(None)
33 }
34
35 /// A concrete party identity
36 ///
37 /// # Errors
38 /// [`ProfileError::EmptyPartyIdentity`] if the bytes are empty; an empty
39 /// identity is indistinguishable in intent from nil, so the profile
40 /// forbids it.
41 pub fn from_bytes(identity: Vec<u8>) -> Result<Self, ProfileError> {
42 if identity.is_empty() {
43 return Err(ProfileError::EmptyPartyIdentity);
44 }
45 Ok(Self(Some(identity)))
46 }
47
48 /// The identity bytes, when present.
49 #[must_use]
50 pub fn as_bytes(&self) -> Option<&[u8]> {
51 self.0.as_deref()
52 }
53}
54
55/// `PartyU` = message sender, `PartyV` = recipient, per RFC 9053 §5.1.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct KdfParties {
58 /// The sender's identity slot.
59 pub party_u: PartyIdentity,
60 /// The recipient's identity slot.
61 pub party_v: PartyIdentity,
62}
63
64impl KdfParties {
65 /// Both slots nil (the basil invocation v1 posture).
66 #[must_use]
67 pub const fn anonymous() -> Self {
68 Self {
69 party_u: PartyIdentity::nil(),
70 party_v: PartyIdentity::nil(),
71 }
72 }
73
74 /// Whether both slots are nil.
75 #[must_use]
76 pub const fn is_anonymous(&self) -> bool {
77 self.party_u.0.is_none() && self.party_v.0.is_none()
78 }
79}
80
81/// Key-derivation failure marker (only reachable on an out-of-range HKDF
82/// output length, never for the fixed 32-byte key; kept so the construction
83/// cannot `unwrap`).
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct KdfFailed;
86
87/// Derive the 256-bit content-encryption key: HKDF-SHA-256 with no salt over
88/// the X25519 shared secret, with the serialized `COSE_KDF_Context` as info.
89pub fn derive_cek(
90 shared_secret: &Zeroizing<[u8; 32]>,
91 kdf_context: &[u8],
92) -> Result<Zeroizing<[u8; 32]>, KdfFailed> {
93 let hk = Hkdf::<Sha256>::new(None, shared_secret.as_slice());
94 let mut okm = Zeroizing::new([0u8; 32]);
95 hk.expand(kdf_context, okm.as_mut_slice())
96 .map_err(|_| KdfFailed)?;
97 Ok(okm)
98}