Powered by AppSignal & Oban Pro

Bounded Authority Protocol — end-to-end walkthrough

docs/livebooks/bap-walkthrough.livemd

Bounded Authority Protocol — end-to-end walkthrough

Mix.install([
  {:bounded_authority_protocol, "~> 0.7.0"}
])

Hex 0.7.0 is published (2026-09-28 UTC), so the Mix.install above resolves from the public registry. The immutable package identity is the published Hex release; the source tag is reviewable release evidence, not a substitute for it.

What this notebook covers

The full produce → assemble → verify → reject loop with the verifier package alone. We mint our OWN ephemeral Ed25519 keys at runtime (nothing tracked, nothing persisted — the package accepts public keys only; the notebook does the signing, the package does the verifying), build a grant and a proof through the package's deterministic producers, verify the envelope, and then watch every classic attack fail.

Run top to bottom. Every cell is pure — rerunning the notebook mints fresh keys and produces different bytes with the same verdicts.

1. Ephemeral keys

{issuer_public, issuer_private} = :crypto.generate_key(:eddsa, :ed25519)
{holder_public, holder_private} = :crypto.generate_key(:eddsa, :ed25519)

{:ok, holder_thumbprint} =
  BoundedAuthorityProtocol.V1.Jwk.public_key_thumbprint_raw(holder_public, %{})

%{issuer_public: issuer_public, holder_thumbprint_raw: holder_thumbprint}

2. Build and sign a grant

alias BoundedAuthorityProtocol.V1.{Grant, Operation}

grant = %Grant{
  key_id: "issuer-1",
  issuer: "https://issuer.example",
  grant_id: "grant-2026-001",
  audiences: ["https://service.example"],
  issued_at: 1_800_000_000,
  not_before: 1_800_000_000,
  expires_at: 1_800_003_600,
  holder_thumbprint: holder_thumbprint,
  operations: [
    %Operation{
      name: "read_record",
      selectors: [
        {:equals, ["record", "region"], {:string, "us-east"}},
        {:one_of, ["record", "tier"], [{:string, "gold"}, {:string, "platinum"}]},
        :all
      ]
    }
  ]
}

{:ok, signing_input} = BoundedAuthorityProtocol.V1.grant_signing_input(grant, %{})
issuer_signature = :crypto.sign(:eddsa, :none, signing_input.message, [issuer_private, :ed25519])
{:ok, grant_compact} = BoundedAuthorityProtocol.V1.assemble_compact(signing_input, issuer_signature)

grant_compact

3. Build and sign the holder proof

The proof binds THIS grant (via ath, the digest of the exact grant compact bytes), THIS request (via the server-derived operation and typed arguments), and THIS context.

alias BoundedAuthorityProtocol.V1.Proof

now = 1_800_001_000

proof = %Proof{
  holder_public_key: holder_public,
  proof_id: "proof-2026-001",
  method: "GET",
  target_uri: "https://service.example/records/1",
  issued_at: now,
  nonce: nil,
  invocation_id: "7d444840-9dc0-11ed-a8fc-0242ac120002",
  operation: "read_record",
  grant_compact: grant_compact,
  cast_arguments:
    {:object,
     [
       {"record", {:object, [{"tier", {:string, "gold"}}, {"region", {:string, "us-east"}}]}}
     ]}
}

{:ok, proof_input} = BoundedAuthorityProtocol.V1.proof_signing_input(proof, %{})
holder_signature = :crypto.sign(:eddsa, :none, proof_input.message, [holder_private, :ed25519])
{:ok, proof_compact} = BoundedAuthorityProtocol.V1.assemble_compact(proof_input, holder_signature)

proof_compact

4. Verify the grant

The verifier holds no state and reads no clock — we supply the trusted key, the expected context, and the evaluation time.

alias BoundedAuthorityProtocol.V1.{TrustedIssuer, ExpectedGrant}

trusted = %TrustedIssuer{key_id: "issuer-1", public_key: issuer_public}

expected_grant = %ExpectedGrant{
  issuer: "https://issuer.example",
  audience: "https://service.example",
  evaluation_time: now,
  clock_skew: 60,
  bounds: %{}
}

{:ok, grant_facts} = BoundedAuthorityProtocol.V1.verify_grant(grant_compact, trusted, expected_grant)

# Facts are value-bearing and redacted, and authorization is explicitly not evaluated:
Map.from_struct(grant_facts) |> Map.take([:issuer, :grant_id, :authorization])

5. Verify the full envelope

alias BoundedAuthorityProtocol.V1.{Credentials, ExpectedRequest}

credentials = %Credentials{grant: grant_compact, proof: proof_compact}

expected_request = %ExpectedRequest{
  trusted_issuer: trusted,
  issuer: "https://issuer.example",
  audience: "https://service.example",
  method: "GET",
  target_uri: "https://service.example/records/1",
  invocation_id: "7d444840-9dc0-11ed-a8fc-0242ac120002",
  operation: "read_record",
  cast_arguments:
    {:object,
     [
       {"record", {:object, [{"tier", {:string, "gold"}}, {"region", {:string, "us-east"}}]}}
     ]},
  evaluation_time: now,
  clock_skew: 60,
  proof_max_age: 300,
  nonce: :not_required,
  bounds: %{}
}

{:ok, envelope_facts} = BoundedAuthorityProtocol.V1.check_envelope(credentials, expected_request)

Map.from_struct(envelope_facts) |> Map.take([:operation, :authorization])

6. Every attack fails closed

# A tampered signature byte:
tampered =
  grant_compact
  |> String.split(".")
  |> List.update_at(2, fn segment ->
    i = div(String.length(segment), 2)
    <<head::binary-size(^i), byte, tail::binary>> = segment
    head <> <<Bitwise.bxor(byte, 1)>> <> tail
  end)
  |> Enum.join(".")

# The SAME proof against DIFFERENT arguments (selector-disallowed):
wrong_args = %ExpectedRequest{expected_request |
  cast_arguments:
    {:object,
     [
       {"record", {:object, [{"tier", {:string, "bronze"}}, {"region", {:string, "us-east"}}]}}
     ]}
}

# A stale proof (outside the max-age + skew window):
stale = %ExpectedRequest{expected_request | evaluation_time: now + 361}

results = %{
  tampered_grant:
    BoundedAuthorityProtocol.V1.check_envelope(
      %Credentials{grant: tampered, proof: proof_compact},
      expected_request
    ),
  selector_disallowed:
    BoundedAuthorityProtocol.V1.check_envelope(credentials, wrong_args),
  stale_proof: BoundedAuthorityProtocol.V1.check_envelope(credentials, stale)
}

# Every one is exactly {:error, :invalid} — no partial information, no exceptions.
Enum.map(results, fn {k, v} -> {k, v == {:error, :invalid}} end)

7. Produce and verify the local-loopback application proof

The local-development profile is byte-distinct from standard dpop+jwt. It accepts only canonical literal-loopback HTTP targets and requires a server nonce. The target below represents a direct listener-derived request target; never substitute localhost, a forwarding header, or a client-asserted authority.

alias BoundedAuthorityProtocol.ApplicationProfile.LocalLoopbackHttp.V1, as: LocalLoopback

loopback_target = "http://127.0.0.1:4000/invoke"
loopback_nonce = "challenge-loopback-001"

{:ok, ^loopback_target} = LocalLoopback.normalize_uri(loopback_target, %{})

loopback_proof = %Proof{
  holder_public_key: holder_public,
  proof_id: "proof-loopback-2026-001",
  method: "POST",
  target_uri: loopback_target,
  issued_at: now,
  nonce: loopback_nonce,
  invocation_id: "7d444840-9dc0-11ed-a8fc-0242ac120003",
  operation: "read_record",
  grant_compact: grant_compact,
  cast_arguments:
    {:object,
     [
       {"record", {:object, [{"tier", {:string, "gold"}}, {"region", {:string, "us-east"}}]}}
     ]}
}

{:ok, loopback_input} = LocalLoopback.proof_signing_input(loopback_proof, %{})

loopback_signature =
  :crypto.sign(:eddsa, :none, loopback_input.message, [holder_private, :ed25519])

{:ok, loopback_compact} =
  LocalLoopback.assemble_compact(loopback_input, loopback_signature)

loopback_credentials = %Credentials{grant: grant_compact, proof: loopback_compact}

loopback_expected = %ExpectedRequest{
  trusted_issuer: trusted,
  issuer: "https://issuer.example",
  audience: "https://service.example",
  method: "POST",
  target_uri: loopback_target,
  invocation_id: "7d444840-9dc0-11ed-a8fc-0242ac120003",
  operation: "read_record",
  cast_arguments: loopback_proof.cast_arguments,
  evaluation_time: now,
  clock_skew: 60,
  proof_max_age: 300,
  nonce: {:required, loopback_nonce},
  bounds: %{}
}

{:ok, loopback_facts} =
  LocalLoopback.check_envelope(loopback_credentials, loopback_expected)

Map.from_struct(loopback_facts) |> Map.take([:target_uri, :operation, :authorization])

8. Cross-profile, wrong-nonce, and hostname attempts fail closed

loopback_rejections = %{
  wrong_nonce:
    LocalLoopback.check_envelope(
      loopback_credentials,
      %{loopback_expected | nonce: {:required, "wrong-nonce"}}
    ),
  standard_profile:
    BoundedAuthorityProtocol.V1.check_envelope(loopback_credentials, loopback_expected),
  hostname: LocalLoopback.normalize_uri("http://localhost:4000/invoke", %{})
}

Enum.map(loopback_rejections, fn {k, v} -> {k, v == {:error, :invalid}} end)

This notebook exercises the pure profile APIs. The repository's mix local_loopback_http.verify gate separately opens real IPv4 and IPv6 listeners and proves a verifier-bypass mutation RED. Loopback HTTP is not TLS and does not isolate another local process.

9. Produce and verify a role attestation

The role-attestation profile (ADR 0036) is the protocol's first sibling ATTESTATION profile: a standalone, grant-unbound compact JWS (typ: "ba+role-attestation") in which an attestor key binds a subject key to the issuer or holder role for a window contained in the attestor key's own validity. Like every sibling profile, selection is explicit — no contract-major parses these bytes and the profile parses none of theirs.

alias BoundedAuthorityProtocol.RoleAttestation.V1, as: RoleAttestationV1
alias BoundedAuthorityProtocol.V1.HistoricalPublicKey

{attestor_public, attestor_private} = :crypto.generate_key(:eddsa, :ed25519)
{subject_public, _subject_private} = :crypto.generate_key(:eddsa, :ed25519)

attestation = %RoleAttestationV1.RoleAttestation{
  attestor_key_id: "authority-attestor-1",
  jti: "urn:example:attestation:2026-001",
  key_id: "holder-key-1",
  public_key: subject_public,
  role: "issuer",
  nbf: now,
  exp: now + 3_600
}

{:ok, attestation_input} = RoleAttestationV1.attestation_signing_input(attestation, %{})

attestation_signature =
  :crypto.sign(:eddsa, :none, attestation_input.message, [attestor_private, :ed25519])

{:ok, attestation_compact} =
  RoleAttestationV1.assemble_compact(attestation_input, attestation_signature)

expected_attestation = %RoleAttestationV1.ExpectedAttestation{
  attestor: %HistoricalPublicKey{
    key_id: "authority-attestor-1",
    public_key: attestor_public,
    valid_from: now - 600,
    valid_before: now + 7_200
  },
  subject_key_id: "holder-key-1",
  subject_public_key: subject_public,
  now: now + 60,
  bounds: %{}
}

{:ok, attestation_facts} =
  RoleAttestationV1.verify_attestation(attestation_compact, expected_attestation)

# Anchor-posture facts — trust is explicitly not evaluated and there is no
# authorization marker at all:
Map.from_struct(attestation_facts)
|> Map.take([:attestor_key_id, :subject_key_id, :role, :trust])

10. Role-attestation attacks fail closed

# A true self-attestation: the wire itself binds the attestor's own key id AND key material,
# and the expected subject binding matches it — so every binding gate passes and only the
# structural self-attestation rejection can fire:
self_attestation = %{
  attestation
  | key_id: "authority-attestor-1",
    public_key: attestor_public
}

{:ok, self_input} = RoleAttestationV1.attestation_signing_input(self_attestation, %{})

self_signature =
  :crypto.sign(:eddsa, :none, self_input.message, [attestor_private, :ed25519])

{:ok, self_compact} = RoleAttestationV1.assemble_compact(self_input, self_signature)

role_attestation_rejections = %{
  self_attested:
    RoleAttestationV1.verify_attestation(self_compact, %{
      expected_attestation
      | subject_key_id: "authority-attestor-1",
        subject_public_key: attestor_public
    }),
  # now == exp — the acceptance window is the half-open [nbf, exp):
  now_at_exp:
    RoleAttestationV1.verify_attestation(attestation_compact, %{
      expected_attestation
      | now: now + 3_600
    }),
  # The attestation outlives the attestor key's own validity window:
  outliving_window:
    RoleAttestationV1.verify_attestation(attestation_compact, %{
      expected_attestation
      | attestor: %{expected_attestation.attestor | valid_before: now + 1_800}
    }),
  # Cross-profile, both directions:
  attestation_through_v1: BoundedAuthorityProtocol.V1.decode_grant(attestation_compact, %{}),
  grant_through_attestation: RoleAttestationV1.decode_attestation(grant_compact, %{})
}

Enum.map(role_attestation_rejections, fn {k, v} -> {k, v == {:error, :invalid}} end)

A role attestation is evidence of a binding, not an authorization: which attestor to trust, which role a consumer requires, and replay reservation stay with the caller (spec/bap-role-attestation-v1.md §5).

11. Produce and verify a content assertion

The content-assertion profile (ADR 0037) is a second sibling attestation profile: a standalone, grant-unbound compact JWS (typ: "ba+content-assertion") that binds the digest of exact external content bytes to an issuer, audience, lineage subject, semantic profile, validity window, and predecessor. The package hashes the bytes you pass and never parses them; what the content means stays with you.

alias BoundedAuthorityProtocol.ContentAssertion.V1, as: ContentAssertionV1

{content_attestor_public, content_attestor_private} = :crypto.generate_key(:eddsa, :ed25519)

content_bytes = ~s({"document":"example","revision":1})
{:ok, content_digest} = ContentAssertionV1.content_digest(content_bytes, %{})

# The semantic profile's identity is a caller-pinned opaque 32-byte digest:
profile_digest = :crypto.hash(:sha256, "urn:example:profile:document/1 schema bytes")

assertion = %ContentAssertionV1.ContentAssertion{
  attestor_key_id: "content-attestor-1",
  jti: "urn:example:assertion:2026-001",
  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: content_digest,
  gen: 1,
  prev: <<0::256>>,
  iat: now,
  nbf: now,
  exp: now + 3_600
}

{:ok, assertion_input} = ContentAssertionV1.assertion_signing_input(assertion, %{})

assertion_signature =
  :crypto.sign(:eddsa, :none, assertion_input.message, [content_attestor_private, :ed25519])

{:ok, assertion_compact} =
  ContentAssertionV1.assemble_compact(assertion_input, assertion_signature)

expected_assertion = %ContentAssertionV1.ExpectedContentAssertion{
  attestor: %HistoricalPublicKey{
    key_id: "content-attestor-1",
    public_key: content_attestor_public,
    valid_from: now - 600,
    valid_before: now + 7_200
  },
  issuer: "urn:example:issuer",
  audience: "urn:example:audience",
  subject: "urn:example:lineage:document-1",
  profile: "urn:example:profile:document/1",
  profile_digest: profile_digest,
  content_digest: content_digest,
  now: now + 60,
  bounds: %{}
}

{:ok, assertion_facts} =
  ContentAssertionV1.verify_assertion(assertion_compact, expected_assertion)

Map.from_struct(assertion_facts)
|> Map.take([:attestor_key_id, :sub, :gen, :verification, :trust])

Generation 2 names generation 1 by the SHA-256 of its exact compact bytes. The pairwise check compares two verified facts values; it keeps no history of its own.

successor = %{
  assertion
  | jti: "urn:example:assertion:2026-002",
    gen: 2,
    prev: assertion_facts.digest,
    iat: now + 120,
    nbf: now + 120
}

{:ok, successor_input} = ContentAssertionV1.assertion_signing_input(successor, %{})

successor_signature =
  :crypto.sign(:eddsa, :none, successor_input.message, [content_attestor_private, :ed25519])

{:ok, successor_compact} =
  ContentAssertionV1.assemble_compact(successor_input, successor_signature)

{:ok, successor_facts} =
  ContentAssertionV1.verify_assertion(successor_compact, %{expected_assertion | now: now + 180})

ContentAssertionV1.verify_successor(assertion_facts, successor_facts, %{})

12. Content-assertion attacks fail closed

{:ok, changed_digest} = ContentAssertionV1.content_digest(content_bytes <> " ", %{})

content_assertion_rejections = %{
  # One changed content byte changes the digest the verifier requires:
  changed_content:
    ContentAssertionV1.verify_assertion(assertion_compact, %{
      expected_assertion
      | content_digest: changed_digest
    }),
  # now == exp: the acceptance window is the half-open [nbf, exp):
  now_at_exp:
    ContentAssertionV1.verify_assertion(assertion_compact, %{expected_assertion | now: now + 3_600}),
  # Lineage runs forward only:
  reversed_lineage: ContentAssertionV1.verify_successor(successor_facts, assertion_facts, %{}),
  # Cross-profile, both directions:
  assertion_through_role: RoleAttestationV1.decode_attestation(assertion_compact, %{}),
  role_through_assertion: ContentAssertionV1.decode_assertion(attestation_compact, %{}),
  assertion_through_v1: BoundedAuthorityProtocol.V1.decode_grant(assertion_compact, %{})
}

Enum.map(content_assertion_rejections, fn {k, v} -> {k, v == {:error, :invalid}} end)

A content assertion proves that a trusted-by-you key signed this exact digest and context. It does not prove the content is true, that the key is still trusted, or that you hold the latest generation. Trusted keys, durable lineage state, and replay handling stay with the caller (spec/bap-content-assertion-v1.md §7).

What you just saw

  • Deterministic producers + assemble_compact built exact compact JWS values from ephemeral signatures — the package never sees a private key.
  • verify_grant and check_envelope checked every binding from caller-supplied trusted inputs and returned redacted, non-authorizing facts.
  • A tampered byte, a selector-disallowed argument swap, and a stale proof each failed closed with the single error value.
  • The local-loopback profile produced and verified byte-distinct proof bytes with a required nonce; the wrong nonce, the standard verifier, and a hostname target each failed closed.
  • The role-attestation profile bound a subject key to a role inside the attestor key's own window and returned anchor-posture facts; a true self-attestation, now == exp, an outliving window, and cross-profile bytes in both directions each failed closed.
  • The content-assertion profile bound an exact content digest and context, linked a successor to its predecessor's compact digest, and returned non-authorizing facts; changed content, now == exp, a reversed lineage, and cross-profile bytes each failed closed.

What this notebook did NOT do: select trusted keys, reserve replay, check revocation, or make an authorization decision — those belong to a stateful authority runtime. See the specification's verification-contract section for the boundary in normative terms.