Four integration shapes: a network-HSM key handle, a KMS key-identity handle, a Plug consumer, and a porting note for non-Elixir holders. Runnable shapes, not a runnable app — each code block compiles as written against this package + the named dependencies. The HSM/KMS recipes reference your client module via config; compiling them as-is emits undefined-integration warnings naming exactly those modules — that is the paste-verify passing (the marked integration points are yours to fill).
Recipe: the local-loopback development listener
sign_local_loopback_report/3 exists for one situation: a development
listener that is on the literal loopback interface and cannot do TLS. It
produces the byte-distinct ba+loopback-proof profile
(bap-application-proof/local-loopback-http/1); the standard
dpop+jwt verifier rejects those bytes and the profile verifier rejects
standard bytes, so the two flows can never be confused on the wire.
# The holder side — the target is the listener's OWN canonical URL.
report = %{
grant_compact: grant_compact, # issuer-signed, passes through untouched
operation: "report_external_materialization",
method: "POST",
target_uri: "http://127.0.0.1:4000/invoke", # or "http://[::1]:4000/invoke"
invocation_id: invocation_id, # a fresh UUIDv4 per request
cast_arguments: cast_arguments, # BAP-tagged Json.value()
nonce: nonce # REQUIRED — the listener's challenge
}
{:ok, %{grant: grant, proof: proof}} =
BoundedAuthorityReportAdapter.sign_local_loopback_report(
report, {MyApp.DevHandle, :dev}, %{}
)The verifier (the listener's operator) checks it with the profile's envelope verifier, deriving the expected target from the listener's own bound address — never from a Host or X-Forwarded-* header:
alias BoundedAuthorityProtocol.ApplicationProfile.LocalLoopbackHttp.V1, as: Loopback
alias BoundedAuthorityProtocol.V1, as: StandardV1
# 1. The LISTENER mints the challenge (single-use, atomically consumed at
# verify) — the signer never chooses the nonce.
challenge = MyApp.ChallengeLedger.reserve()
# 2. The signer binds it (this is the adapter call above).
# 3. The verifier: the expected target comes from the listener's own bound
# address — never a Host or X-Forwarded-* header.
:ok = MyApp.ChallengeLedger.consume!(challenge)
{:ok, _facts} =
Loopback.check_envelope(
%StandardV1.Credentials{grant: grant, proof: proof},
%StandardV1.ExpectedRequest{
trusted_issuer: trusted_issuer,
issuer: issuer,
audience: audience,
method: "POST",
target_uri: "http://127.0.0.1:4000/invoke", # listener-derived
invocation_id: invocation_id,
operation: "report_external_materialization",
cast_arguments: cast_arguments,
evaluation_time: System.system_time(:second),
clock_skew: 60,
proof_max_age: 300,
nonce: {:required, challenge}, # mandatory on this profile
bounds: StandardV1.Bounds.maximum()
}
)The rules that bite:
- The nonce is required at signing and
{:required, nonce}at verification — and it is the LISTENER's challenge, not the signer's choice: the receiver mints it (single-use) and consumes it atomically at verification, so a captured request cannot be replayed even once. The signer reserves the challenge first (GET), then signs it in. - Only canonical literal-loopback targets sign.
localhost,127.0.0.2,0x7f.0.0.1,2130706433,[0:0:0:0:0:0:0:1],[::ffff:127.0.0.1], uppercase schemes, explicit:80, leading-zero ports, dot-segments, queries, fragments, and HTTPS all fail closed with{:error, {:producer_error, :invalid}}. Nothing is rewritten for you. - Loopback HTTP is not equivalent to HTTPS — no confidentiality, no server authentication, and not process isolation. Keep it on the literal loopback interface and behind an explicit profile decision.
The runnable end-to-end version (sign, POST, verify, replay-dedupe over real IPv4 and IPv6 sockets) is the edge-agent example's run_local_loopback entry and its receiver's profile mode — see examples/edge_agent in the repository.
Recipe: a network-HSM key handle
Requires: nothing beyond this package (the HSM client is injected via config — your client module is the integration point).
The handle's sign/2 fronts a network HSM. The two properties that matter: the callback
must return the closed {:ok, binary} | {:error, term} contract, and a TIMEOUT (a
GenServer.call exit) must not escape — the adapter's safe_callback catch-all maps an
exited callback to :invalid_key_handle / :signing_failed rather than crashing your
caller, so return errors explicitly whenever you can.
defmodule MyApp.HsmHandle do
@moduledoc """
Key handle backed by a network HSM. The handle term is the HSM's key reference
(never key material). The client module is injected:
config :my_app, :hsm_client, MyApp.HsmClient # implements sign_ed25519/2
A client timeout (a GenServer.call exit) never crashes your caller — the adapter's
safe_callback exit-catch contains it. WHERE it surfaces depends on the callback: a
sign/2 timeout maps to :signing_failed; a public_key/key_identity/signing_identity
timeout maps to :invalid_key_handle.
"""
@behaviour BoundedAuthorityReportAdapter
@hsm Application.compile_env(:my_app, :hsm_client, MyApp.HsmClient)
@impl true
def sign(message, key_ref) when is_binary(message) do
case @hsm.sign_ed25519(key_ref, message) do
{:ok, signature} -> {:ok, signature}
{:error, reason} -> {:error, reason}
end
end
def sign(_message, _key_ref), do: {:error, :invalid_handle}
@impl true
def public_key(key_ref), do: @hsm.public_key(key_ref)
@impl true
def thumbprint(key_ref) do
with {:ok, public_key} <- public_key(key_ref) do
BoundedAuthorityProtocol.V1.Jwk.public_key_thumbprint_raw(public_key, %{})
end
end
@impl true
def key_identity(key_ref), do: @hsm.key_identity(key_ref)
@impl true
def signing_identity(key_ref), do: @hsm.signing_identity(key_ref)
endRecipe: a KMS key-identity handle (why the snapshot is ONE call)
Requires: nothing beyond this package (same config-injection shape as above).
sign_anchor/3 and sign_key_transition/3 resolve key_id AND public_key from ONE
atomic key_identity/1 call. Against a KMS with key VERSIONS, a handle that resolves the
two in separate calls can straddle a rotation — kid from version N, public key from
version N+1 — and the signed header then names a key it was not signed with. One call,
one version:
defmodule MyApp.KmsHandle do
@moduledoc """
A KMS-backed handle whose key_identity/1 takes the atomic snapshot.
config :my_app, :kms_client, MyApp.KmsClient
# implements current_version/1 returning {:ok, {key_id, public_key}}
# from ONE versioned API call (or ONE cached snapshot of a version).
"""
@behaviour BoundedAuthorityReportAdapter
@kms Application.compile_env(:my_app, :kms_client, MyApp.KmsClient)
@impl true
def sign(message, key_ref) when is_binary(message),
do: @kms.sign_current_version(key_ref, message)
def sign(_message, _key_ref), do: {:error, :invalid_handle}
@impl true
def public_key(key_ref) do
case key_identity(key_ref) do
{:ok, {_key_id, public_key}} -> {:ok, public_key}
error -> error
end
end
@impl true
def thumbprint(key_ref) do
with {:ok, {_key_id, public_key}} <- key_identity(key_ref) do
BoundedAuthorityProtocol.V1.Jwk.public_key_thumbprint_raw(public_key, %{})
end
end
@impl true
def key_identity(key_ref) do
# ONE call. The adapter signs the kid into the header and verifies the
# signature against this same public key — a split snapshot cannot pass
# the wrong-key guard silently, but it CAN sign the wrong kid into the
# header. Do not "helpfully" cache the halves separately.
@kms.current_version(key_ref)
end
@impl true
def signing_identity(key_ref) do
with {:ok, {key_id, public_key}} <- key_identity(key_ref) do
# :issuer only if this KMS key REALLY is the issuer's — see the C1 note
# in docs/security.md: the gate rejects non-issuer declarations, it does
# not verify them.
{:ok, {:issuer, key_id, public_key}}
end
end
endRecipe: a Plug consumer (condensed)
Requires: plug (named, not pulled by this package). The canonical long form lives in
consumer integration; this is the condensed request-side shape
— raw-body retention, the envelope headers, and the verify with:
defmodule MyApp.ReportPlug do
@behaviour Plug
alias BoundedAuthorityProtocol.V1
@impl true
def init(opts), do: opts
@impl true
def call(conn, _opts) do
# Retain the RAW body bytes: cast_arguments must come from
# V1.Json.decode of the SAME bytes on both sides — parsing loses them.
case read_body(conn) do
{:ok, raw_body, conn} ->
with {:ok, grant} <- header(conn, "x-ba-grant"),
{:ok, proof} <- header(conn, "x-ba-proof"),
{:ok, nonce} <- header(conn, "x-ba-nonce"),
{:ok, cast_arguments} <- V1.Json.decode(raw_body, %{}),
{:ok, facts} <-
V1.check_envelope(
%V1.Credentials{grant: grant, proof: proof},
expected_request(conn, cast_arguments, nonce)
),
:ok <- bind_identity(conn, facts),
# The ledger spends the HEADER nonce (the value the proof bound) —
# the facts struct carries no nonce field of its own.
:ok <- MyApp.NonceLedger.spend(nonce) do
send_resp(conn, 200, "accepted")
else
_ -> send_resp(conn, 401, "invalid")
end
_ ->
send_resp(conn, 400, "bad body")
end
end
# Your consumer obligations — consumer-integration.md §8/§9 are canonical.
# BOTH stubs raise until filled: an integrator who skips identity binding must
# crash in dev, not silently accept — a fail-open stub here IS the
# cross-identity-replay misuse security.md names.
defp bind_identity(_conn, _facts),
do: raise("bind the verified holder to the authenticated reporter — consumer-integration §8")
defp expected_request(_conn, _cast_arguments, _nonce),
do: raise("build your ExpectedRequest — consumer-integration §4")
defp read_body(conn) do
case Plug.Conn.read_body(conn) do
{:ok, body, conn} -> {:ok, body, conn}
{:more, _partial, conn} -> {:error, :too_large, conn}
{:error, _} = err -> err
end
end
defp header(conn, name) do
case Plug.Conn.get_req_header(conn, name) do
[value] -> {:ok, value}
_ -> {:error, :missing_header}
end
end
defp send_resp(conn, status, body), do: Plug.Conn.send_resp(conn, status, body)
endPorting the signing side (beyond Elixir)
The adapter is a convenience, not a protocol requirement. What a non-Elixir holder implements is normative in the protocol package's own spec — the "V1 signing inputs" and "compact serialization" sections of the Bounded Authority Protocol specification (the durable document identity; the protocol repo is its home), not any particular repo path:
- Produce the object's signing input per the spec (the deterministic
protected.payloadbytes the protocol defines per object kind — proof, grant, boundary anchor, key transition). - Ed25519-sign those exact bytes with the holder's private key (the key stays in your custody stack; nothing about the port changes that).
- Assemble the compact per the spec (unpadded base64url segments, the codec's field order).
- Check yourself against the published conformance corpus — the protocol package
ships it (
priv/conformance), and its vectors are the oracle for whether your port produces bytes the verifier accepts. A port that round-trips the corpus is a port that verifies; anything less is a guess.
The one thing NOT to port: the key-handle indirection is an Elixir-library convenience. A port signs with its own custody stack directly — but keep the adapter's invariant (signed kid/key consistency and wrong-key rejection) or you inherit the failure modes security.md names.