Bounded Authority Protocol — end-to-end walkthrough
Mix.install([
{:bounded_authority_protocol, "~> 0.8.0"}
])
Hex 0.8.0 is published (October 13, 2026), 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 and P-256 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.
The later sections cover the local-loopback, role-attestation, and content-assertion sibling profiles; strict Ed25519 verification (rule S1), with forged keys and signatures taken from the certified strict-Ed25519 corpus; and contract-major 3, the ES256 suite.
Run top to bottom. Rerunning the notebook mints fresh keys and produces different bytes with the same verdicts. The only file it reads is the certified corpus that ships inside the package.
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_major_one:
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_major_one: 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).
13. Strict Ed25519 verification (rule S1)
Since release 0.8.0, every Ed25519 signature the protocol verifies is checked under one strict
rule, rule S1 (ADR 0040, spec/bap-v1.md §14.1). 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/3checks a signature under rule S1.acceptable_public_key?/1is the check every key passes before it enters a verification input.torsion_free?/1is 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:
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)
}
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.
# The prime order of the base point (spec/bap-v1.md §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 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.
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)}
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 (spec/bap-v1.md §14.1). 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, released in 0.5.0, ADR 0035,
spec/bap-v3.md) 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 same ones the earlier sections used.
:crypto returns ECDSA signatures in DER and may return either s, so the signer converts the
signature to r || s and normalizes s to the low half before assembly. Release 0.9.0
(unreleased, planned for 0.9.0) adds BoundedAuthorityProtocol.Es256, a public module with the
ES256 checks, including der_to_raw/1 and normalize_low_s/1 for this step. The cell below
does the conversion by hand, so it runs on every release, including the ones without that
module.
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)
# The P-256 group order (spec/bap-v3.md §3.2):
n = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
es256_sign = fn message, private_key ->
der = :crypto.sign(:ecdsa, :sha256, message, [private_key, :prime256v1])
{:"ECDSA-Sig-Value", r, s} = :public_key.der_decode(:"ECDSA-Sig-Value", der)
low_s = if s > div(n, 2), do: n - s, else: s
<<r::256, low_s::256>>
end
{:ok, es_holder_jwk} = EcJwk.encode_public(es_holder_public, %{})
{:ok, es_holder_thumbprint} = EcJwk.thumbprint(es_holder_jwk, %{})
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: Base.url_decode64!(es_holder_thumbprint, padding: false),
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.from_struct(es_grant_facts) |> Map.take([:version, :grant_id, :authorization]),
envelope: Map.from_struct(es_envelope_facts) |> Map.take([:operation, :authorization])
}
16. ES256 attacks fail closed
ECDSA signatures are malleable: if (r, s) verifies, so does (r, n - s). The v3 profile
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.
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 v3 decoders
refuse the grant and proof of the v1 envelope from section 5, and the v1 and v2 decoders refuse
the v3 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 v3
grant with a proof signed by a key the grant does not name.
<<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)
high_s_der = :public_key.der_encode(:"ECDSA-Sig-Value", {:"ECDSA-Sig-Value", es_r, n - es_s})
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,
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 high-S proof was framed by hand. A v3 verifier rejects it whatever produced it, and a conforming signer never emits it.
What you just saw
- Deterministic producers +
assemble_compactbuilt exact compact JWS values from ephemeral signatures — the package never sees a private key. verify_grantandcheck_envelopechecked 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. BoundedAuthorityProtocol.Ed25519accepted the honest key and signature under rule S1. A key-only forgery that the backend alone accepts, under a small-order key and under a non-canonical key, anS + Lsignature, and the corpus grants for an order-8 key,y = p, and an identityReach failed closed. The torsioned corpus key verified, as the rule specifies, andtorsion_free?/1refused it for the host to act on at enrollment.- Contract-major 3 produced and verified an ES256 grant and holder proof from ephemeral P-256
keys and EC JWK holder binding. The high-S twin of a valid signature and a proof from a key
the grant does not name failed
check_envelope; the v3 decoders refused the v1 grant and proof, and the v1 and v2 decoders refused the v3 grant and proof, while each decoded under its own major.
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.