Mix.install([
{:bounded_authority_protocol, "~> 1.0"}
])What this notebook covers
The walkthrough shows one grant and one proof verifying. A service that accepts them in production needs a little more around that call, and this notebook builds each piece with ephemeral keys:
- issuer key rotation: a trusted-key set with two keys, and the untrusted
kidhint; - the nonce modes;
- clock skew and proof maximum age;
- diagnosing a rejection from your own inputs;
- what the host still owns after a successful verification, with replay reservation as the example.
Every function here belongs to contract-major 1 (BoundedAuthorityProtocol.V1); contract-majors 2
and 3 have the same function names and shapes. After each cell, the text states the value the
cell returns, and the package's test suite runs this notebook and checks each of those
statements.
1. An issuer with two keys
The host keeps its own trusted-key set: a map from key id to the raw public key it already trusts, loaded from configuration or a key-distribution endpoint it authenticates. During a rotation the issuer signs new grants with its new key while grants signed with the old key are still live, so the set holds both.
alias BoundedAuthorityProtocol.V1
alias BoundedAuthorityProtocol.V1.{Grant, Operation, TrustedIssuer, ExpectedGrant}
{old_public, old_private} = :crypto.generate_key(:eddsa, :ed25519)
{new_public, new_private} = :crypto.generate_key(:eddsa, :ed25519)
{holder_public, holder_private} = :crypto.generate_key(:eddsa, :ed25519)
{:ok, holder_thumbprint} = V1.Jwk.public_key_thumbprint_raw(holder_public, %{})
trusted_keys = %{"issuer-2026-04" => old_public, "issuer-2026-10" => new_public}
sign_grant = fn key_id, private_key, grant_id ->
grant = %Grant{
key_id: key_id,
issuer: "https://issuer.example",
grant_id: grant_id,
audiences: ["https://builds.example"],
issued_at: 1_800_000_000,
not_before: 1_800_000_000,
expires_at: 1_800_086_400,
holder_thumbprint: holder_thumbprint,
operations: [
%Operation{
name: "trigger_build",
selectors: [{:one_of, ["branch"], [{:string, "main"}, {:string, "release"}]}]
}
]
}
{:ok, input} = V1.grant_signing_input(grant, %{})
signature = :crypto.sign(:eddsa, :none, input.message, [private_key, :ed25519])
{:ok, compact} = V1.assemble_compact(input, signature)
compact
end
old_grant = sign_grant.("issuer-2026-04", old_private, "grant-build-001")
new_grant = sign_grant.("issuer-2026-10", new_private, "grant-build-002")
map_size(trusted_keys)The cell returns 2: both keys are trusted during the rotation.
2. Select the key from your own set
untrusted_key_locator/2 reads only the grant's protected header and returns its kid. The
kid is a lookup hint chosen by whoever produced the bytes, so the host uses it only to pick a
key from its own set. If the hint names no trusted key, the host refuses before verification.
If it names a trusted key, the signature still has to verify under exactly that key.
expected_grant = %ExpectedGrant{
issuer: "https://issuer.example",
audience: "https://builds.example",
evaluation_time: 1_800_001_000,
clock_skew: 60,
bounds: %{}
}
verify_with = fn compact, keys ->
with {:ok, locator} <- V1.untrusted_key_locator(compact, %{}),
{:ok, public_key} <- Map.fetch(keys, locator.kid),
trusted = %TrustedIssuer{key_id: locator.kid, public_key: public_key},
{:ok, facts} <- V1.verify_grant(compact, trusted, expected_grant) do
{:ok, facts.grant_id}
else
:error -> {:error, :unknown_key}
{:error, :invalid} -> {:error, :invalid}
end
end
# A grant signed by the old key but labeled with the new key's id:
mislabeled = sign_grant.("issuer-2026-10", old_private, "grant-build-003")
# After the rotation completes, the host drops the old key from its set:
after_rotation = Map.delete(trusted_keys, "issuer-2026-04")
%{
old_key: verify_with.(old_grant, trusted_keys),
new_key: verify_with.(new_grant, trusted_keys),
mislabeled: verify_with.(mislabeled, trusted_keys),
old_key_after_rotation: verify_with.(old_grant, after_rotation)
}The cell returns:
%{
old_key: {:ok, "grant-build-001"},
new_key: {:ok, "grant-build-002"},
mislabeled: {:error, :invalid},
old_key_after_rotation: {:error, :unknown_key}
}The kid never makes a key trusted: the mislabeled grant names a trusted key id and still fails,
because the old key's signature does not verify under the new key. {:error, :unknown_key} is
the host's own result, not the package's; the package only ever returns {:error, :invalid}.
3. A proof helper
The remaining sections present proofs under different conditions. This helper signs one holder proof for the new grant; the service-side expectation below is what the service derives from the request it received.
alias BoundedAuthorityProtocol.V1.{Proof, Credentials, ExpectedRequest}
arguments = {:object, [{"branch", {:string, "main"}}, {"clean", {:boolean, true}}]}
issued_at = 1_800_001_000
sign_proof = fn overrides ->
proof =
struct!(
%Proof{
holder_public_key: holder_public,
proof_id: "proof-build-001",
method: "POST",
target_uri: "https://builds.example/pipelines/42/runs",
issued_at: issued_at,
nonce: nil,
invocation_id: "0f8fad5b-d9cb-469f-a165-70867728950e",
operation: "trigger_build",
grant_compact: new_grant,
cast_arguments: arguments
},
overrides
)
{:ok, input} = V1.proof_signing_input(proof, %{})
signature = :crypto.sign(:eddsa, :none, input.message, [holder_private, :ed25519])
{:ok, compact} = V1.assemble_compact(input, signature)
%Credentials{grant: new_grant, proof: compact}
end
expected = %ExpectedRequest{
trusted_issuer: %TrustedIssuer{key_id: "issuer-2026-10", public_key: new_public},
issuer: "https://issuer.example",
audience: "https://builds.example",
method: "POST",
target_uri: "https://builds.example/pipelines/42/runs",
invocation_id: "0f8fad5b-d9cb-469f-a165-70867728950e",
operation: "trigger_build",
cast_arguments: arguments,
evaluation_time: issued_at,
clock_skew: 60,
proof_max_age: 300,
nonce: :not_required,
bounds: %{}
}
verdict = fn credentials, expected ->
case V1.check_envelope(credentials, expected) do
{:ok, facts} -> {:ok, facts.operation}
{:error, :invalid} -> {:error, :invalid}
end
end
verdict.(sign_proof.([]), expected)The cell returns {:ok, "trigger_build"}.
4. Nonce modes
ExpectedRequest.nonce is either :not_required or {:required, nonce}. A service that wants
freshness beyond the time window issues a nonce (for example in a challenge response), remembers
it, and requires the next proof to carry exactly that value. The match is exact in both
directions: in :not_required mode a proof must carry no nonce at all, and in required mode it
must carry the one you issued. The local-loopback profile
(BoundedAuthorityProtocol.ApplicationProfile.LocalLoopbackHttp.V1) always requires one.
issued_nonce = "challenge-7f3a91"
%{
not_required_without_nonce: verdict.(sign_proof.([]), expected),
not_required_with_nonce: verdict.(sign_proof.(nonce: issued_nonce), expected),
required_with_issued_nonce:
verdict.(sign_proof.(nonce: issued_nonce), %{expected | nonce: {:required, issued_nonce}}),
required_with_other_nonce:
verdict.(sign_proof.(nonce: "challenge-000000"), %{
expected
| nonce: {:required, issued_nonce}
}),
required_without_nonce:
verdict.(sign_proof.([]), %{expected | nonce: {:required, issued_nonce}})
}The cell returns:
%{
not_required_without_nonce: {:ok, "trigger_build"},
not_required_with_nonce: {:error, :invalid},
required_with_issued_nonce: {:ok, "trigger_build"},
required_with_other_nonce: {:error, :invalid},
required_without_nonce: {:error, :invalid}
}A nonce is single-use correlation the service owns: issue it, scope it to this verifier, and retire it after one successful verification.
5. Clock skew and proof maximum age
Every time is Unix seconds, and the verifier reads no clock: evaluation_time is your clock
reading. A proof issued at iat verifies while
iat - clock_skew <= evaluation_time <= iat + proof_max_age + clock_skew. clock_skew is at most
60 seconds and proof_max_age at most 300; an expectation above either limit is itself invalid.
credentials = sign_proof.([])
at = fn seconds -> verdict.(credentials, %{expected | evaluation_time: issued_at + seconds}) end
%{
sixty_one_seconds_early: at.(-61),
sixty_seconds_early: at.(-60),
at_issue: at.(0),
at_max_age_plus_skew: at.(360),
one_second_later: at.(361),
skew_above_limit: verdict.(credentials, %{expected | clock_skew: 61}),
max_age_above_limit: verdict.(credentials, %{expected | proof_max_age: 301})
}The cell returns:
%{
sixty_one_seconds_early: {:error, :invalid},
sixty_seconds_early: {:ok, "trigger_build"},
at_issue: {:ok, "trigger_build"},
at_max_age_plus_skew: {:ok, "trigger_build"},
one_second_later: {:error, :invalid},
skew_above_limit: {:error, :invalid},
max_age_above_limit: {:error, :invalid}
}Pass System.os_time(:second) as evaluation_time in a service. A millisecond reading falls
outside every window, and a DateTime or a float is an invalid input.
6. Diagnosing a rejection
{:error, :invalid} carries no reason, by design: a reason would tell an attacker which check to
work on. To diagnose a rejection in your own service, decode the credentials without verifying
them and compare what they claim with what your service derived. decode_grant/2 and
decode_proof/2 check no signature, so use them only for diagnosis, never for a decision.
The service below derived the method from a framework that lower-cases it, and built the target
URI from a Host header with an upper-case letter. Both differ from the bytes the holder signed.
derived = %{expected | method: "post", target_uri: "https://Builds.example/pipelines/42/runs"}
diagnose = fn %Credentials{grant: grant, proof: proof}, %ExpectedRequest{} = expected ->
{:ok, decoded_grant} = V1.decode_grant(grant, %{})
{:ok, decoded_proof} = V1.decode_proof(proof, %{})
{:ok, digest} = V1.request_digest(expected.operation, expected.cast_arguments, %{})
checks = [
issuer: decoded_grant.issuer == expected.issuer,
audience: expected.audience in decoded_grant.audiences,
key_id: decoded_grant.key_id == expected.trusted_issuer.key_id,
method: decoded_proof.method == expected.method,
target_uri: decoded_proof.target_uri == expected.target_uri,
invocation_id: decoded_proof.invocation_id == expected.invocation_id,
operation: decoded_proof.operation == expected.operation,
cast_arguments: Base.url_encode64(decoded_proof.request_hash, padding: false) == digest,
nonce:
(expected.nonce == :not_required and decoded_proof.nonce == nil) or
expected.nonce == {:required, decoded_proof.nonce},
proof_window:
expected.evaluation_time >= decoded_proof.issued_at - expected.clock_skew and
expected.evaluation_time <=
decoded_proof.issued_at + expected.proof_max_age + expected.clock_skew
]
for {field, false} <- checks, do: field
end
%{verdict: verdict.(credentials, derived), mismatches: diagnose.(credentials, derived)}The cell returns %{verdict: {:error, :invalid}, mismatches: [:method, :target_uri]}.
The comparison names fields, not values, so it is safe to log. It covers the common causes; the troubleshooting guide is the full checklist by stage, including selectors, limits, and key acceptance. With both fields corrected, the same credentials verify:
verdict.(credentials, %{derived | method: "POST", target_uri: expected.target_uri})The cell returns {:ok, "trigger_build"}.
7. What the host still owns
A successful check_envelope/2 proves that the bytes satisfy the inputs you supplied. It does
not reserve the invocation, check revocation, or decide anything. Verification is stateless, so
the same envelope verifies again on a second presentation:
first = V1.check_envelope(credentials, expected)
second = V1.check_envelope(credentials, expected)
{elem(first, 0), elem(second, 0)}The cell returns {:ok, :ok}. Replay defense is the host's job. A host reserves each
invocation_id atomically after verification and before acting, in storage shared by every
instance of the service (a database unique constraint, for example); a second presentation then
fails the reservation. The sketch below keeps the reservations in a map to show the order of
steps; a real service never keeps them in process memory.
accept = fn credentials, expected, reserved ->
with {:ok, facts} <- V1.check_envelope(credentials, expected),
false <- MapSet.member?(reserved, expected.invocation_id) do
{{:accepted, facts.operation}, MapSet.put(reserved, expected.invocation_id)}
else
true -> {{:rejected, :replayed}, reserved}
{:error, :invalid} -> {{:rejected, :invalid}, reserved}
end
end
{first_result, reserved} = accept.(credentials, expected, MapSet.new())
{second_result, _reserved} = accept.(credentials, expected, reserved)
[first_result, second_result]The cell returns [{:accepted, "trigger_build"}, {:rejected, :replayed}].
The host also owns:
- trust selection: which issuers and keys it trusts, and when it stops trusting them;
- revocation: whether a grant id, holder key, or issuer key has been revoked since issue;
- the authorization decision: facts carry
authorization: :not_evaluated, and whether this verified operation may run now is a business decision the host makes after verification.
8. Signing in production
This notebook signs with :crypto and in-memory keys. In production the issuer's private key
stays in a KMS or HSM and the holder's in its own key store. The package computes the exact
bytes to sign and assembles the compact form, and never accepts a private key. The public
companion package
bounded_authority_report_adapter
signs those inputs through a key handle you supply (an HSM, a KMS client, or a test key), and the
recipes show converting a KMS or HSM
ES256 signature with BoundedAuthorityProtocol.Es256.der_to_raw/1 and normalize_low_s/1.