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
@type encoding() :: :base64url | :base64 | :hex | :raw
@type key_status() ::
:ready
| :unconfigured
| {:error, :invalid_seed | :insecure_file | :unreadable}
Functions
@spec child_spec(options()) :: Supervisor.child_spec()
Build a child specification containing source descriptors, never loaded seeds.
Parameters
opts- the same:nameand:keysoptions asstart_link/1.
Returns
- a
Supervisor.child_spec/0map whose:idis:name(or this module when no name is given) and whose:startcallsstart_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
@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 defaultGenServer.call/3timeout.
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}
@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/2would 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
@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 (nottrueorfalse). Default: not registered.:keys- required keyword list ofkey_name: {"ed25519", source}entries with unique atom key names. Sources:{:env, variable, encoding}- the seed is the value ofvariable.{:file, path, encoding}- the seed is the content of the file atpath.{:file_from_env, variable, encoding}- reads a file path fromvariable, 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}-optsis not a keyword list, has an unknown or duplicate option, lacks a keyword-list:keys, repeats a key name, or has a:namethat is not an atom or is a boolean.- any other
GenServer.on_start/0result, such as{:error, {:already_started, pid}}when:nameis 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}
@spec status(GenServer.server()) :: %{required(atom()) => key_status()} | {:error, :owner_unavailable}
Return each configured key's bounded status.
Parameters
owner- the owner's PID or registered name (GenServer.server/0).
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 defaultGenServer.call/3timeout.
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}