BoundedAuthorityReportAdapter behaviour (Bounded Authority Report Adapter v0.2.0)

Copy Markdown View Source

Universal 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 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.

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/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.

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

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_report(report, key_handle, opts \\ %{})

@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)

  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).