RequestSeal.Custody.Local.Owner (RequestSeal v0.5.2)

Copy Markdown View Source

Caller-supervised owner of Ed25519 signing handles.

Add {RequestSeal.Custody.Local.Owner, name: MyApp.Custody, keys: keys} to your supervision tree. start_link/1 documents the key sources and encodings; there is no guess-decoding.

Files must be regular, not symbolic links, with mode 0600 or stricter (no execute, group, other, or special permission bits). Use {:file, ...} in production: environment seeds are inherited by OS child processes. Avoid :raw environment seeds; environment values cannot safely represent arbitrary binary data. Keep source paths and their parent directories under trusted control during startup.

Bad keys do not prevent startup; status/1 reports each key's bounded status. No signing capability is issued for a bad source. Key names, variable names, and paths are configuration, never seed values.

The owner becomes sensitive before reading sources. Each decoded seed goes directly to RequestSeal.Custody.Local.new/2; only handles and bounded statuses remain in state. Holders monitor this owner, not the process calling fetch/2. Sources are read on every start and restart; environment variables are never deleted. Registration after startup is unsupported. Fetch per operation, or fetch again and retry once on custody :key_not_found after an owner restart. During restart, :owner_unavailable means wait for the supervised owner to become available before fetching again. A file that disappears or changes identity after its initial checks reports :insecure_file. Abnormal exits retain a bounded atom reason; private reason terms are discarded. Loading the library starts nothing.

Summary

Functions

Build a child specification containing source descriptors, never loaded seeds.

Fetch a handle; distinguish an unconfigured key from an unavailable owner.

Report whether a handle is available for the named key.

Start a linked owner with a fixed set of source descriptors.

Return each configured key's bounded status.

Types

encoding()

@type encoding() :: :base64url | :base64 | :hex | :raw

key_status()

@type key_status() ::
  :ready
  | :unconfigured
  | {:error, :invalid_seed | :insecure_file | :unreadable}

options()

@type options() :: [name: atom(), keys: [{atom(), {binary(), source()}}]]

source()

@type source() :: {:env | :file | :file_from_env, binary(), encoding()}

Functions

child_spec(init_arg)

@spec child_spec(options()) :: Supervisor.child_spec()

Build a child specification containing source descriptors, never loaded seeds.

Parameters

Returns

  • a Supervisor.child_spec/0 map whose :id is :name (or this module when no name is given) and whose :start calls start_link/1. A key entry that is not a valid {"ed25519", source} descriptor is replaced by :invalid_seed, so the supervisor never retains it.

Raises ArgumentError ("invalid custody owner options") when opts fails the checks that make start_link/1 return {:error, :invalid_options}.

Examples

iex> keys = [builds: {"ed25519", {:file, "/etc/example/builds.key", :base64url}}]
iex> RequestSeal.Custody.Local.Owner.child_spec(name: MyApp.Custody, keys: keys)
%{
  id: MyApp.Custody,
  start: {RequestSeal.Custody.Local.Owner, :start_link, [[name: MyApp.Custody, keys: keys]]}
}
iex> RequestSeal.Custody.Local.Owner.child_spec(keys: [builds: {"rsa", {:env, "BUILD_KEY", :hex}}])
%{
  id: RequestSeal.Custody.Local.Owner,
  start: {RequestSeal.Custody.Local.Owner, :start_link, [[name: nil, keys: [builds: :invalid_seed]]]}
}
iex> RequestSeal.Custody.Local.Owner.child_spec(keys: :none)
** (ArgumentError) invalid custody owner options

fetch(owner, name)

@spec fetch(GenServer.server(), atom()) ::
  {:ok, RequestSeal.KeyHandle.t()}
  | {:error, :unconfigured | :owner_unavailable}

Fetch a handle; distinguish an unconfigured key from an unavailable owner.

The handle's holder is owned and monitored by the owner, not by the caller; do not release it unless the key should stay unavailable until the owner restarts.

Parameters

  • owner - the owner's PID or registered name (GenServer.server/0).
  • name - the atom key name given in :keys.

Returns

  • {:ok, %RequestSeal.KeyHandle{}} - an Ed25519 signing handle whose holder was alive when the owner replied.
  • {:error, :unconfigured} - the name is unknown, its source was missing, empty, or failed, or its holder has stopped.
  • {:error, :owner_unavailable} - the owner is stopped or did not reply within the default GenServer.call/3 timeout.

Examples

iex> {_public, seed} = :crypto.generate_key(:eddsa, :ed25519)
iex> variable = "REQUEST_SEAL_DOC_SEED_#{System.unique_integer([:positive])}"
iex> System.put_env(variable, Base.encode16(seed))
:ok
iex> {:ok, owner} = RequestSeal.Custody.Local.Owner.start_link(keys: [crawler: {"ed25519", {:env, variable, :hex}}])
iex> System.delete_env(variable)
:ok
iex> {:ok, handle} = RequestSeal.Custody.Local.Owner.fetch(owner, :crawler)
iex> {:ok, _signature} = RequestSeal.Custody.sign(handle, "GET https://www.example.org/robots.txt")
iex> RequestSeal.Custody.Local.Owner.fetch(owner, :messaging)
{:error, :unconfigured}
iex> GenServer.stop(owner)
:ok
iex> RequestSeal.Custody.Local.Owner.fetch(owner, :crawler)
{:error, :owner_unavailable}

ready?(owner, name)

@spec ready?(GenServer.server(), atom()) :: boolean()

Report whether a handle is available for the named key.

Equivalent to match?({:ok, _}, fetch(owner, name)). Readiness is a snapshot; a holder can still stop between this call and a signing call.

Parameters

  • owner - the owner's PID or registered name (GenServer.server/0).
  • name - the atom key name given in :keys.

Returns

  • true - fetch/2 would return a handle.
  • false - the key is unconfigured or the owner is unavailable.

Examples

iex> unset = "REQUEST_SEAL_DOC_UNSET_#{System.unique_integer([:positive])}"
iex> {:ok, owner} = RequestSeal.Custody.Local.Owner.start_link(keys: [documents: {"ed25519", {:env, unset, :hex}}])
iex> RequestSeal.Custody.Local.Owner.ready?(owner, :documents)
false
iex> GenServer.stop(owner)
:ok
iex> RequestSeal.Custody.Local.Owner.ready?(owner, :documents)
false

start_link(opts)

@spec start_link(options()) :: GenServer.on_start()

Start a linked owner with a fixed set of source descriptors.

Every source is read once during start (and again on each restart); each valid seed becomes one RequestSeal.Custody.Local handle owned by this process.

Parameters

  • opts - keyword list; see Options. Unknown or duplicate options reject.

Options

  • :name - optional atom used to register the owner (not true or false). Default: not registered.

  • :keys - required keyword list of key_name: {"ed25519", source} entries with unique atom key names. Sources:

    • {:env, variable, encoding} - the seed is the value of variable.
    • {:file, path, encoding} - the seed is the content of the file at path.
    • {:file_from_env, variable, encoding} - reads a file path from variable, then reads that file.

    Encodings are :base64url (padding optional), :base64 (padded), :hex (either case), or :raw. Only ASCII space, tab, CR, and LF are trimmed from text encodings; raw bytes are unchanged. Base64 encodings must be canonical. Decoded seeds must be exactly 32 bytes. Source inputs are limited to 4,096 bytes, including whitespace. An entry that is not of this shape does not stop startup; its status is {:error, :invalid_seed}.

Returns

  • {:ok, pid} - the owner started, whatever the status of individual keys.
  • {:error, :invalid_options} - opts is not a keyword list, has an unknown or duplicate option, lacks a keyword-list :keys, repeats a key name, or has a :name that is not an atom or is a boolean.
  • any other GenServer.on_start/0 result, such as {:error, {:already_started, pid}} when :name is taken.

Examples

A build signer reads its seed from a 0600 file. The file is created and restricted before the seed is written, then removed after the owner stops.

iex> {_public, seed} = :crypto.generate_key(:eddsa, :ed25519)
iex> path = Path.join(System.tmp_dir!(), "request-seal-builds-#{System.unique_integer([:positive])}.key")
iex> File.touch!(path)
:ok
iex> File.chmod!(path, 0o600)
:ok
iex> File.write!(path, Base.url_encode64(seed, padding: false) <> "\n")
:ok
iex> {:ok, owner} = RequestSeal.Custody.Local.Owner.start_link(keys: [builds: {"ed25519", {:file, path, :base64url}}])
iex> RequestSeal.Custody.Local.Owner.status(owner)
%{builds: :ready}
iex> GenServer.stop(owner)
:ok
iex> File.rm!(path)
:ok
iex> RequestSeal.Custody.Local.Owner.start_link(keys: [builds: {"ed25519", {:file, path, :base64url}}], timeout: 1_000)
{:error, :invalid_options}

status(owner)

@spec status(GenServer.server()) ::
  %{required(atom()) => key_status()} | {:error, :owner_unavailable}

Return each configured key's bounded status.

Parameters

Returns

  • a map from each configured key name to:
    • :ready - a handle is available.
    • :unconfigured - missing or empty variable, missing file, or stopped holder.
    • {:error, :invalid_seed} - the source decoded to no valid 32-byte seed, or the key entry was malformed.
    • {:error, :insecure_file} - the file is not a regular file with mode 0600 or stricter, or it changed identity during reading.
    • {:error, :unreadable} - the source could not be read.
  • {:error, :owner_unavailable} - the owner is stopped or did not reply within the default GenServer.call/3 timeout.

Examples

iex> unset = "REQUEST_SEAL_DOC_UNSET_#{System.unique_integer([:positive])}"
iex> keys = [
...>   messaging: {"ed25519", {:env, unset, :base64url}},
...>   orders: {"ed25519", {:url, "https://keys.example.com/orders", :hex}}
...> ]
iex> {:ok, owner} = RequestSeal.Custody.Local.Owner.start_link(keys: keys)
iex> RequestSeal.Custody.Local.Owner.status(owner)
%{messaging: :unconfigured, orders: {:error, :invalid_seed}}
iex> GenServer.stop(owner)
:ok
iex> RequestSeal.Custody.Local.Owner.status(owner)
{:error, :owner_unavailable}