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}