coapcore/
ace.rs

1//! Experimental types for ACE, COSE and CWT structures
2//!
3//! On the long run, those might contribute to
4//! <https://github.com/namib-project/dcaf-rs/issues/29>.
5
6use defmt_or_log::trace;
7
8use crate::error::{CredentialError, CredentialErrorDetail};
9
10use crate::helpers::COwn;
11
12/// Fixed length of the ACE OSCORE nonce issued by this module.
13pub(crate) const OWN_NONCE_LEN: usize = 8;
14
15/// Size allocated for the ACE OSCORE nonces chosen by the peers.
16const MAX_SUPPORTED_PEER_NONCE_LEN: usize = 16;
17
18/// Maximum size a CWT processed by this module can have (at least when it needs to be copied)
19const MAX_SUPPORTED_ACCESSTOKEN_LEN: usize = 256;
20/// Maximum size of a COSE_Encrypt0 protected header (used to size the AAD buffer)
21const MAX_SUPPORTED_ENCRYPT_PROTECTED_LEN: usize = 32;
22
23/// The content of an application/ace+cbor file.
24///
25/// Full attribute references are in the [OAuth Parameters CBOR Mappings
26/// registry](https://www.iana.org/assignments/ace/ace.xhtml#oauth-parameters-cbor-mappings).
27#[cfg_attr(feature = "defmt", derive(defmt::Format))]
28#[derive(minicbor::Decode, minicbor::Encode, Default)]
29#[cbor(map)]
30#[non_exhaustive]
31struct AceCbor<'a> {
32    #[cbor(b(1), with = "minicbor::bytes")]
33    access_token: Option<&'a [u8]>,
34    #[cbor(b(40), with = "minicbor::bytes")]
35    nonce1: Option<&'a [u8]>,
36    #[cbor(b(42), with = "minicbor::bytes")]
37    nonce2: Option<&'a [u8]>,
38    #[cbor(b(43), with = "minicbor::bytes")]
39    ace_client_recipientid: Option<&'a [u8]>,
40    #[cbor(b(44), with = "minicbor::bytes")]
41    ace_server_recipientid: Option<&'a [u8]>,
42}
43
44/// The content of a POST to the /authz-info endpoint of a client.
45///
46/// # Open questions
47/// Should we subset the type to add more constraints on fields?
48///
49/// * Pro type alias: Shared parsing code for all cases.
50/// * Pro subtype: Easier usability, errors directly from minicbor.
51type UnprotectedAuthzInfoPost<'a> = AceCbor<'a>;
52
53/// A COSE header map.
54///
55/// Full attribute references are in the [COSE Header Parameters
56/// registry](https://www.iana.org/assignments/cose/cose.xhtml#header-parameters).
57#[cfg_attr(feature = "defmt", derive(defmt::Format))]
58#[derive(minicbor::Decode)]
59#[cbor(map)]
60#[non_exhaustive]
61pub struct HeaderMap<'a> {
62    #[n(1)]
63    // Might be extended as more exotic algorithms are supported
64    pub(crate) alg: Option<i32>,
65    #[cbor(b(5), with = "minicbor::bytes")]
66    pub(crate) iv: Option<&'a [u8]>,
67}
68
69impl HeaderMap<'_> {
70    /// Merge two header maps, using the latter's value in case of conflict.
71    fn updated_with(&self, other: Self) -> Self {
72        Self {
73            alg: self.alg.or(other.alg),
74            iv: self.iv.or(other.iv),
75        }
76    }
77}
78
79/// A COSE_Key as described in Section 7 of RFC9052.
80///
81/// This combines [COSE Key Common
82/// Parameters](https://www.iana.org/assignments/cose/cose.xhtml#key-common-parameters) with [COSE
83/// Key Type Parameters](https://www.iana.org/assignments/cose/cose.xhtml#key-type-parameters)
84/// under the assumption that the key type is 1 (OKP) or 2 (EC2), which so far have non-conflicting
85/// entries.
86#[cfg_attr(feature = "defmt", derive(defmt::Format))]
87#[derive(minicbor::Decode, Debug)]
88#[allow(
89    dead_code,
90    reason = "Presence of the item makes CBOR derive tolerate the item"
91)]
92#[cbor(map)]
93#[non_exhaustive]
94pub(crate) struct CoseKey<'a> {
95    #[n(1)]
96    pub(crate) kty: i32, // or tstr (unsupported here so far)
97    #[cbor(b(2), with = "minicbor::bytes")]
98    pub(crate) kid: Option<&'a [u8]>,
99    #[n(3)]
100    pub(crate) alg: Option<i32>, // or tstr (unsupported here so far)
101
102    #[n(-1)]
103    pub(crate) crv: Option<i32>, // or tstr (unsupported here so far)
104    #[cbor(b(-2), with = "minicbor::bytes")]
105    pub(crate) x: Option<&'a [u8]>,
106    #[cbor(b(-3), with = "minicbor::bytes")]
107    pub(crate) y: Option<&'a [u8]>, // or bool (unsupported here so far)
108}
109
110/// A COSE_Encrypt0 structure as defined in [RFC8152](https://www.rfc-editor.org/rfc/rfc8152)
111#[cfg_attr(feature = "defmt", derive(defmt::Format))]
112#[derive(minicbor::Decode)]
113#[cbor(tag(16))]
114#[non_exhaustive]
115struct CoseEncrypt0<'a> {
116    #[cbor(b(0), with = "minicbor::bytes")]
117    protected: &'a [u8],
118    #[b(1)]
119    unprotected: HeaderMap<'a>,
120    #[cbor(b(2), with = "minicbor::bytes")]
121    encrypted: &'a [u8],
122}
123
124impl CoseEncrypt0<'_> {
125    /// Performs the common steps of processing the inner headers and building an AAD before
126    /// passing the output on to an authority's `.decrypt_symmetric_token` method.
127    ///
128    /// The buffer could be initialized anew and place-returned, but as it is large, it is taken as
129    /// a reference so that (eg. in `process_edhoc_token`) it can be guaranteed to be shared with
130    /// the large buffer of the other path.
131    fn prepare_decryption<'t>(
132        &self,
133        buffer: &'t mut heapless::Vec<u8, MAX_SUPPORTED_ACCESSTOKEN_LEN>,
134    ) -> Result<(HeaderMap<'_>, impl AsRef<[u8]>, &'t mut [u8]), CredentialError> {
135        trace!("Preparing decryption of {:?}", self);
136
137        // Could have the extra exception for empty byte strings expressing the empty map, but we don't
138        // encounter this here
139        let protected: HeaderMap = minicbor::decode(self.protected)?;
140        trace!("Protected decoded as header map: {:?}", protected);
141        let headers = self.unprotected.updated_with(protected);
142
143        #[derive(minicbor::Encode)]
144        struct Encrypt0<'a> {
145            #[n(0)]
146            context: &'static str,
147            #[cbor(b(1), with = "minicbor::bytes")]
148            protected: &'a [u8],
149            #[cbor(b(2), with = "minicbor::bytes")]
150            external_aad: &'a [u8],
151        }
152        let aad = Encrypt0 {
153            context: "Encrypt0",
154            protected: self.protected,
155            external_aad: &[],
156        };
157        const AADSIZE: usize = 1 + 1 + 8 + 1 + MAX_SUPPORTED_ENCRYPT_PROTECTED_LEN + 1;
158        let mut aad_encoded = heapless::Vec::<u8, AADSIZE>::new();
159        minicbor::encode(&aad, minicbor_adapters::WriteToHeapless(&mut aad_encoded))
160            .map_err(|_| CredentialErrorDetail::ConstraintExceeded)?;
161        trace!("Serialized AAD: {:02x}", aad_encoded); // :02x could be :cbor
162
163        buffer.clear();
164        // Copying around is not a constraint of this function (well that too but that could
165        // change) -- but the callers don't usually get their data in a mutable buffer for in-place
166        // decryption.
167        buffer
168            .extend_from_slice(self.encrypted)
169            .map_err(|_| CredentialErrorDetail::ConstraintExceeded)?;
170
171        Ok((headers, aad_encoded, buffer))
172    }
173}
174
175type EncryptedCwt<'a> = CoseEncrypt0<'a>;
176
177/// A COSE_Sign1 structure as defined in [RFC8152](https://www.rfc-editor.org/rfc/rfc8152)
178#[cfg_attr(feature = "defmt", derive(defmt::Format))]
179#[derive(minicbor::Decode)]
180#[cbor(tag(18))]
181#[non_exhaustive]
182struct CoseSign1<'a> {
183    #[cbor(b(0), with = "minicbor::bytes")]
184    protected: &'a [u8],
185    #[b(1)]
186    unprotected: HeaderMap<'a>,
187    // Payload could also be nil, but we don't support detached signatures here right now.
188    #[cbor(b(2), with = "minicbor::bytes")]
189    payload: &'a [u8],
190    #[cbor(b(3), with = "minicbor::bytes")]
191    signature: &'a [u8],
192}
193
194type SignedCwt<'a> = CoseSign1<'a>;
195
196/// A CWT Claims Set.
197///
198/// Full attribute references are in the [CWT Claims
199/// registry](https://www.iana.org/assignments/cwt/cwt.xhtml#claims-registry).
200#[derive(minicbor::Decode, Debug)]
201#[allow(
202    dead_code,
203    reason = "Presence of the item makes CBOR derive tolerate the item"
204)]
205#[cbor(map)]
206#[non_exhaustive]
207pub struct CwtClaimsSet<'a> {
208    #[n(3)]
209    pub(crate) aud: Option<&'a str>,
210    #[n(4)]
211    pub(crate) exp: u64,
212    #[n(6)]
213    pub(crate) iat: u64,
214    #[b(8)]
215    cnf: Cnf<'a>,
216    #[cbor(b(9), with = "minicbor::bytes")]
217    pub(crate) scope: &'a [u8],
218}
219
220/// A single CWT Claims Set Confirmation value.
221///
222/// All possible variants are in the [CWT Confirmation Methods
223/// registry](https://www.iana.org/assignments/cwt/cwt.xhtml#confirmation-methods).
224///
225/// ## Open questions
226///
227/// This should be an enum, but minicbor-derive can only have them as `array(2)` or using
228/// `index_only`. Can this style of an enum be added to minicbor?
229///
230/// Or is it really an enum? RFC8747 just [talks
231/// of](https://www.rfc-editor.org/rfc/rfc8747.html#name-confirmation-claim) "At most one of the
232/// `COSE_Key` and `Encrypted_COSE_Key` […] may be present", doesn't rule out that items without
233/// key material can't be attached.
234#[derive(minicbor::Decode, Debug)]
235#[cbor(map)]
236#[non_exhaustive]
237struct Cnf<'a> {
238    #[b(4)]
239    osc: Option<OscoreInputMaterial<'a>>,
240    #[b(1)]
241    cose_key: Option<minicbor_adapters::WithOpaque<'a, CoseKey<'a>>>,
242}
243
244/// OSCORE_Input_Material.
245///
246/// All current parameters are described in [Section 3.2.1 of
247/// RFC9203](https://datatracker.ietf.org/doc/html/rfc9203#name-the-oscore_input_material); the
248/// [OSCORE Security Context Parameters
249/// registry](https://www.iana.org/assignments/ace/ace.xhtml#oscore-security-context-parameters)
250/// has the full set in case it gets extended.
251#[cfg_attr(feature = "defmt", derive(defmt::Format))]
252#[derive(minicbor::Decode, Debug)]
253#[allow(
254    dead_code,
255    reason = "Presence of the item makes CBOR derive tolerate the item"
256)]
257#[cbor(map)]
258#[non_exhaustive]
259struct OscoreInputMaterial<'a> {
260    #[cbor(b(0), with = "minicbor::bytes")]
261    id: &'a [u8],
262    #[cbor(b(2), with = "minicbor::bytes")]
263    ms: &'a [u8],
264}
265
266impl OscoreInputMaterial<'_> {
267    fn derive(
268        &self,
269        nonce1: &[u8],
270        nonce2: &[u8],
271        sender_id: &[u8],
272        recipient_id: &[u8],
273    ) -> Result<liboscore::PrimitiveContext, CredentialError> {
274        // We don't process the algorithm fields
275        let hkdf = liboscore::HkdfAlg::from_number(5).expect("Default algorithm is supported");
276        let aead = liboscore::AeadAlg::from_number(10).expect("Default algorithm is supported");
277
278        // This is the only really custom part of ACE-OSCORE; the rest is just passing around
279        // inputs.
280        const { assert!(OWN_NONCE_LEN < 256) };
281        const { assert!(MAX_SUPPORTED_PEER_NONCE_LEN < 256) };
282        let mut combined_salt =
283            heapless::Vec::<u8, { 1 + 2 + MAX_SUPPORTED_PEER_NONCE_LEN + 2 + OWN_NONCE_LEN }>::new(
284            );
285        let mut encoder =
286            minicbor::Encoder::new(minicbor_adapters::WriteToHeapless(&mut combined_salt));
287        // We don't process the salt field
288        encoder
289            .bytes(b"")
290            .and_then(|encoder| encoder.bytes(nonce1))
291            .and_then(|encoder| encoder.bytes(nonce2))?;
292
293        let immutables = liboscore::PrimitiveImmutables::derive(
294            hkdf,
295            self.ms,
296            &combined_salt,
297            None, // context ID field not processed
298            aead,
299            sender_id,
300            recipient_id,
301        )
302        // Unknown HKDF is probably the only case here.
303        .map_err(|_| CredentialErrorDetail::UnsupportedAlgorithm)?;
304
305        // It is fresh because it is derived from.
306        Ok(liboscore::PrimitiveContext::new_from_fresh_material(
307            immutables,
308        ))
309    }
310}
311
312/// An owned variety of the subset of `AceCbor` data.
313///
314/// It needs a slim owned form that is kept by the server between processing an ACE-OSCORE token
315/// POST request and sending the response, and conveniently encapsulates its own rendering into a
316/// response message.
317pub struct AceCborAuthzInfoResponse {
318    nonce2: [u8; OWN_NONCE_LEN],
319    ace_server_recipientid: COwn,
320}
321
322impl AceCborAuthzInfoResponse {
323    pub(crate) fn render<M: coap_message::MutableWritableMessage>(
324        &self,
325        message: &mut M,
326    ) -> Result<(), M::UnionError> {
327        let full = AceCbor {
328            nonce2: Some(&self.nonce2),
329            ace_server_recipientid: Some(self.ace_server_recipientid.as_slice()),
330            ..Default::default()
331        };
332
333        use coap_message::Code;
334        message.set_code(M::Code::new(coap_numbers::code::CHANGED)?);
335
336        const { assert!(OWN_NONCE_LEN < 256) };
337        const { assert!(COwn::MAX_SLICE_LEN < 256) };
338        let required_len = 1 + 2 + 2 + OWN_NONCE_LEN + 2 + 2 + COwn::MAX_SLICE_LEN;
339        let payload = message.payload_mut_with_len(required_len)?;
340
341        let mut cursor = minicbor::encode::write::Cursor::new(payload);
342        minicbor::encode(full, &mut cursor).expect("Sufficient size was requested");
343        let written = cursor.position();
344        message.truncate(written)?;
345
346        Ok(())
347    }
348}
349
350/// Given an application/ace+cbor payload as is posted to an /authz-info endpoint, decrypt all
351/// that's needed for the ACE-OSCORE profile.
352///
353/// This needs to be provided with
354///
355/// * the request's `payload`
356/// * a list of recognized `authorities` (Authorization Servers) to authenticate the token,
357///   the output of which is also later used to parse the token's scope.
358/// * a random nonce2
359/// * a callback that, once the peer's recipient ID is known, chooses an own recipient ID
360///   (because it's up to the pool of security contexts to pick one, and the peers can not pick
361///   identical ones)
362///
363/// ## Caveats
364///
365/// * This allocates on the stack for two fields: the AAD and the token's plaintext. Both will
366///   eventually need to be configurable.
367///
368///   Alternatives to allocation are streaming AADs for the AEAD traits, and coap-handler offering
369///   an exclusive reference to the incoming message.
370///
371/// * Instead of the random nonce2, it would be preferable to pass in an RNG -- but some owners of
372///   an RNG may have a hard time lending out an exclusive reference to it for the whole function
373///   call duration.
374pub(crate) fn process_acecbor_authz_info<GC: crate::GeneralClaims>(
375    payload: &[u8],
376    authorities: &impl crate::seccfg::ServerSecurityConfig<GeneralClaims = GC>,
377    nonce2: [u8; OWN_NONCE_LEN],
378    server_recipient_id: impl FnOnce(&[u8]) -> COwn,
379) -> Result<(AceCborAuthzInfoResponse, liboscore::PrimitiveContext, GC), CredentialError> {
380    trace!("Processing authz_info {=[u8]:02x}", payload); // :02x could be :cbor
381
382    let decoded: UnprotectedAuthzInfoPost = minicbor::decode(payload)?;
383    // FIXME: The `..` should be "all others are None"; se also comment on UnprotectedAuthzInfoPost
384    // on type alias vs new type
385    let AceCbor {
386        access_token: Some(access_token),
387        nonce1: Some(nonce1),
388        ace_client_recipientid: Some(ace_client_recipientid),
389        ..
390    } = decoded
391    else {
392        return Err(CredentialErrorDetail::ProtocolViolation.into());
393    };
394
395    trace!(
396        "Decodeded authz_info as application/ace+cbor: {:?}",
397        decoded
398    );
399
400    let encrypt0: EncryptedCwt = minicbor::decode(access_token)?;
401
402    let mut buffer = Default::default();
403    let (headers, aad_encoded, buffer) = encrypt0.prepare_decryption(&mut buffer)?;
404
405    // Can't go through liboscore's decryption backend b/c that expects unprotect-in-place; doing
406    // something more custom on a bounded copy instead, and this is part of where dcaf on alloc
407    // could shine by getting an exclusive copy of something in RAM
408
409    if headers.alg != Some(31) {
410        return Err(CredentialErrorDetail::UnsupportedAlgorithm.into());
411    }
412
413    let (processed, parsed) =
414        authorities.decrypt_symmetric_token(&headers, aad_encoded.as_ref(), buffer)?;
415
416    // Currently disabled because no formatting is available while there; works with
417    // <https://codeberg.org/chrysn/minicbor-adapters/pulls/1>
418    // trace!("Decrypted CWT claims: {}", parsed);
419
420    let Cnf {
421        osc: Some(osc),
422        cose_key: None,
423    } = parsed.cnf
424    else {
425        return Err(CredentialErrorDetail::InconsistentDetails.into());
426    };
427
428    let ace_server_recipientid = server_recipient_id(ace_client_recipientid);
429
430    let derived = osc.derive(
431        nonce1,
432        &nonce2,
433        ace_client_recipientid,
434        ace_server_recipientid.as_slice(),
435    )?;
436
437    let response = AceCborAuthzInfoResponse {
438        nonce2,
439        ace_server_recipientid,
440    };
441
442    Ok((response, derived, processed))
443}
444
445pub(crate) fn process_edhoc_token<GeneralClaims>(
446    ead3: &[u8],
447    authorities: &impl crate::seccfg::ServerSecurityConfig<GeneralClaims = GeneralClaims>,
448) -> Result<(lakers::Credential, GeneralClaims), CredentialError> {
449    let mut buffer = heapless::Vec::<u8, MAX_SUPPORTED_ACCESSTOKEN_LEN>::new();
450
451    // Trying and falling back means that the minicbor error is not too great ("Expected tag 16"
452    // rather than "Expected tag 16 or 18"), but we don't
453    // show much of that anyway.
454    let (processed, parsed) = if let Ok(encrypt0) = minicbor::decode::<EncryptedCwt>(ead3) {
455        let (headers, aad_encoded, buffer) = encrypt0.prepare_decryption(&mut buffer)?;
456
457        authorities.decrypt_symmetric_token(&headers, aad_encoded.as_ref(), buffer)?
458    } else if let Ok(sign1) = minicbor::decode::<SignedCwt>(ead3) {
459        let protected: HeaderMap = minicbor::decode(sign1.protected)?;
460        trace!(
461            "Decoded protected header map {:?} inside sign1 container {:?}",
462            &protected,
463            &sign1
464        );
465        let headers = sign1.unprotected.updated_with(protected);
466
467        #[derive(minicbor::Encode)]
468        struct SigStructureForSignature1<'a> {
469            #[n(0)]
470            context: &'static str,
471            #[cbor(b(1), with = "minicbor::bytes")]
472            body_protected: &'a [u8],
473            #[cbor(b(2), with = "minicbor::bytes")]
474            external_aad: &'a [u8],
475            #[cbor(b(3), with = "minicbor::bytes")]
476            payload: &'a [u8],
477        }
478        let aad = SigStructureForSignature1 {
479            context: "Signature1",
480            body_protected: sign1.protected,
481            external_aad: &[],
482            payload: sign1.payload,
483        };
484        buffer = heapless::Vec::new();
485        minicbor::encode(&aad, minicbor_adapters::WriteToHeapless(&mut buffer))?;
486        trace!("Serialized AAD: {:#02x}", buffer);
487
488        authorities.verify_asymmetric_token(&headers, &buffer, sign1.signature, sign1.payload)?
489    } else {
490        return Err(CredentialErrorDetail::UnsupportedExtension.into());
491    };
492
493    let Cnf {
494        osc: None,
495        cose_key: Some(cose_key),
496    } = parsed.cnf
497    else {
498        return Err(CredentialErrorDetail::InconsistentDetails.into());
499    };
500
501    let mut prefixed = lakers::BufferCred::new();
502    // The prefix for naked COSE_Keys from Section 3.5.2 of RFC9528
503    prefixed
504        .extend_from_slice(&[0xa1, 0x08, 0xa1, 0x01])
505        .unwrap();
506    prefixed
507        .extend_from_slice(&cose_key.opaque)
508        .map_err(|_| CredentialErrorDetail::ConstraintExceeded)?;
509    let credential = lakers::Credential::new_ccs(
510        prefixed,
511        cose_key
512            .parsed
513            .x
514            .ok_or(CredentialErrorDetail::InconsistentDetails)?
515            .try_into()
516            .map_err(|_| CredentialErrorDetail::InconsistentDetails)?,
517    );
518
519    Ok((credential, processed))
520}