Aira

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.

Why a protocol

Aira ships Ed25519 by default. It's fast, has tiny signatures (64 bytes), is verifiable in OpenSSL and every major language, and has been the de facto standard for compliance-grade signing for a decade. For the EU AI Act timeframe, Ed25519 is the right answer.

But the regulatory horizon includes post-quantum signatures. NIST FIPS 204 standardized ML-DSA-65 (the production name for CRYSTALS-Dilithium) in August 2024. Some organizations want a migration path today, even though the verification ecosystem is still catching up. Hardware HSMs and KMS-backed signers are another common requirement we don't ship by default.

Rather than picking one algorithm and hard-coding it, Aira's signing layer is a Protocol. You implement four methods, register your backend, and the rest of the system (notarization, verification, JWKS, compliance bundles) flows through your implementation without code changes.


The protocol

# app/services/signing_backend.py

from typing import Any, Protocol


class SigningBackend(Protocol):
    """Contract for any signature algorithm Aira can use to mint receipts."""

    algorithm: str  # "Ed25519" | "ML-DSA-65" | ...
    key_id: str    # stable identifier; used as JWKS kid

    def public_key_b64(self) -> str:
        """Base64-encoded raw public key. Published via JWKS."""
        ...

    def sign(self, payload: dict[str, Any]) -> tuple[str, str]:
        """Canonicalize and sign. Returns (payload_hash, signature) where
        signature is algorithm-prefixed (e.g. 'ed25519:BASE64URL')."""
        ...

    def verify(self, payload: dict[str, Any], signature: str, public_key_b64: str) -> bool:
        """Verify a signature using the supplied public key. Used by the
        public verification endpoint."""
        ...

That's the entire surface area. There is no __init__.py registry to update, no migration to write, no test scaffolding to thread through. If your implementation satisfies the four methods, every Aira feature already knows how to use it.


How a backend gets picked up

# app/services/signing_backend.py

BACKENDS: dict[str, type[SigningBackend]] = {
    "Ed25519": Ed25519SigningBackend,
}


def get_active_backend() -> SigningBackend:
    algorithm = os.environ.get("SIGNING_ALGORITHM", "Ed25519")
    backend_cls = BACKENDS.get(algorithm)
    if backend_cls is None:
        raise RuntimeError(f"Unknown SIGNING_ALGORITHM={algorithm!r}")
    return backend_cls()

The crypto_service module is now a thin facade that delegates to get_active_backend(). To swap algorithms in production:

  1. Implement SigningBackend.
  2. Add it to BACKENDS.
  3. Set SIGNING_ALGORITHM=YourAlgo in your environment.
  4. Restart.

The notary, verifier, and JWKS endpoints all flow through the registered backend automatically.


Signature prefix dispatch

The verifier dispatches by signature prefix, not by the active backend. Receipts signed with ed25519:-prefixed signatures verify even after the active backend has been rotated to another algorithm. This matters for two reasons:

  • Key rotation: when you swap the active key, historic receipts must keep verifying.
  • Algorithm migration: when you eventually move from Ed25519 to ML-DSA-65, every receipt minted before the switch must keep verifying with the old algorithm forever.
def verify_signature(payload, signature, public_key_b64):
    if signature.startswith("ed25519:"):
        return Ed25519SigningBackend().verify(payload, signature, public_key_b64)
    if signature.startswith("ml-dsa-65:"):
        return MlDsa65SigningBackend().verify(payload, signature, public_key_b64)
    # ...

Adding ML-DSA-65 (NIST FIPS 204)

The simplest path to a quantum-safe backend is oqs-python (a binding for liboqs). Sketch:

import oqs

class MlDsa65SigningBackend:
    algorithm = "ML-DSA-65"

    def __init__(self, key_id: str = "aira-mldsa65-key-v1"):
        self.key_id = key_id
        self._signer: oqs.Signature | None = None

    def _load(self) -> oqs.Signature:
        if self._signer is None:
            secret_key = bytes.fromhex(os.environ["MLDSA65_PRIVATE_KEY_HEX"])
            self._signer = oqs.Signature("ML-DSA-65", secret_key=secret_key)
        return self._signer

    def public_key_b64(self) -> str:
        signer = self._load()
        return base64.b64encode(signer.export_public_key()).decode()

    def sign(self, payload: dict) -> tuple[str, str]:
        signer = self._load()
        canonical = canonical_json(payload)
        payload_hash = sha256_hex(canonical)
        sig = signer.sign(canonical.encode())
        return f"sha256:{payload_hash}", f"ml-dsa-65:{base64.urlsafe_b64encode(sig).decode()}"

    def verify(self, payload: dict, signature: str, public_key_b64: str) -> bool:
        sig_data = signature.removeprefix("ml-dsa-65:")
        sig_bytes = base64.urlsafe_b64decode(sig_data)
        pub_bytes = base64.b64decode(public_key_b64)
        verifier = oqs.Signature("ML-DSA-65")
        return verifier.verify(canonical_json(payload).encode(), sig_bytes, pub_bytes)

Register it:

BACKENDS["ML-DSA-65"] = MlDsa65SigningBackend

Set SIGNING_ALGORITHM=ML-DSA-65 and restart. Every new receipt is now signed with ML-DSA-65. Old receipts continue to verify with the Ed25519 backend via prefix dispatch.


Trade-offs

Ed25519ML-DSA-65
Signature size64 bytes3,309 bytes
Public key size32 bytes1,952 bytes
Signing speedVery fastFast
Verification ecosystemOpenSSL, every JWT library, every browserliboqs, growing
Quantum-safeNoYes (NIST FIPS 204)
Recommended forToday's compliance + audit10+ year retention requirements

For the EU AI Act timeframe and SOC 2 audits today, Ed25519 is the right default. For systems where receipts need to remain unforgeable in 10+ years, swap to ML-DSA-65 — the migration is one environment variable.

You can also run two backends in parallel with separate key ids if you want to dual-sign during a transition window. Implement two SigningBackend instances, register both, and have the notary call both sign() methods. The receipt schema's attestations block (added in receipt v1.3) is the supported way to carry multiple signatures from different backends.


DID and VC compatibility

W3C Verifiable Credentials and did:web documents are bound to Ed25519 at the spec level (Ed25519Signature2020). Aira's DID and VC services explicitly fall back to the Ed25519 backend regardless of the active SIGNING_ALGORITHM. You cannot run Aira's DID/VC features on a non-Ed25519 backend.

This is a deliberate constraint: W3C signatures use Ed25519's native byte format and a custom canonicalization scheme, both of which would break if we tried to substitute another algorithm under the hood. If your deployment depends on DIDs and VCs, keep SIGNING_ALGORITHM=Ed25519 (the default).

On this page