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        #[expect(
187            clippy::ignored_unit_patterns,
188            reason = "heapless has non-recommended error type"
189        )]
190        buffer
191            .extend_from_slice(self.encrypted)
192            .map_err(|_| CredentialErrorDetail::ConstraintExceeded)?;
193
194        Ok((headers, aad_encoded, buffer))
195    }
196}
197
198type EncryptedCwt<'a> = CoseEncrypt0<'a>;
199
200/// A `COSE_Sign1` structure as defined in [RFC8152](https://www.rfc-editor.org/rfc/rfc8152)
201#[cfg_attr(feature = "defmt", derive(defmt::Format))]
202#[derive(minicbor::Decode, Debug)]
203#[cbor(tag(18))]
204#[non_exhaustive]
205struct CoseSign1<'a> {
206    #[cbor(b(0), with = "minicbor::bytes")]
207    protected: &'a [u8],
208    #[b(1)]
209    unprotected: HeaderMap<'a>,
210    // Payload could also be nil, but we don't support detached signatures here right now.
211    #[cbor(b(2), with = "minicbor::bytes")]
212    payload: &'a [u8],
213    #[cbor(b(3), with = "minicbor::bytes")]
214    signature: &'a [u8],
215}
216
217type SignedCwt<'a> = CoseSign1<'a>;
218
219/// The `Signature1` object that feeds the AAD during the processing of a `COSE_Sign1`.
220#[derive(minicbor::Encode)]
221struct SigStructureForSignature1<'a> {
222    #[n(0)]
223    context: &'static str,
224    #[cbor(b(1), with = "minicbor::bytes")]
225    body_protected: &'a [u8],
226    #[cbor(b(2), with = "minicbor::bytes")]
227    external_aad: &'a [u8],
228    #[cbor(b(3), with = "minicbor::bytes")]
229    payload: &'a [u8],
230}
231
232/// A CWT Claims Set.
233///
234/// Full attribute references are in the [CWT Claims
235/// registry](https://www.iana.org/assignments/cwt/cwt.xhtml#claims-registry).
236#[derive(minicbor::Decode, Debug)]
237#[allow(
238    dead_code,
239    reason = "Presence of the item makes CBOR derive tolerate the item"
240)]
241#[allow(
242    missing_docs,
243    reason = "Fields correspond 1:1 to the domain items of the same name"
244)]
245#[cbor(map)]
246#[non_exhaustive]
247pub struct CwtClaimsSet<'a> {
248    #[n(3)]
249    pub aud: Option<&'a str>,
250    #[n(4)]
251    pub(crate) exp: u64,
252    #[n(6)]
253    pub(crate) iat: u64,
254    #[b(8)]
255    cnf: Cnf<'a>,
256    #[cbor(b(9), with = "minicbor::bytes")]
257    pub scope: &'a [u8],
258}
259
260/// A single CWT Claims Set Confirmation value.
261///
262/// All possible variants are in the [CWT Confirmation Methods
263/// registry](https://www.iana.org/assignments/cwt/cwt.xhtml#confirmation-methods).
264///
265/// ## Open questions
266///
267/// This should be an enum, but minicbor-derive can only have them as `array(2)` or using
268/// `index_only`. Can this style of an enum be added to minicbor?
269///
270/// Or is it really an enum? RFC8747 just [talks
271/// of](https://www.rfc-editor.org/rfc/rfc8747.html#name-confirmation-claim) "At most one of the
272/// `COSE_Key` and `Encrypted_COSE_Key` […] may be present", doesn't rule out that items without
273/// key material can't be attached.
274#[derive(minicbor::Decode, Debug)]
275#[cbor(map)]
276#[non_exhaustive]
277struct Cnf<'a> {
278    #[b(4)]
279    osc: Option<OscoreInputMaterial<'a>>,
280    #[b(1)]
281    cose_key: Option<minicbor_adapters::WithOpaque<'a, CoseKey<'a>>>,
282}
283
284/// `OSCORE_Input_Material`.
285///
286/// All current parameters are described in [Section 3.2.1 of
287/// RFC9203](https://datatracker.ietf.org/doc/html/rfc9203#name-the-oscore_input_material); the
288/// [OSCORE Security Context Parameters
289/// registry](https://www.iana.org/assignments/ace/ace.xhtml#oscore-security-context-parameters)
290/// has the full set in case it gets extended.
291#[cfg_attr(feature = "defmt", derive(defmt::Format))]
292#[derive(minicbor::Decode, Debug)]
293#[allow(
294    dead_code,
295    reason = "Presence of the item makes CBOR derive tolerate the item"
296)]
297#[cbor(map)]
298#[non_exhaustive]
299struct OscoreInputMaterial<'a> {
300    #[cbor(b(0), with = "minicbor::bytes")]
301    id: &'a [u8],
302    #[cbor(b(2), with = "minicbor::bytes")]
303    ms: &'a [u8],
304}
305
306impl OscoreInputMaterial<'_> {
307    /// Produces an OSCORE context from the ACE OSCORE inputs.
308    ///
309    /// FIXME: When this errs and panics could need some clean-up: the same kind of error produces
310    /// a panic in some and an error in
311    ///
312    /// # Errors
313    ///
314    /// Produces an error if any used algorithm is not supported by libOSCORE's backend, or sizes
315    /// mismatch.
316    fn derive(
317        &self,
318        nonce1: &[u8],
319        nonce2: &[u8],
320        sender_id: &[u8],
321        recipient_id: &[u8],
322    ) -> Result<liboscore::PrimitiveContext, CredentialError> {
323        // We don't process the algorithm fields
324        let hkdf = liboscore::HkdfAlg::from_number(5)
325            .map_err(|_| CredentialErrorDetail::UnsupportedAlgorithm)?;
326        let aead = liboscore::AeadAlg::from_number(10)
327            .map_err(|_| CredentialErrorDetail::UnsupportedAlgorithm)?;
328
329        // This is the only really custom part of ACE-OSCORE; the rest is just passing around
330        // inputs.
331        const { assert!(OWN_NONCE_LEN < 256) };
332        const { assert!(MAX_SUPPORTED_PEER_NONCE_LEN < 256) };
333        let mut combined_salt =
334            heapless::Vec::<u8, { 1 + 2 + MAX_SUPPORTED_PEER_NONCE_LEN + 2 + OWN_NONCE_LEN }>::new(
335            );
336        let mut encoder =
337            minicbor::Encoder::new(minicbor_adapters::WriteToHeapless(&mut combined_salt));
338        // We don't process the salt field
339        encoder
340            .bytes(b"")
341            .and_then(|encoder| encoder.bytes(nonce1))
342            .and_then(|encoder| encoder.bytes(nonce2))?;
343
344        let immutables = liboscore::PrimitiveImmutables::derive(
345            hkdf,
346            self.ms,
347            &combined_salt,
348            None, // context ID field not processed
349            aead,
350            sender_id,
351            recipient_id,
352        )
353        // Unknown HKDF is probably the only case here.
354        .map_err(|_| CredentialErrorDetail::UnsupportedAlgorithm)?;
355
356        // It is fresh because it is derived from.
357        Ok(liboscore::PrimitiveContext::new_from_fresh_material(
358            immutables,
359        ))
360    }
361}
362
363/// An owned variety of the subset of `AceCbor` data.
364///
365/// It needs a slim owned form that is kept by the server between processing an ACE-OSCORE token
366/// POST request and sending the response, and conveniently encapsulates its own rendering into a
367/// response message.
368pub struct AceCborAuthzInfoResponse {
369    nonce2: [u8; OWN_NONCE_LEN],
370    ace_server_recipientid: COwn,
371}
372
373impl AceCborAuthzInfoResponse {
374    /// Renders the response into a CoAP message
375    ///
376    /// # Errors
377    ///
378    /// The implementation may fail like [any CoAP response
379    /// rendering][coap_handler::Handler::extract_request_data()].
380    #[allow(
381        clippy::missing_panics_doc,
382        reason = "will never panic for any user input"
383    )]
384    pub(crate) fn render<M: coap_message::MutableWritableMessage>(
385        &self,
386        message: &mut M,
387    ) -> Result<(), M::UnionError> {
388        let full = AceCbor {
389            nonce2: Some(&self.nonce2),
390            ace_server_recipientid: Some(self.ace_server_recipientid.as_slice()),
391            ..Default::default()
392        };
393
394        message.set_code(M::Code::new(coap_numbers::code::CHANGED)?);
395
396        const { assert!(OWN_NONCE_LEN < 256) };
397        const { assert!(COwn::MAX_SLICE_LEN < 256) };
398        let required_len = 1 + 2 + 2 + OWN_NONCE_LEN + 2 + 2 + COwn::MAX_SLICE_LEN;
399        let payload = message.payload_mut_with_len(required_len)?;
400
401        let mut cursor = minicbor::encode::write::Cursor::new(payload);
402        minicbor::encode(full, &mut cursor).expect("Sufficient size was requested");
403        let written = cursor.position();
404        message.truncate(written)?;
405
406        Ok(())
407    }
408}
409
410/// Given an application/ace+cbor payload as is posted to an /authz-info endpoint, decrypt all
411/// that's needed for the ACE-OSCORE profile.
412///
413/// This needs to be provided with
414///
415/// * the request's `payload`
416/// * a list of recognized `authorities` (Authorization Servers) to authenticate the token,
417///   the output of which is also later used to parse the token's scope.
418/// * a random nonce2
419/// * a callback that, once the peer's recipient ID is known, chooses an own recipient ID
420///   (because it's up to the pool of security contexts to pick one, and the peers can not pick
421///   identical ones)
422///
423/// ## Caveats
424///
425/// * This allocates on the stack for two fields: the AAD and the token's plaintext. Both will
426///   eventually need to be configurable.
427///
428///   Alternatives to allocation are streaming AADs for the AEAD traits, and coap-handler offering
429///   an exclusive reference to the incoming message.
430///
431/// * Instead of the random nonce2, it would be preferable to pass in an RNG -- but some owners of
432///   an RNG may have a hard time lending out an exclusive reference to it for the whole function
433///   call duration.
434///
435/// # Errors
436///
437/// This produces errors if the input (which is typically received from the network) is malformed
438/// or contains unsupported items.
439pub(crate) fn process_acecbor_authz_info<GC: crate::GeneralClaims>(
440    payload: &[u8],
441    authorities: &impl crate::seccfg::ServerSecurityConfig<GeneralClaims = GC>,
442    nonce2: [u8; OWN_NONCE_LEN],
443    server_recipient_id: impl FnOnce(&[u8]) -> COwn,
444) -> Result<(AceCborAuthzInfoResponse, liboscore::PrimitiveContext, GC), CredentialError> {
445    trace!(
446        "Processing authz_info {}",
447        defmt_or_log::wrappers::Cbor(payload)
448    );
449
450    let decoded: UnprotectedAuthzInfoPost = minicbor::decode(payload)?;
451    // FIXME: The `..` should be "all others are None"; se also comment on UnprotectedAuthzInfoPost
452    // on type alias vs new type
453    let AceCbor {
454        access_token: Some(access_token),
455        nonce1: Some(nonce1),
456        ace_client_recipientid: Some(ace_client_recipientid),
457        ..
458    } = decoded
459    else {
460        return Err(CredentialErrorDetail::ProtocolViolation.into());
461    };
462
463    trace!(
464        "Decodeded authz_info as application/ace+cbor: {:?}",
465        decoded
466    );
467
468    let encrypt0: EncryptedCwt = minicbor::decode(access_token)?;
469
470    let mut buffer = heapless::Vec::new();
471    let (headers, aad_encoded, buffer) = encrypt0.prepare_decryption(&mut buffer)?;
472
473    // Can't go through liboscore's decryption backend b/c that expects unprotect-in-place; doing
474    // something more custom on a bounded copy instead, and this is part of where dcaf on alloc
475    // could shine by getting an exclusive copy of something in RAM
476
477    if headers.alg != Some(31) {
478        return Err(CredentialErrorDetail::UnsupportedAlgorithm.into());
479    }
480
481    let (processed, parsed) =
482        authorities.decrypt_symmetric_token(&headers, aad_encoded.as_ref(), buffer)?;
483
484    // Currently disabled because no formatting is available while there; works with
485    // <https://codeberg.org/chrysn/minicbor-adapters/pulls/1>
486    // trace!("Decrypted CWT claims: {}", parsed);
487
488    let Cnf {
489        osc: Some(osc),
490        cose_key: None,
491    } = parsed.cnf
492    else {
493        return Err(CredentialErrorDetail::InconsistentDetails.into());
494    };
495
496    let ace_server_recipientid = server_recipient_id(ace_client_recipientid);
497
498    let derived = osc.derive(
499        nonce1,
500        &nonce2,
501        ace_client_recipientid,
502        ace_server_recipientid.as_slice(),
503    )?;
504
505    let response = AceCborAuthzInfoResponse {
506        nonce2,
507        ace_server_recipientid,
508    };
509
510    Ok((response, derived, processed))
511}
512
513/// Verifies an ACE token sent in an EAD3 by the rules of the `authorities`, and produces both the
514/// decrypted claims and the extracted EDHOC specific credential.
515///
516/// # Errors
517///
518/// This produces errors if the input (which is typically received from the network) is
519/// malformed or contains unsupported items.
520#[expect(
521    clippy::missing_panics_doc,
522    reason = "panic only happens when fixed-length array gets placed into larger array"
523)]
524pub(crate) fn process_edhoc_token<GeneralClaims>(
525    ead3: &[u8],
526    authorities: &impl crate::seccfg::ServerSecurityConfig<GeneralClaims = GeneralClaims>,
527) -> Result<(lakers::Credential, GeneralClaims), CredentialError> {
528    let mut buffer = heapless::Vec::<u8, MAX_SUPPORTED_ACCESSTOKEN_LEN>::new();
529
530    // Trying and falling back means that the minicbor error is not too great ("Expected tag 16"
531    // rather than "Expected tag 16 or 18"), but we don't
532    // show much of that anyway.
533    let (processed, parsed) = if let Ok(encrypt0) = minicbor::decode::<EncryptedCwt>(ead3) {
534        let (headers, aad_encoded, buffer) = encrypt0.prepare_decryption(&mut buffer)?;
535
536        authorities.decrypt_symmetric_token(&headers, aad_encoded.as_ref(), buffer)?
537    } else if let Ok(sign1) = minicbor::decode::<SignedCwt>(ead3) {
538        let protected: HeaderMap = minicbor::decode(sign1.protected)?;
539        trace!(
540            "Decoded protected header map {:?} inside sign1 container {:?}",
541            &protected, &sign1
542        );
543        let headers = sign1.unprotected.updated_with(&protected);
544
545        let aad = SigStructureForSignature1 {
546            context: "Signature1",
547            body_protected: sign1.protected,
548            external_aad: &[],
549            payload: sign1.payload,
550        };
551        buffer = heapless::Vec::new();
552        minicbor::encode(&aad, minicbor_adapters::WriteToHeapless(&mut buffer))?;
553        trace!("Serialized AAD: {}", defmt_or_log::wrappers::Hex(&buffer));
554
555        authorities.verify_asymmetric_token(&headers, &buffer, sign1.signature, sign1.payload)?
556    } else {
557        return Err(CredentialErrorDetail::UnsupportedExtension.into());
558    };
559
560    let Cnf {
561        osc: None,
562        cose_key: Some(cose_key),
563    } = parsed.cnf
564    else {
565        return Err(CredentialErrorDetail::InconsistentDetails.into());
566    };
567
568    let mut prefixed = lakers::BufferCred::new();
569    // The prefix for naked COSE_Keys from Section 3.5.2 of RFC9528
570    prefixed
571        .extend_from_slice(&[0xa1, 0x08, 0xa1, 0x01])
572        .unwrap();
573    prefixed
574        .extend_from_slice(&cose_key.opaque)
575        .map_err(|_| CredentialErrorDetail::ConstraintExceeded)?;
576    let credential = lakers::Credential::new_ccs(
577        prefixed,
578        cose_key
579            .parsed
580            .x
581            .ok_or(CredentialErrorDetail::InconsistentDetails)?
582            .try_into()
583            .map_err(|_| CredentialErrorDetail::InconsistentDetails)?,
584    );
585
586    Ok((credential, processed))
587}