RequestSeal.Custody.Local (RequestSeal v0.5.2)

Copy Markdown View Source

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

import(algorithm, value, format, opts \\ [])

@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 in new/3.
  • value - the encoded private key, matching format:
    • 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 explicit null alg, use, or key_ops reject.
  • 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 in new/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 (unknown format, 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 JWK key_ops value outside the allowed set), :key_mismatch (JWK alg or use that does not fit, key_ops without the required operation, a PSS-only key for a non-PSS algorithm, or material that does not fit algorithm), and :invalid_options. Holder startup can also return :custodian_failure or :deadline_exceeded. Other reasons: see RequestSeal.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}}

new(algorithm, material, opts \\ [])

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 from RequestSeal.Crypto.algorithms/0, {:jws, name} for a JWS extension listed in RequestSeal.Crypto, or {:jwe, "RSA-OAEP"} / {:jwe, "RSA-OAEP-256"} for unwrap-only custody.
  • material - an operation-local OTP descriptor that fits algorithm:
    • {:rsa, otp_private_key} - a two-prime :RSAPrivateKey record 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 :symmetric RequestSeal.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 :equivalence on 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 fit algorithm). Holder startup can also return :custodian_failure or :deadline_exceeded. Other reasons: see RequestSeal.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}}

release(handle)

@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

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}}