# Bounded Authority Protocol: end-to-end walkthrough

```elixir
Mix.install([
  {:bounded_authority_protocol, "~> 1.0"}
])
```

## What this notebook covers

This notebook runs the full produce, assemble, verify, and reject loop with the verifier package
alone. It mints its own ephemeral Ed25519 and P-256 keys at runtime: nothing is tracked or
persisted. The package accepts public keys only, so the notebook does the signing and the package
does the verifying. It builds a grant and a proof through the package's deterministic producers,
verifies the envelope, and then watches each classic attack fail.

The later sections cover the local-loopback, role-attestation, and content-assertion sibling
profiles; strict Ed25519 verification, with forged keys and signatures taken from the certified
strict-Ed25519 corpus; and contract-major 3, the ES256 suite.

Run it top to bottom. A rerun mints fresh keys and produces different bytes with the same
verdicts. After each cell, the text states the value the cell returns; the package's test suite
runs this notebook and checks every one of those statements. The only file the notebook reads
is the certified corpus that ships inside the package.

Two companion notebooks continue from here:
[service integration](https://hexdocs.pm/bounded_authority_protocol/service-integration.html)
(key rotation, nonces, clocks, diagnosing a rejection) and
[chains and archives](https://hexdocs.pm/bounded_authority_protocol/chains-and-archives.html)
(consumption chains, boundary anchors, key transitions, anchored exports).

## 1. Ephemeral keys

```elixir
{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, %{})

{byte_size(issuer_public), byte_size(holder_thumbprint)}
```

The cell returns `{32, 32}`: a raw 32-byte Ed25519 public key, and the raw 32-byte RFC 7638
thumbprint of the holder key that the grant will name.

## 2. Build and sign a grant

```elixir
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
```

The cell's value matches `"eyJ" <> _`: a compact JWS whose first segment is the base64url form of
the protected header `{"alg":"EdDSA",…}`. The signature is fresh on every run.

## 3. Build and sign the holder proof

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

```elixir
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
```

The cell's value matches `"eyJ" <> _`: the proof is a compact JWS of type `dpop+jwt`.

## 4. Verify the grant

The verifier holds no state and reads no clock. The caller supplies the trusted key, the
expected context, and the evaluation time.

```elixir
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)

Map.take(Map.from_struct(grant_facts), [:issuer, :grant_id, :authorization])
```

The cell returns `%{issuer: "https://issuer.example", grant_id: "grant-2026-001", authorization: :not_evaluated}`.
Facts carry identifiers, digests, and times, never keys or signatures, and
`authorization: :not_evaluated` says that deciding is still your job.

## 5. Verify the full envelope

```elixir
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.take(Map.from_struct(envelope_facts), [:operation, :authorization])
```

The cell returns `%{operation: "read_record", authorization: :not_evaluated}`. The facts struct
itself prints as `#BoundedAuthorityProtocol.V1.EnvelopeFacts<redacted>`, so logging it leaks
nothing.

## 6. Every attack fails closed

```elixir
# A changed byte in the grant signature:
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 (the tier selector disallows "bronze"):
wrong_args = %ExpectedRequest{
  expected_request
  | cast_arguments:
      {:object,
       [
         {"record", {:object, [{"tier", {:string, "bronze"}}, {"region", {:string, "us-east"}}]}}
       ]}
}

# A stale proof, outside the maximum age plus the clock skew:
stale = %ExpectedRequest{expected_request | evaluation_time: now + 361}

%{
  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)
}
```

The cell returns `%{tampered_grant: {:error, :invalid}, selector_disallowed: {:error, :invalid}, stale_proof: {:error, :invalid}}`.
Each attack gets exactly `{:error, :invalid}`: no partial information and no exception. The error
never says which check failed; the
[troubleshooting guide](https://hexdocs.pm/bounded_authority_protocol/troubleshooting.html)
walks through diagnosing a rejection from your own inputs.

## 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 stands for
a target derived directly from the listener; never substitute `localhost`, a forwarding header,
or a client-asserted authority.

```elixir
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.take(Map.from_struct(loopback_facts), [:target_uri, :operation, :authorization])
```

The cell returns `%{target_uri: "http://127.0.0.1:4000/invoke", operation: "read_record", authorization: :not_evaluated}`.

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

```elixir
%{
  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", %{})
}
```

The cell returns `%{wrong_nonce: {:error, :invalid}, standard_profile: {:error, :invalid}, hostname: {:error, :invalid}}`.
A host selects one profile and never retries another after a failure. This notebook exercises the
pure profile functions only. Loopback HTTP is not TLS, and it does not isolate one local process
from another.

## 9. Produce and verify a role attestation

A role attestation is a standalone compact JWS (`typ: "ba+role-attestation"`), bound to no
grant, in which an attestor key binds a subject key to the `issuer` or `holder` role for a window
inside the attestor key's own validity. As with every sibling profile, selection is explicit: no
contract-major parses these bytes, and the profile parses none of theirs.

```elixir
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)

Map.take(Map.from_struct(attestation_facts), [:attestor_key_id, :subject_key_id, :role, :trust])
```

The cell returns `%{attestor_key_id: "authority-attestor-1", subject_key_id: "holder-key-1", role: "issuer", trust: :not_evaluated}`.
The facts say `trust: :not_evaluated` and carry no authorization marker at all.

## 10. Role-attestation attacks fail closed

```elixir
# A true self-attestation: the payload binds the attestor's own key id and key material, and
# the expected subject matches it, so every binding check 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)

%{
  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_major_one:
    BoundedAuthorityProtocol.V1.decode_grant(attestation_compact, %{}),
  grant_through_attestation: RoleAttestationV1.decode_attestation(grant_compact, %{})
}
```

The cell returns `%{self_attested: {:error, :invalid}, now_at_exp: {:error, :invalid}, outliving_window: {:error, :invalid}, attestation_through_major_one: {:error, :invalid}, grant_through_attestation: {:error, :invalid}}`.

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.

## 11. Produce and verify a content assertion

A content assertion is a second sibling attestation profile: a standalone 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.

```elixir
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.take(Map.from_struct(assertion_facts), [:attestor_key_id, :sub, :gen, :verification, :trust])
```

The cell returns `%{attestor_key_id: "content-attestor-1", sub: "urn:example:lineage:document-1", gen: 1, verification: :signature_and_window, trust: :not_evaluated}`.

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.

```elixir
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, %{})
```

The cell returns `:ok`: generation 2 follows generation 1.

## 12. Content-assertion attacks fail closed

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

%{
  # 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_major_one: BoundedAuthorityProtocol.V1.decode_grant(assertion_compact, %{})
}
```

The cell returns:

<!-- livebook:{"force_markdown":true} -->

```elixir
%{
  changed_content: {:error, :invalid},
  now_at_exp: {:error, :invalid},
  reversed_lineage: {:error, :invalid},
  assertion_through_role: {:error, :invalid},
  role_through_assertion: {:error, :invalid},
  assertion_through_major_one: {:error, :invalid}
}
```

A content assertion proves that a key you trust signed this exact digest and context. It does
not prove that 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.

## 13. Strict Ed25519 verification

Every Ed25519 signature the protocol verifies is checked under one strict rule, rule S1
([specification § 14.1](https://hexdocs.pm/bounded_authority_protocol/bap-v1.html#strict-ed25519)).
Before any backend call, the public key `A` and the signature's point `R` must be canonical
encodings (the encoded `y` is below `p = 2^255 - 19`, and the sign bit is clear when `x = 0`) and
must not be one of the eight points of small order, and the scalar `S` must be below `L`, the
prime order of the base point `B`. Only then does the cofactorless equation
`encode([S]B - [k]A) == R` run, compared byte for byte.

`BoundedAuthorityProtocol.Ed25519` owns the rule:

- `verify_strict/3` checks a signature under rule S1.
- `acceptable_public_key?/1` is the check every key passes before it enters a verification
  input.
- `torsion_free?/1` is the stricter host check for key enrollment and trust install.

The honest issuer key and grant signature from sections 1 and 2 pass all three:

```elixir
alias BoundedAuthorityProtocol.Ed25519

%{
  verify_strict: Ed25519.verify_strict(signing_input.message, issuer_signature, issuer_public),
  acceptable_public_key?: Ed25519.acceptable_public_key?(issuer_public),
  torsion_free?: Ed25519.torsion_free?(issuer_public)
}
```

The cell returns `%{verify_strict: true, acceptable_public_key?: true, torsion_free?: true}`.

## 14. Forged Ed25519 keys and signatures fail closed

The byte values below come from the certified strict-Ed25519 corpus that ships inside the
package (`priv/conformance/strict-ed25519`, profile `bap-strict-ed25519/1`). Corpus cases
`key-only-v1-identity-a-basepoint-r-s1-*` carry a forgery that needs no secret: `R` is the base
point `B` and `S = 1`. Under a key that encodes the identity point, `[k]A` is the identity, so
`[1]B - [k]A = B = R` for every message, and the corpus records that the bare cofactorless
equation accepts it. We attach that signature to our own grant from section 2 and trust two
encodings of the identity point: the canonical one, which is of small order, and `y = p + 1`,
which is not canonical (corpus cases `key-only-v1-y-p-plus-1-prime-r-*`, same signature). The
`:crypto` backend alone accepts the forgery under both keys. `verify_grant` refuses both keys at
trusted-issuer validation, before it looks at the signature, because neither passes
`acceptable_public_key?/1`; `verify_strict/3` refuses the signature under each key too.

The third attack adds `L` to the `S` of the honest holder signature from section 3 (corpus
construction `s-plus-l`). Both scalars give the same point, `[S + L]B = [S]B`, so only a range
check tells them apart. The backend enforces `S < L` as well (RFC 8032 § 5.1.7), so this one is
refused twice: by rule S1 before the backend call, and by the backend.

```elixir
# The prime order of the base point (specification § 14.1, RFC 8032):
l = 2 ** 252 + 27_742_317_777_372_353_535_851_937_790_883_648_493

# Corpus case key-only-v1-identity-a-basepoint-r-s1-0: R = B, then S = 1 (little-endian).
key_only_forgery =
  Base.decode16!(
    "5866666666666666666666666666666666666666666666666666666666666666" <>
      "0100000000000000000000000000000000000000000000000000000000000000",
    case: :lower
  )

# Corpus key small-order-0: the identity point, of order 1.
small_order_key =
  Base.decode16!("0100000000000000000000000000000000000000000000000000000000000000",
    case: :lower
  )

# Corpus key y-p-plus-1 (case key-only-v1-y-p-plus-1-prime-r-0): y = p + 1, a non-canonical
# encoding of the same identity point.
noncanonical_key =
  Base.decode16!("eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",
    case: :lower
  )

forged_grant =
  signing_input.message <> "." <> Base.url_encode64(key_only_forgery, padding: false)

<<holder_r::binary-size(32), holder_s::little-size(256)>> = holder_signature
s_plus_l_signature = holder_r <> <<holder_s + l::little-size(256)>>

s_plus_l_proof =
  proof_input.message <> "." <> Base.url_encode64(s_plus_l_signature, padding: false)

trusting = fn public_key -> %TrustedIssuer{key_id: "issuer-1", public_key: public_key} end

backend_verify = fn message, signature, public_key ->
  :crypto.verify(:eddsa, :none, message, signature, [public_key, :ed25519])
end

%{
  backend: %{
    accepts_key_only_forgery: %{
      small_order_key: backend_verify.(signing_input.message, key_only_forgery, small_order_key),
      noncanonical_key: backend_verify.(signing_input.message, key_only_forgery, noncanonical_key)
    },
    accepts_s_plus_l: backend_verify.(proof_input.message, s_plus_l_signature, holder_public)
  },
  primitives: %{
    small_order_key_acceptable?: Ed25519.acceptable_public_key?(small_order_key),
    noncanonical_key_acceptable?: Ed25519.acceptable_public_key?(noncanonical_key),
    forgery_under_small_order_key:
      Ed25519.verify_strict(signing_input.message, key_only_forgery, small_order_key),
    forgery_under_noncanonical_key:
      Ed25519.verify_strict(signing_input.message, key_only_forgery, noncanonical_key),
    s_plus_l: Ed25519.verify_strict(proof_input.message, s_plus_l_signature, holder_public)
  },
  verification: %{
    small_order_key:
      BoundedAuthorityProtocol.V1.verify_grant(
        forged_grant,
        trusting.(small_order_key),
        expected_grant
      ),
    noncanonical_key:
      BoundedAuthorityProtocol.V1.verify_grant(
        forged_grant,
        trusting.(noncanonical_key),
        expected_grant
      ),
    s_plus_l:
      BoundedAuthorityProtocol.V1.check_envelope(
        %Credentials{grant: grant_compact, proof: s_plus_l_proof},
        expected_request
      )
  }
}
```

The cell returns:

<!-- livebook:{"force_markdown":true} -->

```elixir
%{
  backend: %{
    accepts_key_only_forgery: %{small_order_key: true, noncanonical_key: true},
    accepts_s_plus_l: false
  },
  primitives: %{
    small_order_key_acceptable?: false,
    noncanonical_key_acceptable?: false,
    forgery_under_small_order_key: false,
    forgery_under_noncanonical_key: false,
    s_plus_l: false
  },
  verification: %{
    small_order_key: {:error, :invalid},
    noncanonical_key: {:error, :invalid},
    s_plus_l: {:error, :invalid}
  }
}
```

The backend alone accepts the key-only forgery under both identity-point keys; the package
refuses all three attacks.

The corpus also carries complete signed grants for each clause of the rule. The next cell reads
six of them from the installed package and runs each through `verify_grant`: the honest control,
an isolated forgery under an order-8 key, an isolated forgery under `y = p`, an honest key that
signed with `R` at the identity point, the `S + L` signature, and a key with a torsion component.

```elixir
corpus_cases =
  :bounded_authority_protocol
  |> Application.app_dir("priv/conformance/strict-ed25519/cases/grant-verify/grant-verify-01.json")
  |> File.read!()
  |> JSON.decode!()
  |> Map.fetch!("cases")
  |> Map.new(&{&1["id"], &1["input"]})

run_corpus_case = fn id ->
  input = Map.fetch!(corpus_cases, id)
  public_key = Base.url_decode64!(input["public_key"], padding: false)

  verdict =
    BoundedAuthorityProtocol.V1.verify_grant(
      input["compact"],
      %TrustedIssuer{key_id: input["key_id"], public_key: public_key},
      %ExpectedGrant{
        issuer: input["issuer"],
        audience: input["audience"],
        evaluation_time: input["evaluation_time"],
        clock_skew: input["clock_skew"],
        bounds: %{}
      }
    )

  %{
    verify_grant: with({:ok, facts} <- verdict, do: {:ok, facts.grant_id}),
    acceptable_public_key?: Ed25519.acceptable_public_key?(public_key),
    torsion_free?: Ed25519.torsion_free?(public_key)
  }
end

for id <- [
      "grant-verify-v1-issuer-honest",
      "grant-verify-v1-issuer-iso-small-order-a-1",
      "grant-verify-v1-issuer-iso-noncanonical-a-y-p-plus-0",
      "grant-verify-v1-issuer-honest-a-identity-r",
      "grant-verify-v1-issuer-s-plus-l",
      "grant-verify-v1-issuer-mixed-a-mixed-r-both"
    ],
    do: {id, run_corpus_case.(id)}
```

The cell returns:

<!-- livebook:{"force_markdown":true} -->

```elixir
[
  {"grant-verify-v1-issuer-honest",
   %{verify_grant: {:ok, "urn:example:grant:1"}, acceptable_public_key?: true, torsion_free?: true}},
  {"grant-verify-v1-issuer-iso-small-order-a-1",
   %{verify_grant: {:error, :invalid}, acceptable_public_key?: false, torsion_free?: false}},
  {"grant-verify-v1-issuer-iso-noncanonical-a-y-p-plus-0",
   %{verify_grant: {:error, :invalid}, acceptable_public_key?: false, torsion_free?: false}},
  {"grant-verify-v1-issuer-honest-a-identity-r",
   %{verify_grant: {:error, :invalid}, acceptable_public_key?: true, torsion_free?: true}},
  {"grant-verify-v1-issuer-s-plus-l",
   %{verify_grant: {:error, :invalid}, acceptable_public_key?: true, torsion_free?: true}},
  {"grant-verify-v1-issuer-mixed-a-mixed-r-both",
   %{verify_grant: {:ok, "urn:example:grant:1"}, acceptable_public_key?: true, torsion_free?: false}}
]
```

Every forgery returns the single closed error, `{:error, :invalid}`. The last case is the one
rule S1 accepts on purpose. Its key is an honest key plus a small torsion component: canonical,
not of small order, and on the curve, so it is acceptable, and its signature satisfies the
cofactorless equation. Verification performs no subgroup check. One secret can sign under its
honest key and up to seven torsioned variants of it, each with its own thumbprint. A host
therefore calls `torsion_free?/1` on a key at enrollment, at trust install, and before any
deny-list or revocation comparison, and refuses the key at each of those points.

## 15. Contract-major 3: ES256 grants and proofs

Contract-major 3 (`BoundedAuthorityProtocol.V3`,
[specification](https://hexdocs.pm/bounded_authority_protocol/bap-v3.html)) is the
`BAP3-ES256-SHA256` suite: ECDSA over P-256 with SHA-256. Grants and proofs carry `v: 3` and
`alg: "ES256"`, the holder key travels as an EC JWK, and a signature is the raw 64-byte `r || s`
form of RFC 7518 § 3.4 with `s` in the low half (`s <= n/2`). `BoundedAuthorityProtocol.V3` has
its own grant and proof producers; the trusted-issuer, expected-context, and credentials structs
are the ones the earlier sections used.

`:crypto`, like most KMS and HSM interfaces, returns an ECDSA signature in DER and may return
either `s`. The signer converts it with `BoundedAuthorityProtocol.Es256.der_to_raw/1` and then
`BoundedAuthorityProtocol.Es256.normalize_low_s/1` before assembly. Assembly itself never
normalizes; it refuses a high-S signature.

```elixir
alias BoundedAuthorityProtocol.Es256
alias BoundedAuthorityProtocol.V3
alias BoundedAuthorityProtocol.V3.EcJwk

{es_issuer_public, es_issuer_private} = :crypto.generate_key(:ecdh, :prime256v1)
{es_holder_public, es_holder_private} = :crypto.generate_key(:ecdh, :prime256v1)

es256_sign = fn message, private_key ->
  der = :crypto.sign(:ecdsa, :sha256, message, [private_key, :prime256v1])
  {:ok, raw} = Es256.der_to_raw(der)
  {:ok, low_s} = Es256.normalize_low_s(raw)
  low_s
end

{:ok, es_holder_thumbprint} = EcJwk.public_key_thumbprint_raw(es_holder_public, %{})

es_grant = %V3.Grant{
  key_id: "es-issuer-1",
  issuer: "https://issuer.example",
  grant_id: "grant-2026-es-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: es_holder_thumbprint,
  operations: [
    %V3.Operation{
      name: "start_job",
      selectors: [
        {:equals, ["job", "queue"], {:string, "batch"}},
        {:lte, ["job", "gpu_hours"], {:integer, 8}}
      ]
    }
  ]
}

{:ok, es_grant_input} = V3.grant_signing_input(es_grant, %{})

{:ok, es_grant_compact} =
  V3.assemble_compact(es_grant_input, es256_sign.(es_grant_input.message, es_issuer_private))

es_arguments =
  {:object,
   [{"job", {:object, [{"queue", {:string, "batch"}}, {"gpu_hours", {:integer, 4}}]}}]}

es_proof = %V3.Proof{
  holder_public_key: es_holder_public,
  proof_id: "proof-2026-es-001",
  method: "POST",
  target_uri: "https://service.example/jobs",
  issued_at: now,
  nonce: nil,
  invocation_id: "7d444840-9dc0-11ed-a8fc-0242ac120004",
  operation: "start_job",
  grant_compact: es_grant_compact,
  cast_arguments: es_arguments
}

{:ok, es_proof_input} = V3.proof_signing_input(es_proof, %{})
es_proof_signature = es256_sign.(es_proof_input.message, es_holder_private)
{:ok, es_proof_compact} = V3.assemble_compact(es_proof_input, es_proof_signature)

es_trusted = %TrustedIssuer{key_id: "es-issuer-1", public_key: es_issuer_public}
es_credentials = %Credentials{grant: es_grant_compact, proof: es_proof_compact}

es_expected = %ExpectedRequest{
  trusted_issuer: es_trusted,
  issuer: "https://issuer.example",
  audience: "https://service.example",
  method: "POST",
  target_uri: "https://service.example/jobs",
  invocation_id: "7d444840-9dc0-11ed-a8fc-0242ac120004",
  operation: "start_job",
  cast_arguments: es_arguments,
  evaluation_time: now,
  clock_skew: 60,
  proof_max_age: 300,
  nonce: :not_required,
  bounds: %{}
}

{:ok, es_grant_facts} = V3.verify_grant(es_grant_compact, es_trusted, expected_grant)
{:ok, es_envelope_facts} = V3.check_envelope(es_credentials, es_expected)

%{
  grant: Map.take(Map.from_struct(es_grant_facts), [:version, :grant_id, :authorization]),
  envelope: Map.take(Map.from_struct(es_envelope_facts), [:operation, :authorization])
}
```

The cell returns:

<!-- livebook:{"force_markdown":true} -->

```elixir
%{
  grant: %{version: 3, grant_id: "grant-2026-es-001", authorization: :not_evaluated},
  envelope: %{operation: "start_job", authorization: :not_evaluated}
}
```

## 16. ES256 attacks fail closed

ECDSA signatures are malleable: if `(r, s)` verifies, so does `(r, n - s)`. Contract-major 3
accepts only the low-`s` member of each pair (`REQ3-SIGNING-low-s`). Below, the backend alone
accepts the high-S twin of the holder signature, and `check_envelope` refuses it. The `Es256`
helpers turn the twin's DER form back into the original low-S signature, which is what a signer
does with whatever its KMS or HSM returns.

Each contract-major parses only its own bytes (`REQ3-CORE-cross-major-reject`). Decoding takes no
key, so the cross-major attempts below use `decode_grant` and `decode_proof`: the contract-major 3
decoders refuse the grant and proof of the contract-major 1 envelope from section 5, and the
contract-major 1 and 2 decoders refuse the contract-major 3 grant and proof. The
`same_major_decodes` entries show each set of bytes decoding under its own major, so the
refusals come from the major check alone. The last attempt presents the contract-major 3 grant
with a proof signed by a key the grant does not name.

```elixir
# The P-256 group order (contract-major 3 specification § 3.2):
n = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551

<<es_r::256, es_s::256>> = es_proof_signature
high_s_signature = <<es_r::256, n - es_s::256>>

high_s_proof =
  es_proof_input.message <> "." <> Base.url_encode64(high_s_signature, padding: false)

# The backend verifies DER, so frame the twin as a DER ECDSA-Sig-Value
# (SEQUENCE { INTEGER r, INTEGER s }; a leading zero byte keeps an INTEGER positive):
der_integer = fn integer ->
  bytes = :binary.encode_unsigned(integer)
  bytes = if :binary.first(bytes) >= 0x80, do: <<0>> <> bytes, else: bytes
  <<0x02, byte_size(bytes)>> <> bytes
end

high_s_body = der_integer.(es_r) <> der_integer.(n - es_s)
high_s_der = <<0x30, byte_size(high_s_body)>> <> high_s_body

backend_accepts_high_s_twin =
  :crypto.verify(:ecdsa, :sha256, es_proof_input.message, high_s_der, [
    es_holder_public,
    :prime256v1
  ])

# A different P-256 key presents the same grant with a correctly signed proof of its own:
{other_public, other_private} = :crypto.generate_key(:ecdh, :prime256v1)
{:ok, other_input} = V3.proof_signing_input(%{es_proof | holder_public_key: other_public}, %{})

{:ok, other_proof} =
  V3.assemble_compact(other_input, es256_sign.(other_input.message, other_private))

decodes? = &match?({:ok, _decoded}, &1)

%{
  backend_accepts_high_s_twin: backend_accepts_high_s_twin,
  helpers_recover_low_s:
    with({:ok, raw} <- Es256.der_to_raw(high_s_der), do: Es256.normalize_low_s(raw)) ==
      {:ok, es_proof_signature},
  same_major_decodes: %{
    major_one_grant: decodes?.(BoundedAuthorityProtocol.V1.decode_grant(grant_compact, %{})),
    major_one_proof: decodes?.(BoundedAuthorityProtocol.V1.decode_proof(proof_compact, %{})),
    major_three_grant: decodes?.(V3.decode_grant(es_grant_compact, %{})),
    major_three_proof: decodes?.(V3.decode_proof(es_proof_compact, %{}))
  },
  rejections: %{
    high_s:
      V3.check_envelope(%Credentials{grant: es_grant_compact, proof: high_s_proof}, es_expected),
    major_one_grant_through_major_three: V3.decode_grant(grant_compact, %{}),
    major_one_proof_through_major_three: V3.decode_proof(proof_compact, %{}),
    major_three_grant_through_major_one:
      BoundedAuthorityProtocol.V1.decode_grant(es_grant_compact, %{}),
    major_three_proof_through_major_one:
      BoundedAuthorityProtocol.V1.decode_proof(es_proof_compact, %{}),
    major_three_grant_through_major_two:
      BoundedAuthorityProtocol.V2.decode_grant(es_grant_compact, %{}),
    major_three_proof_through_major_two:
      BoundedAuthorityProtocol.V2.decode_proof(es_proof_compact, %{}),
    wrong_holder_key:
      V3.check_envelope(%Credentials{grant: es_grant_compact, proof: other_proof}, es_expected)
  }
}
```

The cell returns:

<!-- livebook:{"force_markdown":true} -->

```elixir
%{
  backend_accepts_high_s_twin: true,
  helpers_recover_low_s: true,
  same_major_decodes: %{
    major_one_grant: true,
    major_one_proof: true,
    major_three_grant: true,
    major_three_proof: true
  },
  rejections: %{
    high_s: {:error, :invalid},
    major_one_grant_through_major_three: {:error, :invalid},
    major_one_proof_through_major_three: {:error, :invalid},
    major_three_grant_through_major_one: {:error, :invalid},
    major_three_proof_through_major_one: {:error, :invalid},
    major_three_grant_through_major_two: {:error, :invalid},
    major_three_proof_through_major_two: {:error, :invalid},
    wrong_holder_key: {:error, :invalid}
  }
}
```

The high-S proof was framed by hand. A contract-major 3 verifier rejects it whatever produced
it, and a conforming signer never emits it.

## What you just saw

- Deterministic producers and `assemble_compact` built exact compact JWS values from ephemeral
  signatures; the package never saw a private key.
- `verify_grant` and `check_envelope` checked every binding against caller-supplied trusted
  inputs and returned redacted, non-authorizing facts.
- A changed 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; 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 and linked a
  successor to its predecessor; changed content, `now == exp`, a reversed lineage, and
  cross-profile bytes each failed closed.
- `BoundedAuthorityProtocol.Ed25519` accepted the honest key and signature under rule S1 and
  refused every forgery; the torsioned corpus key verified, as the rule specifies, and
  `torsion_free?/1` refused it for the host to act on at enrollment.
- Contract-major 3 produced and verified an ES256 grant and holder proof, with the `Es256`
  helpers turning DER signatures into low-S raw form; the high-S twin, a proof from a key the
  grant does not name, and every cross-major decode failed closed.

What this notebook did not do: select trusted keys, reserve replay, check revocation, or make an
authorization decision. Those belong to the host or a stateful authority runtime. See
[usage rules](https://hexdocs.pm/bounded_authority_protocol/usage-rules.html) for the boundary.
