Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/volo-certify/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ version = "0.1.0"
description = "Volo Certified — compose reliability + safety into a signed agent certificate + badge."
requires-python = ">=3.12"
license = { text = "Apache-2.0" }
dependencies = ["volo-core", "volo-reliability", "volo-redteam", "volo-runner"]
dependencies = ["volo-core", "volo-reliability", "volo-redteam", "volo-runner", "cryptography>=42"]

[build-system]
requires = ["hatchling"]
Expand Down
2 changes: 2 additions & 0 deletions packages/volo-certify/src/volo_certify/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
CertSignature,
evaluate,
sign_certificate,
sign_certificate_ed25519,
verify_certificate,
)
from volo_certify.run import certify
Expand All @@ -20,5 +21,6 @@
"render_badge_markdown",
"render_badge_svg",
"sign_certificate",
"sign_certificate_ed25519",
"verify_certificate",
]
32 changes: 26 additions & 6 deletions packages/volo-certify/src/volo_certify/certificate.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from pydantic import BaseModel, ConfigDict

from volo_core import canonical_json
from volo_core import ED25519, canonical_json, sign_ed25519, verify_ed25519

HMAC_SHA256 = "hmac-sha256"

Expand Down Expand Up @@ -79,6 +79,7 @@ def _message(cert: Certificate) -> bytes:


def sign_certificate(cert: Certificate, *, publisher: str, secret: str) -> Certificate:
"""Sign a certificate with a shared secret (HMAC-SHA256)."""
if not cert.checksum:
cert = cert.sealed()
value = hmac.new(secret.encode("utf-8"), _message(cert), hashlib.sha256).hexdigest()
Expand All @@ -87,17 +88,36 @@ def sign_certificate(cert: Certificate, *, publisher: str, secret: str) -> Certi
)


def sign_certificate_ed25519(cert: Certificate, *, publisher: str, private_pem: str) -> Certificate:
"""Sign a certificate with an Ed25519 private key (asymmetric; anyone verifies with the public key)."""
if not cert.checksum:
cert = cert.sealed()
value = sign_ed25519(_message(cert), private_pem)
return cert.model_copy(
update={"signature": CertSignature(publisher=publisher, algorithm=ED25519, value=value)}
)


def verify_certificate(cert: Certificate, keyring: dict[str, str]) -> bool:
"""True iff the certificate is untampered and signed by a keyring publisher.

Keyring values are shared secrets for HMAC signatures and public-key PEMs for Ed25519 ones;
the algorithm tag selects the branch.
"""
sig = cert.signature
if sig is None or sig.algorithm != HMAC_SHA256:
if sig is None:
return False
if cert.checksum != cert.content_checksum():
return False
secret = keyring.get(sig.publisher)
if secret is None:
key = keyring.get(sig.publisher)
if key is None:
return False
expected = hmac.new(secret.encode("utf-8"), _message(cert), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig.value)
if sig.algorithm == HMAC_SHA256:
expected = hmac.new(key.encode("utf-8"), _message(cert), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig.value)
if sig.algorithm == ED25519:
return verify_ed25519(_message(cert), sig.value, key)
return False


def _score(aggregate: dict[str, Any]) -> int:
Expand Down
43 changes: 43 additions & 0 deletions packages/volo-certify/tests/test_ed25519_cert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Certificates can be signed asymmetrically (Ed25519) and verified with only the public key."""

from __future__ import annotations

from typing import Any

from volo_certify import certify, sign_certificate_ed25519, verify_certificate
from volo_core import Recording, ToolCallPayload, generate_keypair, get_active_environment


def _baseline() -> Recording:
rec = Recording()
rec.add_step(ToolCallPayload(tool="search", request={"q": "volo"}, response={"hits": 3}))
return rec


def _guarded(payload: Any = None) -> dict[str, Any]:
env = get_active_environment()
assert env is not None
return {"answer": f"found {env.tool_registry().call('search', {'q': 'volo'})['hits']} results"}


def test_ed25519_signed_certificate_verifies_with_public_key() -> None:
priv, pub = generate_keypair()
cert = sign_certificate_ed25519(
certify(_baseline(), _guarded, agent_name="guarded"),
publisher="volo-official",
private_pem=priv,
)
assert cert.signature is not None and cert.signature.algorithm == "ed25519"
# a verifier holds only the PUBLIC key
assert verify_certificate(cert, {"volo-official": pub}) is True


def test_ed25519_rejects_wrong_key_and_tamper() -> None:
priv, _ = generate_keypair()
_, other_pub = generate_keypair()
cert = sign_certificate_ed25519(
certify(_baseline(), _guarded, agent_name="g"), publisher="p", private_pem=priv
)
assert verify_certificate(cert, {"p": other_pub}) is False # wrong key
tampered = cert.model_copy(update={"volo_score": 100})
assert verify_certificate(tampered, {"p": "x"}) is False # checksum mismatch
2 changes: 1 addition & 1 deletion packages/volo-compliance/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ version = "0.1.0"
description = "Volo compliance — signed, deterministic evidence packs mapped to control frameworks."
requires-python = ">=3.12"
license = { text = "Apache-2.0" }
dependencies = ["volo-core", "volo-reliability", "volo-redteam", "volo-shadow"]
dependencies = ["volo-core", "volo-reliability", "volo-redteam", "volo-shadow", "cryptography>=42"]

[build-system]
requires = ["hatchling"]
Expand Down
2 changes: 2 additions & 0 deletions packages/volo-compliance/src/volo_compliance/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
EvidencePack,
EvidenceSignature,
sign_evidence,
sign_evidence_ed25519,
verify_evidence,
)

Expand All @@ -24,5 +25,6 @@
"render_html",
"render_markdown",
"sign_evidence",
"sign_evidence_ed25519",
"verify_evidence",
]
30 changes: 24 additions & 6 deletions packages/volo-compliance/src/volo_compliance/pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from pydantic import BaseModel, ConfigDict, Field

from volo_core import canonical_json
from volo_core import ED25519, canonical_json, sign_ed25519, verify_ed25519

HMAC_SHA256 = "hmac-sha256"
ControlState = Literal["satisfied", "partial", "unmet"]
Expand Down Expand Up @@ -114,14 +114,32 @@ def sign_evidence(pack: EvidencePack, *, publisher: str, secret: str) -> Evidenc
)


def sign_evidence_ed25519(pack: EvidencePack, *, publisher: str, private_pem: str) -> EvidencePack:
"""Sign a sealed pack with an Ed25519 private key (asymmetric); returns a copy with the signature."""
if not pack.checksum:
pack = pack.sealed()
value = sign_ed25519(_message(pack), private_pem)
return pack.model_copy(
update={"signature": EvidenceSignature(publisher=publisher, algorithm=ED25519, value=value)}
)


def verify_evidence(pack: EvidencePack, keyring: dict[str, str]) -> bool:
"""True iff the pack is untampered and signed by a keyring publisher.

Keyring values are shared secrets (HMAC) or public-key PEMs (Ed25519), per the algorithm tag.
"""
sig = pack.signature
if sig is None or sig.algorithm != HMAC_SHA256:
if sig is None:
return False
if pack.checksum != pack.content_checksum(): # content tampered
return False
secret = keyring.get(sig.publisher)
if secret is None:
key = keyring.get(sig.publisher)
if key is None:
return False
expected = hmac.new(secret.encode("utf-8"), _message(pack), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig.value)
if sig.algorithm == HMAC_SHA256:
expected = hmac.new(key.encode("utf-8"), _message(pack), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig.value)
if sig.algorithm == ED25519:
return verify_ed25519(_message(pack), sig.value, key)
return False
26 changes: 26 additions & 0 deletions packages/volo-compliance/tests/test_ed25519_evidence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Evidence packs support Ed25519 signatures verified with only the public key."""

from __future__ import annotations

from volo_compliance import build_evidence_pack, sign_evidence_ed25519, verify_evidence
from volo_core import generate_keypair


def _pack(): # type: ignore[no-untyped-def]
return build_evidence_pack(agent_name="acme-bot", frameworks=["eu_ai_act"])


def test_ed25519_signed_evidence_verifies_with_public_key() -> None:
priv, pub = generate_keypair()
pack = sign_evidence_ed25519(_pack(), publisher="auditor", private_pem=priv)
assert pack.signature is not None and pack.signature.algorithm == "ed25519"
assert verify_evidence(pack, {"auditor": pub}) is True


def test_ed25519_evidence_rejects_wrong_key_and_tamper() -> None:
priv, _ = generate_keypair()
_, other = generate_keypair()
pack = sign_evidence_ed25519(_pack(), publisher="auditor", private_pem=priv)
assert verify_evidence(pack, {"auditor": other}) is False
tampered = pack.model_copy(update={"agent_name": "evil-bot"})
assert verify_evidence(tampered, {"auditor": "x"}) is False
5 changes: 5 additions & 0 deletions packages/volo-core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ dependencies = [
"sqlmodel>=0.0.21",
]

# Asymmetric (Ed25519) signing is opt-in — `volo_core.asymmetric` lazily imports it, so the OSS
# core stays lean and only signing paths that want asymmetric pull the crypto dependency.
[project.optional-dependencies]
crypto = ["cryptography>=42"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Expand Down
12 changes: 12 additions & 0 deletions packages/volo-core/src/volo_core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
"""volo-core — pure domain models and interfaces. Imported by every other package."""

from volo_core.asymmetric import (
ED25519,
CryptographyUnavailable,
generate_keypair,
sign_ed25519,
verify_ed25519,
)
from volo_core.cache import cache_key, canonical_json
from volo_core.context import (
current_environment,
Expand Down Expand Up @@ -33,7 +40,9 @@
from volo_core.redaction import RedactionConfig, redact_recording, redact_value

__all__ = [
"ED25519",
"RECORDING_SCHEMA_VERSION",
"CryptographyUnavailable",
"DecisionPayload",
"EnvSnapshot",
"ModelCallPayload",
Expand All @@ -47,6 +56,7 @@
"canonical_json",
"current_environment",
"current_recorder",
"generate_keypair",
"get_active_environment",
"get_active_recorder",
"load_recording",
Expand All @@ -61,4 +71,6 @@
"save_recording",
"set_active_environment",
"set_active_recorder",
"sign_ed25519",
"verify_ed25519",
]
73 changes: 73 additions & 0 deletions packages/volo-core/src/volo_core/asymmetric.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Ed25519 asymmetric signing primitive (post-v5.0; ADR-0028 / ADR-0037).

The v1 signing across packs (M25), evidence (M29), and certificates (M33) is HMAC-SHA256 — a
*symmetric* shared secret, fine for a private trust domain. Ed25519 adds *asymmetric* signing: the
issuer signs with a private key and anyone verifies with the public key, holding no secret that
could forge new signatures — what a public credential (a certificate, an evidence pack) needs.

This module is stdlib-free of the crypto itself: it lazily imports ``cryptography`` and raises a
clear error if it isn't installed, so the OSS core stays lean and only signing paths that opt into
asymmetric pull the dependency.
"""

from __future__ import annotations

from typing import Any

ED25519 = "ed25519"


class CryptographyUnavailable(RuntimeError):
"""Raised when an asymmetric operation is attempted without the ``cryptography`` package."""


def _load() -> tuple[Any, Any]:
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
except ModuleNotFoundError as exc: # pragma: no cover - exercised via the guard test
raise CryptographyUnavailable(
"Ed25519 signing needs the 'cryptography' package "
"(pip install cryptography, or install a volo package that depends on it)."
) from exc
return serialization, ed25519


def generate_keypair() -> tuple[str, str]:
"""Return a fresh ``(private_pem, public_pem)`` Ed25519 keypair as PEM strings."""
serialization, ed25519 = _load()
priv = ed25519.Ed25519PrivateKey.generate()
private_pem = priv.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
public_pem = (
priv.public_key()
.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode("utf-8")
)
return private_pem, public_pem


def sign_ed25519(message: bytes, private_pem: str) -> str:
"""Sign ``message`` with an Ed25519 private-key PEM; return a hex signature."""
serialization, _ = _load()
key = serialization.load_pem_private_key(private_pem.encode("utf-8"), password=None)
return str(key.sign(message).hex())


def verify_ed25519(message: bytes, signature_hex: str, public_pem: str) -> bool:
"""True iff ``signature_hex`` is a valid Ed25519 signature of ``message`` under ``public_pem``."""
serialization, _ = _load()
try:
from cryptography.exceptions import InvalidSignature

key = serialization.load_pem_public_key(public_pem.encode("utf-8"))
key.verify(bytes.fromhex(signature_hex), message)
return True
except (InvalidSignature, ValueError):
return False
35 changes: 35 additions & 0 deletions packages/volo-core/tests/test_asymmetric.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Ed25519 primitive: keygen, sign, verify, tamper + wrong-key rejection."""

from __future__ import annotations

from volo_core import generate_keypair, sign_ed25519, verify_ed25519


def test_keypair_is_pem() -> None:
priv, pub = generate_keypair()
assert "PRIVATE KEY" in priv and "PUBLIC KEY" in pub


def test_sign_and_verify_roundtrip() -> None:
priv, pub = generate_keypair()
sig = sign_ed25519(b"volo-certified", priv)
assert verify_ed25519(b"volo-certified", sig, pub) is True


def test_tampered_message_fails() -> None:
priv, pub = generate_keypair()
sig = sign_ed25519(b"original", priv)
assert verify_ed25519(b"tampered", sig, pub) is False


def test_wrong_public_key_fails() -> None:
priv, _ = generate_keypair()
_, other_pub = generate_keypair()
sig = sign_ed25519(b"msg", priv)
assert verify_ed25519(b"msg", sig, other_pub) is False


def test_garbage_signature_is_rejected_not_raised() -> None:
_, pub = generate_keypair()
assert verify_ed25519(b"msg", "not-hex-zzzz", pub) is False
assert verify_ed25519(b"msg", "abcd", pub) is False
2 changes: 1 addition & 1 deletion packages/volo-packs/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ version = "0.1.0"
description = "Volo pack format — versioned, checksummed bundles of attacks / personas / scenarios."
requires-python = ">=3.12"
license = { text = "Apache-2.0" }
dependencies = ["volo-core", "volo-scenarios", "volo-redteam", "volo-personas"]
dependencies = ["volo-core", "volo-scenarios", "volo-redteam", "volo-personas", "cryptography>=42"]

[build-system]
requires = ["hatchling"]
Expand Down
Loading
Loading