Caller-owned OTP private keys and symmetric secrets, held in sensitive processes.
new/3 builds a handle from OTP key material and import/4 from one private
PEM, PKCS #8 DER, or private JWK. All six HTTP algorithms and the eight
explicit JWS extensions in RequestSeal.Crypto are supported for signing, and
{:jwe, "RSA-OAEP"} or {:jwe, "RSA-OAEP-256"} for unwrap-only custody. Each
handle binds exactly one algorithm; an unwrap handle cannot sign or verify.
Construction derives a public key and performs an actual sign/verify
before returning a signing handle. Unwrap construction validates RSA CRT components
and performs an actual OAEP wrap/unwrap. Only equivalence: nonsecret_binary is accepted, and
only for HMAC (1–256 bytes). Absent HMAC equivalence remains :unknown; no value
is derived from the secret. HMAC verification stays inside the custodian.
No private key export, global store, server, or telemetry is provided.
Each successful construction starts one unsupervised holder process. It marks
itself sensitive before receiving key material; the handle references only its
PID and a random 32-byte token. The holder performs OTP cryptography and returns
signature bytes, unwrapped key bytes, verification results, or public metadata,
never private state. RequestSeal.Custody.unwrap/3 shares signing deadlines,
cancellation, and safe errors. RSA-OAEP uses SHA-1/MGF1 SHA-1 and RSA-OAEP-256
uses SHA-256/MGF1 SHA-256, both with an empty label, as defined by
RFC 7518 Section 4.3.
Process and system introspection do not export that state.
Holders live until release/1 or the creating process exits, including after a
handle is transferred. Dropping a handle does not stop its holder. A long-lived
owner such as a GenServer must call release/1 for handles it no longer needs,
or create handles once at startup and reuse them for its lifetime.
Operations against a stopped holder return :key_not_found. No supervisor,
registry, application callback, or process starts when the library is loaded.
For Ed25519 seeds from explicitly encoded environment or file sources, add
RequestSeal.Custody.Local.Owner to the consumer's supervision tree.
Decryption custody
Import a recipient key once in its long-lived owner's process, then return the handle from the JWE policy resolver:
{:ok, handle} = RequestSeal.Custody.Local.import({:jwe, "RSA-OAEP-256"}, private_pem, :pem)
policy = %{
algorithms: ["RSA-OAEP-256"],
encryption: ["A256GCM"],
max_plaintext: 1_048_576,
timeout: 5_000,
key_resolver: fn _ -> {:ok, %{algorithm: "RSA-OAEP-256", key: handle}} end
}
RequestSeal.JOSE.JWE.decrypt(compact, policy)The resolver's work and the holder request share the JWE deadline. Private RSA
material stays in the holder. The CEK passes through the sensitive custody
runner and middle process to JWE's sensitive worker for authenticated content
decryption. OAEP padding and GCM tag failures return the same complete
RequestSeal.JOSE.Error. A released or unavailable handle produces that same
error as a forged message, by design; use
RequestSeal.Custody.public_key(handle) as a startup health check to expose
custody availability independently. Deadline expiration remains distinct.
Release the handle with release/1 when its owner no longer needs it.
Summary
Functions
Import one unencrypted private PEM, PKCS #8 DER, or private JWK with an explicit algorithm.
Construct an algorithm-bound signing or RSA-OAEP unwrap capability from OTP material.
Stop a local holder and discard its key material. Releasing it again returns :ok.
Functions
@spec import(RequestSeal.Custody.algorithm(), term(), :pem | :der | :jwk, keyword()) :: {:ok, RequestSeal.KeyHandle.t()} | {:error, RequestSeal.Custody.Error.t()}
Import one unencrypted private PEM, PKCS #8 DER, or private JWK with an explicit algorithm.
The decoded key goes through the same validation and round trip as new/3.
Public-only material, inconsistent private components, algorithm/type/curve
mismatches, and encrypted containers reject.
Parameters
algorithm- as innew/3.value- the encoded private key, matchingformat:- PEM: a binary of 1–16,384 bytes holding exactly one unencrypted entry, PKCS #1 RSA, SEC1 EC (P-256 or P-384), or PKCS #8 RSA, RSASSA-PSS, EC, or Ed25519. DER inside the entry must be canonical.
- DER: a canonical PKCS #8 binary of 1–16,384 bytes with the same key types.
- JWK: a private JWK map of at most 32 fields:
"RSA"with all two-prime CRT members,"EC"(P-256 or P-384),"OKP"Ed25519, or"oct". Components must be canonical unpadded Base64url;"oth"and explicitnullalg,use, orkey_opsreject.
format-:pem,:der, or:jwk.opts- keyword list; see Options.
A PSS-only private key never becomes a PKCS #1 signing key: it binds only
PS256, PS384, or PS512. Parameter-constrained PSS containers reject rather
than dropping restrictions. Imported EC/OKP public components must agree with
the derived private key. PKCS #8 v2 (OneAsymmetricKey) containers reject with
:unsupported_format.
JWK alg, use, and key_ops restrictions must permit the selected
operation. alg must be absent or equal the selected JOSE name (for example
"EdDSA" for "ed25519"). For signing, use must be absent or "sig" and
key_ops must be absent or a duplicate-free subset of "sign" and "verify"
that includes "sign". For OAEP, use must be absent or "enc", and key_ops
must be absent or a duplicate-free subset of "encrypt", "decrypt",
"wrapKey", and "unwrapKey" that includes "decrypt" or "unwrapKey";
signing operations reject. Public metadata retains these restrictions, and the
handle's capabilities are limited to the permitted operations: [:unwrap] for
OAEP, [:sign, :verify] when key_ops is absent or includes "verify", and
[:sign] otherwise.
Options
:equivalence- as innew/3: a nonsecret binary of 1–256 bytes, accepted only for HMAC algorithms. No default.
Returns
{:ok, %RequestSeal.KeyHandle{}}- a handle whose capabilities follow the rules above.{:error, %RequestSeal.Custody.Error{}}- reasons this function can return::unsupported_algorithm,:unsupported_format(unknownformat, encrypted PEM, constrained PSS parameters, PKCS #8 v2, or an unrecognized PKCS #8 algorithm),:invalid_key(malformed, oversized, noncanonical, public-only, or inconsistent input, or a JWKkey_opsvalue outside the allowed set),:key_mismatch(JWKalgorusethat does not fit,key_opswithout the required operation, a PSS-only key for a non-PSS algorithm, or material that does not fitalgorithm), and:invalid_options. Holder startup can also return:custodian_failureor:deadline_exceeded. Other reasons: seeRequestSeal.Custody.Error.
Examples
A crawler's signing key arrives as a private JWK restricted to signing.
iex> {public, seed} = :crypto.generate_key(:eddsa, :ed25519)
iex> jwk = %{
...> "kty" => "OKP",
...> "crv" => "Ed25519",
...> "x" => Base.url_encode64(public, padding: false),
...> "d" => Base.url_encode64(seed, padding: false),
...> "key_ops" => ["sign"]
...> }
iex> {:ok, handle} = RequestSeal.Custody.Local.import({:jws, "EdDSA"}, jwk, :jwk)
iex> handle.capabilities
[:sign]
iex> {:ok, %RequestSeal.PublicKey{operations: ["sign"]}} = RequestSeal.Custody.public_key(handle)
iex> RequestSeal.Custody.Local.release(handle)
:ok
iex> RequestSeal.Custody.Local.import("ed25519", Map.put(jwk, "alg", "ES256"), :jwk)
{:error, %RequestSeal.Custody.Error{reason: :key_mismatch, retryable: false}}
iex> RequestSeal.Custody.Local.import("ed25519", Map.delete(jwk, "d"), :jwk)
{:error, %RequestSeal.Custody.Error{reason: :invalid_key, retryable: false}}
iex> RequestSeal.Custody.Local.import("ed25519", jwk, :yaml)
{:error, %RequestSeal.Custody.Error{reason: :unsupported_format, retryable: false}}
@spec new(RequestSeal.Custody.algorithm(), tuple(), keyword()) :: {:ok, RequestSeal.KeyHandle.t()} | {:error, RequestSeal.Custody.Error.t()}
Construct an algorithm-bound signing or RSA-OAEP unwrap capability from OTP material.
Construction validates the material, derives its public key (none for HMAC),
performs an actual sign/verify (or OAEP wrap/unwrap) round trip, and then starts
one holder process owned by the caller. Release the handle with release/1.
Parameters
algorithm- an HTTP identifier fromRequestSeal.Crypto.algorithms/0,{:jws, name}for a JWS extension listed inRequestSeal.Crypto, or{:jwe, "RSA-OAEP"}/{:jwe, "RSA-OAEP-256"}for unwrap-only custody.material- an operation-local OTP descriptor that fitsalgorithm:{:rsa, otp_private_key}- a two-prime:RSAPrivateKeyrecord with a 2048–8192-bit modulus and consistent CRT components; RSA signing algorithms and both JWE algorithms.{:ec, "P-256" | "P-384", scalar_bytes}- a 32- or 48-byte scalar on the algorithm's curve.{:ed25519, seed_bytes}- a 32-byte seed.{:hmac, secret_bytes}- a 32–16,384-byte secret.
opts- keyword list; see Options.
Options
:equivalence- a nonsecret binary of 1–256 bytes, accepted only for HMAC algorithms; it becomes the handle's:symmetricRequestSeal.KeyIdentity. When absent, an HMAC handle's identity is:unknown. No default.
Returns
{:ok, %RequestSeal.KeyHandle{}}- capabilities[:sign, :verify]for a signing algorithm,[:unwrap]for a JWE algorithm.{:error, %RequestSeal.Custody.Error{}}- reasons this function can return::unsupported_algorithm,:invalid_options(an option other than a single:equivalence, or:equivalenceon a non-HMAC algorithm or outside 1–256 bytes),:invalid_key(malformed, out-of-range, or inconsistent material), and:key_mismatch(material of a type or curve that does not fitalgorithm). Holder startup can also return:custodian_failureor:deadline_exceeded. Other reasons: seeRequestSeal.Custody.Error.
Examples
iex> {_public, seed} = :crypto.generate_key(:eddsa, :ed25519)
iex> {:ok, handle} = RequestSeal.Custody.Local.new("ed25519", {:ed25519, seed})
iex> handle.capabilities
[:sign, :verify]
iex> {:ok, _signature} = RequestSeal.Custody.sign(handle, "release manifest for build 7781")
iex> RequestSeal.Custody.Local.release(handle)
:ok
iex> RequestSeal.Custody.Local.new("ed25519", {:ed25519, seed}, timeout: 1_000)
{:error, %RequestSeal.Custody.Error{reason: :invalid_options, retryable: false}}
iex> RequestSeal.Custody.Local.new("hmac-sha256", {:hmac, "too short"})
{:error, %RequestSeal.Custody.Error{reason: :invalid_key, retryable: false}}
iex> RequestSeal.Custody.Local.new({:jwe, "RSA-OAEP-256"}, {:ed25519, seed})
{:error, %RequestSeal.Custody.Error{reason: :key_mismatch, retryable: false}}
@spec release(RequestSeal.KeyHandle.t()) :: :ok | {:error, RequestSeal.Custody.Error.t()}
Stop a local holder and discard its key material. Releasing it again returns :ok.
Waits up to 5,000 ms for the holder to exit. Later operations on the handle
return :key_not_found.
Parameters
handle- aRequestSeal.KeyHandle.t/0created by this custodian.
Returns
:ok- the holder has exited, or had already exited.{:error, %RequestSeal.Custody.Error{}}- reasons this function can return::invalid_handle(not a handle created by this custodian, or a malformed one) and:deadline_exceeded(the holder did not exit within 5,000 ms). Meanings:RequestSeal.Custody.Error.
Examples
iex> {:ok, handle} = RequestSeal.Custody.Local.new("hmac-sha256", {:hmac, :crypto.strong_rand_bytes(32)})
iex> RequestSeal.Custody.Local.release(handle)
:ok
iex> RequestSeal.Custody.Local.release(handle)
:ok
iex> RequestSeal.Custody.sign(handle, "order 5512 shipped")
{:error, %RequestSeal.Custody.Error{reason: :key_not_found, retryable: false}}
iex> RequestSeal.Custody.Local.release(:not_a_handle)
{:error, %RequestSeal.Custody.Error{reason: :invalid_handle, retryable: false}}