coapcore/
ace.rs

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