Packages

RFC 8613 (OSCORE) message protection for CoAP, with the protocol core implemented in Rust via a Rustler NIF.

Current section

Files

Jump to
oscore lib oscore.ex
Raw

lib/oscore.ex

defmodule OSCORE do
@moduledoc """
RFC 8613 (OSCORE) message protection for CoAP, with the crypto/protocol
core implemented in Rust.
This module handles security context derivation, message protection and
verification, and replay-window bookkeeping. Full CoAP message framing and
OSCORE option classification (Class E/I/U, see RFC 8613 §4.1) are the
caller's responsibility: `inner_options` is the already-serialized CoAP
Class E options to encrypt, and `class_i_options` is the already-serialized
Class I options to authenticate (unencrypted).
Only the mandatory AES-CCM-16-64-128 / HKDF-SHA-256 suite is supported.
"""
alias OSCORE.{Context, Protected, RequestEcho}
@typedoc "An atom describing why an OSCORE operation failed."
@type error_reason ::
:sequence_number_exhausted
| :malformed_option
| :malformed_plaintext
| :decryption_failed
| :unknown_kid
| :replayed
| :too_old
@doc """
Derives a Security Context (RFC 8613 §3.2) from a Master Secret and the
Sender/Recipient IDs.
## Options
* `:master_secret` (required)
* `:sender_id` (required)
* `:recipient_id` (required)
* `:master_salt` - defaults to `<<>>` (the RFC 8613 default)
* `:id_context` - defaults to `nil` (not present)
"""
@spec derive_context(keyword()) :: {:ok, Context.t()}
def derive_context(opts) do
master_secret = Keyword.fetch!(opts, :master_secret)
master_salt = Keyword.get(opts, :master_salt, <<>>)
sender_id = Keyword.fetch!(opts, :sender_id)
recipient_id = Keyword.fetch!(opts, :recipient_id)
id_context = Keyword.get(opts, :id_context)
resource =
Oscore.Native.derive_context(
master_secret,
master_salt,
sender_id,
recipient_id,
id_context
)
{:ok, %Context{resource: resource}}
end
@doc """
Protects an outgoing CoAP request (RFC 8613 §8.1).
Encrypts `code`, `inner_options`, and `payload` under a freshly
incremented Sender Sequence Number, and returns the protected message
along with a `RequestEcho` that must be kept around to later protect or
verify the matching response.
## Options
* `:code` (required)
* `:inner_options` - defaults to `<<>>`
* `:payload` - defaults to `<<>>`
"""
@spec protect_request(Context.t(), keyword()) ::
{:ok, Protected.t(), RequestEcho.t()} | {:error, error_reason()}
def protect_request(%Context{resource: resource}, opts) do
code = Keyword.fetch!(opts, :code)
inner_options = Keyword.get(opts, :inner_options, <<>>)
payload = Keyword.get(opts, :payload, <<>>)
case Oscore.Native.protect_request(resource, code, inner_options, payload) do
{:ok, {oscore_option, ciphertext, kid, piv, nonce}} ->
{:ok, %Protected{oscore_option: oscore_option, ciphertext: ciphertext},
%RequestEcho{kid: kid, piv: piv, nonce: nonce}}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Verifies an incoming CoAP request (RFC 8613 §8.2): decrypts it and checks
the Replay Window.
Returns the decrypted `code`, `inner_options`, and `payload`, plus the
`RequestEcho` needed to protect the matching response.
## Options
* `:oscore_option` (required)
* `:ciphertext` (required)
* `:class_i_options` - defaults to `<<>>`
"""
@spec unprotect_request(Context.t(), keyword()) ::
{:ok, code :: byte(), inner_options :: binary(), payload :: binary(), RequestEcho.t()}
| {:error, error_reason()}
def unprotect_request(%Context{resource: resource}, opts) do
oscore_option = Keyword.fetch!(opts, :oscore_option)
ciphertext = Keyword.fetch!(opts, :ciphertext)
class_i_options = Keyword.get(opts, :class_i_options, <<>>)
case Oscore.Native.unprotect_request(resource, oscore_option, ciphertext, class_i_options) do
{:ok, {code, inner_options, payload, kid, piv, nonce}} ->
{:ok, code, inner_options, payload, %RequestEcho{kid: kid, piv: piv, nonce: nonce}}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Protects an outgoing CoAP response (RFC 8613 §8.3) matching `echo`.
By default reuses the request's AEAD nonce (no Partial IV is sent - the
common case for a single response). Pass `partial_iv: :new` to generate a
fresh Partial IV instead, as required for Observe notifications after the
first one.
## Options
* `:code` (required)
* `:inner_options` - defaults to `<<>>`
* `:payload` - defaults to `<<>>`
* `:partial_iv` - `:reuse_request` (default) or `:new`
"""
@spec protect_response(Context.t(), RequestEcho.t(), keyword()) ::
{:ok, Protected.t()} | {:error, error_reason()}
def protect_response(%Context{resource: resource}, %RequestEcho{} = echo, opts) do
code = Keyword.fetch!(opts, :code)
inner_options = Keyword.get(opts, :inner_options, <<>>)
payload = Keyword.get(opts, :payload, <<>>)
reuse_request_nonce = Keyword.get(opts, :partial_iv, :reuse_request) == :reuse_request
case Oscore.Native.protect_response(
resource,
code,
inner_options,
payload,
echo.kid,
echo.piv,
echo.nonce,
reuse_request_nonce
) do
{:ok, {oscore_option, ciphertext}} ->
{:ok, %Protected{oscore_option: oscore_option, ciphertext: ciphertext}}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Verifies an incoming CoAP response (RFC 8613 §8.4) against the
`RequestEcho` of the request it answers.
## Options
* `:oscore_option` (required)
* `:ciphertext` (required)
* `:class_i_options` - defaults to `<<>>`
"""
@spec unprotect_response(Context.t(), RequestEcho.t(), keyword()) ::
{:ok, code :: byte(), inner_options :: binary(), payload :: binary()}
| {:error, error_reason()}
def unprotect_response(%Context{resource: resource}, %RequestEcho{} = echo, opts) do
oscore_option = Keyword.fetch!(opts, :oscore_option)
ciphertext = Keyword.fetch!(opts, :ciphertext)
class_i_options = Keyword.get(opts, :class_i_options, <<>>)
case Oscore.Native.unprotect_response(
resource,
oscore_option,
ciphertext,
class_i_options,
echo.kid,
echo.piv,
echo.nonce
) do
{:ok, {code, inner_options, payload}} ->
{:ok, code, inner_options, payload}
{:error, reason} ->
{:error, reason}
end
end
end