BoundedAuthorityReportAdapter behaviour (Bounded Authority Report Adapter v0.5.0)

Copy Markdown View Source

Universal companion signer to BoundedAuthorityProtocol.

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 application report by producing a holder proof, returning the {grant, proof} envelope the verifier verifies via check_envelope/2.
  • Boundary-anchor signing (sign_anchor/3, RA4) — signs a boundary anchor (a durable chain checkpoint), returning the compact a verifier checks via verify_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 via verify_grant/3. The handle's signing_identity/1 must resolve to the :issuer role (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 via verify_key_transition/4. Role-agnostic (mirrors sign_anchor/3, NOT sign_grant/3): the current key's identity is resolved atomically via key_identity/1 — see ADR-0009.
  • Local-loopback proof signing (sign_local_loopback_report/3) — the explicit holder-side signer for the byte-distinct local-loopback HTTP application proof (bap-application-proof/local-loopback-http/1, protected typ: "ba+loopback-proof"; BAP 0.3.0 / BAP ADR-0027). Same report shape and key-handle callbacks as sign_report/3, with TWO profile divergences: the nonce is REQUIRED, and the target must be a canonical http://127.0.0.1/http://[::1] URI. Profile selection is the FUNCTION NAME — never an option, never inferred from the URI, headers, environment, or a failed producer call.

The pattern generalizes to any BAP protocol object; the four standard instantiations plus the local-loopback profile sibling 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/1optional (declared via @optional_callbacks); required by sign_anchor/3 and sign_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/1optional; required only by sign_grant/3, which resolves the key's role (:issuer or :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 :issuer then 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.

Signs the byte-distinct local-loopback HTTP application proof — the explicit holder-side entry point for BAP's bap-application-proof/local-loopback-http/1 profile (typ: "ba+loopback-proof", BAP ADR-0027). Returns the same %{grant: grant, proof: proof_compact} envelope shape as sign_report/3; the verifier checks it via BoundedAuthorityProtocol.ApplicationProfile.LocalLoopbackHttp.V1.check_envelope/2.

Binds an issuer-signed grant to a application report by producing a holder proof.

Types

anchor_compact()

@type anchor_compact() :: %{anchor: binary()}

anchor_input()

@type anchor_input() :: %{
  anchor_id: binary(),
  chain_id: binary(),
  sequence: non_neg_integer(),
  chain_hash: binary()
}

anchor_opts()

@type anchor_opts() :: %{
  optional(:bounds) => BoundedAuthorityProtocol.V1.Bounds.t() | map(),
  optional(:anchored_at) => integer()
}

anchor_sign_error()

@type anchor_sign_error() ::
  :invalid_anchor
  | :invalid_key_handle
  | :signing_failed
  | {:producer_error, :invalid}

envelope()

@type envelope() :: %{grant: binary(), proof: binary()}

grant_compact()

@type grant_compact() :: %{grant: binary()}

grant_input()

@type grant_input() :: %{
  issuer: binary(),
  grant_id: binary(),
  audiences: [binary()],
  issued_at: integer(),
  not_before: integer(),
  expires_at: integer(),
  holder_thumbprint: binary(),
  operations: [BoundedAuthorityProtocol.V1.Operation.t()]
}

grant_opts()

@type grant_opts() :: %{
  optional(:bounds) => BoundedAuthorityProtocol.V1.Bounds.t() | map()
}

grant_sign_error()

@type grant_sign_error() ::
  :invalid_grant
  | :invalid_key_handle
  | :signing_failed
  | {:producer_error, :invalid}

key_handle()

@type key_handle() :: {module(), term()}

local_loopback_report()

@type local_loopback_report() :: %{
  grant_compact: binary(),
  operation: binary(),
  method: binary(),
  target_uri: binary(),
  invocation_id: binary(),
  cast_arguments: BoundedAuthorityProtocol.V1.Json.value(),
  nonce: binary()
}

The report shape sign_local_loopback_report/3 consumes — the same field set as report(), except the nonce is REQUIRED (a non-empty binary; enforced at runtime — a typespec cannot express non-emptiness).

opts()

@type opts() :: %{
  optional(:bounds) => BoundedAuthorityProtocol.V1.Bounds.t() | map(),
  optional(:issued_at) => integer(),
  optional(:proof_id) => binary()
}

report()

@type report() :: %{
  grant_compact: binary(),
  operation: binary(),
  method: binary(),
  target_uri: binary(),
  invocation_id: binary(),
  cast_arguments: BoundedAuthorityProtocol.V1.Json.value(),
  nonce: nil | binary()
}

sign_error()

@type sign_error() ::
  :invalid_report
  | :invalid_key_handle
  | :signing_failed
  | {:producer_error, :invalid}

transition_compact()

@type transition_compact() :: %{key_transition: binary()}

transition_input()

@type transition_input() :: %{
  transition_id: binary(),
  chain_id: binary(),
  effective_at: integer(),
  next_key_id: binary(),
  next_public_key: binary()
}

transition_opts()

@type transition_opts() :: %{
  optional(:bounds) => BoundedAuthorityProtocol.V1.Bounds.t() | map()
}

transition_sign_error()

@type transition_sign_error() ::
  :invalid_transition
  | :invalid_key_handle
  | :signing_failed
  | {:producer_error, :invalid}

Callbacks

key_identity(handle)

(optional)
@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.

public_key(handle)

@callback public_key(handle :: term()) :: {:ok, binary()} | {:error, term()}

Returns the 32-byte raw Ed25519 public key for the holder key behind handle.

sign(message, handle)

@callback sign(message :: binary(), handle :: term()) ::
  {:ok, binary()} | {:error, term()}

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.

signing_identity(handle)

(optional)
@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).

thumbprint(handle)

@callback thumbprint(handle :: term()) :: {:ok, binary()} | {:error, term()}

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

sign_anchor(anchor_input, key_handle, opts \\ %{})

@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, lacks key_identity/1, or its public_key/1 / key_identity/1 rejected / returned an invalid value.
  • :signing_failed — the sign/2 callback 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).

sign_grant(grant_input, key_handle, opts \\ %{})

@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, lacks signing_identity/1, the resolved role is not :issuer, or the snapshot returned an invalid value.
  • :signing_failedsign/2 rejected, returned a non-64-byte signature, violated the {:ok, _} | {:error, _} contract, or the signature did not verify against the snapshot's public_key.
  • {:producer_error, :invalid} — BAP's producer or assembler rejected the grant.

sign_key_transition(transition_input, key_handle, opts \\ %{})

@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, or next_public_key is not a 32-byte Ed25519 key.
  • :invalid_key_handle — the handle is malformed, lacks key_identity/1, or its key_identity/1 rejected / returned an invalid value.
  • :signing_failedsign/2 rejected, returned a non-64-byte signature, violated the {:ok, _} | {:error, _} contract, or the signature did not verify against the snapshot's public_key.
  • {:producer_error, :invalid} — BAP's producer or assembler rejected the transition (e.g. a self-transition where next_public_key == current_public_key, which BAP's distinct_fingerprints check rejects).

sign_local_loopback_report(report, key_handle, opts \\ %{})

(since 0.5.0)
@spec sign_local_loopback_report(local_loopback_report(), key_handle(), opts()) ::
  {:ok, envelope()} | {:error, sign_error()}

Signs the byte-distinct local-loopback HTTP application proof — the explicit holder-side entry point for BAP's bap-application-proof/local-loopback-http/1 profile (typ: "ba+loopback-proof", BAP ADR-0027). Returns the same %{grant: grant, proof: proof_compact} envelope shape as sign_report/3; the verifier checks it via BoundedAuthorityProtocol.ApplicationProfile.LocalLoopbackHttp.V1.check_envelope/2.

This library signs; BAP verifies. Local-loopback HTTP is plain HTTP on the literal loopback interface — it has no TLS confidentiality or server authentication and is not equivalent to HTTPS. The verifier (the listener's operator) owns trusted profile selection, nonce reservation and replay control, listener-derived target state, policy, and effects — none of that lives here.

The two profile divergences from sign_report/3

  • The nonce is required. nonce must be a non-empty binary; the proof binds it and the verifier MUST reserve + check it (the profile's replay defense on a transport with no TLS).
  • The target must be a canonical literal-loopback HTTP URI — exactly http://127.0.0.1[:port]/path or http://[::1][:port]/path (decimal port, :80 elided, lowercase scheme, normalized path). Admission is BAP's, by delegation: the caller's target_uri is passed through UNCHANGED to BAP's local-profile producer, which requires the URI to equal its own normalization — so localhost, other 127/8 spellings, integer/hex/octal IPv4 forms, mapped/expanded IPv6, userinfo, trailing dots, percent-encoded hosts, uppercase schemes, explicit :80, leading-zero ports, dot-segment paths, queries, fragments, HTTPS, and every other non-canonical or non-loopback form fails closed here as {:producer_error, :invalid}. This library adds NO URI logic and never silently rewrites a caller-supplied target.

Profile selection is the function name — there is no profile option on sign_report/3, and no inference from the URI, headers, environment, or a failed producer call. The two proof families are byte-distinct and mutually rejected in both directions: a ba+loopback-proof is rejected by the standard check_envelope/2, and a standard dpop+jwt proof is rejected by the profile's check_envelope/2.

Options

Same as sign_report/3: :bounds (forwarded unchanged to BAP's producer AND the profile assembler), :issued_at, :proof_id.

Errors (closed-atom set — no key material, nonce values, proof material, or report content)

  • :invalid_report — a required field is missing, or the nonce is absent, empty, or not a binary.
  • :invalid_key_handle — the handle is malformed, or the handle's public_key/1 rejected / returned a non-32-byte key.
  • :signing_failed — the holder's sign/2 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 local-profile producer or assembler rejected the proof: a non-canonical or non-loopback target_uri (see above), a nonce that violates BAP's bounds (oversized or not a valid UTF-8 string — BARA checks presence and shape, BAP checks the profile's semantics), or a bound violation.

sign_report(report, key_handle, opts \\ %{})

@spec sign_report(report(), key_handle(), opts()) ::
  {:ok, envelope()} | {:error, sign_error()}

Binds an issuer-signed grant to a application 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)

  1. Resolve the holder public key from key_handle (callback).
  2. Build the Proof struct: holder key + the report's request fields + grant_compact (BAP's proof_signing_input derives ath = grant hash from it, and ba_req = request digest from cast_arguments).
  3. Produce the deterministic proof signing input via BAP.
  4. Sign + assemble via the shared signing tail (sign_and_assemble/4) — the adapter signs ONLY the proof; the grant is never signed here.
  5. 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's iat (default: System.system_time(:second)). Pin this when the verifier's evaluation_time is far from wall-clock (e.g. tests) so the proof's time window overlaps the grant's.
  • :proof_id — the proof's jti (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's public_key/1 rejected / returned a non-32-byte key.
  • :signing_failed — the holder's sign/2 callback 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).