Decibel (decibel v1.0.1)

Copy Markdown View Source

Decibel is an implementation of The Noise Protocol Framework.

Noise is a framework for building crypto protocols. Noise protocols support mutual and optional authentication, identity hiding, forward secrecy, zero round-trip encryption, and other advanced features.

For more information about Noise, its rationale, supported protocols etc, please refer to The Noise Specification.

The rest of this document assumes the reader is familiar with the above specification.

API conventions

Decibel has a direct, raising API. Operations that produce data return it directly: a session handle, ciphertext or plaintext, a boolean, a handshake hash, a nonce, or a remote key. Operations whose only result is a state change return :ok: close/1, rekey/2, and set_nonce/3.

Plaintext, associated data, and inbound Noise messages accept iodata. The message and payload return types are also iodata, so callers that need a binary for framing or transport I/O should use IO.iodata_to_binary/1 rather than depend on an incidental list or binary shape.

There are no bang and non-bang variants. Invalid construction and arguments raise ArgumentError; peer-message failures raise Decibel.DecryptionError; nonce and one-way direction failures raise Decibel.NonceError and Decibel.TransportDirectionError; and ownership, lifetime, or phase failures raise Decibel.SessionError. Applications should rescue these stable exceptions only at boundaries where they have an explicit recovery or failure policy. Rejected operations do not commit session state.

Overview

Decibel encrypts and decrypts messages according to the Noise Protocol, and the client's selection of handshake and cryptographic primitives. It does not act as a transport, nor does it say anything about how Noise messages should be transmitted between participants.

Each party - either the initiator (the party that starts the handshake) or responder (the other party) - advances the handshake until it completes, at which point a secure channel is established. Interactive handshakes establish bidirectional transport. The one-way N, K, and X patterns permit only the initiator to encrypt and only the responder to decrypt transport messages.

Decibel supports all the handshake patterns outlined in r34 of the specification including the fundamental patterns, deferred patterns and one-way patterns. It also supports pre-shared keys as outlined in the specification, and fallback handshakes for Noise Pipes support.

Example

Consider the unauthenticated NN handshake defined in the Noise Protocol:

NN:
  -> e
  <- e, ee

The parties agree on this handshake and its cryptographic parameters and express this in a protocol name. This runnable example drives both roles in one process; a real peer process must create and operate its own session and exchange framed Noise messages rather than handles.

iex> initiator = Decibel.new("Noise_NN_25519_AESGCM_SHA256", :ini)
iex> responder = Decibel.new("Noise_NN_25519_AESGCM_SHA256", :rsp)
iex> Decibel.handshake_encrypt(initiator) |> then(&Decibel.handshake_decrypt(responder, &1))
""
iex> Decibel.handshake_encrypt(responder) |> then(&Decibel.handshake_decrypt(initiator, &1))
""
iex> ciphertext = Decibel.encrypt(initiator, "Hello, world")
iex> Decibel.decrypt(responder, ciphertext)
"Hello, world"
iex> {Decibel.close(initiator), Decibel.close(responder)}
{:ok, :ok}

The Getting Started guide adds authenticated key validation, application framing, the exact message-size boundary, and focused usage recipes.

Lifecycle

Ownership and lifetime

new/4 returns an opaque session/0 handle. The session state belongs to the process that calls new/4 and is stored in that process until close/1 is called or the owner process exits. A handle contains no cryptographic state and must not be inspected, altered, or constructed by callers.

Every operation on a session must run serially in its owner process. Do not pass the handle to a task, worker, or peer process, and do not call it concurrently. Pass Noise messages and application data between processes instead. A GenServer or similar long-lived process can own a session and serialize all operations in its callbacks. If that process terminates, its supervisor must establish a new session; the old one cannot be recovered or transferred.

Decibel does not support session ownership transfer. Using a structurally valid handle in another process raises Decibel.SessionError with reason: :not_owner, including when the owner has closed it or exited. This classification uses the handle's owner PID alone by design; Decibel has no handle registry, issuance proof, or signature. A legacy bare reference, malformed value, or unknown owner-local handle uses reason: :unknown. After close/1, every operation by the owner, including another close, uses reason: :closed.

During a live handshake, only the operation for the next pattern message is permitted. handshake_encrypt/2 requires the :handshake_write phase and handshake_decrypt/2 requires :handshake_read. Transport and cipher-state operations require :transport. A phase-invalid call raises Decibel.SessionError with reason: :wrong_phase, the operation, and the expected and actual phases, without changing session state.

Creation

Each party begins by creating a new handshake, via new/4, specifying the protocol name, the role the party plays in the handshake (:ini for initiator, :rsp for responder), and optionally any pre-message keys.

# In the IK handshake, the responder's public (static) key is known to the
# initiator prior to the handshake.
keys = %{s: {<<...>>, <<...>>}, rs: <<...>>}
ini  = Decibel.new("Noise_IK_448_ChaChaPoly_BLAKE2b", :ini, keys)

The result of new/4 is an opaque owner-aware handle used for the rest of the session. It is valid only in the process that created it.

Handshake

During the handshake phase, the protocol is advanced by each party in turn. For initiators, this typically starts with calling handshake_encrypt/2 and sending the result to the responder. In turn, the responder calls handshake_decrypt/2 before typically encrypting its own handshake message and sending that to the initiator.

This sequence continues until the handshake is complete. If the selected protocol is known at compile time, the parties can just assume its completion in the absence of an error (as in the example above). Alternatively, each party can call handshake_complete?/1 after each handshake encryption/decryption.

Once the handshake is complete, a secure channel is established with the properties of the selected protocol.

Additionally, once the handshake is complete, a unique 'session-hash' is available via handshake_hash/1 - see the channel-binding section of the specification for more details.

Session

Once the handshake is complete, the parties use encrypt/3 and decrypt/3 to exchange 'application' messages between each other. Both functions provide for optional 'associated authenticated data' to be specified, that provides message-integrity assurance for the application data.

Interactive handshake patterns allow both parties to encrypt and decrypt transport messages. For the one-way N, K, and X patterns, only the initiator may encrypt and only the responder may decrypt. Reverse-direction transport and cipher-management operations raise Decibel.TransportDirectionError without changing session state.

Each call encrypts or decrypts exactly one Noise transport message. Noise messages are limited to 65,535 bytes, so transport plaintexts are limited to 65,519 bytes after allowing for the 16-byte authentication tag. Applications must split and frame larger logical messages before passing them to Decibel.

Each keyed channel may use nonces from 0 through 2^64 - 2. Consuming the final nonce exhausts that channel; later encryption or decryption raises Decibel.NonceError. An exhausted channel cannot be revived by rekey/2, so the application must close it and establish a new session.

When the application is finished with the session, each party should call close/1 to discard its cryptographic state. Owner-process termination also discards the state automatically.

Summary

Types

A Noise handshake hash.

Key material and prologue data used to initialize a handshake.

A public-private Diffie-Hellman keypair.

A cipher nonce, including Noise's reserved exhausted value.

The role whose outbound channel uses the first split key.

The role the party plays in the protocol.

An opaque, process-owned Noise session handle.

A nonce value that may be selected for an active cipher.

Functions

Release the resources associated with the session.

Decrypts a message over an established session, using an optionally provided AAD for message integrity.

Encrypts a message over an established session, using an optionally provided AAD for message integrity.

Deprecated alias for handshake_hash/1.

Deprecated alias for nonce/2.

Deprecated alias for remote_key/1.

Returns true if the handshake is complete, false otherwise.

Decrypt an inbound handshake message, returning any optionally provided application data.

Encrypt an outbound handshake message, optionally folding in application data.

Returns the handshake hash unique to the established session.

Deprecated alias for handshake_complete?/1.

Get the current nonce value of the specified cipher.

Rekey the inbound or outbound channel of the session.

Get the remote (static) key if available.

Set the current value of nonce for the specified cipher.

Types

handshake_hash()

@type handshake_hash() :: <<_::256>> | <<_::512>>

A Noise handshake hash.

SHA256 and BLAKE2s produce 32-byte hashes; SHA512 and BLAKE2b produce 64-byte hashes.

key_material()

@type key_material() :: %{
  optional(:s) => keypair(),
  optional(:rs) => binary(),
  optional(:e) => keypair(),
  optional(:re) => binary(),
  optional(:psks) => [<<_::256>>],
  optional(:prologue) => iodata()
}

Key material and prologue data used to initialize a handshake.

keypair()

@type keypair() :: {binary(), binary()}

A public-private Diffie-Hellman keypair.

nonce()

@type nonce() :: 0..18_446_744_073_709_551_615

A cipher nonce, including Noise's reserved exhausted value.

option()

@type option() :: {:swap, role()}

The role whose outbound channel uses the first split key.

role()

@type role() :: :ini | :rsp

The role the party plays in the protocol.

session()

@type session() :: Decibel.Session.t()

An opaque, process-owned Noise session handle.

usable_nonce()

@type usable_nonce() :: 0..18_446_744_073_709_551_614

A nonce value that may be selected for an active cipher.

Functions

close(session)

@spec close(session()) :: :ok

Release the resources associated with the session.

Returns :ok after discarding the session state.

This discards handshake or transport state immediately, including pending key material. The state is also released automatically when the owner process terminates. The handle remains closed and cannot be reused.

Invalid ownership or an unknown handle raises Decibel.SessionError. Calling close/1 again raises it with reason: :closed.

decrypt(session, ciphertext, ad \\ [])

@spec decrypt(session(), iodata(), iodata()) :: iodata()

Decrypts a message over an established session, using an optionally provided AAD for message integrity.

Returns the decrypted message. Raises Decibel.DecryptionError with reason: :truncated or :authentication_failed if the message cannot be decrypted. The inbound state and nonce remain unchanged on either failure. Raises ArgumentError if the message exceeds 65,535 bytes. Raises Decibel.TransportDirectionError before any state change if inbound transport is not permitted by a one-way handshake. Raises Decibel.NonceError without changing state if the inbound channel's nonce is exhausted.

See Failure handling for the policy an application must apply to unauthenticated transport messages.

Requires the :transport phase. Invalid ownership, a closed/unknown handle, or use during the handshake raises Decibel.SessionError before any state change.

encrypt(session, plaintext, ad \\ [])

@spec encrypt(session(), iodata(), iodata()) :: iodata()

Encrypts a message over an established session, using an optionally provided AAD for message integrity.

Returns the encrypted message.

The application must provide framing and authenticated termination and follow the nonce and rekeying guidance.

Raises ArgumentError if plaintext exceeds 65,519 bytes, the largest plaintext that leaves room for the 16-byte authentication tag within a Noise message. Raises Decibel.TransportDirectionError before any state change if outbound transport is not permitted by a one-way handshake. Raises Decibel.NonceError without changing state if the outbound channel's nonce is exhausted.

Requires the :transport phase. Invalid ownership, a closed/unknown handle, or use during the handshake raises Decibel.SessionError before any state change.

get_handshake_hash(session)

This function is deprecated. Use handshake_hash/1 instead.
@spec get_handshake_hash(session()) :: handshake_hash() | nil

Deprecated alias for handshake_hash/1.

Scheduled for removal in Decibel 2.0.

get_nonce(session, dir)

This function is deprecated. Use nonce/2 instead.
@spec get_nonce(session(), :in | :out) :: nonce()

Deprecated alias for nonce/2.

Scheduled for removal in Decibel 2.0.

get_remote_key(session)

This function is deprecated. Use remote_key/1 instead.
@spec get_remote_key(session()) :: binary() | nil

Deprecated alias for remote_key/1.

Scheduled for removal in Decibel 2.0.

handshake_complete?(session)

@spec handshake_complete?(session()) :: boolean()

Returns true if the handshake is complete, false otherwise.

This accessor is valid during either handshake turn and transport. Invalid ownership or a closed/unknown handle raises Decibel.SessionError.

handshake_decrypt(session, ciphertext)

@spec handshake_decrypt(session(), iodata()) :: iodata()

Decrypt an inbound handshake message, returning any optionally provided application data.

Raises Decibel.DecryptionError with reason: :truncated, :authentication_failed, or :invalid_public_key if the peer message cannot be processed. Its :remote_keys field contains keys processed before the failure, and the stored handshake state remains unchanged. Raises ArgumentError if the message exceeds 65,535 bytes.

See Failure handling before deciding whether to abandon the handshake or enter a reviewed fallback protocol.

Requires the session's :handshake_read phase. Ownership, closed/unknown handles, and a wrong handshake turn raise Decibel.SessionError before any state change.

handshake_encrypt(session, plaintext \\ [])

@spec handshake_encrypt(session(), iodata()) :: iodata()

Encrypt an outbound handshake message, optionally folding in application data.

The reader is encouraged to understand the ramifications of providing application data during the handshake. As the handshake is not yet completed, the properties of any secure channel have not yet been established. Such data may even be sent in the clear. Consult the Payload Security Properties in the specification for more information.

Applications are responsible for framing and authenticating termination and for their failure policy.

Raises ArgumentError if the complete handshake message would exceed the Noise limit of 65,535 bytes. The maximum application-data size varies with the handshake pattern and cryptographic primitives because public keys and authentication tags are part of the same message.

A peer public key received in an earlier message might not be used until this write step. If that key is invalid, this function raises Decibel.DecryptionError with reason: :invalid_public_key. The session state remains unchanged so the caller can abandon the handshake cleanly.

Requires the session's :handshake_write phase. Ownership, closed/unknown handles, and a wrong handshake turn raise Decibel.SessionError before any state change.

handshake_hash(session)

@spec handshake_hash(session()) :: handshake_hash() | nil

Returns the handshake hash unique to the established session.

The hash is 32 bytes for SHA256 and BLAKE2s, or 64 bytes for SHA512 and BLAKE2b. Returns nil if the handshake is not yet completed.

Examples

iex> initiator = Decibel.new("Noise_NN_25519_ChaChaPoly_SHA256", :ini)
iex> responder = Decibel.new("Noise_NN_25519_ChaChaPoly_SHA256", :rsp)
iex> Decibel.handshake_encrypt(initiator) |> then(&Decibel.handshake_decrypt(responder, &1))
""
iex> Decibel.handshake_encrypt(responder) |> then(&Decibel.handshake_decrypt(initiator, &1))
""
iex> match?(<<_::32-bytes>>, Decibel.handshake_hash(initiator))
true
iex> Decibel.handshake_hash(initiator) == Decibel.handshake_hash(responder)
true
iex> {Decibel.close(initiator), Decibel.close(responder)}
{:ok, :ok}

iex> initiator = Decibel.new("Noise_NN_448_AESGCM_BLAKE2b", :ini)
iex> responder = Decibel.new("Noise_NN_448_AESGCM_BLAKE2b", :rsp)
iex> Decibel.handshake_encrypt(initiator) |> then(&Decibel.handshake_decrypt(responder, &1))
""
iex> Decibel.handshake_encrypt(responder) |> then(&Decibel.handshake_decrypt(initiator, &1))
""
iex> match?(<<_::64-bytes>>, Decibel.handshake_hash(initiator))
true
iex> Decibel.handshake_hash(initiator) == Decibel.handshake_hash(responder)
true
iex> {Decibel.close(initiator), Decibel.close(responder)}
{:ok, :ok}

This accessor is valid during either handshake turn and transport. Invalid ownership or a closed/unknown handle raises Decibel.SessionError.

is_handshake_complete?(session)

This function is deprecated. Use handshake_complete?/1 instead.
@spec is_handshake_complete?(session()) :: boolean()

Deprecated alias for handshake_complete?/1.

Scheduled for removal in Decibel 2.0.

new(protocol_name, role, keys \\ %{}, opts \\ [])

@spec new(String.t(), role(), key_material(), [option()]) :: session()

Start a new handshake.

The caller should provide a protocol name and the role the caller will play in the protocol. The caller must provide all keys required by the protocol, including local static keys first used by later handshake messages. These are normally static keys or pre-shared keys (PSKs). Local ephemeral keys for ordinary handshakes are generated internally when their outbound e token is processed. The list of provided keys should be identified as follows:

Before constructing a session, review Authentication and key handling and Negotiation and rollback.

  • :s: the party's public-private static key pair as a tuple. It is required whenever the selected role uses a static key anywhere in the fully modified pattern.
  • :rs: the peer's public static key as a binary, only when the peer has a static pre-message.
  • :e: only for a fallback handshake where the caller sent the failed handshake's original ephemeral; the caller's public-private ephemeral key pair as a tuple.
  • :re: only for a fallback handshake where the peer sent the failed handshake's original ephemeral; the peer's ephemeral public key as a binary.
  • :psks: a list of pre-shared symmetric keys (as binaries), exactly one 32-byte key for each pskN modifier.
  • :prologue: any prologue data represented as iodata.

Public and private DH values must each have the exact length required by the selected DH function: 32 bytes for 25519 or 56 bytes for 448.

The only supported option is :swap, whose value must be :ini or :rsp and defaults to :ini. For interactive handshakes, the named role uses the first key returned by Noise Split() as its outbound key, and the other role uses that key as its inbound key. Both peers must use the same value. Noise Pipes fallback uses swap: :rsp because the responder sends the first fallback message. This option never reverses the fixed direction of a one-way handshake.

Ephemeral keypairs belong to exactly one protocol run. They must never be shared across sessions, processes, or protocol names. The fallback inputs above reuse a key within the same compound-protocol run; they do not make general reuse safe.

Protocol names are limited to 255 bytes and must use the canonical Noise syntax. Modifiers are applied from left to right, so pskN after fallback indexes the remaining handshake messages. PSK modifiers whose relative order does not affect the resulting pattern must be sorted alphabetically, as required by Noise section 8.1.

Raises ArgumentError for malformed or unsupported protocol names, invalid or non-canonical modifiers, impossible PSK placements, PSK lists that do not contain exactly one 32-byte key per modifier, missing or malformed static key material, invalid prologue iodata, and invalid options. It also raises ArgumentError for caller-supplied ephemeral keys outside their role-specific fallback pre-message or with lengths that do not match the selected DH function. Validation completes before any session state is stored.

Returns an opaque session handle representing the handshake. The calling process owns the session for its lifetime; see Ownership and lifetime.

nonce(session, dir)

@spec nonce(session(), :in | :out) :: nonce()

Get the current nonce value of the specified cipher.

Connectionless senders should read the outbound nonce immediately before calling encrypt/3 and send that value with the ciphertext. See Connectionless Transports for the replay protection the recipient must provide, and Nonces, replay protection, and rekeying for the safe-use requirements.

After the final usable nonce, 2^64 - 2, is consumed, this returns the reserved value 2^64 - 1 to indicate that the channel is exhausted.

Raises Decibel.TransportDirectionError before any state change if the selected direction is not permitted by a one-way handshake.

Requires the :transport phase. Invalid ownership, a closed/unknown handle, or use during the handshake raises Decibel.SessionError.

rekey(session, dir)

@spec rekey(session(), :in | :out) :: :ok

Rekey the inbound or outbound channel of the session.

Returns :ok after replacing the selected key.

Noise rekeying changes the selected channel's key but does not reset its nonce. Applications must coordinate rekeying with the peer and continue the existing counter. For connectionless transports, retain the corresponding replay window as well; delayed messages encrypted under the old key cannot be decrypted after rekeying.

See Nonces, replay protection, and rekeying for the application responsibilities around this operation.

Raises Decibel.TransportDirectionError before any state change if the selected direction is not permitted by a one-way handshake.

Requires the :transport phase. Invalid ownership, a closed/unknown handle, or use during the handshake raises Decibel.SessionError before any state change.

remote_key(session)

@spec remote_key(session()) :: nil | binary()

Get the remote (static) key if available.

A returned key is protocol output, not a trust decision. Authenticate it according to Authentication and key handling.

This accessor is valid during either handshake turn and transport. Invalid ownership or a closed/unknown handle raises Decibel.SessionError.

set_nonce(session, dir, n)

@spec set_nonce(session(), :in | :out, usable_nonce()) :: :ok

Set the current value of nonce for the specified cipher.

Returns :ok after selecting the nonce.

Danger: low-level nonce control

This function does not provide replay protection. Applications selecting inbound nonces must reject every nonce that has already authenticated and must record a nonce only after successful decryption. Applications should normally read outbound nonces with nonce/2; moving an outbound nonce backwards is rejected because reusing a nonce with the same key is a catastrophic AEAD failure.

The nonce must be an integer from 0 through 2^64 - 2.

An inbound nonce may be selected in any order. An outbound nonce may remain unchanged or move forward, but cannot move backwards. A rejected operation leaves session state unchanged.

Raises Decibel.TransportDirectionError before any state change if the selected direction is not permitted by a one-way handshake. Raises Decibel.NonceError without changing state if the nonce is outside the usable range or would move the outbound channel backwards.

See Nonces, replay protection, and rekeying before using this low-level operation.

Requires the :transport phase. Invalid ownership, a closed/unknown handle, or use during the handshake raises Decibel.SessionError before any state change.