Aira

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 id aira-signing-key-v1.
  • The policy evaluator signs the upstream PolicyEvaluation row with a different key. Stored on policy_evaluations.receipt_signature, key id aira-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():

  1. Aira matches every active policy against the action.
  2. For each matched policy, the policy engine produces an evaluation: decision, confidence, reasoning. The evaluation is canonicalized and signed with the policy-evaluator key.
  3. The evaluation row is persisted with receipt_hash, receipt_signature, and signing_key_id="aira-policy-evaluator-key-v1".
  4. When the action transitions to notarized, the action gateway key signs the receipt payload, which includes an authorization_ref block 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.

On this page