Bounded Authority Protocol: service integration

Copy Markdown View Source
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 kid hint;
  • 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.