Bounded Authority Report Adapter
The Elixir signer for the Bounded Authority Protocol.
The protocol package builds the exact bytes to sign for every protocol object, verifies them, and never signs. This library does the signing: you give it a key handle that reaches your own key custody (an HSM, a KMS, or an in-process key for development) and it returns the signed compact form. The private key never enters the library.
Verifiers depend only on the protocol package, never on this one.
Installation
def deps do
[
{:bounded_authority_report_adapter, "~> 0.9.0"}
]
end
Requires Elixir 1.18, 1.19 or 1.20 on Erlang/OTP 27 through 29.
Quick start
A key handle is a {module, term} pair. The module implements the
BoundedAuthorityReportAdapter callbacks against your key store:
defmodule MyApp.HolderKey do
@behaviour BoundedAuthorityReportAdapter
@impl true
def sign(message, ref), do: {:ok, MyApp.Custody.sign_ed25519(ref, message)}
@impl true
def public_key(ref), do: {:ok, MyApp.Custody.public_key(ref)}
@impl true
def thumbprint(ref),
do: BoundedAuthorityProtocol.V1.Jwk.public_key_thumbprint_raw(MyApp.Custody.public_key(ref), %{})
@impl true
def key_identity(ref), do: {:ok, {"holder-key-1", MyApp.Custody.public_key(ref)}}
@impl true
def signing_identity(ref), do: {:ok, {:holder, "holder-key-1", MyApp.Custody.public_key(ref)}}
end
An agent proves one request by signing a holder proof over an issuer-signed grant:
{:ok, %{grant: grant, proof: proof}} =
BoundedAuthorityReportAdapter.sign_report(
%{
grant_compact: grant_from_issuer,
operation: "transfer",
method: "POST",
target_uri: "https://api.example.test/invoke",
invocation_id: "123e4567-e89b-42d3-a456-426614174000",
cast_arguments: {:object, [{"amount", {:integer, 5000}}]},
nonce: "challenge-001"
},
{MyApp.HolderKey, :holder},
%{}
)
The resource verifies grant and proof with the protocol package's
BoundedAuthorityProtocol.V1.check_envelope/2 and gets cryptographic facts back, never an
authorization decision. Getting started walks the whole loop,
including the verifier side.
What it signs
| Function | Object | Role |
|---|---|---|
sign_report/3 |
holder proof (the grant passes through unchanged) | holder |
sign_local_loopback_report/3 |
local-loopback development proof (ba+loopback-proof) |
holder |
sign_grant/3 |
capability grant | issuer only |
sign_anchor/3 |
consumption-chain boundary anchor | any |
sign_key_transition/3 |
historical-key transition | any |
sign_content_assertion/3 |
content assertion (ba+content-assertion) |
any |
BoundedAuthorityReportAdapter.V3 provides sign_report/3, sign_grant/3,
sign_anchor/3 and sign_key_transition/3 for the ES256 suite (contract-major 3). The
key's shape selects the suite: 32-byte Ed25519 keys for the functions above, 65-byte
P-256 keys for V3.
Every function returns {:ok, map} or {:error, reason} with a closed set of reasons and
no values in errors. See Errors.
Safety properties
- Key custody stays with you. Every handle callback may reach a remote custodian.
- Wrong-key guard. Every signature is verified against the handle's public key before
it is returned; a custodian that signs with the wrong key fails as
:signing_failed. - Role gate.
sign_grant/3signs only when the handle resolves the issuer role, so a holder can never mint its own capability. It can additionally require an authority-signed role attestation. - Explicit profiles. The local-loopback and content-assertion profiles are selected by calling their functions; nothing is inferred from URIs, headers or the environment.
- Signing is not authorization. The verifier decides what a signature proves; trusted keys, replay protection and policy stay with the verifying host.
Content assertions
sign_content_assertion/3 signs a standalone assertion that binds the SHA-256 digest of
exact content bytes to an issuer, audience, lineage subject, semantic profile, validity
window and predecessor:
{:ok, digest} = BoundedAuthorityProtocol.ContentAssertion.V1.content_digest(content_bytes, %{})
{:ok, %{content_assertion: compact}} =
BoundedAuthorityReportAdapter.sign_content_assertion(
%{
jti: "urn:example:assertion:1",
iss: "urn:example:issuer",
aud: "urn:example:audience",
sub: "urn:example:lineage:document-1",
profile: "urn:example:profile:document/1",
profile_digest: profile_digest,
content_digest: digest,
gen: 1,
prev: <<0::256>>,
iat: now,
nbf: now,
exp: now + 3600
},
{MyApp.HolderKey, :holder},
%{}
)
The signer does not read or interpret the content. The consumer verifies with
BoundedAuthorityProtocol.ContentAssertion.V1.verify_assertion/2, supplying the trusted
key, the expected context and the digest of the bytes it holds.
Telemetry
Each signing call emits [:bounded_authority_report_adapter, :sign, :start | :stop] with
atom-only metadata: the object kind and a result class. No key material, message bytes or
content appears. A sustained :signing_failed rate means custody is misconfigured. See
Telemetry.
Documentation
- Getting started: first sign, then a production key handle.
- Usage rules: the integration contract as a checklist.
- Errors: every error reason and what to check.
- Recipes: HSM and KMS handles, a Plug consumer, the loopback listener.
- Security model: trust boundaries and named misuses.
- Telemetry: events, result classes and alerting.
- Consumer integration: the verifier side of the contract.
- Upgrading: per-version migration notes.
- Changelog: release notes.
- Contributing and the code of conduct.
Runnable examples live in the repository's examples/ directory: a Livebook that plays
issuer, holder and verifier, and an edge_agent app that runs the full loop over HTTP.
Development
mix deps.get
MIX_ENV=test mix ci
mix ci runs the same checks as CI: formatting, compilation with warnings as errors,
Credo, the test suite (including a conformance round-trip against the protocol's
published vectors), coverage, Dialyzer, documentation, advisory audits, and the package
and reproducibility checks, for the library and the example app.
Security
Report vulnerabilities as described in SECURITY.md.