RequestSeal.Custody behaviour (RequestSeal v0.2.0)

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.

sign/3, verify/4, and unwrap/3 accept only timeout: milliseconds (default 5,000; 1–300,000, no infinity). Unknown and duplicate options reject. Public-key and identity resolution use the default deadline. Exact input bytes are limited to 1,048,576; signatures and public container inputs are bounded to 16,384 bytes. 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.

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()

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.

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.

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.

context is a RequestSeal.Custody.Context with an integer deadline in monotonic milliseconds and an owner PID. Construct the deadline with System.monotonic_time(:millisecond) + timeout. 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. Owner death returns {:error, %RequestSeal.Custody.Error{reason: :custodian_failure, retryable: false}}.

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.

Callback {:ok, value} and {:error, reason} results pass through unchanged; :ok becomes {:ok, :ok}. Callback errors and successful values belong to the caller and are not redacted. Custody and cryptographic validation failures retain their bounded RequestSeal.Custody.Error reason. Other returns, exceptions, unrecognized throws, exits, or unexpected worker termination return {:error, %RequestSeal.Custody.Error{reason: :custodian_failure, retryable: false}} without retaining exception text. Deadline expiry returns the same error shape with reason: :deadline_exceeded, retryable: true. Invalid context or callback arguments return reason: :invalid_options, retryable: false.

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.

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}

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.

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 :unwrap capability are accepted. Encrypted keys are 1–1,024 bytes and must match the RSA modulus width inside the custodian. Returns unwrapped bytes, never the private key. OAEP failures return :decryption_failed; 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. An empty OAEP plaintext returns :decryption_failed.

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.

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.