Short, complete answers to the integration questions that come up after Getting started. Every Elixir block on this page runs, in order, in the repository's test suite, and the sentence after each block states the value it ends with.

The first block sets up the keys and a helper the later recipes share. The examples sign with :crypto and throwaway keys; in production the issuer signs in its KMS or HSM and the holder signs with its own key store, for example through the companion bounded_authority_report_adapter.

alias BoundedAuthorityProtocol.{Es256, V1, V2, V3}
alias BoundedAuthorityProtocol.V1.{Credentials, ExpectedGrant, ExpectedRequest, TrustedIssuer}

{issuer_public, issuer_private} = :crypto.generate_key(:eddsa, :ed25519)
{holder_public, holder_private} = :crypto.generate_key(:eddsa, :ed25519)
{:ok, holder_thumbprint} = V1.Jwk.public_key_thumbprint_raw(holder_public, %{})

ed25519_sign = fn message, private_key ->
  :crypto.sign(:eddsa, :none, message, [private_key, :ed25519])
end

now = 1_800_000_100

{byte_size(issuer_public), byte_size(holder_thumbprint)}

The value is {32, 32}: a raw Ed25519 public key and a raw SHA-256 thumbprint.

Plug and Phoenix integration

A service verifies a request in three steps: derive the expected values from its own routing and configuration, read the two compact credentials from the request, and call check_envelope/2. The protocol defines no HTTP binding, so the header names are a choice your deployment makes and documents; the example uses authorization: DPoP <grant> and dpop: <proof>, as in RFC 9449, and an invocation-id header.

Turn already-parsed params into tagged arguments

The request digest covers the operation's arguments in the package's tagged JSON form. When Plug has already parsed the body, and after your own code has cast it to the operation's declared types, convert the result once, at the boundary. The module below handles what Plug.Parsers and Ecto produce: string or atom keys, strings, integers, floats, booleans, nil, lists, and maps. It refuses anything else, such as a struct or a tuple, instead of guessing.

defmodule MyApp.CastArguments do
  @moduledoc "Converts cast request params into the protocol's tagged JSON value."

  def tag(value) do
    {:ok, do_tag(value)}
  catch
    :invalid -> {:error, :invalid}
  end

  defp do_tag(nil), do: :null
  defp do_tag(value) when is_boolean(value), do: {:boolean, value}
  defp do_tag(value) when is_integer(value), do: {:integer, value}
  defp do_tag(value) when is_float(value), do: {:float, value}
  defp do_tag(value) when is_binary(value), do: {:string, value}
  defp do_tag(values) when is_list(values), do: {:array, Enum.map(values, &do_tag/1)}

  defp do_tag(%{__struct__: _}), do: throw(:invalid)

  defp do_tag(%{} = map) do
    members = for {key, value} <- map, do: {member_name(key), do_tag(value)}
    names = Enum.map(members, &elem(&1, 0))
    if length(Enum.uniq(names)) != length(names), do: throw(:invalid)
    {:object, Enum.sort(members)}
  end

  defp do_tag(_other), do: throw(:invalid)

  defp member_name(key) when is_binary(key), do: key
  defp member_name(key) when is_atom(key) and not is_nil(key) and not is_boolean(key),
    do: Atom.to_string(key)

  defp member_name(_key), do: throw(:invalid)
end

MyApp.CastArguments.tag(%{"dataset" => "tides", "rows" => 500, "dry_run" => false})

The value is {:ok, {:object, [{"dataset", {:string, "tides"}}, {"dry_run", {:boolean, false}}, {"rows", {:integer, 500}}]}}.

Cast before you tag. A JSON body of {"rows": 500.0} parses to the float 500.0, which tags as {:float, 500.0}; that is a different argument from {:integer, 500}, so a proof signed over one never verifies against the other. Your operation's schema decides which one the request means. When you have the raw body bytes instead, V1.Json.decode/2 produces the tagged value directly, with duplicate-name and bounds checks.

The verification boundary

The plug below reads the credentials, builds ExpectedRequest from the service's own configuration (the public origin, the trusted issuer keys, the route's operation name), and verifies. The request URL comes from the configured public origin and the request path, never from the Host header or a forwarding header, which the client controls.

defmodule MyAppWeb.BoundedAuthority do
  @moduledoc """
  Verifies a bounded-authority envelope. Use it as a plug with
  `plug MyAppWeb.BoundedAuthority, operation: "export_dataset"`.
  """

  alias BoundedAuthorityProtocol.V1
  alias BoundedAuthorityProtocol.V1.{Credentials, ExpectedRequest, TrustedIssuer}

  def init(options), do: Map.new(options)

  def call(conn, options) do
    case verify(conn, Map.merge(options, config()), System.os_time(:second)) do
      {:ok, facts} ->
        Plug.Conn.assign(conn, :bounded_authority_facts, facts)

      {:error, :invalid} ->
        conn |> Plug.Conn.send_resp(401, "") |> Plug.Conn.halt()
    end
  end

  # The pure part: everything it needs is an argument, so it is easy to test.
  def verify(conn, options, now) do
    with {:ok, grant} <- header(conn, "authorization", "DPoP "),
         {:ok, proof} <- header(conn, "dpop", ""),
         {:ok, invocation_id} <- header(conn, "invocation-id", ""),
         {:ok, arguments} <- MyApp.CastArguments.tag(conn.body_params),
         {:ok, locator} <- V1.untrusted_key_locator(grant, %{}),
         {:ok, issuer_key} <- Map.fetch(options.issuer_keys, locator.kid) do
      expected = %ExpectedRequest{
        trusted_issuer: %TrustedIssuer{key_id: locator.kid, public_key: issuer_key},
        issuer: options.issuer,
        audience: options.audience,
        method: conn.method,
        target_uri: options.origin <> conn.request_path,
        invocation_id: invocation_id,
        operation: options.operation,
        cast_arguments: arguments,
        evaluation_time: now,
        clock_skew: 60,
        proof_max_age: 300,
        nonce: :not_required,
        bounds: %{}
      }

      V1.check_envelope(%Credentials{grant: grant, proof: proof}, expected)
    else
      _rejected -> {:error, :invalid}
    end
  end

  defp header(conn, name, prefix) do
    case for({^name, value} <- conn.req_headers, do: value) do
      [value] -> strip_prefix(value, prefix)
      _missing_or_repeated -> {:error, :invalid}
    end
  end

  defp strip_prefix(value, prefix) do
    if String.starts_with?(value, prefix),
      do: {:ok, binary_part(value, byte_size(prefix), byte_size(value) - byte_size(prefix))},
      else: {:error, :invalid}
  end

  defp config, do: Map.new(Application.get_env(:my_app, __MODULE__, []))
end

:ok

The value is :ok. In Phoenix, add the plug to a pipeline or a controller, then read conn.assigns.bounded_authority_facts in the action. The facts say the bytes verified; your action still reserves facts.invocation_id in its replay store, checks revocation of facts.grant_id, and decides.

To see the boundary work, a holder signs a grant and a proof, and a request shaped like a Plug.Conn (the plug reads only method, request_path, req_headers, and body_params) goes through verify/3:

grant = %V1.Grant{
  key_id: "issuer-2027-01",
  issuer: "https://issuer.example",
  grant_id: "grant-export-001",
  audiences: ["https://data.example"],
  issued_at: 1_800_000_000,
  not_before: 1_800_000_000,
  expires_at: 1_800_003_600,
  holder_thumbprint: holder_thumbprint,
  operations: [
    %V1.Operation{
      name: "export_dataset",
      selectors: [{:one_of, ["dataset"], [{:string, "tides"}, {:string, "currents"}]}]
    }
  ]
}

{:ok, grant_input} = V1.grant_signing_input(grant, %{})
{:ok, grant_compact} = V1.assemble_compact(grant_input, ed25519_sign.(grant_input.message, issuer_private))

params = %{"dataset" => "tides", "rows" => 500, "dry_run" => false}
{:ok, arguments} = MyApp.CastArguments.tag(params)

proof = %V1.Proof{
  holder_public_key: holder_public,
  proof_id: "proof-export-001",
  method: "POST",
  target_uri: "https://data.example/exports",
  issued_at: now,
  nonce: nil,
  invocation_id: "0c7d2f4a-5b1e-4f8a-9d3c-2e6b8a1f4c70",
  operation: "export_dataset",
  grant_compact: grant_compact,
  cast_arguments: arguments
}

{:ok, proof_input} = V1.proof_signing_input(proof, %{})
{:ok, proof_compact} = V1.assemble_compact(proof_input, ed25519_sign.(proof_input.message, holder_private))

conn = %{
  method: "POST",
  request_path: "/exports",
  body_params: params,
  req_headers: [
    {"authorization", "DPoP " <> grant_compact},
    {"dpop", proof_compact},
    {"invocation-id", "0c7d2f4a-5b1e-4f8a-9d3c-2e6b8a1f4c70"}
  ]
}

options = %{
  issuer: "https://issuer.example",
  audience: "https://data.example",
  origin: "https://data.example",
  operation: "export_dataset",
  issuer_keys: %{"issuer-2027-01" => issuer_public}
}

{:ok, facts} = MyAppWeb.BoundedAuthority.verify(conn, options, now)

{facts.operation, facts.invocation_id,
 MyAppWeb.BoundedAuthority.verify(%{conn | body_params: %{params | "dataset" => "salinity"}}, options, now)}

The value is {"export_dataset", "0c7d2f4a-5b1e-4f8a-9d3c-2e6b8a1f4c70", {:error, :invalid}}: the request verifies, and the same credentials with a dataset outside the grant's one_of selector do not.

Selectors per contract-major

A selector restricts one argument path. Every contract-major has equals, one_of, and all (which applies no restriction); contract-majors 2 and 3 add the inclusive ranges lte and gte over numbers. The kinds and their exact rules are in spec/bap-v1.md and spec/bap-v2.md ยง 4.

KindFormContract-majors
equals{:equals, path, value}1, 2, 3
one_of{:one_of, path, [value, ...]}1, 2, 3
all:all1, 2, 3
lte{:lte, path, {:integer, n} or {:float, x}}2, 3
gte{:gte, path, {:integer, n} or {:float, x}}2, 3

A contract-major 2 grant can cap a numeric argument:

v2_grant = %V2.Grant{
  key_id: "issuer-2027-01",
  issuer: "https://issuer.example",
  grant_id: "grant-render-001",
  audiences: ["https://render.example"],
  issued_at: 1_800_000_000,
  not_before: 1_800_000_000,
  expires_at: 1_800_003_600,
  holder_thumbprint: holder_thumbprint,
  operations: [
    %V2.Operation{
      name: "render_frames",
      selectors: [
        {:equals, ["project"], {:string, "harbor-survey"}},
        {:gte, ["frames"], {:integer, 1}},
        {:lte, ["frames"], {:integer, 240}}
      ]
    }
  ]
}

{:ok, v2_input} = V2.grant_signing_input(v2_grant, %{})
{:ok, v2_compact} = V2.assemble_compact(v2_input, ed25519_sign.(v2_input.message, issuer_private))

v2_envelope = fn frames ->
  arguments = {:object, [{"frames", {:integer, frames}}, {"project", {:string, "harbor-survey"}}]}

  v2_proof = %V2.Proof{
    holder_public_key: holder_public,
    proof_id: "proof-render-#{frames}",
    method: "POST",
    target_uri: "https://render.example/jobs",
    issued_at: now,
    nonce: nil,
    invocation_id: "5e0a9c1d-2b7f-4e3a-8c6d-1f4b9a2e7" <> String.pad_leading("#{frames}", 3, "0"),
    operation: "render_frames",
    grant_compact: v2_compact,
    cast_arguments: arguments
  }

  {:ok, proof_input} = V2.proof_signing_input(v2_proof, %{})
  {:ok, proof_compact} = V2.assemble_compact(proof_input, ed25519_sign.(proof_input.message, holder_private))

  expected = %ExpectedRequest{
    trusted_issuer: %TrustedIssuer{key_id: "issuer-2027-01", public_key: issuer_public},
    issuer: "https://issuer.example",
    audience: "https://render.example",
    method: "POST",
    target_uri: "https://render.example/jobs",
    invocation_id: v2_proof.invocation_id,
    operation: "render_frames",
    cast_arguments: arguments,
    evaluation_time: now,
    clock_skew: 60,
    proof_max_age: 300,
    nonce: :not_required,
    bounds: %{}
  }

  case V2.check_envelope(%Credentials{grant: v2_compact, proof: proof_compact}, expected) do
    {:ok, _facts} -> :verified
    {:error, :invalid} -> :rejected
  end
end

Enum.map([1, 240, 241], v2_envelope)

The value is [:verified, :verified, :rejected]: both bounds are inclusive.

A range compares integers with integers and floats with floats; an integer argument never satisfies a float bound, and the reverse. Pick the tag the argument will carry.

Tag numbers by what they are

Tag a whole number as {:integer, n}. Every grant producer refuses a float-tagged integral selector value, such as {:float, 2.0}, on every contract-major: canonical JSON writes 2.0 as 2, which decodes integer-tagged, so the signed grant would not mean what you built (erratum 6). A non-integral float stays a valid range bound.

selector_grant = fn module, selector ->
  struct(Module.concat(module, Grant), %{
    Map.from_struct(v2_grant)
    | operations: [struct(Module.concat(module, Operation), name: "render_frames", selectors: [selector])]
  })
end

{
  V1.grant_signing_input(selector_grant.(V1, {:equals, ["scale"], {:float, 2.0}}), %{}),
  V3.grant_signing_input(selector_grant.(V3, {:lte, ["scale"], {:float, 2.0}}), %{}),
  elem(V2.grant_signing_input(selector_grant.(V2, {:lte, ["scale"], {:float, 2.5}}), %{}), 0)
}

The value is {{:error, :invalid}, {:error, :invalid}, :ok}.

Verification closes the same gap from the other side: a grant whose equals value or one_of member holds a float zero (0.0, -0.0, or an underflowing literal such as 1e-400) does not decode on any contract-major, even when the issuer signed those bytes, because conforming verifiers would otherwise disagree about whether 0.0 and -0.0 are the same value. The block signs such a payload by hand to show it:

sign_compact = fn message -> message <> "." <> Base.url_encode64(ed25519_sign.(message, issuer_private), padding: false) end

zero_selector = [{:equals, ["offset"], {:integer, 0}}]
zero_grant = %{grant | operations: [%V1.Operation{name: "export_dataset", selectors: zero_selector}]}
{:ok, zero_input} = V1.grant_signing_input(zero_grant, %{})

[protected, payload] = String.split(zero_input.message, ".")

float_zero_payload =
  payload
  |> Base.url_decode64!(padding: false)
  |> String.replace(~s("value":0), ~s("value":0.0))
  |> Base.url_encode64(padding: false)

{:ok, _decoded} = V1.decode_grant(sign_compact.(zero_input.message), %{})
V1.decode_grant(sign_compact.(protected <> "." <> float_zero_payload), %{})

The value is {:error, :invalid}: the integer zero decodes, and the float zero, signed by the same issuer, is refused.

ES256 from a KMS or HSM

Contract-major 3 signs with ES256 (ECDSA P-256, SHA-256). Cloud KMS and HSM APIs return ECDSA signatures in DER (SEQUENCE { INTEGER r, INTEGER s }) and may return either of the two valid s values. The wire needs the raw 64-byte r || s with the low s, so the signer converts and normalizes before assembly; assembly itself never rewrites a signature and refuses one that is not canonical.

Ask the KMS to sign the signing input's message bytes with SHA-256 (for example AWS KMS ECDSA_SHA_256 with message type RAW, or Cloud KMS EC_SIGN_P256_SHA256 given the SHA-256 digest of message). Below, :crypto stands in for the KMS: it also returns DER.

{kms_public, kms_private} = :crypto.generate_key(:ecdh, :prime256v1)
{es_holder_public, _es_holder_private} = :crypto.generate_key(:ecdh, :prime256v1)
{:ok, es_holder_thumbprint} = V3.EcJwk.public_key_thumbprint_raw(es_holder_public, %{})

kms_sign = fn message -> :crypto.sign(:ecdsa, :sha256, message, [kms_private, :prime256v1]) end

es_grant = %V3.Grant{
  key_id: "kms-issuer-1",
  issuer: "https://issuer.example",
  grant_id: "grant-es-001",
  audiences: ["https://render.example"],
  issued_at: 1_800_000_000,
  not_before: 1_800_000_000,
  expires_at: 1_800_003_600,
  holder_thumbprint: es_holder_thumbprint,
  operations: [%V3.Operation{name: "render_frames", selectors: [{:lte, ["frames"], {:integer, 240}}]}]
}

{:ok, es_input} = V3.grant_signing_input(es_grant, %{})
der = kms_sign.(es_input.message)

{:ok, raw} = Es256.der_to_raw(der)
{:ok, low_s} = Es256.normalize_low_s(raw)
{:ok, es_compact} = V3.assemble_compact(es_input, low_s)

es_expected = %ExpectedGrant{
  issuer: "https://issuer.example",
  audience: "https://render.example",
  evaluation_time: now,
  clock_skew: 60,
  bounds: %{}
}

{:ok, es_facts} =
  V3.verify_grant(es_compact, %TrustedIssuer{key_id: "kms-issuer-1", public_key: kms_public}, es_expected)

{es_facts.version, es_facts.grant_id, Es256.canonical_signature?(low_s)}

The value is {3, "grant-es-001", true}.

The s value's twin n - s verifies under plain ECDSA too, which is why the profile accepts only the low one. normalize_low_s/1 maps either twin to the low one, and assembly refuses the high one:

n = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
<<r::unsigned-big-256, s::unsigned-big-256>> = low_s
high_s = <<r::unsigned-big-256, n - s::unsigned-big-256>>

{Es256.normalize_low_s(high_s) == {:ok, low_s}, V3.assemble_compact(es_input, high_s), Es256.der_to_raw(raw)}

The value is {true, {:error, :invalid}, {:error, :invalid}}: der_to_raw/1 also refuses input that is not strict DER, such as an already-raw signature.

Rotate issuer keys

Keep the issuer keys you trust in your own key ring, each with the window in which you accept it. Read the grant's kid with untrusted_key_locator/2, which decodes only the protected header and marks the result trust: :not_evaluated, and look it up in the ring. An unknown or retired kid fails like any other rejection; a known one still has to verify under the key you selected.

{next_public, next_private} = :crypto.generate_key(:eddsa, :ed25519)

key_ring = [
  %{kid: "issuer-2027-01", public_key: issuer_public, accept_from: 1_790_000_000, accept_until: 1_800_000_200},
  %{kid: "issuer-2027-07", public_key: next_public, accept_from: 1_800_000_000, accept_until: nil}
]

trusted_issuer = fn compact, at ->
  with {:ok, locator} <- V1.untrusted_key_locator(compact, %{}),
       %{public_key: key} <-
         Enum.find(key_ring, fn entry ->
           entry.kid == locator.kid and entry.accept_from <= at and
             (entry.accept_until == nil or at < entry.accept_until)
         end) do
    {:ok, %TrustedIssuer{key_id: locator.kid, public_key: key}}
  else
    _unknown_or_retired -> {:error, :invalid}
  end
end

verify_at = fn compact, at ->
  expected = %ExpectedGrant{issuer: "https://issuer.example", audience: "https://data.example", evaluation_time: at, clock_skew: 60, bounds: %{}}

  with {:ok, trusted} <- trusted_issuer.(compact, at),
       {:ok, facts} <- V1.verify_grant(compact, trusted, expected) do
    facts.grant_id
  end
end

next_grant = %{grant | key_id: "issuer-2027-07", grant_id: "grant-export-002"}
{:ok, next_input} = V1.grant_signing_input(next_grant, %{})
{:ok, next_compact} = V1.assemble_compact(next_input, ed25519_sign.(next_input.message, next_private))

{verify_at.(grant_compact, now), verify_at.(next_compact, now), verify_at.(grant_compact, now + 200)}

The value is {"grant-export-001", "grant-export-002", {:error, :invalid}}: during the overlap both keys verify, and once the old key's window closes its grants fail.

Before a new Ed25519 key enters the ring, check it with BoundedAuthorityProtocol.Ed25519.acceptable_public_key?/1 and torsion_free?/1 (usage rule 17); for a P-256 key, use Es256.acceptable_public_key?/1. For archives that must stay verifiable across a rotation, sign a key transition; see Evidence.

Nonce modes

ExpectedRequest.nonce takes one of two values, and the proof must agree with it exactly:

  • :not_required: the proof carries no nonce (nonce: nil). A proof that carries one is rejected, so a holder cannot add a value the service did not ask for.
  • {:required, nonce}: the service issued a challenge nonce, and the proof must carry exactly that value. Use it when you cannot bound replay with the invocation id alone, for example when the holder's clock is untrusted. Issue each nonce once, keep it until it is used or expires, and pass the one you issued, never one the client names.

The local-loopback profile always requires a nonce.

nonce_proof = fn nonce ->
  proof = %{proof | proof_id: "proof-nonce-#{nonce || "none"}", nonce: nonce}
  {:ok, input} = V1.proof_signing_input(proof, %{})
  {:ok, compact} = V1.assemble_compact(input, ed25519_sign.(input.message, holder_private))
  %Credentials{grant: grant_compact, proof: compact}
end

nonce_expected = %ExpectedRequest{
  trusted_issuer: %TrustedIssuer{key_id: "issuer-2027-01", public_key: issuer_public},
  issuer: "https://issuer.example",
  audience: "https://data.example",
  method: "POST",
  target_uri: "https://data.example/exports",
  invocation_id: proof.invocation_id,
  operation: "export_dataset",
  cast_arguments: arguments,
  evaluation_time: now,
  clock_skew: 60,
  proof_max_age: 300,
  nonce: :not_required,
  bounds: %{}
}

verdict = fn credentials, nonce_mode ->
  case V1.check_envelope(credentials, %{nonce_expected | nonce: nonce_mode}) do
    {:ok, _facts} -> :verified
    {:error, :invalid} -> :rejected
  end
end

[
  verdict.(nonce_proof.(nil), :not_required),
  verdict.(nonce_proof.("challenge-7f3a"), {:required, "challenge-7f3a"}),
  verdict.(nonce_proof.("challenge-7f3a"), {:required, "challenge-0000"}),
  verdict.(nonce_proof.(nil), {:required, "challenge-7f3a"}),
  verdict.(nonce_proof.("challenge-7f3a"), :not_required)
]

The value is [:verified, :verified, :rejected, :rejected, :rejected].

Time works the same way: the verifier reads no clock. evaluation_time is your clock in Unix seconds, clock_skew (at most 60) tolerates clock differences, and proof_max_age (at most 300) bounds how old a proof may be. A proof is accepted from issued_at - clock_skew until issued_at + proof_max_age + clock_skew.

Run every packaged corpus

The package ships every certified corpus in its priv directory, and BoundedAuthorityProtocol.Conformance.Cli.run/1 verifies one and returns the exit status: 0 on complete agreement, 1 on any disagreement or integrity failure, 2 on a usage error. Run all nine from your own project's test suite, or after each upgrade:

alias BoundedAuthorityProtocol.Conformance.Cli

corpora = [
  "v1/corpus",
  "v2/corpus",
  "v3/corpus",
  "strict-ed25519",
  "producer-canonicality",
  "selector-float-zero",
  "application-profiles/local-loopback-http/v1",
  "attestation-profiles/role-attestation/v1",
  "attestation-profiles/content-assertion/v1"
]

report = Path.join(System.tmp_dir!(), "bap-conformance-report.json")

results =
  for corpus <- corpora do
    directory = Application.app_dir(:bounded_authority_protocol, Path.join("priv/conformance", corpus))
    status = Cli.run(["--corpus", directory, "--report", report])
    {corpus, status, report |> File.read!() |> JSON.decode!() |> Map.take(["agreement", "disagreed"])}
  end

File.rm(report)
Enum.map(results, fn {_corpus, status, summary} -> {status, summary} end) |> Enum.uniq()

The value is [{0, %{"agreement" => true, "disagreed" => 0}}]: every corpus agrees.

The report is canonical JSON with the fields format, agreement, exit_status, total, agreed, disagreed, and index_sha256_base64url. From a shell, the same check is the bounded_authority_conformance command (mix escript.install hex bounded_authority_protocol), run once per corpus with --corpus deps/bounded_authority_protocol/priv/conformance/<corpus>. Exit 0 is conformance evidence, not an authorization.