BoundedAuthorityReportAdapter behaviour (Bounded Authority Report Adapter v0.2.0)
Copy Markdown View SourceUniversal companion signer to BoundedAuthorityProtocol (ROADMAP B2).
BAP produces the deterministic signing input for each protocol object (proof,
grant, boundary anchor, key transition) but refuses to sign — it is a pure
verifier + signing-input producer. THIS library is the signing glue: it takes a
key-handle + a BAP signing input, signs the input's message via the handle's
local key, and assembles the compact via BAP. The signing tail — resolve the
key, sign via the handle, verify the signature against the resolved key, assemble
— is shared across every object the library signs.
What has landed
- Proof signing (
sign_report/3, RA1) — binds an issuer-signed grant to a CDC report by producing a holder proof, returning the{grant, proof}envelope the control plane verifies viacheck_envelope/2. - Boundary-anchor signing (
sign_anchor/3, RA4) — signs a boundary anchor (a durable chain checkpoint), returning the compact a verifier checks viaverify_historical_anchor/3. - Grant signing (
sign_grant/3, RA7) — the issuer-role instantiation: signs a grant (the issuer's authority assertion), returning the compact a verifier checks viaverify_grant/3. The handle'ssigning_identity/1must resolve to the:issuerrole (the C1 gate — ADR-0006's grant-signing pre-commitment). - Key-transition signing (
sign_key_transition/3, RA8) — the 4th instantiation: signs a key transition (the current retiring key's assertion of its successor), returning the compact a verifier checks viaverify_key_transition/4. Role-agnostic (mirrorssign_anchor/3, NOTsign_grant/3): the current key's identity is resolved atomically viakey_identity/1— see ADR-0009.
The pattern generalizes to any BAP protocol object; the four named instantiations (proof, boundary-anchor, grant, key-transition) are complete.
The key-handle contract (charter §6 invariant 1)
The private key NEVER enters this library. Callers supply a {module(), term()}
handle whose module implements the callbacks below. The library calls the handle's
callbacks; the private key bytes live in the caller's module/process.
sign/2,public_key/1— required for every signing operation.thumbprint/1— required (exposed for caller-side self-checking).key_identity/1— optional (declared via@optional_callbacks); required bysign_anchor/3andsign_key_transition/3, which resolve the key's registry id (kid) AND its public key as ONE atomic snapshot (defense-in-depth: prevents a stateful handle from splitting them across a rotation race). A proof-only handle need not implement it.signing_identity/1— optional; required only bysign_grant/3, which resolves the key's role (:issueror:holder) AND its registry id AND public key as ONE atomic snapshot. The role gate (C1) + the rotation-race defense both ride this single call: a stateful handle cannot return:issuerthen rotate to a different key between role resolution and signing. An issuer-role handle implements it; a proof/anchor-only handle need not.
A test-only reference implementation (BoundedAuthorityReportAdapter.Keys.RawKey)
ships under test/support/ for local development; production holders implement the
callbacks with proper key custody (HSM, etc.).
What this library does NOT do
- Not a verifier — verification lives in every party via the protocol package
(
check_envelope/2,verify_grant/3,verify_historical_anchor/3,verify_key_transition/4). Consumers verify; this library signs. - Not the runtime — grant issuance, key custody/rotation, and revocation are the stateful authority runtime's job. This library holds a key handle and signs on invocation; it does not mint capabilities.
- Not a transport — transport layers stay protocol-free. This library is a composable lib an edge agent (or any signing party) calls.
Summary
Callbacks
Returns the key's identity — its registry kid AND its 32-byte raw Ed25519
public key — as a single atomic {key_id, public_key} snapshot. Required by
sign_anchor/3 and sign_key_transition/3, which resolve both in ONE call so a
stateful handle cannot split kid from public_key across a rotation race
(defense-in-depth at sign time; any sign/2-vs-snapshot mismatch is then caught by
the verify_signature guard). Optional callback — a proof-only handle (used
only with sign_report/3) need not implement it.
Returns the 32-byte raw Ed25519 public key for the holder key behind handle.
Signs the holder proof for message using the holder key behind handle.
Returns the key's signing identity for a role-gated operation — its role
(:issuer or :holder) AND its registry kid AND its 32-byte raw Ed25519 public
key — as a single atomic {:role, key_id, public_key} snapshot. Required only by
sign_grant/3, which both (a) gates on role == :issuer (the C1 pre-commitment —
a handle that declares :holder, or omits this callback, is rejected before
sign/2; see sign_grant/3's @doc for the precise property — this is
declaration-rejection, not cryptographic key-role separation) and (b) uses the snapshot's
key_id + public_key — all from ONE call, so a stateful handle cannot return
:issuer then rotate to a different key between role resolution and signing (the
rotation-race defense; any sign/2-vs-snapshot mismatch is caught by the
verify_signature guard). Optional callback — a proof-only or anchor-only handle
need not implement it (and is then rejected by sign_grant/3 as :invalid_key_handle).
Returns the RFC 7638 thumbprint (raw 32-byte SHA-256 digest) of the holder
public key behind handle. Exposed for caller-side self-checking (e.g.
asserting the handle matches a grant's cnf.jkt); the adapter does NOT
enforce thumbprint equality — that is the verifier's job (charter §3).
Functions
Signs a boundary anchor — a durable chain checkpoint a verifier checks via
BoundedAuthorityProtocol.V1.verify_historical_anchor/3.
Signs a grant — the issuer's authority assertion. The issuer-role instantiation of
the universal companion-signer tail (ADR-0006; this slice is recorded in ADR-0007).
Returns %{grant: grant_compact}, verifiable via BoundedAuthorityProtocol.V1.verify_grant/3
and envelope-compatible with check_envelope/2 (it flows through the envelope when
paired with a holder proof from sign_report/3).
Signs a key transition — the current (retiring) key's assertion of its successor.
The 4th instantiation of the universal companion-signer tail (ADR-0006; recorded in
ADR-0009), mirroring sign_anchor/3: role-agnostic, the current key's identity resolved
atomically via key_identity/1. Returns %{key_transition: compact}, verifiable via
BoundedAuthorityProtocol.V1.verify_key_transition/4.
Binds an issuer-signed grant to a CDC report by producing a holder proof.
Types
@type anchor_compact() :: %{anchor: binary()}
@type anchor_input() :: %{ anchor_id: binary(), chain_id: binary(), sequence: non_neg_integer(), chain_hash: binary() }
@type anchor_opts() :: %{ optional(:bounds) => BoundedAuthorityProtocol.V1.Bounds.t() | map(), optional(:anchored_at) => integer() }
@type anchor_sign_error() ::
:invalid_anchor
| :invalid_key_handle
| :signing_failed
| {:producer_error, :invalid}
@type grant_compact() :: %{grant: binary()}
@type grant_opts() :: %{ optional(:bounds) => BoundedAuthorityProtocol.V1.Bounds.t() | map() }
@type grant_sign_error() ::
:invalid_grant
| :invalid_key_handle
| :signing_failed
| {:producer_error, :invalid}
@type opts() :: %{ optional(:bounds) => BoundedAuthorityProtocol.V1.Bounds.t() | map(), optional(:issued_at) => integer(), optional(:proof_id) => binary() }
@type sign_error() ::
:invalid_report
| :invalid_key_handle
| :signing_failed
| {:producer_error, :invalid}
@type transition_compact() :: %{key_transition: binary()}
@type transition_opts() :: %{ optional(:bounds) => BoundedAuthorityProtocol.V1.Bounds.t() | map() }
@type transition_sign_error() ::
:invalid_transition
| :invalid_key_handle
| :signing_failed
| {:producer_error, :invalid}
Callbacks
@callback key_identity(handle :: term()) :: {:ok, {key_id :: binary(), public_key :: binary()}} | {:error, term()}
Returns the key's identity — its registry kid AND its 32-byte raw Ed25519
public key — as a single atomic {key_id, public_key} snapshot. Required by
sign_anchor/3 and sign_key_transition/3, which resolve both in ONE call so a
stateful handle cannot split kid from public_key across a rotation race
(defense-in-depth at sign time; any sign/2-vs-snapshot mismatch is then caught by
the verify_signature guard). Optional callback — a proof-only handle (used
only with sign_report/3) need not implement it.
Returns the 32-byte raw Ed25519 public key for the holder key behind handle.
Signs the holder proof for message using the holder key behind handle.
The holder's callback performs the actual :crypto.sign; the adapter never
references the private key. Returns the raw 64-byte Ed25519 signature.
@callback signing_identity(handle :: term()) :: {:ok, {:issuer | :holder, key_id :: binary(), public_key :: binary()}} | {:error, term()}
Returns the key's signing identity for a role-gated operation — its role
(:issuer or :holder) AND its registry kid AND its 32-byte raw Ed25519 public
key — as a single atomic {:role, key_id, public_key} snapshot. Required only by
sign_grant/3, which both (a) gates on role == :issuer (the C1 pre-commitment —
a handle that declares :holder, or omits this callback, is rejected before
sign/2; see sign_grant/3's @doc for the precise property — this is
declaration-rejection, not cryptographic key-role separation) and (b) uses the snapshot's
key_id + public_key — all from ONE call, so a stateful handle cannot return
:issuer then rotate to a different key between role resolution and signing (the
rotation-race defense; any sign/2-vs-snapshot mismatch is caught by the
verify_signature guard). Optional callback — a proof-only or anchor-only handle
need not implement it (and is then rejected by sign_grant/3 as :invalid_key_handle).
Returns the RFC 7638 thumbprint (raw 32-byte SHA-256 digest) of the holder
public key behind handle. Exposed for caller-side self-checking (e.g.
asserting the handle matches a grant's cnf.jkt); the adapter does NOT
enforce thumbprint equality — that is the verifier's job (charter §3).
Functions
@spec sign_anchor(anchor_input(), key_handle(), anchor_opts()) :: {:ok, anchor_compact()} | {:error, anchor_sign_error()}
Signs a boundary anchor — a durable chain checkpoint a verifier checks via
BoundedAuthorityProtocol.V1.verify_historical_anchor/3.
Returns {:ok, %{anchor: anchor_compact}}. The caller supplies the anchor's
content (anchor_id, chain_id, sequence, chain_hash); BOTH key
identifiers (public_key, key_id) are resolved from key_handle — never
trusted from the caller — so the signed header's kid + key_fingerprint are
consistent with the key sign/2 actually used.
Why both key identifiers come from the handle
BAP puts key_id in the anchor's SIGNED header (typ: ba+chain-anchor) and, at
verify, binds it to the verifier's HistoricalPublicKey.key_id. Letting the
caller supply key_id would let an anchor assert "kid K" while being signed by a
different key. Deriving key_id from the same handle as public_key makes that
inconsistency impossible by construction (the one thing NOT sign-enforced — that
key_id names the key in any external registry — is verify-enforced by BAP).
Defense-in-depth — the atomic key_identity/1 snapshot
sign_anchor/3 resolves key_id AND public_key in a SINGLE key_identity/1
call, so a stateful handle cannot split them across a rotation race (the
separate-callback design a cross-vendor review probe once exploited). The
remaining surface — sign/2 signing with a key different from the snapshot's
public_key — is caught by the verify_signature guard in the shared tail, so
a post-snapshot rotation fails loudly as :signing_failed, never a silent
false-success. The one thing still NOT sign-enforced — that key_id names
the key in any external registry — is verify-enforced by BAP
(verify_historical_anchor/3 binds key_id + public_key + key_fingerprint
together via the verifier's HistoricalPublicKey).
Options
:anchored_at— the anchor's timestamp (default:System.system_time(:second)). Pin this when the verifier's evaluation time is far from wall-clock so it falls inside the key's validity window.:bounds— resource ceilings forwarded unchanged to BAP's producer and bounds-aware compact assembler (default%{}).
Errors (closed-atom set — no key material or anchor content in errors)
:invalid_anchor— a required content field is missing or malformed.:invalid_key_handle— the handle is malformed, lackskey_identity/1, or itspublic_key/1/key_identity/1rejected / returned an invalid value.:signing_failed— thesign/2callback rejected, returned a non-64-byte signature, violated the{:ok, _} | {:error, _}contract, or the signature did not verify against the resolved public key.{:producer_error, :invalid}— BAP's producer or assembler rejected the anchor (the input violated a bound or field constraint).
@spec sign_grant(grant_input(), key_handle(), grant_opts()) :: {:ok, grant_compact()} | {:error, grant_sign_error()}
Signs a grant — the issuer's authority assertion. The issuer-role instantiation of
the universal companion-signer tail (ADR-0006; this slice is recorded in ADR-0007).
Returns %{grant: grant_compact}, verifiable via BoundedAuthorityProtocol.V1.verify_grant/3
and envelope-compatible with check_envelope/2 (it flows through the envelope when
paired with a holder proof from sign_report/3).
C1 gate (ADR-0006 pre-commitment) — what it does and does NOT guarantee
sign_grant/3 resolves the handle's atomic signing identity {:issuer | :holder, key_id, public_key} from ONE signing_identity/1 call. If the resolved role is not
:issuer (or the callback is absent), it returns {:error, :invalid_key_handle}
BEFORE sign/2 is called — so a handle that declares :holder (or implements no
signing_identity/1) cannot sign a grant through this API. That is the C1
pre-commitment realized structurally.
This is NOT cryptographic key-role separation. A handle that consistently
mis-declares its role — whose signing_identity/1 returns
{:issuer, holder_key_id, holder_public_key} while sign/2 holds the matching
holder private key — signs a grant successfully, because every value is internally
consistent. The adapter resolves only the handle's public_key and signs against
it; it cannot prove key identity. Key-role separation (an issuer key and a holder
key are cryptographically distinct custodied entities) is the key-custody
boundary's job — the runtime / HSM / key server behind the handle (charter §4).
Key-identifier sourcing (ADR-0006 decision 3)
The grant's signed-header kid (key_id) comes from the atomic signing_identity/1
snapshot, NEVER from caller input — a caller-supplied :key_id in the grant map is
ignored. holder_thumbprint IS caller-supplied (the grant's subject — the holder the
capability is issued to; the issuer knows it at minting).
Options
:bounds— resource ceilings forwarded unchanged to BAP's producer and bounds-aware compact assembler (default%{}).
Errors (closed-atom set — no key material or grant content in errors)
:invalid_grant— a required grant field is missing or malformed.:invalid_key_handle— the handle is malformed, lackssigning_identity/1, the resolved role is not:issuer, or the snapshot returned an invalid value.:signing_failed—sign/2rejected, returned a non-64-byte signature, violated the{:ok, _} | {:error, _}contract, or the signature did not verify against the snapshot'spublic_key.{:producer_error, :invalid}— BAP's producer or assembler rejected the grant.
@spec sign_key_transition(transition_input(), key_handle(), transition_opts()) :: {:ok, transition_compact()} | {:error, transition_sign_error()}
Signs a key transition — the current (retiring) key's assertion of its successor.
The 4th instantiation of the universal companion-signer tail (ADR-0006; recorded in
ADR-0009), mirroring sign_anchor/3: role-agnostic, the current key's identity resolved
atomically via key_identity/1. Returns %{key_transition: compact}, verifiable via
BoundedAuthorityProtocol.V1.verify_key_transition/4.
Role posture (why this mirrors sign_anchor/3, not sign_grant/3)
A key transition is a historical-key operation (an artifact of the key chain), verified
via HistoricalPublicKey — the same role-neutral input verify_historical_anchor/3
takes. The transition's authenticity is guaranteed by the signature against
current_key.public_key, not by a role binding; "the right key" is the current key
(charter §5). The adapter signs for whichever party holds it, per ADR-0006's universal
posture. The deployment reality (the retired key is an issuer key) is a custody property,
not something a signing-side role gate adds to — the verifier-side HistoricalPublicKey
check is the authority binding. See ADR-0009 §Decision.
Key-identifier sourcing (ADR-0006 decision 3)
current_{key_id, public_key} come from ONE atomic key_identity/1 snapshot (the
signing key IS the retiring current key; same as sign_anchor/3's kid+pub). A
caller-supplied :current_key_id / :current_public_key is ignored.
next_{key_id, public_key} are caller-supplied (the successor).
Options
:bounds— resource ceilings forwarded unchanged to BAP's producer and bounds-aware compact assembler (default%{}).
Errors (closed-atom set — no key material or transition content in errors)
:invalid_transition— a required content field is missing, ornext_public_keyis not a 32-byte Ed25519 key.:invalid_key_handle— the handle is malformed, lackskey_identity/1, or itskey_identity/1rejected / returned an invalid value.:signing_failed—sign/2rejected, returned a non-64-byte signature, violated the{:ok, _} | {:error, _}contract, or the signature did not verify against the snapshot'spublic_key.{:producer_error, :invalid}— BAP's producer or assembler rejected the transition (e.g. a self-transition wherenext_public_key == current_public_key, which BAP'sdistinct_fingerprintscheck rejects).
@spec sign_report(report(), key_handle(), opts()) :: {:ok, envelope()} | {:error, sign_error()}
Binds an issuer-signed grant to a CDC report by producing a holder proof.
Returns {:ok, %{grant: grant_compact, proof: proof_compact}} — the grant is
the pass-through of report.grant_compact (issuer-signed, untouched); the
proof is the holder's binding of that grant to the report, signed via the
holder key behind key_handle.
The flow (design §2 Q5)
- Resolve the holder public key from
key_handle(callback). - Build the
Proofstruct: holder key + the report's request fields +grant_compact(BAP'sproof_signing_inputderivesath= grant hash from it, andba_req= request digest fromcast_arguments). - Produce the deterministic proof signing input via BAP.
- Sign + assemble via the shared signing tail (
sign_and_assemble/4) — the adapter signs ONLY the proof; the grant is never signed here. - Return
%{grant: report.grant_compact, proof: proof_compact}.
Options
:bounds— resource ceilings forwarded unchanged to BAP's producer and bounds-aware compact assembler (default%{}, BAP's maxima).:issued_at— the proof'siat(default:System.system_time(:second)). Pin this when the verifier'sevaluation_timeis far from wall-clock (e.g. tests) so the proof's time window overlaps the grant's.:proof_id— the proof'sjti(default: a generated UUID v4).
Errors (closed-atom set — no key material or report content in errors)
:invalid_report— a required report field is missing.:invalid_key_handle— the handle is malformed, or the handle'spublic_key/1rejected / returned a non-32-byte key.:signing_failed— the holder'ssign/2callback rejected, returned a non-64-byte signature, violated the{:ok, _} | {:error, _}contract, or the signature did not verify against the resolved holder public key.{:producer_error, :invalid}— BAP's producer or assembler rejected the proof (the input violated a bound or field constraint).