RequestSeal.Custody behaviour (RequestSeal v0.5.2)

Copy Markdown View Source

Caller-owned signing, secret verification, key unwrapping, and public metadata.

Each callback runs in a fresh runner, never linked to the caller. A monitored middle process traps exits, monitors the caller before linking the runner, and terminates the runner when the caller dies, even when a callback is blocked. Both processes set Process.flag(:sensitive, true) before any custody work, for every operation. Runner exceptions are reduced to :custodian_failure without logging exception text. Deadline expiry kills the runner and drains its uniquely tagged result. Loading the library starts no process, store, or background work. Local handle construction explicitly starts its key holder.

Each operation validates its options, the handle, and its inputs before any custody work; the function documentation lists the bounds and the error reasons each one can return. Signing returns bytes; verification establishes mathematical validity only. Neither establishes identity attribution, authorization, or replay protection.

Custodians are trusted code: they must return only public keys and nonsecret identities, never log secrets, and must propagate the context deadline and cancellation to any additional work they own. Killing the worker closes sockets it owns; it cannot revoke work already accepted by an external peer. A custodian signing with RequestSeal.Crypto.sign/4 or verifying with RequestSeal.Crypto.verify/5 must pass max_bytes: RequestSeal.Crypto.max_bytes_ceiling() because custody has already enforced the caller's bound, which RequestSeal.Custody.Context does not carry.

Summary

Functions

Resolve internal trusted equivalence; keep the value out of general results.

Resolve validated public-only material; symmetric keys return :no_public_key.

Run a zero-arity callback in a sensitive process under an absolute deadline.

Sign exact bounded bytes with the algorithm bound to a handle.

Unwrap a bounded RSA-OAEP encrypted key using the handle's exact algorithm.

Verify exact bytes through secret custody or the custodian's public key.

Types

algorithm()

@type algorithm() :: RequestSeal.Crypto.algorithm() | unwrap_algorithm()

unwrap_algorithm()

@type unwrap_algorithm() :: {:jwe, binary()}

Callbacks

identity(term)

@callback identity(term()) ::
  RequestSeal.KeyIdentity.t() | {:error, RequestSeal.Custody.Error.reason()}

public_key(term)

@callback public_key(term()) ::
  {:ok, RequestSeal.PublicKey.t()}
  | {:error, RequestSeal.Custody.Error.reason()}

sign(term, algorithm, binary, t)

unwrap(term, unwrap_algorithm, binary, t)

(optional)
@callback unwrap(term(), unwrap_algorithm(), binary(), RequestSeal.Custody.Context.t()) ::
  {:ok, binary()} | {:error, RequestSeal.Custody.Error.reason()}

verify(term, algorithm, binary, binary, t)

(optional)

Functions

identity(handle)

Resolve internal trusted equivalence; keep the value out of general results.

Resolution runs under the default 5,000 ms deadline and takes no options. The result is a RequestSeal.KeyIdentity; compare identities only with RequestSeal.KeyIdentity.same?/2, and keep them out of general results, logs, and telemetry.

Parameters

Returns

  • {:ok, %RequestSeal.KeyIdentity{}} - kind :public (validated public components, with :rsa_pss normalized to :rsa), :symmetric (an opaque custodian-supplied binary of 1–256 bytes), or :unknown (value nil).
  • {:error, %RequestSeal.Custody.Error{}} - reasons this function can return: :invalid_handle, :unsupported_algorithm (the handle names an unknown algorithm), :key_not_found (local holder released or stopped), :deadline_exceeded, :invalid_key (a public identity fails validation), and :custodian_failure (including any other identity shape). Meanings: RequestSeal.Custody.Error.

Examples

Two handles constructed from the same build-signing seed have the same public identity.

iex> {_public, seed} = :crypto.generate_key(:eddsa, :ed25519)
iex> {:ok, first} = RequestSeal.Custody.Local.new("ed25519", {:ed25519, seed})
iex> {:ok, second} = RequestSeal.Custody.Local.new({:jws, "EdDSA"}, {:ed25519, seed})
iex> {:ok, a} = RequestSeal.Custody.identity(first)
iex> {:ok, b} = RequestSeal.Custody.identity(second)
iex> RequestSeal.KeyIdentity.same?(a, b)
true
iex> RequestSeal.Custody.Local.release(first)
:ok
iex> RequestSeal.Custody.Local.release(second)
:ok
iex> RequestSeal.Custody.identity(first)
{:error, %RequestSeal.Custody.Error{reason: :key_not_found, retryable: false}}

public_key(handle)

@spec public_key(RequestSeal.KeyHandle.t()) ::
  {:ok, RequestSeal.PublicKey.t()} | {:error, RequestSeal.Custody.Error.t()}

Resolve validated public-only material; symmetric keys return :no_public_key.

Resolution runs under the default 5,000 ms deadline and takes no options. The custodian's returned key is revalidated before it is returned.

Parameters

Returns

  • {:ok, %RequestSeal.PublicKey{}} - validated public material with any algorithm, use, and operation restrictions the key carries.
  • {:error, %RequestSeal.Custody.Error{}} - reasons this function can return: :invalid_handle, :unsupported_algorithm (the handle names an unknown algorithm), :no_public_key (HMAC custody), :key_not_found (local holder released or stopped), :deadline_exceeded, :invalid_key (the custodian returned public material that fails validation), and :custodian_failure. A custodian reason outside the RequestSeal.Custody.Error list becomes :custodian_failure. Meanings: RequestSeal.Custody.Error.

Examples

iex> {_public, seed} = :crypto.generate_key(:eddsa, :ed25519)
iex> {:ok, handle} = RequestSeal.Custody.Local.new("ed25519", {:ed25519, seed})
iex> {:ok, %RequestSeal.PublicKey{material: {:ed25519, public}}} = RequestSeal.Custody.public_key(handle)
iex> byte_size(public)
32
iex> RequestSeal.Custody.Local.release(handle)
:ok
iex> RequestSeal.Custody.public_key(handle)
{:error, %RequestSeal.Custody.Error{reason: :key_not_found, retryable: false}}

iex> {:ok, handle} = RequestSeal.Custody.Local.new("hmac-sha256", {:hmac, :crypto.strong_rand_bytes(32)})
iex> RequestSeal.Custody.public_key(handle)
{:error, %RequestSeal.Custody.Error{reason: :no_public_key, retryable: false}}
iex> RequestSeal.Custody.Local.release(handle)
:ok

run(context, callback)

@spec run(RequestSeal.Custody.Context.t(), (-> :ok | {:ok, term()} | {:error, term()})) ::
  {:ok, term()} | {:error, term()}

Run a zero-arity callback in a sensitive process under an absolute deadline.

An expired deadline returns immediately without invoking the callback. The caller and context.owner are both monitored; either dying cancels the callback, including blocked callbacks that trap exits. Nested runs monitor their immediate caller as well as their context owner, so cancellation propagates through nested operations. An already-dead context owner prevents the callback from starting.

The callback and its supervising middle process set sensitivity before work. Neither is linked to the caller. The runner is terminated and its exit awaited before the middle process sends a result; the caller awaits the middle process's exit before returning. Deadline expiry cancels the runner and awaits cleanup. Work inside a dirty NIF cannot be preempted: cancellation waits for the runner's exit, so a long native call can overshoot the deadline. Replies use a revocable process alias: queued replies are drained and later replies to that alias are discarded, without consuming unrelated caller messages.

Callbacks are trusted code. They must propagate the deadline and cancellation to additional processes or external work they start. Cancellation cannot undo effects already accepted by a peer, prevent messages explicitly sent to a caller PID by callback code, or terminate arbitrary unlinked callback descendants. Use nested run/2 calls for work requiring the same cancellation boundary.

Parameters

  • context - a RequestSeal.Custody.Context with an integer deadline in monotonic milliseconds and an owner PID. Construct the deadline with System.monotonic_time(:millisecond) + timeout.
  • callback - a zero-arity function returning :ok, {:ok, value}, or {:error, reason}.

Returns

  • {:ok, value} or {:error, reason} - the callback's own result, unchanged; :ok becomes {:ok, :ok}. Callback errors and successful values belong to the caller and are not redacted.
  • {:error, %RequestSeal.Custody.Error{}} - produced by run/2 itself:
    • a custody or cryptographic validation failure thrown inside the callback keeps its bounded RequestSeal.Custody.Error reason;
    • :custodian_failure (retryable: false) for any other return, exception, unrecognized throw, exit, unexpected worker termination, or owner death, without retaining exception text;
    • :deadline_exceeded (retryable: true) when the deadline expires;
    • :invalid_options (retryable: false) for an invalid context or callback argument.

Meanings: RequestSeal.Custody.Error.

Examples

iex> context = %RequestSeal.Custody.Context{owner: self(), deadline: System.monotonic_time(:millisecond) + 5_000}
iex> RequestSeal.Custody.run(context, fn -> {:ok, :completed} end)
{:ok, :completed}
iex> RequestSeal.Custody.run(context, fn -> :ok end)
{:ok, :ok}
iex> RequestSeal.Custody.run(context, fn -> raise "crawler fetch failed" end)
{:error, %RequestSeal.Custody.Error{reason: :custodian_failure, retryable: false}}
iex> expired = %RequestSeal.Custody.Context{owner: self(), deadline: System.monotonic_time(:millisecond) - 1}
iex> RequestSeal.Custody.run(expired, fn -> {:ok, :never_runs} end)
{:error, %RequestSeal.Custody.Error{reason: :deadline_exceeded, retryable: true}}
iex> RequestSeal.Custody.run(context, fn _ -> :ok end)
{:error, %RequestSeal.Custody.Error{reason: :invalid_options, retryable: false}}

sign(handle, bytes, opts \\ [])

@spec sign(RequestSeal.KeyHandle.t(), binary(), keyword()) ::
  {:ok, binary()} | {:error, RequestSeal.Custody.Error.t()}

Sign exact bounded bytes with the algorithm bound to a handle.

The custodian's signing callback runs in a fresh sensitive runner under the operation deadline, as described in the module documentation.

Parameters

  • handle - a RequestSeal.KeyHandle.t/0 with the :sign capability.
  • bytes - the exact binary to sign, at most :max_bytes bytes.
  • opts - keyword list; see Options.

Options

  • :timeout - operation deadline in milliseconds, integer 1–300,000 (:infinity is refused). Default 5_000.
  • :max_bytes - largest accepted bytes size, integer 1–16,777,216 (RequestSeal.Crypto.max_bytes_ceiling/0). Default 1_048_576.

Unknown, duplicate, out-of-range, or non-integer options return :invalid_options before the handle is checked.

Returns

  • {:ok, signature} - signature bytes, 1–16,384 bytes long.
  • {:error, %RequestSeal.Custody.Error{}} - reasons this function can return: :invalid_options, :invalid_handle, :unsupported_algorithm (the handle names an unknown algorithm), :unsupported_operation (the handle lacks :sign), :invalid_data (bytes is not a binary or exceeds :max_bytes), :deadline_exceeded, :key_not_found (local holder released or stopped, or agent key removed), :invalid_signing_output (the custodian returned a non-binary, empty, or over-16,384-byte signature), and :custodian_failure. Custodian reasons pass through: both built-in custodians return :key_mismatch when the handle's algorithm no longer matches the custodian's bound algorithm, and RequestSeal.Custody.SSHAgent also returns :custodian_unavailable, :custodian_rejected, and :custodian_protocol. A custodian reason outside the RequestSeal.Custody.Error list becomes :custodian_failure. Meanings: RequestSeal.Custody.Error.

Examples

Opt into a larger input bound on each signing and verification call. This local example uses actual Ed25519 cryptography under RFC 8032; it is not an external conformance vector.

iex> {_public, seed} = :crypto.generate_key(:eddsa, :ed25519)
iex> {:ok, handle} = RequestSeal.Custody.Local.new("ed25519", {:ed25519, seed})
iex> bytes = :binary.copy(<<0>>, 1_235_403)
iex> {:ok, signature} = RequestSeal.Custody.sign(handle, bytes, max_bytes: 2_097_152)
iex> RequestSeal.Custody.verify(handle, bytes, signature, max_bytes: 2_097_152)
:ok
iex> RequestSeal.Custody.sign(handle, bytes)
{:error, %RequestSeal.Custody.Error{reason: :invalid_data, retryable: false}}
iex> RequestSeal.Custody.Local.release(handle)
:ok

unwrap(handle, encrypted_key, opts \\ [])

@spec unwrap(RequestSeal.KeyHandle.t(), binary(), keyword()) ::
  {:ok, binary()} | {:error, RequestSeal.Custody.Error.t()}

Unwrap a bounded RSA-OAEP encrypted key using the handle's exact algorithm.

Only {:jwe, "RSA-OAEP"} and {:jwe, "RSA-OAEP-256"} handles with the :unwrap capability are accepted. The result is the unwrapped key bytes, never the private key. Callers must authenticate content before exposing plaintext; RequestSeal.JOSE.JWE performs that step with a random fallback CEK on unwrap failure.

Unwrapped bytes pass from the sensitive local holder through the sensitive custody runner and middle process to the caller. On the JWE handle path that caller is JWE's sensitive worker, which authenticates content before returning plaintext. A direct unwrap/3 caller receives the bytes and owns their protection.

JWE deliberately maps a released or unavailable handle to the same error as a forged message. Operators should check RequestSeal.Custody.public_key(handle) at startup to distinguish custody availability from message rejection; {:ok, public_key} confirms public resolution, while {:error, error} exposes the custody failure. Deadline expiration remains a distinct JWE error.

Parameters

  • handle - a RequestSeal.KeyHandle.t/0 whose algorithm is {:jwe, "RSA-OAEP"} or {:jwe, "RSA-OAEP-256"} and whose capabilities are exactly [:unwrap].
  • encrypted_key - the JWE encrypted key, a binary of 1–1,024 bytes. Inside the custodian it must also equal the RSA modulus width.
  • opts - keyword list; see Options.

Options

  • :timeout - operation deadline in milliseconds, integer 1–300,000 (:infinity is refused). Default 5_000.

:max_bytes is refused. Unknown, duplicate, out-of-range, or non-integer options return :invalid_options before the handle is checked.

Returns

  • {:ok, key_bytes} - the unwrapped key, 1–1,024 bytes.
  • {:error, %RequestSeal.Custody.Error{}} - reasons this function can return: :invalid_options, :invalid_handle (malformed handle, or an :unwrap capability on a handle that is not an RSA-OAEP handle), :unsupported_algorithm (a non-JWE handle naming an unknown algorithm), :unsupported_operation (the handle lacks :unwrap), :invalid_data (encrypted_key is not a binary of 1–1,024 bytes), :decryption_failed (OAEP failure, modulus-width mismatch, or an empty plaintext), :deadline_exceeded, :key_not_found (holder released or stopped), and :custodian_failure. RequestSeal.Custody.Local also returns :key_mismatch when the handle's algorithm no longer matches its holder. A custodian reason outside the RequestSeal.Custody.Error list becomes :custodian_failure. Meanings: RequestSeal.Custody.Error.

Examples

A document recipient key unwraps a content-encryption key that was wrapped to its public key; a forged encrypted key is refused.

iex> private = :public_key.generate_key({:rsa, 2048, 65_537})
iex> {:RSAPrivateKey, _, n, e, _, _, _, _, _, _, _} = private
iex> {:ok, handle} = RequestSeal.Custody.Local.new({:jwe, "RSA-OAEP-256"}, {:rsa, private})
iex> cek = :crypto.strong_rand_bytes(32)
iex> {:ok, %{encrypted_key: encrypted_key}} =
...>   RequestSeal.JOSE.KeyManagement.wrap("RSA-OAEP-256", cek, %{}, {:rsa, {:RSAPublicKey, n, e}})
iex> RequestSeal.Custody.unwrap(handle, encrypted_key) == {:ok, cek}
true
iex> RequestSeal.Custody.unwrap(handle, :binary.copy(<<1>>, 256))
{:error, %RequestSeal.Custody.Error{reason: :decryption_failed, retryable: false}}
iex> RequestSeal.Custody.unwrap(handle, encrypted_key, max_bytes: 1_024)
{:error, %RequestSeal.Custody.Error{reason: :invalid_options, retryable: false}}
iex> RequestSeal.Custody.Local.release(handle)
:ok

verify(handle, bytes, signature, opts \\ [])

@spec verify(RequestSeal.KeyHandle.t(), binary(), binary(), keyword()) ::
  :ok | {:error, RequestSeal.Custody.Error.t()}

Verify exact bytes through secret custody or the custodian's public key.

A custodian that implements the optional verify/5 callback verifies inside its own boundary (HMAC custody uses this path). Otherwise the custodian's public key is resolved and the signature is checked with RequestSeal.Crypto.verify/5. :ok establishes mathematical validity only.

Parameters

  • handle - a RequestSeal.KeyHandle.t/0 with the :verify capability.
  • bytes - the exact signed binary, at most :max_bytes bytes.
  • signature - the signature binary, at most 16,384 bytes.
  • opts - keyword list; see Options.

Options

  • :timeout - operation deadline in milliseconds, integer 1–300,000 (:infinity is refused). Default 5_000.
  • :max_bytes - largest accepted bytes size, integer 1–16,777,216 (RequestSeal.Crypto.max_bytes_ceiling/0). Default 1_048_576.

Unknown, duplicate, out-of-range, or non-integer options return :invalid_options before handle or capability validation.

Returns

  • :ok - the signature is mathematically valid for bytes.
  • {:error, %RequestSeal.Custody.Error{}} - reasons this function can return: :invalid_options, :invalid_handle, :unsupported_algorithm (the handle names an unknown algorithm), :unsupported_operation (the handle lacks :verify), :invalid_data (bytes is not a binary or exceeds :max_bytes), :invalid_signature (signature is not a binary, or it does not verify), :limit (signature exceeds 16,384 bytes), :deadline_exceeded, :key_not_found (local holder released or stopped), and :custodian_failure. Key-binding reasons pass through from the custodian or from RequestSeal.Crypto.verify/5: :key_mismatch (the key or the handle's algorithm does not fit the bound algorithm), :invalid_key, and :no_public_key (a custodian without verify/5 that has no public key). A custodian reason outside the RequestSeal.Custody.Error list becomes :custodian_failure. Meanings: RequestSeal.Custody.Error.

Examples

A telemetry batch signed with an HMAC handle verifies inside custody; a changed batch does not.

iex> {:ok, handle} = RequestSeal.Custody.Local.new("hmac-sha256", {:hmac, :crypto.strong_rand_bytes(32)})
iex> batch = ~s({"sensor":"line-4","readings":[21.4,21.6]})
iex> {:ok, tag} = RequestSeal.Custody.sign(handle, batch)
iex> RequestSeal.Custody.verify(handle, batch, tag)
:ok
iex> RequestSeal.Custody.verify(handle, batch <> " ", tag)
{:error, %RequestSeal.Custody.Error{reason: :invalid_signature, retryable: false}}
iex> RequestSeal.Custody.verify(handle, batch, tag, timeout: 0)
{:error, %RequestSeal.Custody.Error{reason: :invalid_options, retryable: false}}
iex> RequestSeal.Custody.Local.release(handle)
:ok