Multi-Party Signatures
Aira's chain of custody has two independent signatures: the action gateway signs the receipt, the policy evaluator signs the upstream policy decision. A regulator can verify both against JWKS without trusting Aira to mediate.
Why two signatures
A receipt that says "Aira saw this action" is one signature on one event. A regulator inspecting it has to trust that Aira's gateway signed it correctly. If you want a stronger claim — that two independent parties signed off on the same authorization chain — you need two distinct keys, two distinct signatures, two distinct verification paths.
That is what Aira's multi-party signing provides:
- The action gateway signs the receipt payload with its key. Stored on
action_receipts.signature, key idaira-signing-key-v1. - The policy evaluator signs the upstream
PolicyEvaluationrow with a different key. Stored onpolicy_evaluations.receipt_signature, key idaira-policy-evaluator-key-v1.
Both keys are published on the JWKS endpoint. A verifier can fetch either by kid and re-run the signature check independently.
This is the answer to APS's "single-party logs are forgeable" critique from the LangChain RFC #35691 thread.
How it works at runtime
When an agent calls authorize():
- Aira matches every active policy against the action.
- For each matched policy, the policy engine produces an evaluation:
decision,confidence,reasoning. The evaluation is canonicalized and signed with the policy-evaluator key. - The evaluation row is persisted with
receipt_hash,receipt_signature, andsigning_key_id="aira-policy-evaluator-key-v1". - When the action transitions to
notarized, the action gateway key signs the receipt payload, which includes anauthorization_refblock pinning the policy evaluation id.
The two signatures use different private keys that are required to differ at startup time:
# app/services/signing_backend.py
if hex_value == settings.signing_private_key_hex:
raise RuntimeError(
"POLICY_EVALUATOR_PRIVATE_KEY_HEX must differ from "
"SIGNING_PRIVATE_KEY_HEX. Multi-party signing requires two "
"distinct keys so a regulator can confirm the action gateway "
"and the policy evaluator are independent parties."
)If you set them to the same value, Aira refuses to start.
What verify-action returns
GET /api/v1/verify/action/{action_uuid} now returns an optional policy_evaluator_attestation block:
{
"valid": true,
"receipt_uuid": "...",
"action_uuid": "...",
"public_key": "uBa2zVOsAycukt1//mCaACAh0cC4RQ5yZQDNAsfm5io=",
"signature": "ed25519:Kp3Lm7Qw...",
"signed_payload": { ... },
"policy_evaluator_attestation": {
"evaluation_uuid": "...",
"public_key_id": "aira-policy-evaluator-key-v1",
"public_key": "rJ8KLm9Qw2Rv...",
"payload_hash": "sha256:...",
"signature": "ed25519:Bj1Hf6Dg...",
"signed_payload": {
"policy_uuid": "...",
"org_uuid": "...",
"mode": "rules",
"decision": "require_approval",
"confidence": null,
"evaluated_at": "2026-04-10T19:55:00Z"
},
"valid": true
}
}The policy_evaluator_attestation.public_key MUST differ from the receipt's top-level public_key. If they were equal, both signatures would be coming from the same party and the multi-party claim would be a lie.
Verify the second-party signature offline
import json
import base64
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
with open("verify-response.json") as f:
body = json.load(f)
attestation = body["policy_evaluator_attestation"]
# Verify the policy-evaluator's signature on the eval payload
canonical = json.dumps(attestation["signed_payload"], sort_keys=True, separators=(",", ":"))
pub_bytes = base64.b64decode(attestation["public_key"])
pub_key = Ed25519PublicKey.from_public_bytes(pub_bytes)
sig_b64 = attestation["signature"].removeprefix("ed25519:")
sig_bytes = base64.urlsafe_b64decode(sig_b64)
pub_key.verify(sig_bytes, canonical.encode())
print("Policy evaluator signature is valid")
print("Policy evaluation id:", attestation["evaluation_uuid"])
print("Decision:", attestation["signed_payload"]["decision"])
# Confirm the two parties are distinct
assert attestation["public_key"] != body["public_key"]
print("Confirmed: gateway and policy-evaluator keys are distinct")Configuration
# .env
SIGNING_PRIVATE_KEY_HEX=<gateway key, 64 hex chars>
POLICY_EVALUATOR_PRIVATE_KEY_HEX=<DIFFERENT key, 64 hex chars>Generate distinct keys:
python -c "from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey; print(Ed25519PrivateKey.generate().private_bytes_raw().hex())"Run the command twice. Use the first output for SIGNING_PRIVATE_KEY_HEX and the second for POLICY_EVALUATOR_PRIVATE_KEY_HEX. Aira refuses to start in cloud mode without both, and refuses to start at all if they happen to be equal.
In selfhost mode both keys can be empty — Aira generates ephemeral keys with a startup warning. Receipts signed with ephemeral keys cannot be verified after a process restart.
What this neutralizes
- APS's "single-party logs are forgeable" critique. Two distinct private keys, two distinct signatures, both publishable via JWKS.
- The implicit assumption that "Aira signed it" is one trust statement. It is now two trust statements from two key-isolated subsystems.
If you need a third party — for example an agent's own key signing its intent before authorize — the existing mutual_sign_service already handles bilateral org-to-org signing. Wire it in front of authorize() to add a third signature to the chain.
Pluggable Signing Backend
Aira's signing layer is a Protocol, not a hard-coded algorithm. Ed25519 is the default; ML-DSA-65 / SLH-DSA / HSM backends are drop-in implementations.
Merkle Settlement
Periodic Merkle anchoring of action receipts. Every notarized receipt eventually gets sealed into exactly one settlement; the settlement's Merkle root is the cryptographic commitment that the batch existed at a specific moment in time.