Packages
Signer for the Bounded Authority Protocol: signs holder proofs, grants, boundary anchors, key transitions and content assertions (Ed25519 and ES256) through your key handle, over the protocol's deterministic signing inputs. The private key never enters the library.
Retired package: Security issue - Accepts small-order Ed25519 signing keys; upgrade to 0.10.0 (GHSA-q9hh-mwwh-6cg6)
Current section
Files
Jump to
Current section
Files
docs/getting-started.md
# Getting started
`bounded_authority_report_adapter` targets Elixir `~> 1.18` — supported minors 1.18/1.19/1.20 —
on Erlang/OTP 27 through 29 (the protocol package's codecs decode through OTP 27's `:json`
module, so OTP 25/26 are unsupported; the repository refuses to compile outside the set —
ADR-0019). CI runs one lane per supported major — 1.18/27, 1.19/28, 1.20/29 — plus a
windows-latest lane on the pinned versions (clone → build → test holds on Windows). The
package is published on [Hex](https://hex.pm/packages/bounded_authority_report_adapter).
```elixir
{:bounded_authority_report_adapter, "~> 0.6.0"}
```
Or scaffold the starter key-handle with [Igniter](https://hexdocs.pm/igniter)
(add `{:igniter, "~> 0.8"}` to your dev deps first):
```sh
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.)
```elixir
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)}}
end
```
Sign 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](https://github.com/baselabs/bounded_authority_report_adapter/tree/master/examples/edge_agent)
does):
```elixir
# 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](errors.md) 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}`. Use
`Ecto.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 key `sign/2` uses —
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](../usage-rules.md) #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](../usage-rules.md) — the twelve-rule version of everything above.
- [Errors](errors.md) — every closed atom, what it means, what to check.
- [Telemetry](telemetry.md) — the value-free sign events and the custody alarm.
- [Consumer integration](consumer-integration.md) — the verifier side: raw bytes,
identity binding, the nonce ledger.
- The runnable [edge-agent example app](https://github.com/baselabs/bounded_authority_report_adapter/tree/master/examples/edge_agent)
— a full sign → POST → verify loop over HTTP.