OpenFeed.KeyStore behaviour (OpenFeed v0.1.0)

Copy Markdown View Source

Where your OpenFeed keypair comes from.

Under the Recommended client profile a single RSA key does two jobs: it authenticates the client (private_key_jwt) and it signs every DPoP proof. OpenFeed holds the public half, which you register as a JWKS.

Keys are never generated implicitly

This is the important part, and it is a deliberate departure from the obvious design. A store that lazily generates a key when it finds none is convenient on one machine and broken on two: each node mints a different key, signs proofs with it, and OpenFeed rejects them because that key is not in the registered JWKS. The failure is intermittent and load-balancer-dependent, which makes it about as unpleasant as bugs get.

So private_jwk/1 returns {:error, {:no_key, ...}} when no key exists. Generating one is an explicit, operator-driven act:

mix openfeed.gen.key            # generate and persist
mix openfeed.jwks               # print the public JWKS to register

Choosing a store

StoreMulti-node safeNotes
OpenFeed.KeyStore.EnvyesKey supplied out of band. Good for releases and 12-factor deploys.
AshOpenFeed.KeyStore.AshyesKey in your database, shared by every node. Can be encrypted with ash_cloak.
OpenFeed.KeyStore.FilenoSingle-node and local dev only, unless the path is shared storage.

Caching

Every API call signs a DPoP proof, and every proof needs the private key, so the key is cached in :persistent_term after first read. Pass cache: false in the store options to disable, and call invalidate/1 after rotating a key in a running VM. The key material lives in memory either way.

Implementing your own

defmodule MyApp.VaultKeyStore do
  @behaviour OpenFeed.KeyStore

  @impl true
  def private_jwk(opts) do
    case MyApp.Vault.read(opts[:path]) do
      {:ok, json} -> {:ok, OpenFeed.KeyStore.from_json!(json)}
      :error -> {:error, {:no_key, opts[:path]}}
    end
  end
end

Summary

Types

Reason a key could not be loaded. {:no_key, detail} means none exists yet.

Callbacks

Load the private JWK.

Persist a private JWK, for mix openfeed.gen.key.

Functions

The only signing algorithm OpenFeed accepts (PS256).

Parse a JWK from JSON. Returns {:error, {:invalid_key, reason}} on bad input.

Like from_json/1 but raises.

Generate a fresh PS256 signing key.

Drop any cached key for this config. Call after rotating a key in a live VM.

The JWKS to register with OpenFeed, as a map.

Load the private JWK for a config.

The public half of the key, as a bare JWK map.

Serialise a private JWK to JSON, for storage.

The public half of a JWK, as a bare map.

Types

error()

@type error() :: {:no_key, term()} | {:invalid_key, term()} | term()

Reason a key could not be loaded. {:no_key, detail} means none exists yet.

Callbacks

private_jwk(opts)

@callback private_jwk(opts :: keyword()) :: {:ok, JOSE.JWK.t()} | {:error, error()}

Load the private JWK.

Must return {:error, {:no_key, detail}} — not generate a key — when none exists, so callers can tell "not set up yet" apart from "misconfigured".

put_private_jwk(jwk, opts)

(optional)
@callback put_private_jwk(jwk :: JOSE.JWK.t(), opts :: keyword()) ::
  :ok | {:error, term()}

Persist a private JWK, for mix openfeed.gen.key.

Optional: read-only stores (Env) should not implement it.

Functions

alg()

@spec alg() :: String.t()

The only signing algorithm OpenFeed accepts (PS256).

from_json(json)

@spec from_json(String.t()) :: {:ok, JOSE.JWK.t()} | {:error, {:invalid_key, term()}}

Parse a JWK from JSON. Returns {:error, {:invalid_key, reason}} on bad input.

from_json!(json)

@spec from_json!(String.t()) :: JOSE.JWK.t()

Like from_json/1 but raises.

generate()

@spec generate() :: JOSE.JWK.t()

Generate a fresh PS256 signing key.

In memory only — persisting it is the store's job. The kid is the RFC 7638 thumbprint, so the JWS header and the registered JWKS agree on how to name the key without any extra bookkeeping.

invalidate(config)

@spec invalidate(OpenFeed.Config.t()) :: :ok

Drop any cached key for this config. Call after rotating a key in a live VM.

jwks(config)

@spec jwks(OpenFeed.Config.t()) :: {:ok, map()} | {:error, error()}

The JWKS to register with OpenFeed, as a map.

Register the JSON of this at your app registration so OpenFeed can verify your private_key_jwt assertions and DPoP proofs.

private_jwk(config)

@spec private_jwk(OpenFeed.Config.t()) :: {:ok, JOSE.JWK.t()} | {:error, error()}

Load the private JWK for a config.

Caches in :persistent_term unless the store options say cache: false.

public_jwk_map(config)

@spec public_jwk_map(OpenFeed.Config.t()) :: {:ok, map()} | {:error, error()}

The public half of the key, as a bare JWK map.

to_json(jwk)

@spec to_json(JOSE.JWK.t()) :: String.t()

Serialise a private JWK to JSON, for storage.

to_public_map(jwk)

@spec to_public_map(JOSE.JWK.t()) :: map()

The public half of a JWK, as a bare map.