coapcore/
seccfg.rs

1//! Descriptions of trust sources for the Resource Server (RS) which coapcore runs on.
2//!
3//! These descriptions, expressed in implementations of the [`ServerSecurityConfig`] trait,
4//! typically encode known credentials (effectively raw public keys) or ACE Authorization Servers
5//! (AS).
6
7use defmt_or_log::{debug, error, trace};
8
9use crate::ace::HeaderMap;
10use crate::error::{CredentialError, CredentialErrorDetail};
11use crate::generalclaims::{GeneralClaims, Unlimited};
12use crate::time::TimeConstraint;
13
14pub(crate) const MAX_AUD_SIZE: usize = 8;
15
16/// Error type of [`ServerSecurityConfig::render_not_allowed`].
17///
18/// This represents a failure to express the Request Creation Hints of ACE in a message. Unlike
19/// most CoAP rendering errors, this can not just fall back to rendering that produces an Internal
20/// Server Error, as that would be misunderstood by the client to mean that the requested operation
21/// was being performed and failed at runtime (whereas with this error, the requested operation was
22/// not performed). Therefore, no error details can be communicated to the client reliably.
23///
24/// Implementers are encouraged to log an error when returning this.
25pub struct NotAllowedRenderingFailed;
26
27/// A single or collection of authorization servers that a handler trusts to create ACE tokens.
28pub trait ServerSecurityConfig {
29    /// True if the type will at any time need to process tokens at `/authz-info`.
30    ///
31    /// This is used by the handler implementation to shortcut through some message processing
32    /// paths.
33    const PARSES_TOKENS: bool;
34
35    /// True if the type will at any time need to process requests to `/.well-known/edhoc`.
36    ///
37    /// This is used by the handler implementation to shortcut through some message processing
38    /// paths.
39    const HAS_EDHOC: bool;
40
41    /// The way scopes issued with this system as audience by this AS are expressed here.
42    type GeneralClaims: GeneralClaims;
43
44    /// Unprotects a symmetriclly encrypted token and processes the contained [CWT Claims
45    /// Set][crate::ace::CwtClaimsSet] into a [`Self::GeneralClaims`] and returns the full claims.
46    ///
47    /// The steps are performed together rather than in separate functions because it is yet
48    /// unclear how data would precisely be carried around. (Previous iterations of this API had a
49    /// `ScopeGenerator` associated type that would carry such data, but that did not scale well to
50    /// different kinds of tokens; for example, it would need a `TimeConstraintGenerator` in
51    /// parallel because while the token may indicate some time, the issuer's trusted time might be
52    /// more limited).
53    ///
54    /// As part of such a dissection it would be preferable to return a decryption key and let the
55    /// `ace` module do the decryption, but the key is not dyn safe, and
56    /// [`aead::AeadInPlace`](https://docs.rs/aead/latest/aead/trait.AeadInPlace.html) can not be
57    /// enum'd around different potential key types because the associated types are fixed length.
58    /// (Returning a key in some COSE crypto abstraction may work better).
59    ///
60    /// Note that the full AAD (COSE's AAD including the external AAD) is built by the caller; the
61    /// headers are only passed in to enable the AS to select the right key.
62    ///
63    /// The buffer is given as heapless buffer rather than an an
64    /// [`aead::Buffer`](https://docs.rs/aead/latest/aead/trait.Buffer.html) because the latter is
65    /// not on the latest heaples version in its released version.
66    ///
67    /// # Errors
68    ///
69    /// This produces errors if the input (which is typically received from the network) is
70    /// malformed or contains unsupported items.
71    #[allow(
72        unused_variables,
73        reason = "Names are human visible part of API description"
74    )]
75    // The method is already sealed by the use of a HeaderMap and CwtClaimsSet, but that may become
76    // more public over time, and that should not impct this method's publicness.
77    fn decrypt_symmetric_token<'buf>(
78        &self,
79        headers: &HeaderMap,
80        aad: &[u8],
81        ciphertext_buffer: &'buf mut [u8],
82    ) -> Result<(Self::GeneralClaims, crate::ace::CwtClaimsSet<'buf>), CredentialError> {
83        Err(CredentialErrorDetail::KeyNotPresent.into())
84    }
85
86    /// Verify the signature on a symmetrically encrypted token
87    ///
88    /// `signed_payload` is the payload part of the signed CWT; while it is part of `signed_data` and
89    /// can be recovered from it, `signed_data` currently typically resides in a copied buffer
90    /// created for signature verification, and `signed_payload` is around inside the caller for
91    /// longer. As common with signed data, it should only be parsed once the signature has been
92    /// verified.
93    ///
94    /// Like [`Self::decrypt_symmetric_token()`], this conflates a few aspects, which is tolerated
95    /// for the time being.
96    ///
97    /// # Errors
98    ///
99    /// This produces errors if the input (which is typically received from the network) is
100    /// malformed or contains unsupported items.
101    #[allow(
102        unused_variables,
103        reason = "Names are human visible part of API description"
104    )]
105    fn verify_asymmetric_token<'b>(
106        &self,
107        headers: &HeaderMap,
108        signed_data: &[u8],
109        signature: &[u8],
110        signed_payload: &'b [u8],
111    ) -> Result<(Self::GeneralClaims, crate::ace::CwtClaimsSet<'b>), CredentialError> {
112        Err(CredentialErrorDetail::KeyNotPresent.into())
113    }
114
115    /// The credential (by value or by reference) and key that the server reports (and uses) in
116    /// incoming EDHOC exchanges (i.e., in the role of the responder).
117    fn own_edhoc_credential(&self) -> Option<(lakers::Credential, lakers::BytesP256ElemLen)> {
118        None
119    }
120
121    /// Expands an EDHOC `ID_CRED_x` into a parsed `CRED_x` along with the associated
122    /// authorizations.
123    ///
124    /// This is currently used for statically configured known static keys, might also be used in
125    /// situations when a new EDHOC session is run with a credential previously stored, for example
126    /// after an ACE token was submitted.
127    #[allow(
128        unused_variables,
129        reason = "Names are human visible part of API description"
130    )]
131    fn expand_id_cred_x(
132        &self,
133        id_cred_x: lakers::IdCred,
134    ) -> Option<(lakers::Credential, Self::GeneralClaims)> {
135        None
136    }
137
138    /// Generates the scope representing unauthenticated access.
139    ///
140    /// Their time aspect is typically unbounded.
141    fn nosec_authorization(&self) -> Option<Self::GeneralClaims> {
142        None
143    }
144
145    /// Render the "not allowed" message in this scenario.
146    ///
147    /// The default (or any error) renderer produces a generic 4.01 Unauthorized in the handler;
148    /// specifics can be useful in ACE scenarios to return a Request Creation Hint.
149    ///
150    /// # Errors
151    ///
152    /// The implementation may fail like [any CoAP response
153    /// rendering][coap_handler::Handler::extract_request_data()]. No error details are conveyed;
154    /// instead, the caller needs to produce a generic response.
155    #[allow(
156        unused_variables,
157        reason = "Names are human visible part of API description"
158    )]
159    fn render_not_allowed<M: coap_message::MutableWritableMessage>(
160        &self,
161        message: &mut M,
162    ) -> Result<(), NotAllowedRenderingFailed> {
163        Err(NotAllowedRenderingFailed)
164    }
165}
166
167/// The default empty configuration that denies all access.
168pub struct DenyAll;
169
170impl ServerSecurityConfig for DenyAll {
171    const PARSES_TOKENS: bool = false;
172    const HAS_EDHOC: bool = false;
173
174    type GeneralClaims = core::convert::Infallible;
175}
176
177/// A [`ServerSecurityConfig`] representing unconditionally allowed access without the option for
178/// opportunistic EDHOC.
179pub struct AllowAll;
180
181impl ServerSecurityConfig for AllowAll {
182    const PARSES_TOKENS: bool = false;
183    const HAS_EDHOC: bool = false;
184
185    type GeneralClaims = Unlimited<crate::scope::AllowAll>;
186
187    fn nosec_authorization(&self) -> Option<Self::GeneralClaims> {
188        Some(Unlimited(crate::scope::AllowAll))
189    }
190}
191
192/// An implementation of [`ServerSecurityConfig`] that can be extended using builder methods.
193///
194/// This is very much in flux, and will need further exploration as to inhowmuch this can be
195/// type-composed from components.
196///
197/// Lacking better sources of information, the scope's imporatance is chosen by source: Only
198/// preconfigured EDHOC keys are regarded as important, and thus kept around even in the presence
199/// of multiple competing token based contexts.
200pub struct ConfigBuilder {
201    /// Symmetric used when tokens are symmetrically encrypted with AES-CCM-16-128-256
202    as_key_31: Option<[u8; 32]>,
203    /// Asymmetric key used when tokens are signed with ES256
204    ///
205    /// Along with the key, this also holds the audience value of this RS (as signed tokens only
206    /// make sense when the same signing key is used with multiple recipients).
207    as_key_neg7: Option<([u8; 32], [u8; 32], heapless::String<MAX_AUD_SIZE>)>,
208    unauthenticated_scope: Option<crate::scope::UnionScope>,
209    own_edhoc_credential: Option<(lakers::Credential, lakers::BytesP256ElemLen)>,
210    known_edhoc_clients: Option<(lakers::Credential, crate::scope::UnionScope)>,
211    request_creation_hints: &'static [u8],
212}
213
214impl ServerSecurityConfig for ConfigBuilder {
215    // We can't know at build time, assume yes
216    const PARSES_TOKENS: bool = true;
217    const HAS_EDHOC: bool = true;
218
219    type GeneralClaims = ConfigBuilderClaims;
220
221    fn decrypt_symmetric_token<'buf>(
222        &self,
223        headers: &HeaderMap,
224        aad: &[u8],
225        ciphertext_buffer: &'buf mut [u8],
226    ) -> Result<(Self::GeneralClaims, crate::ace::CwtClaimsSet<'buf>), CredentialError> {
227        use ccm::KeyInit;
228        use ccm::aead::AeadInPlace;
229
230        pub type Aes256Ccm = ccm::Ccm<aes::Aes256, ccm::consts::U16, ccm::consts::U13>;
231        // FIXME: should be something Aes256Ccm::TagLength
232        const TAG_SIZE: usize = 16;
233        const NONCE_SIZE: usize = 13;
234
235        let key = self.as_key_31.ok_or_else(|| {
236            error!("Symmetrically encrypted token was sent, but no symmetric key is configured.");
237            CredentialErrorDetail::KeyNotPresent
238        })?;
239
240        let cipher = Aes256Ccm::new((&key).into());
241
242        let nonce: &[u8; NONCE_SIZE] = headers
243            .iv
244            .ok_or_else(|| {
245                error!("IV missing from token.");
246                CredentialErrorDetail::InconsistentDetails
247            })?
248            .try_into()
249            .map_err(|_| {
250                error!("Token's IV length mismatches algorithm.");
251                CredentialErrorDetail::InconsistentDetails
252            })?;
253
254        let ciphertext_len = ciphertext_buffer
255            .len()
256            .checked_sub(TAG_SIZE)
257            .ok_or_else(|| {
258                error!("Token's ciphertext too short for the algorithm's tag.");
259                CredentialErrorDetail::InconsistentDetails
260            })?;
261
262        let (ciphertext, tag) = ciphertext_buffer.split_at_mut(ciphertext_len);
263
264        cipher
265            .decrypt_in_place_detached(nonce.into(), aad, ciphertext, ccm::Tag::from_slice(tag))
266            .map_err(|_| {
267                error!("Token decryption failed.");
268                CredentialErrorDetail::VerifyFailed
269            })?;
270
271        let claims: crate::ace::CwtClaimsSet = minicbor::decode(ciphertext)
272            .map_err(|_| CredentialErrorDetail::UnsupportedExtension)?;
273
274        // FIXME: Consider moving into general parser.
275        let scope = crate::scope::AifValue::parse(claims.scope)
276            .map_err(|_| CredentialErrorDetail::UnsupportedExtension)?
277            .into();
278        let time_constraint = crate::time::TimeConstraint::from_claims_set(&claims);
279
280        Ok((
281            ConfigBuilderClaims {
282                scope,
283                time_constraint,
284                is_important: false,
285            },
286            claims,
287        ))
288    }
289
290    fn verify_asymmetric_token<'b>(
291        &self,
292        headers: &HeaderMap,
293        signed_data: &[u8],
294        signature: &[u8],
295        signed_payload: &'b [u8],
296    ) -> Result<(Self::GeneralClaims, crate::ace::CwtClaimsSet<'b>), CredentialError> {
297        use p256::ecdsa::{VerifyingKey, signature::Verifier};
298
299        if headers.alg != Some(-7) {
300            // ES256
301            return Err(CredentialErrorDetail::UnsupportedAlgorithm.into());
302        }
303
304        let Some((x, y, rs_audience)) = self.as_key_neg7.as_ref() else {
305            return Err(CredentialErrorDetail::KeyNotPresent.into());
306        };
307
308        let as_key = VerifyingKey::from_encoded_point(
309            &p256::EncodedPoint::from_affine_coordinates(x.into(), y.into(), false),
310        )
311        .map_err(|_| CredentialErrorDetail::InconsistentDetails)?;
312        let signature = p256::ecdsa::Signature::from_slice(signature)
313            .map_err(|_| CredentialErrorDetail::InconsistentDetails)?;
314
315        as_key
316            .verify(signed_data, &signature)
317            .map_err(|_| CredentialErrorDetail::VerifyFailed)?;
318
319        let claims: crate::ace::CwtClaimsSet = minicbor::decode(signed_payload)
320            .map_err(|_| CredentialErrorDetail::UnsupportedExtension)?;
321
322        if claims.aud != Some(rs_audience) {
323            // FIXME describe better? "Verified but we're not the audience?"
324            return Err(CredentialErrorDetail::VerifyFailed.into());
325        }
326
327        // FIXME: Consider moving into general parser.
328        let scope = crate::scope::AifValue::parse(claims.scope)
329            .map_err(|_| CredentialErrorDetail::UnsupportedExtension)?
330            .into();
331        let time_constraint = crate::time::TimeConstraint::from_claims_set(&claims);
332
333        Ok((
334            ConfigBuilderClaims {
335                scope,
336                time_constraint,
337                is_important: false,
338            },
339            claims,
340        ))
341    }
342
343    fn nosec_authorization(&self) -> Option<Self::GeneralClaims> {
344        self.unauthenticated_scope
345            .clone()
346            .map(|scope| ConfigBuilderClaims {
347                scope,
348                time_constraint: TimeConstraint::unbounded(),
349                is_important: false,
350            })
351    }
352
353    fn own_edhoc_credential(&self) -> Option<(lakers::Credential, lakers::BytesP256ElemLen)> {
354        #[expect(
355            clippy::clone_on_copy,
356            reason = "the type should not be clone, and will not be in future lakers versions"
357        )]
358        self.own_edhoc_credential.clone()
359    }
360
361    fn expand_id_cred_x(
362        &self,
363        id_cred_x: lakers::IdCred,
364    ) -> Option<(lakers::Credential, Self::GeneralClaims)> {
365        trace!(
366            "Evaluating peer's credential {}",
367            defmt_or_log::wrappers::Cbor(id_cred_x.as_full_value())
368        );
369
370        #[expect(
371            clippy::single_element_loop,
372            reason = "Expected to be extended to actual loop soon"
373        )]
374        for (credential, scope) in &[self.known_edhoc_clients.as_ref()?] {
375            trace!(
376                "Comparing to {}",
377                defmt_or_log::wrappers::Cbor(credential.bytes.as_slice())
378            );
379            if id_cred_x.reference_only() {
380                // ad Ok: If our credential has no KID, it can't be recognized in this branch
381                if credential.by_kid().as_ref() == Ok(&id_cred_x) {
382                    debug!("Peer indicated use of the one preconfigured key by KID.");
383                    #[expect(
384                        clippy::clone_on_copy,
385                        reason = "Lakers items are overly copy happy"
386                    )]
387                    return Some((
388                        credential.clone(),
389                        ConfigBuilderClaims {
390                            scope: scope.clone(),
391                            time_constraint: TimeConstraint::unbounded(),
392                            is_important: true,
393                        },
394                    ));
395                }
396            } else {
397                // ad Ok: This is always the case for CCSs, but inapplicable eg. for PSKs.
398                if credential.by_value().as_ref() == Ok(&id_cred_x) {
399                    debug!("Peer indicated use of the one preconfigured credential by value.");
400                    #[expect(
401                        clippy::clone_on_copy,
402                        reason = "Lakers items are overly copy happy"
403                    )]
404                    return Some((
405                        credential.clone(),
406                        ConfigBuilderClaims {
407                            scope: scope.clone(),
408                            time_constraint: TimeConstraint::unbounded(),
409                            is_important: true,
410                        },
411                    ));
412                }
413            }
414        }
415
416        if let Some(unauthorized_claims) = self.nosec_authorization() {
417            trace!("Unauthenticated clients are generally accepted, evaluating credential.");
418            if let Some(credential_by_value) = id_cred_x.get_ccs().as_ref() {
419                debug!("The unauthorized client provided a usable credential by value.");
420                #[expect(clippy::clone_on_copy, reason = "Lakers items are overly copy happy")]
421                return Some((credential_by_value.clone(), unauthorized_claims));
422            }
423        }
424
425        None
426    }
427
428    fn render_not_allowed<M: coap_message::MutableWritableMessage>(
429        &self,
430        message: &mut M,
431    ) -> Result<(), NotAllowedRenderingFailed> {
432        use coap_message::Code;
433        message.set_code(M::Code::new(coap_numbers::code::UNAUTHORIZED).map_err(|_| {
434            error!("CoAP stack can not represent Unauthorized responses.");
435            NotAllowedRenderingFailed
436        })?);
437        message
438            .set_payload(self.request_creation_hints)
439            .map_err(|_| {
440                error!("Request creation hints do not fit in error message.");
441                NotAllowedRenderingFailed
442            })?;
443        Ok(())
444    }
445}
446
447impl Default for ConfigBuilder {
448    fn default() -> Self {
449        ConfigBuilder::new()
450    }
451}
452
453impl ConfigBuilder {
454    /// Creates an empty server security configuration.
455    ///
456    /// Without any additional building steps, this is equivalent to [`DenyAll`].
457    #[must_use]
458    pub fn new() -> Self {
459        Self {
460            as_key_31: None,
461            as_key_neg7: None,
462            unauthenticated_scope: None,
463            known_edhoc_clients: None,
464            own_edhoc_credential: None,
465            request_creation_hints: &[],
466        }
467    }
468
469    /// Sets a single Authorization Server recognized by a shared `AES-16-128-256` (COSE algorithm
470    /// 31) key.
471    ///
472    /// Scopes are accepted as given by the AS using the AIF REST model as understood by
473    /// [`crate::scope::AifValue`].
474    ///
475    /// # Caveats and evolution
476    ///
477    /// Currently, this type just supports a single AS; it should therefore only be called once,
478    /// and the latest value overwrites any earlier. Building these in type state (as `[(&as_key);
479    /// { N+1 }]` (once that is possible) or `(&as_key1, (&as_key2, ()))` will make sense on the
480    /// long run, but is not implemented yet.
481    ///
482    /// Depending on whether the keys are already referenced in a long-lived location, when
483    /// implementing that, it can also make sense to allow using any `AsRef<[u8; 32]>` types at
484    /// that point.
485    ///
486    /// Currently, keys are taken as byte sequence. With the expected flexibilization of crypto
487    /// backends, this may later allow a more generic type that reflects secure element key slots.
488    #[must_use]
489    pub fn with_aif_symmetric_as_aesccm256(self, key: [u8; 32]) -> Self {
490        Self {
491            as_key_31: Some(key),
492            ..self
493        }
494    }
495
496    /// Sets a single Authorization Server recignized by its `ES256` (COSE algorithm -7) signing
497    /// key.
498    ///
499    /// An audience identifier is taken along with the key; signed tokens are only accepted if they
500    /// have that audience.
501    ///
502    /// Scopes are accepted as given by the AS using the AIF REST model as understood by
503    /// [`crate::scope::AifValue`].
504    ///
505    /// # Caveats and evolution
506    ///
507    /// Same from [`Self::with_aif_symmetric_as_aesccm256`] apply, minus the considerations for
508    /// secure key storage.
509    #[must_use]
510    pub fn with_aif_asymmetric_es256(
511        self,
512        x: [u8; 32],
513        y: [u8; 32],
514        audience: heapless::String<MAX_AUD_SIZE>,
515    ) -> Self {
516        Self {
517            as_key_neg7: Some((x, y, audience)),
518            ..self
519        }
520    }
521
522    /// Allow use of the server within the limits of the given scope by EDHOC clients provided they
523    /// present the given credential.
524    ///
525    /// Unlike many ACE tokens, this credential is accepted without any limitations on time.
526    ///
527    /// # Caveats and evolution
528    ///
529    /// Currently, this type just supports a single credential; it should therefore only be called
530    /// once, and the latest value overwrites any earlier. (See
531    /// [`Self::with_aif_symmetric_as_aesccm256`] for plans).
532    #[must_use]
533    pub fn with_known_edhoc_credential(
534        self,
535        credential: lakers::Credential,
536        scope: crate::scope::UnionScope,
537    ) -> Self {
538        Self {
539            known_edhoc_clients: Some((credential, scope)),
540            ..self
541        }
542    }
543
544    /// Configures an EDHOC credential and private key to be presented by this server.
545    ///
546    /// # Panics
547    ///
548    /// When debug assertions are enabled, this panics if an own credential has already been
549    /// configured.
550    #[must_use]
551    pub fn with_own_edhoc_credential(
552        self,
553        credential: lakers::Credential,
554        key: lakers::BytesP256ElemLen,
555    ) -> Self {
556        debug_assert!(
557            self.own_edhoc_credential.is_none(),
558            "Overwriting previously configured own credential scope"
559        );
560        Self {
561            own_edhoc_credential: Some((credential, key)),
562            ..self
563        }
564    }
565
566    /// Allow use of the server by unauthenticated clients using the given scope.
567    ///
568    /// # Panics
569    ///
570    /// When debug assertions are enabled, this panics if an unauthenticated scope has already been
571    /// configured.
572    #[must_use]
573    pub fn allow_unauthenticated(self, scope: crate::scope::UnionScope) -> Self {
574        debug_assert!(
575            self.unauthenticated_scope.is_none(),
576            "Overwriting previously configured unauthenticated scope"
577        );
578        Self {
579            unauthenticated_scope: Some(scope),
580            ..self
581        }
582    }
583
584    /// Sets the payload of the "Unauthorized" response.
585    ///
586    /// # Panics
587    ///
588    /// When debug assertions are enabled, this panics if an unauthenticated scope has already been
589    /// configured.
590    #[must_use]
591    pub fn with_request_creation_hints(self, request_creation_hints: &'static [u8]) -> Self {
592        debug_assert!(
593            self.request_creation_hints.is_empty(),
594            "Overwriting previously configured unauthenticated scope"
595        );
596        Self {
597            request_creation_hints,
598            ..self
599        }
600    }
601}
602
603/// An implementation of [`GeneralClaims`] for [`ConfigBuilder`].
604///
605/// It stores a [`UnionScope`][crate::scope::UnionScope] (effectively a
606/// [`AifValue`][crate::scope::AifValue]), a [`TimeConstraint`], and a flag for importance.
607#[derive(Debug)]
608pub struct ConfigBuilderClaims {
609    /// The scope of the claims (providing [`GeneralClaims::scope()`]).
610    pub scope: crate::scope::UnionScope,
611    /// Time constraints on the claims (providing [`GeneralClaims::time_constraint()`]).
612    pub time_constraint: crate::time::TimeConstraint,
613    /// Importance of the security context (providing [`GeneralClaims::is_important()`], see there).
614    pub is_important: bool,
615}
616
617impl GeneralClaims for ConfigBuilderClaims {
618    type Scope = crate::scope::UnionScope;
619
620    fn scope(&self) -> &Self::Scope {
621        &self.scope
622    }
623
624    fn time_constraint(&self) -> crate::time::TimeConstraint {
625        self.time_constraint
626    }
627
628    fn is_important(&self) -> bool {
629        self.is_important
630    }
631}