Bounded Authority Report Adapter — see it work, in one notebook
Section
You don't need to know anything about signing authorities to follow this. It runs the whole thing in one notebook — no database, no Docker, no other project running — and each step prints a plain
✅/❌line. Run it cell-by-cell.
The problem this solves (the one-minute version)
An API key answers "who sent this?" It does not answer "were they allowed to do this?" If an API key leaks, whoever has it can send anything until you rotate it.
This library is part of a system that answers the second question. Instead of (or alongside) an API key, a report carries a capability — a digital grant that the authority issued ahead of time, plus a proof the sender signs at the moment of the report. Think of it like a VIP pass (the grant) plus a signature that matches the ID of the person holding it (the proof): possessing the pass alone isn't enough; the signature has to match you, and the pass has to be for this event. A leaked API key alone can't forge one.
This library's only job is to produce that proof. Not the pass, not the check — just the proof.
The model — three roles, kept separate on purpose
sequenceDiagram
autonumber
participant Issuer as Issuer<br/>(the authority runtime)
participant Holder as Holder<br/>(the edge — THIS adapter)
participant Verifier as Verifier<br/>(the verifier / anyone)
Note over Issuer: holds the issuer key
Note over Holder: holds the holder key
Note over Verifier: holds only PUBLIC keys
Issuer->>Holder: a signed grant<br/>(issued ahead of time, out of band)
Note over Holder: report arrives
Holder->>Holder: adapter.sign_report(grant, report, key)<br/>→ signs a PROOF binding them
Holder->>Verifier: send the report + {grant, proof}
Verifier->>Verifier: check_envelope(grant, proof)<br/>vs the published issuer + holder keys
Verifier-->>Holder: ✅ accept / ❌ reject (offline)
Why three separate roles? Two reasons that are the security property:
- The verifier never holds a signing key. So a compromised verifier can't forge anything.
- The issuer never sees each report. The holder proves possession of the grant offline — no call home on every report.
This is the same shape as DPoP (RFC 9449), used for OAuth. This adapter is the Holder box — nothing more. The other two boxes are played below only so you can see the full loop.
Setup
# Pulls this repo (the adapter) + its one dependency, bounded_authority_protocol (the public
# format/verifier package). First run fetches BAP from its private git remote (needs access).
adapter_path = __DIR__ |> Path.join("..") |> Path.expand()
Mix.install([
{:bounded_authority_report_adapter, path: adapter_path}
])
alias BoundedAuthorityProtocol.V1
The key-handle contract — how the adapter signs without ever seeing the private key
The adapter never receives a private key. You hand it a {module, term} handle: a module with
sign/2 + public_key/1 callbacks, and an opaque term (the key reference) that only the module
understands. In production that module fronts an HSM / KMS / key server. Here it's four lines:
defmodule Demo.Handle do
@moduledoc false
def sign(message, {_pub, priv}),
do: {:ok, :crypto.sign(:eddsa, :ed25519, message, [priv, :ed25519])}
def public_key({pub, _priv}), do: {:ok, pub}
end
Two keypairs (one per role that holds a key)
# ISSUER keypair — lives in the authority runtime in production, NOT in this adapter.
{issuer_pub, issuer_priv} = :crypto.generate_key(:eddsa, :ed25519, <<1::256>>)
# HOLDER keypair — lives on the edge (the party that calls this adapter).
{holder_pub, holder_priv} = :crypto.generate_key(:eddsa, :ed25519, <<2::256>>)
holder = {Demo.Handle, {holder_pub, holder_priv}}
now = System.system_time(:second)
"issuer + holder keypairs generated. (In production these live in separate places.)"
Role 1 — ISSUER: mint a grant ⚠️ NOT the adapter's job
The authority signs a grant authorizing a holder (identified by the holder key's thumbprint) to perform an operation. We do it here only so the demo has a real grant to sign a proof over.
{:ok, holder_thumbprint} = V1.Jwk.public_key_thumbprint_raw(holder_pub, %{})
grant = %V1.Grant{
key_id: "demo-issuer",
issuer: "https://demo-issuer.test",
grant_id: "urn:demo:grant:1",
audiences: ["https://demo-verifier.test"],
issued_at: now - 60,
not_before: now - 60,
expires_at: now + 3600,
holder_thumbprint: holder_thumbprint,
operations: [%V1.Operation{name: "report_demo", selectors: [:all]}]
}
{:ok, grant_input} = V1.grant_signing_input(grant, %{})
grant_sig = :crypto.sign(:eddsa, :ed25519, grant_input.message, [issuer_priv, :ed25519])
{:ok, grant_compact} = V1.assemble_compact(grant_input, grant_sig)
"🎟️ issuer-signed grant minted (#{byte_size(grant_compact)} bytes). " <>
"It authorizes holder-thumbprint #{String.slice(Base.url_encode64(holder_thumbprint, padding: false), 0, 8)}… " <>
"to perform operation \"report_demo\"."
Role 2 — HOLDER: the adapter signs the proof ✅ THIS is the adapter's one job
The edge calls sign_report/3 with the grant + the report fields + the holder key-handle. The
adapter builds the proof, signs it through the handle, double-checks the signature against the
holder's public key, and returns {grant, proof}. It signs the proof only — never the grant.
report = %{
grant_compact: grant_compact,
operation: "report_demo",
method: "POST",
target_uri: "https://demo-verifier.test/report",
invocation_id: "00000000-0000-4000-8000-000000000001",
# cast_arguments is the report body in BAP's tagged form. In production this is
# V1.Json.decode(raw_report_body_bytes). A small value here keeps the demo readable.
cast_arguments: {:object, [{"signal", {:string, "ok"}}]},
nonce: "demo-nonce-1"
}
{:ok, %{grant: ^grant_compact, proof: proof}} =
BoundedAuthorityReportAdapter.sign_report(report, holder, %{issued_at: now - 10})
"✍️ adapter signed the PROOF (#{byte_size(proof)} bytes), binding the grant to this report. " <>
"The grant passed through untouched."
Role 3 — VERIFIER: check the envelope (any party with the public protocol package)
The verifier (or any third party) verifies grant + proof against published keys only,
fully offline. It reconstructs what it expects the proof to bind; check_envelope checks both
signatures and every binding.
expected = %V1.ExpectedRequest{
trusted_issuer: %V1.TrustedIssuer{key_id: "demo-issuer", public_key: issuer_pub},
issuer: "https://demo-issuer.test",
audience: "https://demo-verifier.test",
method: "POST",
target_uri: "https://demo-verifier.test/report",
invocation_id: "00000000-0000-4000-8000-000000000001",
operation: "report_demo",
cast_arguments: {:object, [{"signal", {:string, "ok"}}]},
evaluation_time: now,
clock_skew: 60,
proof_max_age: 300,
nonce: {:required, "demo-nonce-1"},
bounds: V1.Bounds.maximum()
}
case V1.check_envelope(%V1.Credentials{grant: grant_compact, proof: proof}, expected) do
{:ok, _facts} ->
"✅ VERIFIED — the grant is genuine, the proof matches this report, the holder is the one the grant was issued to."
{:error, :invalid} ->
"❌ rejected"
end
Now prove the crypto is real — flip one byte of the proof
[protected, payload, sig_b64] = String.split(proof, ".")
<<sig_bytes::binary-size(63), last>> = Base.url_decode64!(sig_b64, padding: false)
flipped = Base.url_encode64(<<sig_bytes::binary, Bitwise.bxor(last, 1)>>, padding: false)
tampered_proof = [protected, payload, flipped] |> Enum.join(".")
case V1.check_envelope(%V1.Credentials{grant: grant_compact, proof: tampered_proof}, expected) do
{:ok, _} -> "❌ UNEXPECTED — a tampered proof verified (that would be a bug)"
{:error, :invalid} -> "✅ tampered proof REJECTED — one flipped byte breaks the signature"
end
And prove the binding is real — a proof from a different holder key
The grant was issued to the original holder's thumbprint. A proof signed by a completely different key must be rejected (otherwise anyone could forge a proof for someone else's grant).
{wrong_pub, wrong_priv} = :crypto.generate_key(:eddsa, :ed25519, <<9::256>>)
{:ok, %{proof: wrong_proof}} =
BoundedAuthorityReportAdapter.sign_report(
report,
{Demo.Handle, {wrong_pub, wrong_priv}},
%{issued_at: now - 10}
)
case V1.check_envelope(%V1.Credentials{grant: grant_compact, proof: wrong_proof}, expected) do
{:ok, _} -> "❌ UNEXPECTED — a stranger's proof verified (that would be a bug)"
{:error, :invalid} -> "✅ stranger's proof REJECTED — the proof must come from the holder the grant was issued to"
end
What just happened
| Role | Who does it in production | What runs here |
|---|---|---|
| Issuer | the bounded_authority runtime |
a demo cell (so there's a grant to sign over) |
| Holder | the edge agent, using this adapter | sign_report/3 ← the adapter's one job |
| Verifier | the verifier / any third party | the public check_envelope/2 |
So this library is a self-contained signing helper: it takes a grant + a report + a key-handle, and returns a capability envelope any public-package verifier accepts. It is not a verifier, not an issuer, not a transport, not a database.
A real consumer does two more things this demo skips, because they're the consumer's job, not the adapter's: (a) bind the verified envelope to the authenticated reporter so a captured envelope can't be replayed under a different identity (
docs/consumer-integration.md§8); and (b) dedupe nonces against a replay ledger (§9). The adapter produces the bytes; the consumer binds + persists + dedupes. That's the only place a database enters — and it's not here.