bounded_authority_report_adapter targets Elixir ~> 1.18 (tested on 1.20 / OTP 29, CI
runs 1.18/27, 1.19/28, 1.20/29). The package is published on
Hex.
{:bounded_authority_report_adapter, "~> 0.3"}Or scaffold the starter key-handle with Igniter
(add {:igniter, "~> 0.8"} to your dev deps first):
mix bounded_authority_report_adapter.install --module MyApp.HolderKey
The scaffold compiles clean and raises on every callback until you wire your custody store — it signs nothing by accident.
The shape of the thing
This library is a HOLDER-side signer. It never sees your private key: you hand it a
{module, term} key handle whose module implements the BoundedAuthorityReportAdapter
behaviour, and it signs protocol objects (holder proofs, boundary anchors, grants, key
transitions) through that handle. Verification is the protocol package's job
(BoundedAuthorityProtocol.V1.check_envelope/2 and friends) — this adapter never
verifies.
Your first sign, in minutes
A development handle is ~25 lines: a seeded Ed25519 pair behind the five callbacks. (The
source repository carries a reference implementation under test/support/ — it is
TEST-ONLY and deliberately NOT shipped in the package; in production the handle fronts
an HSM, OS keychain, or key server.)
defmodule MyApp.DevHandle do
@behaviour BoundedAuthorityReportAdapter
@seed <<2::256>> # DEV ONLY — never a real key
defp keypair, do: :crypto.generate_key(:eddsa, :ed25519, @seed)
@impl true
def sign(message, _handle) when is_binary(message) do
{_pub, priv} = keypair()
{:ok, :crypto.sign(:eddsa, :none, message, [priv, :ed25519])}
end
def sign(_message, _handle), do: {:error, :invalid_handle}
@impl true
def public_key(_handle), do: {:ok, elem(keypair(), 0)}
@impl true
def thumbprint(_handle) do
{:ok, raw} =
BoundedAuthorityProtocol.V1.Jwk.public_key_thumbprint_raw(elem(keypair(), 0), %{})
{:ok, raw}
end
@impl true
def key_identity(_handle), do: {:ok, {"my-dev-key-001", elem(keypair(), 0)}}
@impl true
def signing_identity(_handle), do: {:ok, {:holder, "my-dev-key-001", elem(keypair(), 0)}}
endSign a report against an issuer-signed grant (the grant arrives out-of-band from your issuer — mint one for the dev loop the way the example app does):
# cast_arguments MUST be BAP's tagged form — decode the SAME raw bytes both sides use:
{:ok, cast_arguments} = BoundedAuthorityProtocol.V1.Json.decode(raw_report_body, %{})
report = %{
grant_compact: issuer_signed_grant_compact, # binds YOUR thumbprint via cnf.jkt
operation: "report_external_materialization",
method: "POST",
target_uri: "https://api.example.test/invoke",
invocation_id: "123e4567-e89b-42d3-a456-426614174000",
cast_arguments: cast_arguments,
nonce: "challenge-001"
}
{:ok, %{grant: grant, proof: proof}} =
BoundedAuthorityReportAdapter.sign_report(report, {MyApp.DevHandle, :dev}, %{})
# Verify through the protocol's own verifier (the CONSUMER's side of the contract):
alias BoundedAuthorityProtocol.V1
{:ok, _facts} =
V1.check_envelope(
%V1.Credentials{grant: grant, proof: proof},
%V1.ExpectedRequest{
trusted_issuer: %V1.TrustedIssuer{key_id: issuer_kid, public_key: issuer_pub},
issuer: "https://issuer.example.test",
audience: "https://verifier.example.test",
method: "POST",
target_uri: "https://api.example.test/invoke",
invocation_id: report.invocation_id,
operation: report.operation,
cast_arguments: cast_arguments,
evaluation_time: System.system_time(:second),
clock_skew: 60,
proof_max_age: 300,
nonce: {:required, "challenge-001"},
bounds: V1.Bounds.maximum()
}
)If that comes back {:ok, _}, you have signed a proof the protocol's verifier accepts.
If it comes back {:error, :invalid}: the adapter verified only the PROOF it just
signed and passed your grant through untouched, so a rejection can be an
expected-request field mismatch (times, nonce, audience, arguments) OR the verifier
rejecting the GRANT itself (issuer signature, window, thumbprint binding). The
adapter-side signing errors are a different surface — Errors covers those.
The invocation-id trap (read this before your first 401)
invocation_id is caller-supplied and gated by the protocol's UUID grammar
(valid_uuid? — canonical dashed lowercase hex, version + variant nibbles). Two
consequences that bite in practice:
- A malformed id (uppercase, undashed, wrong version nibbles) is rejected far
from the cause — your proof verifies fine byte-wise, but the verifier's
expected-request comparison fails as a bare
{:error, :invalid}. UseEcto.UUID.generate/0, a UUIDv4 library, or the shape in the examples. - It is part of what the proof BINDS: the verifier reconstructs it from the request line. Same grant, same key, different invocation id = rejected. That is the point (per-invocation binding); it just means you cannot reuse a signed envelope for a retried request with a fresh id.
The path to a production handle
Swap MyApp.DevHandle for one whose sign/2 calls your HSM/KMS and whose
key_identity/1/signing_identity/1 return the registry's real key_id — the adapter's
contract is identical. Two non-negotiables:
public_key/1(and the identity callbacks) must describe the SAME keysign/2uses — the adapter verifies every signature against the resolved public key and a mismatch is:signing_failed(the wrong-key guard).- The private key never enters the library. If an integration passes key bytes to the adapter, it is wrong (Usage rules #2).
For grants, the handle must resolve {:issuer, _, _} from signing_identity/1 — that is
the C1 declaration gate, and it is declaration-rejection, not cryptographic role
separation (Usage rules #3).
Where to next
- Usage rules — the twelve-rule version of everything above.
- Errors — every closed atom, what it means, what to check.
- Telemetry — the value-free sign events and the custody alarm.
- Consumer integration — the verifier side: raw bytes, identity binding, the nonce ledger.
- The runnable edge-agent example app — a full sign → POST → verify loop over HTTP.